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/.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/.github/workflows/ci.yml b/.github/workflows/ci.yml index aadbc076..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: @@ -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 diff --git a/.github/workflows/preflight.yml b/.github/workflows/preflight.yml index a96f6beb..948ddf11 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: @@ -23,10 +23,11 @@ env: # The published release that every public API is compared against. Raise it # after each published version. `dart_apitool` compares the declared version # against the change severity, so a breaking change requires a major bump. - API_BASELINE_VERSION: '1.6.2' + API_BASELINE_VERSION: '1.7.0' # 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: @@ -192,13 +193,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/.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 db80b1d3..77f25aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,96 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2.0.0-alpha.1 + +### Breaking & Substitutions + +* **Single `.g.dart` part & direct JSON emitter (`#189`)**: `ack_generator:ack_generator` + now emits a single `.g.dart` part via `SharedPartBuilder` with direct + `toJson()` / `fromJson()` helpers (no `json_serializable` or `json_annotation` + dependency). + * **Substitute:** Replace `part '.ack.dart';` + `part '.ack.g.dart';` + with `part '.g.dart';`. + * **Substitute:** Replace `ack_generator|ack_models` and + `ack_generator|ack_model_json` in `build.yaml` with + `ack_generator|ack_generator`. +* **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.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()`). +* **Removed legacy 1.x generator annotations & shims (`#188`, `#189`)**: + 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, `/// @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$ + `registerAckTool(..., input: Model.schema)`. + * **Substitute:** `convertAckSchemaModelToBuilder(m)` $\rightarrow$ + `schema.toJsonSchemaBuilder()`. + * **Substitute:** `StandardSchemaV1` $\rightarrow$ `AckSchema` + + `SchemaResult`. +* **Sealed `SchemaError` & `AckSchema` hierarchies + decoder `Error` propagation (`#163`)**: + `SchemaError` and `AckSchema` are now `sealed class`es for exhaustive pattern + matching. `CodecSchema` rethrows programmer `Error`s during `parse` / + `safeParse` (matching `encode` / `safeEncode`) and catches only `Exception`s + as `SchemaTransformError`. +* **JSON Schema Draft 2020-12 default export & `format` annotation mode (`#171`, `#186`)**: + `Ack.fromJsonSchema()` defaults to `assertFormats: false` (pass + `assertFormats: true` or declare the `format-assertion` vocabulary to enforce + format assertions). `toJsonSchema()`, `toSchemaModel()`, and + `toJsonSchemaBuilder()` preserve imported Draft 2020-12 resources by default; + use `toJsonSchemaDraft7()`, `toSchemaModelDraft7()`, or + `toJsonSchemaBuilderDraft7()` when legacy Draft-07 lowering is needed. + +### Added + +* **`JsonMaybe` tri-state presence (`#148`)**: `sealed class JsonMaybe` + (`JsonAbsent`, `JsonValue`) distinguishes omitted keys (`JsonAbsent`), + 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 `@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 + `type: 'string'` without `'x-transformed': true`. +* **Full JSON Schema Draft 2020-12 runtime & `.schema.json` generator (`#167`, `#171`, `#186`)**: + Supports `$dynamicRef`, `$dynamicAnchor`, `unevaluatedProperties`, + `unevaluatedItems`, `prefixItems`, `dependentRequired`, `dependentSchemas`, + `patternProperties`, `minContains`, `maxContains`, bundled offline 2020-12 + meta-schemas, and all 19 Draft 2020-12 formats. Opt-in + `ack_generator:ack_json_schema` compiles `lib/**/*.schema.json` into + `.schema.dart` + `.schema.g.dart`. + +### Fixed + +* Explicit Draft-7 export 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, share identical identified resources across composed imports, and + preserve occurrence-level nullability and constraints (including source + `allOf` when layering conflicting constraints). +* Preserve integral decimal length and item bounds in `.schema.json` generation, + and use validated value models for Unicode regex patterns and named scalar + unions. + ## 1.7.0 * Remove `ack_annotations`; annotations now ship in `ack` (`package:ack/ack.dart`, diff --git a/PUBLISHING.md b/PUBLISHING.md index 7e94adbe..d1a4e064 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -12,24 +12,20 @@ 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 release is **1.7.0**, promoting the 1.7.0 beta series to stable across -all five publishable packages. It ships annotations in `package:ack`, unifies -model and schema generation on `@Schemable()`, adds `multipleOf`, `contains`, -`if`/`then`/`else`, built-in `format` assertions, `x-*` vendor extension -tolerance, Draft-07 `$schema` URI support, and `JsonSchemaValidationError` -diagnostics (`keyword`, `keywordLocation`, `documentUri`, `pointer`) to -`Ack.fromJsonSchema()`, and speeds up validation and JSON Schema export. -The beta.5 imported-pattern change to ECMA-262 Unicode mode 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 applies to consumers coming from 1.6.2 or earlier -betas. 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 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. +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, +`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.7.0. Confirm that all five +packages have published 1.7.0 versions before preparing the release. Melos **8.x** is configured with `mode: fixed` and `workspaceTag: true`, matching this repository's release-tag verifier. `smartDependents: true` preserves @@ -61,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 --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 @@ -76,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.7.0`). 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 @@ -88,15 +84,16 @@ 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 --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/api_check.dart 1.7.0 dart scripts/publish_dry_run.dart dart run melos run validate-jsonschema:batch ``` `--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. @@ -142,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 v1.7.0 -m 'Ack 1.7.0' -dart scripts/verify_release_tag.dart v1.7.0 -git push origin v1.7.0 +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 @@ -172,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 -1.7.0 notes. +2.0.0-alpha.1 notes. diff --git a/README.md b/README.md index d628e738..8c152b00 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()`, `@AckKey()`, +and the `@Validate.*` constraint and presence 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,73 +159,38 @@ 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 -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'; -import 'package:ack/annotations.dart'; + +part 'account.g.dart'; @Schemable() final class Account with _$AccountAck { const Account({required this.name}); - @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. -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, or `@Schemable(schemas: [...])` +on the class, union, or `library;` directive. See the [Model Code Generation guide](docs/core-concepts/typesafe-schemas.mdx). -Upgrading from 1.6.x: 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: @@ -237,7 +201,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.json b/docs.json index a5465ca1..73d03822 100644 --- a/docs.json +++ b/docs.json @@ -28,6 +28,10 @@ { "title": "Quickstart Tutorial", "href": "/getting-started/quickstart-tutorial" + }, + { + "title": "What's New & Migration Guide", + "href": "/getting-started/migration-guide" } ] }, @@ -51,7 +55,11 @@ "href": "/guides/flutter-form-validation" }, { "title": "Common Recipes", "href": "/guides/common-recipes" }, - { "title": "Custom Validation", "href": "/guides/custom-validation" } + { "title": "Custom Validation", "href": "/guides/custom-validation" }, + { + "title": "Ecosystem Integrations", + "href": "/guides/ecosystem-integrations" + } ] }, { @@ -89,8 +97,8 @@ ], "variables": { "versions": { - "default": "1.2.0", - "isPrerelease": false + "default": "2.0.0-alpha.1", + "isPrerelease": true } }, "search": {}, diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index 767cb3b3..48ec9316 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. @@ -311,16 +314,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`, `@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: ```bash dart run build_runner build @@ -355,8 +354,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({ @@ -385,47 +383,38 @@ 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, 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. +`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 `@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. +`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 + +**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.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. `@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 `unknownProperties: AckUnknownPropertyPolicy.` (`reject` by default). Raw `Ack.object(..., additionalProperties: true)` schemas preserve extras. @@ -435,35 +424,39 @@ 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) + @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 @@ -497,9 +490,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` @@ -547,9 +540,8 @@ 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 +### Optional schemas and `JsonMaybe` Every schema can be marked optional via the `optional({bool value = true})` fluent API. @@ -557,4 +549,16 @@ Every schema can be marked optional via the `optional({bool value = true})` flue - `schema.optional(value: false)` clears the optional flag. - Optional affects object-field presence only; combine with `.nullable()` to also allow explicit `null`. -*See the [Schema types](../core-concepts/schemas.mdx) guide for detailed usage and examples.* +In class-first `@Schemable()` models, use `sealed class JsonMaybe` (`JsonAbsent`, `JsonValue`) when you need to distinguish an omitted JSON property from an explicit `null` on both `parse` and `toJson`: + +- `const JsonMaybe.absent()` (`JsonAbsent`) — key omitted from JSON. +- `const JsonMaybe.value(T? value)` (`JsonValue`) — key present in JSON (with non-null or `null` value). +- `const JsonMaybe.nullValue()` — shorthand for `JsonValue(null)`. +- `maybe.isAbsent`, `maybe.isPresent`, `maybe.valueOrNull`. + +### `.schema.json` code generation (`ack_generator:ack_json_schema`) + +- **Builder (`build.yaml`)**: Enable `ack_generator|ack_json_schema` on `lib/**/*.schema.json` to emit `.schema.dart` (with `part '.schema.g.dart';`), which `ack_generator|ack_generator` compiles into typed `@Schemable()` models or validated value models. +- **Programmatic API (`package:ack_generator/ack_generator.dart`)**: `generateAckSchemaLibrary(schema, {schemaFileName, rootName, baseUri, documents, assertFormats})` returns a `GeneratedAckSchemaFile`. + +*See the [Schema types](../core-concepts/schemas.mdx) and [What's New & Migration Guide](../getting-started/migration-guide.mdx) for detailed usage and migration tables.* diff --git a/docs/core-concepts/codecs.mdx b/docs/core-concepts/codecs.mdx index 98468197..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({ @@ -103,11 +102,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/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/schemas.mdx b/docs/core-concepts/schemas.mdx index bd6ecda7..af16c67c 100644 --- a/docs/core-concepts/schemas.mdx +++ b/docs/core-concepts/schemas.mdx @@ -287,6 +287,8 @@ final userSchema = Ack.object({ {'name': 'John', 'bio': 'Developer'} ``` +> **Distinguishing omitted vs. `null` in generated models (`JsonMaybe`):** In raw map schemas (`Ack.object`), `map.containsKey('bio')` tells you whether `'bio'` was omitted or set to `null`. In class-first `@Schemable()` models, declare the field as `JsonMaybe` (`JsonAbsent` when omitted, `JsonValue(null)` when explicitly `null`, `JsonValue('...')` when present) so `PATCH` requests round-trip both states accurately. See [Model Code Generation](./typesafe-schemas.mdx). + ## Object schema operations ### Extension diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index 7cd88a58..1aa65379 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -2,32 +2,21 @@ title: Model Code Generation --- -Ack supports code generation in both directions. You can start with a schema -and generate a Dart model, or start with a Dart model and generate its schema. +Ack supports code generation in three workflows: start from an Ack schema, start +from a Dart class, or start from a `.schema.json` document. -| Starting point | Annotation | Generated result | +| Starting point | Trigger | Generated output | | --- | --- | --- | -| A top-level Ack schema | `@Schemable()` | An immutable Dart model | -| A Dart class | `@Schemable()` | An Ack codec schema and JSON helpers | +| A top-level Ack schema (`final userSchema = ...`) | `@Schemable()` | Immutable `User` class, `User.schema`, `parse`/`safeParse`, `fromJson`/`toJson`, `copyWith` in `.g.dart` | +| 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()` 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. -There is no `@AckSchema()` annotation. `AckSchema` is the +`@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()`. -`@AckInfer()` and `@AckModel()` are deprecated spellings of `@Schemable()` and -will be removed in 2.0.0. + +> **Upgrading from Ack 1.x?** See the [What's New & Migration Guide](../getting-started/migration-guide.mdx) for a complete table of changes, removed 1.x annotations, and drop-in 2.0 substitutions. ## Install the generator @@ -36,11 +25,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 +40,12 @@ 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() @@ -68,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({ @@ -79,16 +65,17 @@ final class Account with _$AccountAck { this.role = 'member', }); - @MinLength(2) + @Validate.minLength(2) final String displayName; - @Email() + @Validate.email() final String email; final String? middleName; final Uri? website; final String role; + static final schema = _accountSchema; static final fromJson = AccountSchema.fromJson; } ``` @@ -186,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, @@ -223,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) => @@ -245,21 +245,13 @@ 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 -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. @@ -300,24 +292,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; } ``` @@ -336,6 +329,30 @@ 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)` | + +Use `JsonMaybe` when a `PATCH` request needs to distinguish an omitted field +(`JsonAbsent`) from an explicit `null` (`JsonValue(null)`): + +```dart +@Schemable() +final class ProfilePatch with _$ProfilePatchAck { + const ProfilePatch({ + this.bio = const JsonMaybe.absent(), + }); + + final JsonMaybe bio; + + static final schema = _profilePatchSchema; + static final fromJson = ProfilePatchSchema.fromJson; +} + +// Omitted key -> JsonAbsent(); toJson() returns {} +final unchanged = ProfilePatch.fromJson({}); + +// Explicit null -> JsonValue(null); toJson() returns {'bio': null} +final cleared = ProfilePatch.fromJson({'bio': 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 @@ -344,31 +361,38 @@ 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: +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.ip`, `@Validate.ipv4`, `@Validate.ipv6`; +- lists and sets: `@Validate.notEmpty`, `@Validate.minItems`, `@Validate.maxItems`, `@Validate.uniqueItems`. -- 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`. -`@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 -`@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. +`@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. -### Types that own their schema +### 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. **`@Schemable(schemas: [...])`** on the class, enclosing union base, current + `library;` directive, or an imported `library;` directive; +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. 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 @@ -461,24 +485,27 @@ 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 and library registries -### 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). ```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, @@ -488,44 +515,14 @@ 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. +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. +types because `Ack.list` has no nullable-item contract. ### Class-first unions @@ -584,7 +581,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; @@ -598,7 +596,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() @@ -619,9 +617,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; @@ -648,60 +653,30 @@ 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.6.x - -- 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`. +## Migrating from 1.x + +- 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 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: '...')` 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/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 19dc06a3..102a32ce 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -16,11 +16,12 @@ dart pub add ack flutter pub add ack ``` -Or add to your `pubspec.yaml` (check [pub.dev](https://pub.dev/packages/ack) for the latest version): +The unversioned commands select the latest stable release. To select the +2.0.0-alpha.1 prerelease, add it to your `pubspec.yaml`: ```yaml dependencies: - ack: ^1.7.0 + ack: ^2.0.0-alpha.1 ``` ## Optional code generator @@ -37,25 +38,25 @@ Or add them to your `pubspec.yaml`: ```yaml dependencies: - ack: ^1.7.0 + ack: ^2.0.0-alpha.1 dev_dependencies: - ack_generator: ^1.7.0 + ack_generator: ^2.0.0-alpha.1 build_runner: ^2.4.0 ``` 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 -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. +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 +`_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'; -part 'user.ack.dart'; -part 'user.ack.g.dart'; +part 'user.g.dart'; @Schemable() final userSchema = Ack.object({ @@ -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; } ``` @@ -83,7 +87,7 @@ tutorials, supported shapes, and guidance on choosing a direction. ## Next step -Import `package:ack/ack.dart` and you're ready — the [Quickstart Tutorial](./quickstart-tutorial.mdx) takes you from your first schema to handling every validation outcome. +Import `package:ack/ack.dart` and you're ready — the [Quickstart Tutorial](./quickstart-tutorial.mdx) takes you from your first schema to handling every validation outcome. Upgrading from Ack 1.x? See the [What's New & Migration Guide](./migration-guide.mdx) for a complete table of changes and drop-in substitutions. ## Requirements diff --git a/docs/getting-started/migration-guide.mdx b/docs/getting-started/migration-guide.mdx new file mode 100644 index 00000000..f0c092f2 --- /dev/null +++ b/docs/getting-started/migration-guide.mdx @@ -0,0 +1,188 @@ +--- +title: What's New & Migration Guide +--- + +This guide summarizes **what exists in Ack today**, **what changed in 1.7 and 2.0 (and why)**, and **every deprecated or removed 1.x symbol with its exact substitution**. + +--- + +## 1. What Exists in Ack + +Ack is organized into five coordinated packages that share one version number and one canonical schema representation (`AckSchema`): + +| 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()`, `@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 | +| **`ack_json_schema_builder`** | `json_schema_builder` bridge | • Bidirectional `schema.toJsonSchemaBuilder()` and `jsbSchema.toAckSchema()` | + +--- + +## 2. What Changed & Why + +| Change | Introduced | What Changed | Why It Changed | +| --- | --- | --- | --- | +| **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 `@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. | +| **Sealed `SchemaError` & `AckSchema` hierarchies + decoder `Error` propagation** | `2.0.0` | `SchemaError` and `AckSchema` are `sealed class`es. `CodecSchema` rethrows programmer `Error`s (`TypeError`, `StateError`) on `parse`/`safeParse` and catches only `Exception`s as `SchemaTransformError`. | Enables exhaustive `switch (error)` pattern matching without a wildcard branch, and prevents programming bugs inside `decode` callbacks from being masked as user input errors. | +| **JSON Schema Draft 2020-12 default + `.schema.json` codegen** | `2.0.0` | `Ack.fromJsonSchema()` supports the full required Draft 2020-12 keyword suite (`$dynamicRef`, `unevaluatedProperties`, `prefixItems`, `minContains`/`maxContains`, `dependentSchemas`, `patternProperties`, and all 19 formats when `assertFormats: true`). `toJsonSchema()` preserves 2020-12 resources by default (`toJsonSchemaDraft7()` lowers to Draft-7). | Enables lossless round-tripping of modern protocol schemas (MCP, A2UI v0.9, OpenAPI 3.1) and compile-time Dart codegen from `.schema.json` files. | +| **ECMA-262 Unicode mode (`u`) for imported `pattern`** | `1.7.0` | Imported JSON Schema `pattern` expressions compile with ECMA-262 Unicode semantics. | Required by the JSON Schema specification so `.` matches full Unicode code points (including emoji) and `\p{...}` property escapes work identically to Ajv. | + +--- + +## 3. What Doesn't Exist Anymore & Exact Substitutions + +### Packages & Imports + +| Removed Item | Removed In | Exact Substitution | +| --- | --- | --- | +| `ack_annotations` package in `pubspec.yaml` | `1.7.0` | Remove `ack_annotations`; `ack` includes all annotations. | +| `import 'package:ack_annotations/ack_annotations.dart';` | `1.7.0` | `import 'package:ack/ack.dart';` | +| `import 'package:ack_annotations/format_annotations.dart';` | `1.7.0` | `import 'package:ack/ack.dart';` (use `@Validate.*`) | +| `import 'package:ack/format_annotations.dart';` | `2.0.0` | `import 'package:ack/ack.dart';` (use `@Validate.dateTime()`, `@Validate.uri()`, `@Validate.ip()`, `@Validate.ipv4()`, `@Validate.ipv6()`) | +| `json_serializable` & `json_annotation` for `@Schemable()` models | `2.0.0` | Remove unless used by other non-Ack classes; `ack_generator` emits `toJson` / `fromJson` directly. | + +### Model & Field Annotations + +| Removed / Deprecated Symbol | Status | Exact Substitution | +| --- | --- | --- | +| `@AckType()` | Removed in `2.0.0` | `@Schemable()` on the top-level schema | +| `@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`) | +| `@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')` / `@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.*`) + +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()` (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()` / `@Validate.uri()` | +| `@Ip()` / `@Ipv4()` / `@Ipv6()` | `@Validate.ip()` / `@Validate.ipv4()` / `@Validate.ipv6()` | +| `@MinItems(n)` / `@MaxItems(n)` / `@UniqueItems()` | `@Validate.minItems(n)` / `@Validate.maxItems(n)` / `@Validate.uniqueItems()` | + + +### Generated Files, Builders & Runtime APIs + +| Removed `1.x` Item | Removed In | Exact `2.0` Substitution | +| --- | --- | --- | +| `part 'model.ack.dart';` + `part 'model.ack.g.dart';` | `2.0.0` | Single `part 'model.g.dart';` | +| `ack_generator\|ack_models` & `ack_generator\|ack_model_json` in `build.yaml` | `2.0.0` | `ack_generator\|ack_generator` (plus optional `ack_generator\|ack_json_schema` for `.schema.json` files) | +| Legacy `*Type` extension type, `.args`, `ModelType.parse(...)` | `2.0.0` | Generated `Model` class with typed fields, `Model.parse(...)`, `Model.safeParse(...)`, `Model.fromJson(...)`, `model.toJson()` | +| `Ack.enumCodec(MyEnum.values)` | `2.0.0` | `Ack.enumValues(MyEnum.values)` | +| `StandardSchemaV1` (`standard_schema.dart`) | `2.0.0` | `AckSchema` + `SchemaResult` | +| `schema.toMap()` | `2.0.0` | `schema.toJsonSchema()` or `schema.toSchemaModel()` | +| `server.registerAckModelTool(...)` (`ack_mcp_dart`) | `2.0.0` | `server.registerAckTool(name, inputSchema: Model.schema, ...)` | +| `convertAckSchemaModelToBuilder(model)` (`ack_json_schema_builder`) | `2.0.0` | `schema.toJsonSchemaBuilder()` | +| `Ack.fromJsonSchema(...)` enforcing `format` by default | `2.0.0` | Pass `Ack.fromJsonSchema(schema, assertFormats: true)` (or declare `format-assertion` in `$vocabulary`) when format assertions are required | +| `schema.toJsonSchema()` lowering imported 2020-12 schemas to Draft-7 | `2.0.0` | `schema.toJsonSchema()` now preserves Draft 2020-12 resources by default; call `schema.toJsonSchemaDraft7()` when Draft-7 lowering is required | + +--- + +## 4. Before & After Migration Examples + +### Class-First Model Migration + +```dart +// ❌ BEFORE (Ack 1.6) +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart' as ack; +import 'package:ack_annotations/format_annotations.dart' as formats; +import 'package:json_annotation/json_annotation.dart'; + +part 'account.ack.dart'; +part 'account.ack.g.dart'; + +@ack.AckModel(caseStyle: ack.AckCaseStyle.snake) +final class Account with _$AccountAck { + const Account({required this.userName, required this.createdAt}); + + @JsonKey(name: 'username') + @ack.MinLength(3) + @ack.Pattern(r'^[a-z0-9_]+$') + final String userName; + + @formats.DateTime() + final String createdAt; +} +``` + +```dart +// ✅ AFTER (Ack 2.0) +import 'package:ack/ack.dart'; + +part 'account.g.dart'; + +@Schemable(caseStyle: AckCaseStyle.snake) +final class Account with _$AccountAck { + const Account({required this.userName, required this.createdAt}); + + @AckKey('username') + @Validate.minLength(3) + @Validate.matches(r'^[a-z0-9_]+$') + final String userName; + + @Validate.dateTime() + final String createdAt; + + static final schema = _accountSchema; + static final fromJson = AccountSchema.fromJson; +} +``` + +### Schema-First Model Migration + +```dart +// ❌ BEFORE (Ack 1.6 / Legacy @AckType or @AckInfer) +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'user.ack.dart'; +part 'user.ack.g.dart'; + +@AckInfer() +final userSchema = Ack.object({ + 'name': Ack.string().minLength(2), + 'email': Ack.string().email(), +}); +``` + +```dart +// ✅ AFTER (Ack 2.0) +import 'package:ack/ack.dart'; + +part 'user.g.dart'; + +@Schemable() +final userSchema = Ack.object({ + 'name': Ack.string().minLength(2), + 'email': Ack.string().email(), +}); + +// Generated class User provides: +// - User.schema (AckSchema, User>) +// - User.parse(...) / User.safeParse(...) +// - User.fromJson(...) / user.toJson() / user.safeToJson() +// - user.copyWith(...) / deep == and hashCode +``` diff --git a/docs/guides/creating-schema-converter-packages.mdx b/docs/guides/creating-schema-converter-packages.mdx index 4e312302..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: ^1.2.0 + ack: ^2.0.0-alpha.1 # Add target SDK dependency if needed # : ^x.y.z meta: ^1.15.0 @@ -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', () { @@ -1025,7 +1023,7 @@ Converts Ack schemas to format for [use case]. Assumes familiarity with \`\`\`yaml dependencies: - ack: ^1.2.0 + 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 new file mode 100644 index 00000000..eb14b660 --- /dev/null +++ b/docs/guides/ecosystem-integrations.mdx @@ -0,0 +1,179 @@ +--- +title: Ecosystem Integrations +--- + +Every `AckSchema` (and every `@Schemable()` model via `Model.schema`) can be reused directly across Ack's official adapter packages: + +| Adapter Package | Target Ecosystem | Primary Entry Point | +| --- | --- | --- | +| **`ack_mcp_dart`** | [Model Context Protocol (`mcp_dart`)](https://pub.dev/packages/mcp_dart) | `server.registerAckTool(...)`, `result.decodeAckOutput(...)` | +| **`ack_firebase_ai`** | [Firebase AI Logic (`firebase_ai` / Gemini)](https://pub.dev/packages/firebase_ai) | `schema.toFirebaseAiSchema()` | +| **`ack_json_schema_builder`** | [`json_schema_builder`](https://pub.dev/packages/json_schema_builder) | `schema.toJsonSchemaBuilder()`, `jsbSchema.toAckSchema()` | + +--- + +## 1. Define a Model or Schema Once + +```dart title="lib/weather_models.dart" +import 'package:ack/ack.dart'; + +part 'weather_models.g.dart'; + +@Schemable(description: 'Parameters for querying current weather.') +final class WeatherQuery with _$WeatherQueryAck { + const WeatherQuery({required this.city, this.units = 'celsius'}); + + /// @description City and country, e.g. "Tokyo, JP". + @Validate.minLength(2) + final String city; + + final String units; + + static final schema = _weatherQuerySchema; + static final fromJson = WeatherQuerySchema.fromJson; +} + +@Schemable(description: 'Current weather report.') +final class WeatherReport with _$WeatherReportAck { + const WeatherReport({required this.city, required this.temperatureC}); + + final String city; + final double temperatureC; + + static final schema = _weatherReportSchema; + static final fromJson = WeatherReportSchema.fromJson; +} +``` + +--- + +## 2. MCP Tool Servers (`ack_mcp_dart`) + +`ack_mcp_dart` bridges `AckSchema` with `McpServer` from `package:mcp_dart/mcp_dart.dart`. It exports the tool's JSON Schema automatically, validates incoming tool arguments before your callback runs, validates structured tool output, and formats validation failures as MCP `CallToolResult(isError: true)` responses. + +### Install + +```bash +dart pub add ack ack_mcp_dart mcp_dart +``` + +### Register a typed input + output tool + +Pass `WeatherQuery.schema` and `WeatherReport.schema` directly to `server.registerAckTool`: + +```dart +import 'package:ack_mcp_dart/ack_mcp_dart.dart'; +import 'package:mcp_dart/mcp_dart.dart'; + +import 'weather_models.dart'; + +void registerWeatherTools(McpServer server) { + server.registerAckTool( + 'get_weather', + description: 'Fetch the current weather for a city.', + inputSchema: WeatherQuery.schema, + outputSchema: WeatherReport.schema, + callback: (query, extra) async { + // `query` is already validated and decoded into a `WeatherQuery` + return WeatherReport( + city: query.city, + temperatureC: 21.5, + ); + }, + ); +} +``` + +Other registration and client helpers: +- `server.registerAckInputTool(name, inputSchema: ..., callback: ...)` — validates input only and returns a `CallToolResult`. +- `server.registerAckOutputTool(name, outputSchema: ..., callback: ...)` — takes no input arguments and validates structured output. +- `callToolResult.decodeAckOutput(WeatherReport.schema)` — validates and decodes `structuredContent` on the client side into a `SchemaResult`. + +--- + +## 3. Firebase AI / Gemini Structured Outputs (`ack_firebase_ai`) + +`ack_firebase_ai` converts any `AckSchema` into a `firebase_ai` `Schema` for Gemini structured JSON output or function calling, then lets you parse the model's response back through the same Ack schema. + +### Install + +```bash +flutter pub add ack ack_firebase_ai firebase_ai firebase_core +``` + +### Generate and validate structured output + +```dart +import 'dart:convert'; + +import 'package:ack_firebase_ai/ack_firebase_ai.dart'; +import 'package:firebase_ai/firebase_ai.dart'; + +import 'weather_models.dart'; + +Future askGeminiWeather(String prompt) async { + final model = FirebaseAI.googleAI().generativeModel( + model: 'gemini-2.5-flash', + generationConfig: GenerationConfig( + responseMimeType: 'application/json', + // Convert the Ack schema to a Firebase AI Schema + responseSchema: WeatherReport.schema.toFirebaseAiSchema(), + ), + ); + + final response = await model.generateContent([Content.text(prompt)]); + final jsonMap = jsonDecode(response.text!); + + // Validate and decode Gemini's response into WeatherReport + return WeatherReport.schema.parse(jsonMap); +} +``` + +> **Note:** Gemini's OpenAPI-based schema subset does not enforce every keyword (such as regex `pattern` or custom `.refine()` callbacks) during generation. `toFirebaseAiSchema()` appends human-readable constraint descriptions so the model has full context, and `WeatherReport.schema.parse(...)` enforces the full Ack contract on the response. + +--- + +## 4. `json_schema_builder` Bridge (`ack_json_schema_builder`) + +`ack_json_schema_builder` provides bidirectional conversion between `AckSchema` and `package:json_schema_builder`: + +### Install + +```bash +dart pub add ack ack_json_schema_builder json_schema_builder +``` + +### Convert in both directions + +```dart +import 'package:ack/ack.dart'; +import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; +import 'package:json_schema_builder/json_schema_builder.dart' as jsb; + +import 'weather_models.dart'; + +void main() { + // 1. AckSchema -> json_schema_builder Schema (preserves Draft 2020-12 by default) + final jsb.Schema builderSchema = WeatherQuery.schema.toJsonSchemaBuilder(); + + // Need legacy Draft-7 definitions lowering instead? + final jsb.Schema draft7Schema = WeatherQuery.schema.toJsonSchemaBuilderDraft7(); + + // 2. json_schema_builder Schema -> executable AckSchema + final AckSchema importedValidator = jsb.Schema.object( + properties: {'city': jsb.Schema.string(minLength: 2)}, + required: ['city'], + ).toAckSchema(assertFormats: true); + + final valid = importedValidator.parse({'city': 'Lisbon'}); + print(valid); +} +``` + +--- + +## Building Your Own Adapter Package + +Want to convert Ack schemas to another target format (OpenAPI, Protobuf, GraphQL, or another LLM SDK)? Traverse `schema.toSchemaModel()` (`AckSchemaModel`). See: +- [Adapter Package Quickstart](./schema-converter-quickstart.mdx) +- [Creating Adapter Packages](./creating-schema-converter-packages.mdx) diff --git a/docs/guides/json-schema-integration.mdx b/docs/guides/json-schema-integration.mdx index 9e278395..d6503680 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 @@ -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,54 @@ 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`, enable the opt-in `ack_generator:ack_json_schema` builder +in `build.yaml` to compile any `lib/**/*.schema.json` Draft 2020-12 asset into +Dart models: + +```yaml title="build.yaml" +targets: + $default: + builders: + ack_generator|ack_json_schema: + enabled: true + generate_for: + - lib/**/*.schema.json + options: + assert_formats: false +``` + +`ack_generator:ack_json_schema` emits `.schema.dart` (declaring +`part '.schema.g.dart';`), which `ack_generator:ack_generator` compiles +in the same `dart run build_runner build` pass: + +```dart +import 'person.schema.dart'; + +final person = Person.parse({'name': 'Ada', 'age': 36}); +print(person.name); // String +print(person.toJson()); // {'name': 'Ada', 'age': 36} +``` + +- **Typed `@Schemable()` models** are emitted for named non-null objects, + `$ref` definitions, inline objects, string-keyed maps, arrays with non-null + items, primitives/bounds, string enums, and simple field unions. +- **Validated value models** (wrapping `Ack.fromJsonSchema(...)`) are emitted + automatically for shapes that Dart classes cannot represent faithfully + without changing validation semantics (such as regex `pattern` assertions, + named scalar unions, recursive/dynamic `$dynamicRef` references, `oneOf`/`allOf`/`not` + composition, `multipleOf`, `unevaluatedProperties`, or `assert_formats: true`). +- For programmatic code generation outside `build_runner`, call + `generateAckSchemaLibrary()` from `package:ack_generator/ack_generator.dart`. ### Validation errors @@ -240,31 +258,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/docs/guides/schema-converter-quickstart.mdx b/docs/guides/schema-converter-quickstart.mdx index c4af868a..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: ^1.2.0 + 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: ^1.2.0 + ack: ^2.0.0-alpha.1 ack_: ^1.0.0 \`\`\` diff --git a/docs/index.mdx b/docs/index.mdx index ade846f8..95b29b65 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -45,6 +45,8 @@ On success, `getOrThrow()` returns your **validated data** as a `Map value.toString().length > 3, - message: "String representation must be longer than 3 characters", - ); - - print( - ' "hello" (length > 3): ${refinedAny.safeParse("hello").isOk ? "PASS" : "FAIL"}', - ); - print( - ' "hi" (length <= 3): ${refinedAny.safeParse("hi").isOk ? "PASS" : "FAIL"}', - ); - print( - ' 12345 (length > 3): ${refinedAny.safeParse(12345).isOk ? "PASS" : "FAIL"}', - ); - print( - ' 42 (length <= 3): ${refinedAny.safeParse(42).isOk ? "PASS" : "FAIL"}', - ); - - // JSON Schema generation - print('\nJSON Schema generation:'); - final describedSchema = Ack.any() - .describe("Accepts any value") - .withDefault("fallback"); - - print(' JSON Schema: ${describedSchema.toJsonSchema()}'); - - // Use cases - print('\n=== Use Cases ==='); - - // 1. Dynamic configuration - print('\n1. Dynamic configuration:'); - final configSchema = Ack.object({ - 'name': Ack.string(), - 'version': Ack.string(), - 'settings': Ack.any(), // Accept any configuration structure - }); - - final config1 = { - 'name': 'MyApp', - 'version': '1.0.0', - 'settings': {'theme': 'dark', 'debug': true}, - }; - - final config2 = { - 'name': 'MyApp', - 'version': '1.0.0', - 'settings': ['feature1', 'feature2', 'feature3'], - }; - - print( - ' Config with object settings: ${configSchema.safeParse(config1).isOk ? "VALID" : "INVALID"}', - ); - print( - ' Config with array settings: ${configSchema.safeParse(config2).isOk ? "VALID" : "INVALID"}', - ); - - // 2. API endpoints with flexible payloads - print('\n2. Flexible API payload:'); - final apiSchema = Ack.object({ - 'action': Ack.string(), - 'timestamp': Ack.string(), - 'payload': Ack.any(), // Accept any payload structure - }); - - final apiCall = { - 'action': 'user_update', - 'timestamp': '2024-01-01T00:00:00Z', - 'payload': { - 'userId': 123, - 'changes': {'name': 'John', 'email': 'john@example.com'}, - }, - }; - - print( - ' API call validation: ${apiSchema.safeParse(apiCall).isOk ? "VALID" : "INVALID"}', - ); - - // 3. Migration scenarios - print('\n3. Migration scenario:'); - final legacySchema = - Ack.object({ - 'id': Ack.string(), - 'data': Ack.any(), // Legacy data can be anything - }).refine( - (obj) => obj['id'] != null && (obj['id'] as String).isNotEmpty, - message: "ID must not be empty", - ); - - final legacyData = { - 'id': 'legacy-123', - 'data': 'some old string format', // Could be string, object, array, etc. - }; - - print( - ' Legacy data validation: ${legacySchema.safeParse(legacyData).isOk ? "VALID" : "INVALID"}', - ); -} 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..ce014f05 100644 --- a/example/lib/class_first_models.dart +++ b/example/lib/class_first_models.dart @@ -1,8 +1,6 @@ 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( @@ -17,12 +15,13 @@ final class Account with _$AccountAck { }); /// The name shown to other users. - @MinLength(2) - @AckField(description: 'The name shown to other users.') + /// @description The name shown to other users. + @Validate.minLength(2) final String displayName; final Uri? website; final String role; + static final schema = _accountSchema; static final fromJson = AccountSchema.fromJson; } @@ -41,8 +40,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) + @Validate.min(1) + @Validate.max(9) final int lives; } 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/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/enums.dart b/example/lib/enums.dart deleted file mode 100644 index 4ae49ebe..00000000 --- a/example/lib/enums.dart +++ /dev/null @@ -1,9 +0,0 @@ -/// Product status enum -enum ProductStatus { - draft, - published, - archived; - - @override - String toString() => name; -} 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..d393991a 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).cast(), + scores: (value['scores'] as List).cast(), + flags: (value['flags'] as List).cast(), + ); 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).cast()) + .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,25 @@ 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).cast(), + optionalTags: switch (value['optionalTags']) { + null => null, + final fieldValue => (fieldValue as List).cast(), + }, + nullableTags: switch (value['nullableTags']) { + null => null, + final fieldValue => (fieldValue as List).cast(), + }, + ); - 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 +971,6 @@ final class _$TaggedItemCopyWith implements $TaggedItemCopyWith { } /// Immutable model generated from `contactListSchema`. -@Schemable.generatedJson final class ContactList { /// Creates a model without validating it. /// @@ -1149,24 +1043,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 +1080,6 @@ final class _$ContactListCopyWith implements $ContactListCopyWith { } /// Immutable model generated from `emptySchema`. -@Schemable.generatedJson final class Empty { /// Creates a model without validating it. /// @@ -1251,12 +1139,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 +1161,6 @@ final class _$EmptyCopyWith implements $EmptyCopyWith { } /// Immutable model generated from `minimalSchema`. -@Schemable.generatedJson final class Minimal { /// Creates a model without validating it. /// @@ -1342,15 +1226,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 +1250,6 @@ final class _$MinimalCopyWith implements $MinimalCopyWith { } /// Immutable model generated from `namedItemSchema`. -@Schemable.generatedJson final class NamedItem { /// Creates a model without validating it. /// @@ -1438,15 +1315,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 +1339,6 @@ final class _$NamedItemCopyWith implements $NamedItemCopyWith { } /// Immutable model generated from `item`. -@Schemable.generatedJson final class Item { /// Creates a model without validating it. /// @@ -1533,15 +1403,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 +1427,6 @@ final class _$ItemCopyWith implements $ItemCopyWith { } /// Immutable model generated from `myCustomSchema123`. -@Schemable.generatedJson final class MyCustomSchema123 { /// Creates a model without validating it. /// @@ -1630,15 +1493,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..393adba1 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,37 @@ 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).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).cast(), + 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/example/pubspec.yaml b/example/pubspec.yaml index 03e2df87..0d0c188b 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -6,13 +6,14 @@ publish_to: none resolution: workspace environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.11.0 <4.0.0' + dependencies: - ack: ^1.2.0 + ack: ^2.0.0-alpha.1 dev_dependencies: - ack_generator: ^1.2.0 + 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/example/user_with_color_example.dart b/example/user_with_color_example.dart deleted file mode 100644 index b9047738..00000000 --- a/example/user_with_color_example.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'lib/pet.dart'; -import 'lib/user_with_color.dart'; - -void main() { - // Valid user data - final validData = { - 'firstName': 'Leo', - 'lastName': 'Farias', - 'age': 30, - 'profile': {'bio': 'Software engineer', 'website': 'https://example.com'}, - 'color': '#FF5733', - 'pet': {'type': 'cat', 'lives': 7}, - 'pets': [ - {'type': 'cat', 'lives': 9}, - {'type': 'dog', 'breed': 'Labrador'}, - ], - }; - - print('--- Valid data ---'); - final user = UserWithColor.parse(validData); - print('Name: ${user.firstName} ${user.lastName}'); - print('Age: ${user.age}'); - print('Bio: ${user.profile.bio}'); - print('Website: ${user.profile.website}'); - print('Color: ${user.color}'); - print( - 'Favorite color: ${user.favoriteColor}', - ); // null - optional, not provided - print(''); - - // Valid with favoriteColor provided - print('--- With favorite color ---'); - final withFav = {...validData, 'favoriteColor': '#00FF00'}; - final userFav = UserWithColor.parse(withFav); - print('Favorite color: ${userFav.favoriteColor}'); - print(''); - - // Nested discriminated pet - print('--- Nested discriminated pet ---'); - final userPet = user; - print('Pet type: ${userPet.pet.type}'); - print('Pets count: ${userPet.pets.length}'); - for (final p in userPet.pets) { - print(' - ${p.type}'); - } - // Can access discriminated subtype fields via cast - final cat = userPet.pet as Cat; - print('Cat lives: ${cat.lives}'); - print(''); - - // Invalid hex color - print('--- Invalid hex color ---'); - final badColor = {...validData, 'color': 'not-a-color'}; - final colorResult = UserWithColor.safeParse(badColor); - colorResult.match( - onOk: (val) => print('OK: $val'), - onFail: (error) => print('Error: $error'), - ); - print(''); - - // Age out of range - print('--- Age out of range ---'); - final badAge = {...validData, 'age': -5}; - final ageResult = UserWithColor.safeParse(badAge); - ageResult.match( - onOk: (val) => print('OK: $val'), - onFail: (error) => print('Error: $error'), - ); - print(''); - - // Missing profile bio - print('--- Missing profile bio ---'); - final badProfile = { - ...validData, - 'profile': {'website': 'https://example.com'}, - }; - final profileResult = UserWithColor.safeParse(badProfile); - profileResult.match( - onOk: (val) => print('OK: $val'), - onFail: (error) => print('Error: $error'), - ); -} diff --git a/llms.txt b/llms.txt index 98fa0dcc..f79f7a60 100644 --- a/llms.txt +++ b/llms.txt @@ -1,26 +1,20 @@ # 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`, `@AckKey`, and the +`@Validate.*` presence and 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 +23,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,33 +34,33 @@ 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. -- 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()` + 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({ @@ -98,20 +92,23 @@ 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'; -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) + @Validate.minLength(2) final String displayName; final String role; + static final schema = _accountSchema; static final fromJson = AccountSchema.fromJson; } ``` @@ -123,26 +120,34 @@ 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 `@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, 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`. -Use `@Schemable(description: ...)` and `@AckField(description: ...)` to export -schema descriptions. A single-line `@description` doc tag is a fallback. +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 `@AckKey('wire_name')` +for a single field's JSON key override. +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. -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 whose type declares a static `schema` resolves to it with no -annotation, also per `List` / `Set` item and as a `Map` value: +A field of custom type `T` (including inside `List`, `Set`, `Map`, +`JsonMaybe`, or generic type-owned schemas `Box`) resolves in priority order: + +1. **`@Schemable(schemas: [...])`** on the class, enclosing union base, current + `library;` directive, or an imported `library;` directive; +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. ```dart final class Slot { @@ -175,28 +180,23 @@ 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. - -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 @@ -206,7 +206,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 +222,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 (`@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, 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 +string-keyed maps), not an arbitrary Dart instance: | Dart field | Inferred schema | | --- | --- | @@ -240,6 +242,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 +274,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.6.x - -- 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 +281,29 @@ 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. + +## 1.x to 2.0 Substitutions (Do Not Use Removed 1.x Symbols) + +| Removed 1.x Symbol / Pattern | Ack 2.0 Replacement | +| --- | --- | +| `package:ack_annotations/*`, `package:ack/format_annotations.dart` | `import 'package:ack/ack.dart';` | +| `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()` | +| `@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')` / `@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, ...)` | +| `convertAckSchemaModelToBuilder(model)` (`ack_json_schema_builder`) | `schema.toJsonSchemaBuilder()` | diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index bce237cd..6a5b33c2 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,3 +1,67 @@ +## 2.0.0-alpha.1 + +### Breaking + +* Remove legacy 1.x generator annotations (`@AckType`, `@AckInfer`, `@AckModel`, + `@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`, + `@Matches`, `@NotEmpty`, `@Email`, `@Url`, `@Uuid`, `@Date`, `@DateTime`, + `@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`). +* 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`). + +### Added + +* 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`). +* Add `@Schemable(schemas: [...])` (supported on classes, sealed unions, and + `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` + 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`). + +### 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 (including source `allOf` when layering + conflicting constraints), and reject conflicting resource definitions. + ## 1.7.0 ### Breaking diff --git a/packages/ack/README.md b/packages/ack/README.md index acef23ba..965f8f97 100644 --- a/packages/ack/README.md +++ b/packages/ack/README.md @@ -69,7 +69,8 @@ The source document is a decoded map or boolean, not a JSON string. `Ack.fromJsonSchema()` returns an executable validator and rejects unsupported semantics with `JsonSchemaImportException`; its immutable diagnostics identify the source keyword and location. Supply reference bundles with `documents` and -`baseUri`; no references are fetched automatically. +`baseUri`; no references are fetched automatically. See +[`example/json_schema_import.dart`](example/json_schema_import.dart). Exports preserve supported validation behavior, not textual document identity. Full A2UI coverage is not supported. MCP registration compatibility remains diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index 090a4e86..816b7543 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 @@ -40,13 +41,13 @@ export 'src/schemas/schema.dart' Refinement, SchemaOperation, 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. Constraint annotations live in annotations.dart. +// Model annotations export 'src/annotations/ack_field.dart'; -export 'src/annotations/ack_model.dart' - show AckCaseStyle, AckUnknownPropertyPolicy; +export 'src/annotations/constraints.dart' show Validate; 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..33fdda2c 100644 --- a/packages/ack/lib/annotations.dart +++ b/packages/ack/lib/annotations.dart @@ -2,8 +2,6 @@ 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'; +export 'src/models/json_maybe.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 af4f7892..1f0fc756 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'; @@ -11,35 +10,39 @@ 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`. + /// 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. @@ -83,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'); @@ -161,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, @@ -182,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 @@ -200,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 @@ -224,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). @@ -240,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 @@ -261,8 +251,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/ack_field.dart b/packages/ack/lib/src/annotations/ack_field.dart index 54d62feb..991f8854 100644 --- a/packages/ack/lib/src/annotations/ack_field.dart +++ b/packages/ack/lib/src/annotations/ack_field.dart @@ -1,84 +1,22 @@ 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. +/// Annotation that overrides the wire JSON key for a class-first field or +/// constructor parameter. /// -/// [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. +/// ```dart +/// @Schemable() +/// final class Account with _$AccountAck { +/// const Account({required this.imageUrl}); /// -/// 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. +/// @AckKey('avatar') +/// final Uri imageUrl; +/// } +/// ``` @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 inferred schema and/or presence for a class-first 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, - }); - - /// Top-level schema-function tear-off followed by `ack_generator`. - final Object Function()? schema; - - /// Schema description for this property. - final String? description; +final class AckKey { + /// Creates a JSON key override annotation for a field or parameter. + const AckKey(this.name); - /// Presence override applied after constructor inference. - @Deprecated( - 'Use @Optional() or @Required(). AckField.presence will be removed in 2.0.0.', - ) - final AckFieldPresence presence; + /// Explicit wire JSON key for this property. + final String name; } 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..8893d76e 100644 --- a/packages/ack/lib/src/annotations/constraints.dart +++ b/packages/ack/lib/src/annotations/constraints.dart @@ -1,120 +1,110 @@ import 'package:meta/meta_meta.dart'; -/// Adds `.min(value)` to an inferred numeric schema. +/// 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({ +/// @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 Min { - const Min(this.value); +final class Validate { + /// The constraint identifier inspected by `ack_generator`. + final String kind; - final num value; -} + /// Optional constraint argument (`num`, `int`, or `String`). + final Object? value; -/// Adds `.max(value)` to an inferred numeric schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class Max { - const Max(this.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; - final num value; -} + /// 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; -/// Adds `.multipleOf(value)` to an inferred numeric schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class MultipleOf { - const MultipleOf(this.value); + /// 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; - final num value; -} + /// Adds `.min(value)` to an inferred numeric schema. + const Validate.min(num this.value) : kind = 'min'; -/// Adds `.positive()` to an inferred numeric schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class Positive { - const Positive(); -} + /// Adds `.max(value)` to an inferred numeric schema. + const Validate.max(num this.value) : kind = 'max'; -/// Adds `.negative()` to an inferred numeric schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class Negative { - const Negative(); -} + /// Adds `.multipleOf(value)` to an inferred numeric schema. + const Validate.multipleOf(num this.value) : kind = 'multipleOf'; -/// Adds `.minLength(length)` to an inferred string schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class MinLength { - const MinLength(this.length); + /// Adds `.positive()` to an inferred numeric schema. + const Validate.positive() : kind = 'positive', value = null; - final int length; -} + /// Adds `.negative()` to an inferred numeric schema. + const Validate.negative() : kind = 'negative', value = null; -/// Adds `.maxLength(length)` to an inferred string schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class MaxLength { - const MaxLength(this.length); + /// Adds `.minLength(length)` to an inferred string schema. + const Validate.minLength(int length) : kind = 'minLength', value = length; - final int length; -} + /// Adds `.maxLength(length)` to an inferred string schema. + const Validate.maxLength(int length) : kind = 'maxLength', value = length; -/// Adds `.matches(pattern)` to an inferred string schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class Matches { - const Matches(this.pattern); + /// Adds `.matches(pattern)` to an inferred string schema. + const Validate.matches(String pattern) : kind = 'matches', value = pattern; - final String pattern; -} + /// Adds `.notEmpty()` to an inferred string or collection (`List` / `Set`) schema. + const Validate.notEmpty() : kind = 'notEmpty', value = null; -/// 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. + const Validate.email() : kind = 'email', value = null; -/// Adds `.email()` to an inferred string schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class Email { - const Email(); -} + /// Adds `.url()` to an inferred string schema. + const Validate.url() : kind = 'url', value = null; -/// Adds `.url()` to an inferred string schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class Url { - const Url(); -} + /// Adds `.uri()` to an inferred string schema. + const Validate.uri() : kind = 'uri', value = null; -/// Adds `.uuid()` to an inferred string schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class Uuid { - const Uuid(); -} + /// Adds `.uuid()` to an inferred string schema. + const Validate.uuid() : kind = 'uuid', value = null; -/// Adds `.date()` to an inferred string schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class Date { - const Date(); -} + /// Adds `.date()` to an inferred string schema. + const Validate.date() : kind = 'date', value = null; -/// Adds `.notEmpty()` to an inferred string schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class NotEmpty { - const NotEmpty(); -} + /// Adds `.datetime()` to an inferred string schema. + const Validate.dateTime() : kind = 'dateTime', value = null; -/// Adds `.minItems(count)` to an inferred collection schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class MinItems { - const MinItems(this.count); + /// Adds `.ip()` to an inferred string schema. + const Validate.ip({int? version}) : kind = 'ip', value = version; - final int count; -} + /// Adds `.ipv4()` to an inferred string schema. + const Validate.ipv4() : kind = 'ipv4', value = null; -/// Adds `.maxItems(count)` to an inferred collection schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class MaxItems { - const MaxItems(this.count); + /// Adds `.ipv6()` to an inferred string schema. + const Validate.ipv6() : kind = 'ipv6', value = null; - final int count; -} + /// Adds `.minItems(count)` to an inferred collection schema. + const Validate.minItems(int count) : kind = 'minItems', value = count; -/// Adds `.unique()` to an inferred collection schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class UniqueItems { - const UniqueItems(); + /// Adds `.maxItems(count)` to an inferred collection schema. + const Validate.maxItems(int count) : kind = 'maxItems', value = count; + + /// Adds `.unique()` to an inferred collection schema. + const Validate.uniqueItems() : kind = 'uniqueItems', value = null; } 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 b098c76f..00000000 --- a/packages/ack/lib/src/annotations/format_constraints.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:meta/meta_meta.dart'; - -/// Adds `.uri()` to an inferred string schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class Uri { - const Uri(); -} - -/// Adds `.datetime()` to an inferred string schema. -@Target({TargetKind.field, TargetKind.parameter}) -final class DateTime { - const DateTime(); -} diff --git a/packages/ack/lib/src/annotations/schemable.dart b/packages/ack/lib/src/annotations/schemable.dart index 55dd1d97..5a4d2676 100644 --- a/packages/ack/lib/src/annotations/schemable.dart +++ b/packages/ack/lib/src/annotations/schemable.dart @@ -1,24 +1,38 @@ 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. +/// 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. -/// [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}) +/// 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 { - /// 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, @@ -29,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. @@ -37,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. @@ -55,4 +72,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/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 f34f281a..622a7374 100644 --- a/packages/ack/lib/src/json_schema/json_schema_compiler.dart +++ b/packages/ack/lib/src/json_schema/json_schema_compiler.dart @@ -1,54 +1,49 @@ 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')}'; } 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; - - /// Keyword values and sub-schemas in document order. [render] and - /// [dependencies] read these; validation reads the typed fields below. + final Uri dialectUri; + bool applicatorVocabulary = true; + bool unevaluatedVocabulary = true; + bool validationVocabulary = true; + bool formatAssertion = false; final keywords = {}; final children = {}; final maps = >{}; final lists = >{}; - - // Validation reads these typed fields, which the compiler assigns in the - // same branch that checks each keyword. - bool get isFalse => source == false; _ImportedNode? reference; - RegExp? pattern; + _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; - List<_ImportedNode>? allOf; - List<_ImportedNode>? anyOf; - List<_ImportedNode>? oneOf; - _ImportedNode? not; - _ImportedNode? ifNode; - _ImportedNode? thenNode; - _ImportedNode? elseNode; - _ImportedNode? items; - _ImportedNode? contains; - _ImportedNode? additionalProperties; - _ImportedNode? propertyNames; - Map? properties; - List? required; num? minimum; num? maximum; num? exclusiveMinimum; @@ -60,11 +55,34 @@ final class _ImportedNode { num? maxItems; num? minProperties; num? maxProperties; + num? minContains; + num? maxContains; bool uniqueItems = false; + RegExp? pattern; 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; List<_ImportedNode> get dependencies => [ ?reference, + ?dynamicReference, ...children.values, for (final map in maps.values) ...map.values, for (final list in lists.values) ...list, @@ -73,17 +91,54 @@ final class _ImportedNode { /// Sub-schemas applied to the same instance, for the cycle check. List<_ImportedNode> get inPlaceDependencies => [ ?reference, - ?not, - ?ifNode, - ?thenNode, - ?elseNode, - ...?anyOf, - ...?allOf, - ...?oneOf, + ?dynamicReference, + if (applicatorVocabulary) ...[ + ?not, + ?ifNode, + ?thenNode, + ?elseNode, + ...?anyOf, + ...?allOf, + ...?oneOf, + ...?dependentSchemas?.values, + ], ]; - /// Renders this node as Draft-7, with [refOf] supplying each `$ref` target. - Map render(String Function(_ImportedNode) refOf) { + /// 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 && + (source as Map).containsKey('format'))) { + throw UnsupportedError( + 'This imported schema needs 2020-12 semantics and cannot be lowered ' + 'with toJsonSchemaDraft7().', + ); + } if (isFalse) return {'not': {}}; // Empty enums are valid in 2020-12 but not Draft-7's meta-schema. if (keywords['enum'] case []) return {'not': {}}; @@ -104,10 +159,30 @@ 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 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 = + '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', @@ -117,21 +192,6 @@ final class _JsonSchemaCompiler { 'dependentSchemas', }; static const childKeywords = { - 'items', - 'additionalProperties', - 'not', - 'contains', - 'propertyNames', - 'unevaluatedProperties', - 'unevaluatedItems', - 'if', - 'then', - 'else', - }; - static const listKeywords = {'anyOf', 'allOf', 'oneOf'}; - - // The subsets of the indexing sets above that validation implements. - static const supportedChildKeywords = { 'items', 'additionalProperties', 'not', @@ -140,8 +200,10 @@ final class _JsonSchemaCompiler { 'if', 'then', 'else', + 'unevaluatedProperties', + 'unevaluatedItems', }; - static const supportedListKeywords = {'anyOf', 'allOf', 'oneOf'}; + static const listKeywords = {'anyOf', 'allOf', 'oneOf', 'prefixItems'}; static const annotations = { 'title', 'description', @@ -151,6 +213,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', @@ -166,29 +242,6 @@ final class _JsonSchemaCompiler { 'exclusiveMinimum', 'exclusiveMaximum', }; - static const supportedSchemaDialects = { - 'https://json-schema.org/draft/2020-12/schema', - 'https://json-schema.org/draft/2020-12/schema#', - '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#', - }; - static const supportedFormats = { - 'date-time', - 'date', - 'email', - 'uuid', - 'uri', - 'ipv4', - 'ipv6', - 'int32', - 'int64', - 'uint32', - 'uint64', - 'float', - 'double', - }; _ImportedNode addDocument(Object document, Uri uri) { if (uri.hasFragment && uri.fragment.isNotEmpty) { @@ -220,6 +273,7 @@ final class _JsonSchemaCompiler { uri.removeFragment(), '#', uri.removeFragment(), + dialect: standardDialect, isRoot: true, ); } @@ -229,10 +283,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.'); } @@ -245,37 +301,45 @@ 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 (!supportedSchemaDialects.contains(dialect)) { - _fail( - node, - r'$schema', - 'Only draft 2020-12 and draft-07 inputs are supported.', - code: 'unsupported_dialect', - ); - } - } 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 || !_anchorPattern.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; - 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.'); @@ -284,12 +348,19 @@ final class _JsonSchemaCompiler { _index( child.value ?? _invalidSchema(node, key), document, - '$path/${_importPointerToken(child.key)}', + '$path/${escapeJsonPointerToken(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.'); @@ -300,6 +371,7 @@ final class _JsonSchemaCompiler { document, '$path/$i', base, + dialect: dialect, ); } } @@ -332,21 +404,115 @@ 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); + } 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) { + if (draft7Dialects.contains(node.dialectUri)) { + node.applicatorVocabulary = true; + node.unevaluatedVocabulary = true; + node.validationVocabulary = true; + node.formatAssertion = assertFormats; + return; + } + _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', + ); + } + _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); + } + 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; } @@ -396,7 +562,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; } @@ -405,7 +571,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) { @@ -418,6 +592,7 @@ final class _JsonSchemaCompiler { r'$id', r'$schema', r'$anchor', + r'$dynamicAnchor', r'$defs', 'definitions', }.contains(key)) { @@ -425,14 +600,39 @@ 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'$vocabulary') { + if (value is! Map || + value.values.any((required) => required is! bool)) { + _fail(node, key, 'Expected a map of vocabulary URIs to booleans.'); + } + _checkRequiredVocabularies(node, key, value); node.keywords[key] = value; - } else if (key == r'$ref') { + } 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()]; @@ -465,43 +665,68 @@ final class _JsonSchemaCompiler { code: 'unresolved_reference', ); } - node.reference = target; - } else if (key == 'properties') { - node.properties = node.maps[key] = { - for (final name in (value as Map).keys) - name: _child(node, '$key/${_importPointerToken(name)}'), - }; - } else if (supportedChildKeywords.contains(key)) { - if ((key == 'items' && source.containsKey('prefixItems')) || - (key == 'additionalProperties' && - source.containsKey('patternProperties'))) { - _unsupported( - node, - key, - 'Cannot retain $key without its unsupported sibling.', - ); + if (key == r'$ref') { + node.reference = target; } else { - 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 'if': - node.ifNode = child; - case 'then': - node.thenNode = child; - case 'else': - node.elseNode = child; + node.dynamicReference = target; + if (!fragment.startsWith('/') && + target.dynamicAnchorName == fragment) { + node.dynamicReferenceName = fragment; } } - } else if (supportedListKeywords.contains(key)) { + } else if (key == 'properties' || + key == 'patternProperties' || + key == 'dependentSchemas') { + final compiledMap = { + for (final name in (value as Map).keys) + name: _child(node, '$key/${escapeJsonPointerToken(name)}'), + }; + 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 (listKeywords.contains(key)) { final targets = node.lists[key] = [ for (var i = 0; i < (value as List).length; i++) _child(node, '$key/$i'), @@ -513,6 +738,8 @@ final class _JsonSchemaCompiler { node.allOf = targets; case 'oneOf': node.oneOf = targets; + case 'prefixItems': + node.prefixItems = targets; } } else if (key == 'type') { const types = { @@ -537,13 +764,27 @@ 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 = _requireUniqueStringList( + node, + key, + value, + 'Expected a list of unique property names.', + ); + } else if (key == 'dependentRequired') { + if (value is! Map) { + _fail(node, key, 'Expected a map of unique property-name lists.'); } node.keywords[key] = value; - node.required = List.unmodifiable(value); + node.dependentRequired = { + for (final entry in value.entries) + entry.key: _requireUniqueStringList( + node, + key, + entry.value, + 'Expected a map of unique property-name lists.', + ), + }; } else if (key == 'enum') { if (value is! List) { _fail(node, key, 'Expected an enum array.'); @@ -561,7 +802,9 @@ final class _JsonSchemaCompiler { } else if (key == 'const') { node.keywords[key] = node.constValue = value; node.hasConst = true; - } else if (counts.contains(key)) { + } 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.'); } @@ -579,6 +822,10 @@ final class _JsonSchemaCompiler { 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.'); @@ -594,8 +841,8 @@ final class _JsonSchemaCompiler { node.exclusiveMaximum = value; } } else if (key == 'multipleOf') { - if (value is! num || value <= 0) { - _fail(node, key, 'Expected a positive number.'); + 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') { @@ -611,19 +858,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 (!supportedFormats.contains(value)) { - _unsupported(node, key, 'Format "$value" is not supported.'); - continue; - } - node.keywords[key] = node.format = value; - } else if (key.startsWith('x-')) { - 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..95be9ac8 --- /dev/null +++ b/packages/ack/lib/src/json_schema/json_schema_formats.dart @@ -0,0 +1,530 @@ +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', + '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' => isValidRfc3339DateTime(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' => StringIpConstraint.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), + '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; + 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 _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 _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 (!StringIpConstraint.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 StringIpConstraint.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/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 new file mode 100644 index 00000000..9d09c182 --- /dev/null +++ b/packages/ack/lib/src/models/json_maybe.dart @@ -0,0 +1,100 @@ +import 'package:meta/meta.dart'; + +import '../utils/collection_utils.dart'; + +/// 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(); + + /// Creates an omitted JSON property marker. + const factory JsonMaybe.absent() = JsonAbsent; + + /// Creates a present JSON property with [value] (which may be `null`). + const factory JsonMaybe.value(T? value) = JsonValue; + + /// 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. + 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/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index 25893fee..85e8b02b 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(); @@ -204,54 +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, - ), - 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()}; @@ -275,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 @@ -328,6 +295,181 @@ 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, + Set> indexedSchemas = const {}, + super.title, + super.description, + super.nullable, + super.defaultValue, + super.warnings, + super.extensions, + }) : _indexedSchemas = indexedSchemas; + + AckRawSchemaModel._( + _AckSchemaModelCommon common, { + required this.schema, + required Set> indexedSchemas, + }) : _indexedSchemas = indexedSchemas, + super._(common); + + final Map schema; + final Set> _indexedSchemas; + + @override + Map toJsonSchema() { + 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( + body, + Uri.parse(body[r'$id'] as String), + resourceKeywords, + _indexedSchemas, + ); + 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, + indexedSchemas: _indexedSchemas, + ); +} + +Map _relocateRootPointers( + Map source, + Uri rootUri, + Set retainedKeywords, + Set> indexedSchemas, +) { + 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'}; + + 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']; + 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 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) { + 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), + ]; + } + } + 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; +} + final class AckRefSchemaModel extends AckSchemaModel { const AckRefSchemaModel({ required this.refName, @@ -346,7 +488,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(); @@ -354,20 +498,16 @@ final class AckRefSchemaModel extends AckSchemaModel { return { ...commonJson, - 'allOf': [refJson], + 'allOf': [ + refJson, + if (commonJson['allOf'] case final List extra) ...extra, + ], }; } @override AckRefSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => AckRefSchemaModel._(common, refName: refName); - - AckSchemaModel _withJsonSchemaKeywords( - Map keywords, - Set commonHandled, - ) { - return _withUnhandledKeywords(keywords, commonHandled); - } } final class AckStringSchemaModel extends AckSchemaModel { @@ -378,8 +518,6 @@ final class AckStringSchemaModel extends AckSchemaModel { this.minLength, this.maxLength, this.pattern, - this.formatMinimum, - this.formatMaximum, super.title, super.description, super.nullable, @@ -396,8 +534,6 @@ final class AckStringSchemaModel extends AckSchemaModel { this.minLength, this.maxLength, this.pattern, - this.formatMinimum, - this.formatMaximum, }) : super._(common); @override @@ -407,21 +543,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({ @@ -444,81 +565,60 @@ final class AckStringSchemaModel extends AckSchemaModel { minLength: minLength, maxLength: maxLength, pattern: pattern, - formatMinimum: formatMinimum, - formatMaximum: formatMaximum, ); - AckStringSchemaModel _copyWith({ - String? format, - List? enumValues, - Object? constValue = _unset, - int? minLength, - int? maxLength, - String? pattern, - String? formatMinimum, - String? formatMaximum, - }) { - 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, - formatMinimum: formatMinimum ?? this.formatMinimum, - formatMaximum: formatMaximum ?? this.formatMaximum, - ); - } - + @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); - } - 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); + 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, @@ -534,8 +634,8 @@ final class AckIntegerSchemaModel extends AckSchemaModel { super.extensions, }); - AckIntegerSchemaModel._( - _AckSchemaModelCommon common, { + _AckNumericSchemaModel._( + super.common, { this.format, this.constValue, this.minimum, @@ -543,20 +643,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, @@ -567,8 +680,8 @@ final class AckIntegerSchemaModel extends AckSchemaModel { }); @override - AckIntegerSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckIntegerSchemaModel._( + _AckNumericSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + _rebuildNumeric( common, format: format, constValue: constValue, @@ -579,78 +692,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, @@ -659,112 +765,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 { @@ -793,6 +880,7 @@ final class AckBooleanSchemaModel extends AckSchemaModel { AckBooleanSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => AckBooleanSchemaModel._(common, constValue: constValue); + @override AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, @@ -853,42 +941,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); } } @@ -958,50 +1040,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); } } @@ -1022,17 +1094,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, @@ -1043,15 +1108,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, @@ -1059,7 +1126,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) => @@ -1068,19 +1157,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, @@ -1090,22 +1172,13 @@ final class AckOneOfSchemaModel extends AckSchemaModel { }); AckOneOfSchemaModel._( - _AckSchemaModelCommon common, { - required this.schemas, - this.discriminator, - }) : super._(common); - - final List schemas; - final AckSchemaDiscriminatorModel? discriminator; + super.common, { + required super.schemas, + super.discriminator, + }) : super._(); @override - Object? get _metadataEquality => { - if (discriminator != null) 'discriminator': discriminator, - }; - - @override - Map toJsonSchema() => - finishCompositionJson('oneOf', schemas); + String get _compositionKeyword => 'oneOf'; @override AckOneOfSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => @@ -1114,18 +1187,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, @@ -1134,25 +1200,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) { @@ -1169,7 +1224,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/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index f3f95df6..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 @@ -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'; @@ -17,18 +19,38 @@ extension AckSchemaModelExtension< Runtime extends Object > on AckSchema { - AckSchemaModel toSchemaModel() => _SchemaModelBuilder().build(this); + /// 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); + + /// 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 = true}); + + 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; + var _composingImports = false; + final _importedResources = >{}; + final _resourceLocations = {}; Map _mergeRootDefinitions(Object? existingDefinitions) { final schemaDefinitions = {}; @@ -84,15 +106,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 +177,43 @@ final class _SchemaModelBuilder { } AckSchemaModel _imported(ImportedJsonSchema schema) { - final prefix = '_ack_import_${_importCount++}_'; + final importIndex = _importCount++; + if (preserveImportedDialect) { + final indexedSchemas = HashSet>.identity(); + if (_composingImports) { + final document = schema.export2020Document( + importIndex: importIndex, + includeFluentNullability: false, + onSchema: indexedSchemas.add, + ); + return AckAllOfSchemaModel( + schemas: [ + AckRawSchemaModel( + schema: _shareImportedResources(document, indexedSchemas), + indexedSchemas: indexedSchemas, + ), + ], + description: schema.description, + nullable: schema.isNullable, + extensions: { + if (!schema.isNullable && schema.sourceAllowsNull) + 'not': const {'type': 'null'}, + }, + ); + } + return AckRawSchemaModel( + schema: schema.export2020Document( + importIndex: importIndex, + onSchema: indexedSchemas.add, + ), + indexedSchemas: indexedSchemas, + 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( @@ -183,6 +235,126 @@ 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) { + 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; + 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']; + 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 (resolvedValidId) 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.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(), + }; + } + _importedResources[uri] = value; + } + } + final rebuilt = _mapImportedChildren( + value, + (child, _) => share(child), + indexedSchemas, + ); + if (indexedSchemas.contains(value)) indexedSchemas.add(rebuilt); + return rebuilt; + } + + return share(normalize(document, base))! as Map; + } + AckSchemaModel _string(StringSchema schema) { return AckStringSchemaModel( description: schema.description, @@ -407,12 +579,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']), }); } } @@ -429,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; @@ -438,7 +629,7 @@ AckSchemaModel _applyConstraints( if (constraint is JsonSchemaSpec) { final keywords = constraint.toJsonSchema(); - if (!projectKeywords) { + if (!projectConstraint) { if (keywords.isNotEmpty) { omittedConstraintKeys.add(constraint.constraintKey); } @@ -462,6 +653,7 @@ AckSchemaModel _applyConstraints( next = next.withJsonSchemaKeywords(newKeywords); } if (conflicts.isNotEmpty) { + appliedKeywordValues.putIfAbsent('allOf', () => []); final existingAllOf = switch (next.extensions['allOf']) { final List value => value, _ => const [], @@ -568,3 +760,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/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/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/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/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 b3ff0081..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( @@ -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/extensions/string_schema_extensions.dart b/packages/ack/lib/src/schemas/extensions/string_schema_extensions.dart index cf097596..686d8633 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 { @@ -126,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 before validation. - /// Returns a one-way codec that applies String.trim() to the input. - CodecSchema trim() { - return transform(_trimString); - } - - /// 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 uppercase after validation. - /// Returns a one-way codec that applies String.toUpperCase() to the input. - CodecSchema toUpperCase() { - return transform(_uppercaseString); - } } - -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/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 633b88be..edf13037 100644 --- a/packages/ack/lib/src/schemas/imported_json_schema.dart +++ b/packages/ack/lib/src/schemas/imported_json_schema.dart @@ -14,33 +14,229 @@ 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)); + 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)) { + 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, + indexedNodes: compiler.locations.values.toList(growable: false), + assertFormats: assertFormats, + ); } ImportedJsonSchema._( this._root, { + required List<_ImportedNode> externalRoots, + required List<_ImportedNode> indexedNodes, + required this.assertFormats, bool? isNullable, super.isOptional, String? description, super.constraints, super.refinements, - }) : super( + }) : _externalRoots = externalRoots, + _indexedNodes = indexedNodes, + super( isNullable: isNullable ?? _checkImportedNode(_root, null) == null, description: description ?? _root.keywords['description'] as String?, ); final _ImportedNode _root; + final List<_ImportedNode> _externalRoots; + final List<_ImportedNode> _indexedNodes; + final bool assertFormats; + + Object get rawSchema => _root.source; + + 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/${escapeJsonPointerToken(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, + 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 = 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 || + _JsonSchemaCompiler.draft7Dialects.contains( + indexed.dialectUri, + )) && + (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 resourceNode in [_root, ..._externalRoots]) { + if (resourceNode.documentUri != resourceNode.baseUri && + resolved.removeFragment() == resourceNode.documentUri) { + exported[keyword] = resourceNode.baseUri + .replace( + fragment: resolved.hasFragment ? resolved.fragment : null, + ) + .toString(); + break; + } + } + } + } + return { + if (source is Map) ...source, + r'$id': (syntheticId ?? node.baseUri).toString(), + if (assertFormats && + (node.dialectUri == _JsonSchemaCompiler.standardDialect || + _JsonSchemaCompiler.draft7Dialects.contains(node.dialectUri))) + 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; + for (final external in _externalRoots) { + while (definitions.containsKey('_ack_document_$index')) { + index++; + } + final name = '_ack_document_${index++}'; + definitions[name] = resource(external); + } + 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 (includeFluentNullability && !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. @@ -111,34 +307,13 @@ final class ImportedJsonSchema extends AckSchema keywordLocation: [ ...violation.evaluationPath, if (violation.keyword.isNotEmpty) violation.keyword, - ].map((token) => '/${_importPointerToken(token)}').join(), + ].map((token) => '/${escapeJsonPointerToken(token)}').join(), ), ); } return applyConstraintsAndRefinements(value, context); } - /// Builds root-scoped definitions for the shared schema-model renderer. - @internal - Map> exportDefinitions(String prefix) { - final refs = <_ImportedNode, String>{}; - void visit(_ImportedNode node) { - if (refs.containsKey(node)) return; - final name = '$prefix${refs.length}'; - refs[node] = '#/definitions/${_importPointerToken(name)}'; - for (final child in node.dependencies) { - visit(child); - } - } - - visit(_root); - String refOf(_ImportedNode node) => refs[node]!; - return { - for (final (i, node) in refs.keys.indexed) - '$prefix$i': node.render(refOf), - }; - } - @override ImportedJsonSchema copyWith({ bool? isNullable, @@ -148,6 +323,9 @@ final class ImportedJsonSchema extends AckSchema List>? refinements, }) => ImportedJsonSchema._( _root, + externalRoots: _externalRoots, + indexedNodes: _indexedNodes, + assertFormats: assertFormats, isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, description: description ?? this.description, @@ -157,6 +335,31 @@ 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; + if (other is! ImportedJsonSchema) return false; + + return baseFieldsEqual(other) && + assertFormats == other.assertFormats && + _root.documentUri == other._root.documentUri && + deepEquals(rawSchema, other.rawSchema) && + deepEquals(_externalDocuments, other._externalDocuments); + } + + @override + int get hashCode => Object.hash( + baseFieldsHashCode, + assertFormats, + _root.documentUri, + deepHashCode(rawSchema), + deepHashCode(_externalDocuments), + ); } final class _ImportViolation { @@ -206,163 +409,382 @@ final class _ImportViolation { ); } -_ImportViolation? _checkImportedNode(_ImportedNode node, Object? value) { - _ImportViolation fail(String keyword) => _ImportViolation(node, keyword); - if (node.isFalse) return fail(''); - 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'); +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 (node.enumValues case final 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']); - } - if (node.allOf case final targets?) { - for (final (i, target) in targets.indexed) { - final error = _checkImportedNode(target, value); - if (error != null) return error.via(['allOf', '$i']); + final result = _evaluateImportedNode(target, value, currentScope); + if (result.error case final error?) { + return _ImportEvaluation(error.via(const [r'$ref'])); } + evaluated.include(result); } - if (node.anyOf case final branches?) { - if (!branches.any((n) => _checkImportedNode(n, value) == null)) { - return fail('anyOf'); + 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.oneOf case final branches?) { - var matches = 0; - for (final branch in branches) { - if (_checkImportedNode(branch, value) == null && ++matches > 1) break; + final result = _evaluateImportedNode(target, value, currentScope); + if (result.error case final error?) { + return _ImportEvaluation(error.via(const [r'$dynamicRef'])); } - if (matches != 1) return fail('oneOf'); + evaluated.include(result); } - if (node.not case final target?) { - if (_checkImportedNode(target, value) == null) return fail('not'); - } - if (node.ifNode case final condition?) { - final passed = _checkImportedNode(condition, value) == null; - final branch = passed ? node.thenNode : node.elseNode; - if (branch != null) { - final error = _checkImportedNode(branch, value); - if (error != null) return error.via([passed ? 'then' : 'else']); + 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); + } } - } - switch (value) { - case num(): - if (node.minimum case final limit? when value < limit) { - return fail('minimum'); + 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.maximum case final limit? when value > limit) { - return fail('maximum'); + 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.exclusiveMinimum case final limit? when value <= limit) { - return fail('exclusiveMinimum'); + 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.exclusiveMaximum case final limit? when value >= limit) { - return fail('exclusiveMaximum'); + } + 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); } - if (node.multipleOf case final divisor? - when !isExactDecimalMultipleOf(value, divisor)) { - return fail('multipleOf'); + } + } + switch (value) { + case num(): + if (node.validationVocabulary) { + if (node.multipleOf case final divisor? + when !isExactDecimalMultipleOf(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'); + } } - if (node.format case final format? - when !_isValidImportedNumericFormat(format, value)) { - return fail('format'); + if (node.formatAssertion) { + if (node.format case final format? + when !_isValidJsonSchemaNumericFormat(format, value)) { + return fail('format'); + } } case String(): - // 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.format case final format? - when !_isValidImportedStringFormat(format, value)) { - return fail('format'); + 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.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(): - 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 fail('required').at(key); + 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); + } + } + } } } - final properties = node.properties; - final additional = node.additionalProperties; - if (properties != null || additional != null) { - for (final MapEntry(:key, value: item) in value.entries) { - final declared = properties?[key]; - final target = declared ?? additional; - if (target == null) continue; - final error = _checkImportedNode(target, item); - if (error != null) { - return error.at( - key as String, - declared == null - ? const ['additionalProperties'] - : ['properties', key], + 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, ['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 (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.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 error = _checkImportedNode(target, key); - if (error != null) { - return error.atPropertyName(key as String, const ['propertyNames']); + 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); } } } case List(): - 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'); + if (node.validationVocabulary) { + final keyword = _sizeViolation( + value.length, + node.minItems, + node.maxItems, + 'minItems', + 'maxItems', + ); + if (keyword != null) return fail(keyword); + if (node.uniqueItems && !hasUniqueJsonItems(value)) { + return fail('uniqueItems'); + } + } + 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', ['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?) { - if (!value.any((item) => _checkImportedNode(target, item) == null)) { - return fail('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 (node.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.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); + } } } } - return null; + return evaluated; } /// The failing size keyword, if any. @@ -388,58 +810,3 @@ bool _isImportType(Object? type, Object? value) => switch (type) { 'object' => value is Map, _ => false, }; -final _importedBigIntInt64Min = BigInt.parse('-9223372036854775808'); -final _importedBigIntInt64Max = BigInt.parse('9223372036854775807'); -final _importedBigIntUint64Max = BigInt.parse('18446744073709551615'); - -bool _isValidImportedNumericFormat(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 >= _importedBigIntInt64Min && - intVal <= _importedBigIntInt64Max; - case 'uint64': - final intVal = _integralBigInt(value); - if (intVal == null) return false; - return intVal >= BigInt.zero && intVal <= _importedBigIntUint64Max; - 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 _isValidImportedStringFormat(String format, String value) => - switch (format) { - 'date-time' => isValidRfc3339DateTime(value), - 'date' => isValidIso8601Date(value), - 'email' => isValidEmailFormat(value), - 'uuid' => isValidUuidFormat(value), - 'uri' => isValidUriFormat(value), - 'ipv4' => StringIpConstraint.isIpv4(value), - 'ipv6' => StringIpConstraint.isIpv6(value), - // Numeric formats do not constrain string instances. - _ => true, - }; diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart index a79e61a2..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. @@ -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 8f36d61a..d37446ea 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -1,11 +1,15 @@ 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/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'; @@ -17,7 +21,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'; @@ -36,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}); @@ -71,17 +76,17 @@ 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; 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; @@ -130,8 +135,10 @@ abstract 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, @@ -150,10 +157,12 @@ abstract 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); @@ -175,11 +184,15 @@ abstract 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); @@ -223,9 +236,9 @@ abstract 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, @@ -236,7 +249,7 @@ abstract 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); @@ -248,15 +261,17 @@ abstract 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, @@ -271,9 +286,11 @@ abstract 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; @@ -311,8 +328,10 @@ abstract 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, @@ -493,22 +512,13 @@ abstract 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(); - 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) { @@ -526,7 +536,7 @@ abstract 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 8342661e..1d1f0b52 100644 --- a/packages/ack/lib/src/schemas/string_schema.dart +++ b/packages/ack/lib/src/schemas/string_schema.dart @@ -1,16 +1,84 @@ 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; + + @internal + int? get firstPostNormalizerConstraintIndex => + _normalizers.isEmpty ? null : _normalizers.first.constraintIndex; + + 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 +99,68 @@ 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, + }) { + return applyConstraintsAndRefinements( + value, + context, + constraints: _constraints.getRange(constraintStart, constraintEnd), + refinements: _refinements.getRange(refinementStart, refinementEnd), + ); } + @override + @protected + SchemaResult encodeWithContext(String value, SchemaContext context) => + validateRuntimeWithContext(value, context); + @override StringSchema copyWith({ bool? isNullable, @@ -42,12 +169,32 @@ final class StringSchema extends AckSchema List>? constraints, List>? refinements, }) { - return StringSchema( + 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, + constraints: nextConstraints, + refinements: nextRefinements, + normalizers: nextNormalizers, ); } @@ -56,12 +203,37 @@ 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), + ); + + /// 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); + + /// 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); + + /// 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/schemas/testing/testing_schemas.dart b/packages/ack/lib/src/schemas/testing/testing_schemas.dart deleted file mode 100644 index 7505329d..00000000 --- a/packages/ack/lib/src/schemas/testing/testing_schemas.dart +++ /dev/null @@ -1,58 +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; -} 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/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/pubspec.yaml b/packages/ack/pubspec.yaml index dab8d1a4..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: 1.7.0 +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 @@ -14,10 +14,13 @@ 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 + 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..99812e61 100644 --- a/packages/ack/test/annotations/ack_model_test.dart +++ b/packages/ack/test/annotations/ack_model_test.dart @@ -1,11 +1,6 @@ -// 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'; -Object _customSchema() => Object(); - void main() { test( 'Schemable options are const and default to class-first conventions', @@ -20,6 +15,7 @@ void main() { expect(defaults.captureField, 'additionalProperties'); const configured = Schemable( + name: 'WireUser', schemaName: 'WireUserSchema', caseStyle: AckCaseStyle.snake, discriminatorKey: 'type', @@ -27,6 +23,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'); @@ -48,54 +45,72 @@ void main() { }, ); - test('AckField accepts a schema tear-off and a presence override', () { - const inferred = AckField(schema: _customSchema); - expect(inferred.schema, same(_customSchema)); - expect(inferred.presence, AckFieldPresence.inferred); - - 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, - ]); + test('AckKey accepts a positional wire key name', () { + const key = AckKey('wire_key'); + expect(key.name, 'wire_key'); }); - 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('constraint sugar annotations are const data', () { - const annotations = [ - Min(1), - Max(9), - MultipleOf(2), - Positive(), - Negative(), - MinLength(1), - MaxLength(100), - Pattern(r'^[a-z]+$'), - Email(), - NotEmpty(), - MinItems(1), - MaxItems(10), - UniqueItems(), - ]; + 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), + 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.ip(), + Validate.ip(version: 4), + Validate.ipv4(), + Validate.ipv6(), + Validate.minItems(1), + Validate.maxItems(10), + Validate.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 Pattern).pattern, r'^[a-z]+$'); - expect((annotations[10] as MinItems).count, 1); - expect((annotations[11] as MaxItems).count, 10); - }); + 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), + ('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), + ('ip', null), + ('ip', 4), + ('ipv4', null), + ('ipv6', null), + ('minItems', 1), + ('maxItems', 10), + ('uniqueItems', null), + ], + ); + }, + ); } 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/dart_core_names_test.dart b/packages/ack/test/annotations/dart_core_names_test.dart index 6670b1ae..fcde020c 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}); + + @AckKey('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 7d4cee43..d1c34a77 100644 --- a/packages/ack/test/annotations/exports_test.dart +++ b/packages/ack/test/annotations/exports_test.dart @@ -4,36 +4,58 @@ import 'package:test/test.dart'; import 'own_types.dart'; +@ack.Schemable(caseStyle: ack.AckCaseStyle.snake) final class Contact { const Contact(this.email); - @ack.Email() + /// @description Primary email + @ack.AckKey('email_address') + @ack.Validate.email() final Email email; } +@Schemable() +final class SingleImportContact { + const SingleImportContact(this.email, this.id); + + /// @description Contact email + @AckKey('email') + @Validate.email() + final Email email; + + @Validate.uuid() + final Uuid id; +} + void main() { - test('package:ack/ack.dart exports the model annotations', () { + test('package:ack/ack.dart exports the model and Validate annotations', () { const values = [ Schemable(caseStyle: AckCaseStyle.snake), - AckField(description: 'id'), - Required(), - Optional(), - NotNull(), + AckKey('id'), + Validate.required(), + Validate.optional(), + Validate.notNull(), + Validate.email(), + Validate.minLength(2), + Validate.matches(r'^\d+$'), + ack.Validate.uuid(), ]; - expect(values, hasLength(5)); + expect(values, hasLength(9)); expect(AckUnknownPropertyPolicy.values, hasLength(3)); }); 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); expect( [ @@ -42,7 +64,10 @@ void main() { url.href, date.iso, min.value, + max.value, pattern.source, + contact.email.address, + contact.id.value, ], [ '7c9e6679', @@ -50,24 +75,29 @@ void main() { 'https://example.com', '2026-10-01', 1, + 10, '^a', + 'ada@example.com', + '7c9e6679', ], ); }, ); - 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('the deprecated Pattern spelling creates a Matches annotation', () { - // ignore: deprecated_member_use_from_same_package - const annotation = ack.Pattern('^a'); + test( + 'a prefixed annotations import exposes Schemable, AckKey, and Validate', + () { + const contact = Contact(Email('ada@example.com')); + const schemable = ack.Schemable(name: 'Contact'); + const key = ack.AckKey('email_address'); + 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(key.name, 'email_address'); + 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/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/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/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/fixtures/json_schema_import/README.md b/packages/ack/test/fixtures/json_schema_import/README.md index 0fc685b6..d2f293f8 100644 --- a/packages/ack/test/fixtures/json_schema_import/README.md +++ b/packages/ack/test/fixtures/json_schema_import/README.md @@ -3,9 +3,8 @@ 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 211 test groups (690 instance cases) that use -the importer's supported subset, selected from `tests/draft2020-12/*.json` and -`tests/draft2020-12/optional/float-overflow.json`. +`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 group's `documents` map, keyed by the upstream test-server URI, for offline use. @@ -14,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 66295a47..9cffd744 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 } ] 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/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..2c1e1d16 --- /dev/null +++ b/packages/ack/test/json_schema_2020_12_full_conformance_test.dart @@ -0,0 +1,57 @@ +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.toJsonSchema()); + 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_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_dynamic_ref_conformance_test.dart b/packages/ack/test/json_schema_dynamic_ref_conformance_test.dart new file mode 100644 index 00000000..72e4c742 --- /dev/null +++ b/packages/ack/test/json_schema_dynamic_ref_conformance_test.dart @@ -0,0 +1,62 @@ +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.toJsonSchema()); + 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..bebdfbf4 --- /dev/null +++ b/packages/ack/test/json_schema_format_conformance_test.dart @@ -0,0 +1,96 @@ +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.toJsonSchema()); + 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.toJsonSchema()); + 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 7705464c..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': 'hostname'}, - }, - }, - }, - ); - 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': 'hostname'}, - }, - }).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 4f67d4c5..298f31b5 100644 --- a/packages/ack/test/json_schema_import_api_test.dart +++ b/packages/ack/test/json_schema_import_api_test.dart @@ -10,10 +10,23 @@ void main() { final Map modelExport = schema .toSchemaModel() .toJsonSchema(); + final Map draft7Export = schema.toJsonSchemaDraft7(); - expect(exported['type'], isNull); - expect(exported['definitions'], isA>()); + 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((draft7Export['definitions'] as Map)['_ack_import_0_0'], { + 'type': 'string', + 'minLength': 2, + }); expect(schema.safeParse('Ada').isOk, isTrue); expect(schema.safeParse('A').isFail, isTrue); }); @@ -90,17 +103,19 @@ void main() { } }); - test('unsupported assertions expose immutable diagnostics', () { - final document = {'type': 'string', 'format': 'hostname'}; + 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 +137,33 @@ void main() { expect(never.safeParse(null).isFail, isTrue); 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', + 'type': 'object', + 'properties': { + 'next': {r'$dynamicRef': '#node'}, + }, + }); + expect(schema.toJsonSchema()[r'$dynamicAnchor'], 'node'); + expect(schema.toJsonSchemaDraft7, throwsUnsupportedError); + }); } 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..358ad7d6 --- /dev/null +++ b/packages/ack/test/json_schema_import_composition_test.dart @@ -0,0 +1,226 @@ +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); + }); + + 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 + 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'}; +} + +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}; +} diff --git a/packages/ack/test/json_schema_import_conformance_test.dart b/packages/ack/test/json_schema_import_conformance_test.dart index 6ea7ac15..698fabfc 100644 --- a/packages/ack/test/json_schema_import_conformance_test.dart +++ b/packages/ack/test/json_schema_import_conformance_test.dart @@ -35,32 +35,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 dae3c593..bac3b1f8 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,31 +90,23 @@ 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. 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); @@ -113,6 +122,70 @@ 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.toJsonSchema()); + 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.toJsonSchema()); + 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.toJsonSchema()); + 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', () { @@ -164,7 +237,7 @@ 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); @@ -177,10 +250,8 @@ 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.toJsonSchema()['enum'], [1, 1.0, 'x', 'x']); + expect(never.toJsonSchema()['enum'], isEmpty); }); test('pattern and propertyNames validate and export verbatim', () { @@ -200,35 +271,47 @@ void main() { 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, - ); + 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.toJsonSchema()).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.toJsonSchema()); + 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({ @@ -257,27 +340,6 @@ void main() { throwsA(isA()), ); } - - for (final draft07Uri 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 draft07Schema = Ack.fromJsonSchema({ - r'$schema': draft07Uri, - 'definitions': { - 'item': {'type': 'string', 'minLength': 2}, - }, - 'type': 'object', - 'properties': { - 'name': {r'$ref': '#/definitions/item'}, - }, - 'required': ['name'], - }); - expect(draft07Schema.safeParse({'name': 'Ada'}).isOk, isTrue); - expect(draft07Schema.safeParse({'name': 'A'}).isFail, isTrue); - } }); test('attributes duplicate anchors to the second anchor keyword', () { @@ -366,200 +428,471 @@ 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'], - }, - }, - }, - }); - 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( - error.toMap(), - allOf( - containsPair('keyword', 'enum'), - containsPair('documentUri', 'ack-import:///root.json'), - containsPair('pointer', '#/properties/tags/items/enum'), - ), - ); - }); - - 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}, - }, - }, - 'required': ['a'], - }, - baseUri: Uri.parse('https://example.test/root.json'), - documents: { - Uri.parse('https://example.test/defs.json'): { - r'$defs': { - 'a/b': {'type': 'string'}, - }, - }, + 'child': {r'$ref': '#'}, }, - ); - 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'); + var value = {}; + for (var i = 0; i < 150; i++) { + value = {'child': value}; + } + expect(schema.safeParse(value).isOk, isTrue); }); - group('error locations use URI fragment encoding', () { - for (final entry in { - '%': '%25', - '%2F': '%252F', - ' ': '%20', - '#': '%23', - '☃😀': '%E2%98%83%F0%9F%98%80', - '/': '~1', - '~': '~0', - '~1': '~01', - }.entries) { - test('runtime keyword location for "${entry.key}"', () { + 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({ - 'properties': { - entry.key: {'type': 'string'}, + r'$defs': { + entry.value: {'const': 'yes'}, }, + r'$ref': entry.key, }); - expect(schema.safeParse({entry.key: 'ok'}).isOk, isTrue); - for (final result in [ - schema.safeParse({entry.key: 42}), - schema.safeEncode({entry.key: 42}), - ]) { - final error = result.getError() as JsonSchemaValidationError; - final pointer = '#/properties/${entry.value}/type'; - expect(error.keyword, 'type'); - expect(error.pointer, pointer); - expect(error.toMap(), containsPair('pointer', pointer)); - expect(error.value, 42); - } - }); + expect(schema.safeParse('yes').isOk, isTrue, reason: entry.key); + expect(schema.safeParse('no').isFail, isTrue, reason: entry.key); + } + }, + ); - test('referenced false schema location for "${entry.key}"', () { - final documentUri = Uri.parse('https://example.test/defs.json'); - final schema = Ack.fromJsonSchema( - {r'$ref': 'child.json#/properties/${entry.value}'}, - baseUri: Uri.parse('https://example.test/root.json'), - documents: { - documentUri: { - r'$defs': { - 'resource': { - r'$id': 'child.json', - 'properties': {entry.key: false}, - }, + 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'], }, }, - ); - final error = - schema.safeParse(42).getError() as JsonSchemaValidationError; - expect(error.keyword, isEmpty); - expect(error.documentUri, documentUri); - expect(error.pointer, '#/\$defs/resource/properties/${entry.value}'); - expect(error.path, '#'); - }); - - test('import diagnostic location for "${entry.key}"', () { - expect( - () => Ack.fromJsonSchema({ - 'properties': { - entry.key: {'type': 'invalid'}, - }, - }), - throwsA( - isA().having( - (error) => error.diagnostics.single.pointer, - 'pointer', - '#/properties/${entry.value}/type', - ), - ), - ); - }); - } - - test('root false schema keeps the empty fragment', () { - final error = - Ack.fromJsonSchema(false).safeParse(42).getError() - as JsonSchemaValidationError; - expect(error.keyword, isEmpty); - expect(error.pointer, '#'); - }); - }); - - group('keywordLocation follows the evaluation path', () { - const draft = 'https://json-schema.org/draft/2020-12/schema'; - String keywordLocation(Map source, Object value) => - (Ack.fromJsonSchema(source).safeParse(value).getError() - as JsonSchemaValidationError) - .keywordLocation; - - test('separates propertyNames from additionalProperties', () { - final schema = Ack.fromJsonSchema({ - r'$schema': draft, - r'$defs': { - 'short': {'type': 'string', 'maxLength': 3}, + r'$ref': 'child/index.json', + 'default': {r'$id': 'child/index.json'}, }, - 'properties': { - 'names': { - 'propertyNames': {r'$ref': r'#/$defs/short'}, - }, - 'values': { - 'additionalProperties': {r'$ref': r'#/$defs/short'}, + documents: { + Uri.parse('https://example.test/child/value.json'): { + 'type': 'boolean', }, }, - }); - JsonSchemaValidationError failure(Object value) => - schema.safeParse(value).getError() as JsonSchemaValidationError; + ); + expect(schema.safeParse({'x': true}).isOk, isTrue); + expect(schema.safeParse({'x': 1}).isFail, isTrue); + }, + ); - final name = failure({ - 'names': {'abcd': 'x'}, - }); - final value = failure({ - 'values': {'abcd': 'abcd'}, - }); + 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( - name.keywordLocation, - r'/properties/names/propertyNames/$ref/maxLength', + Ack.fromJsonSchema( + document, + baseUri: uri, + documents: {uri: document}, + ).safeParse('ok').isOk, + isTrue, ); - expect( - value.keywordLocation, + }, + ); + + 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( + '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('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.toJsonSchema(); + for (final key in [ + 'format', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + 'x-custom', + ]) { + expect(imported[key], document[key], reason: key); + } + expect( + Ack.fromJsonSchema(schema.toJsonSchema()).safeParse('invalid').isOk, + isTrue, + ); + }, + ); + + test('unknown required vocabularies remain unsupported', () { + expect( + () => Ack.fromJsonSchema({ + r'$vocabulary': {'https://example.test/required': true}, + }), + throwsA(isA()), + ); + }); + + 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.toJsonSchema()[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'], + }, + 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'); + }, + ); + + group('error locations use URI fragment encoding', () { + for (final entry in { + '%': '%25', + '%2F': '%252F', + ' ': '%20', + '#': '%23', + '☃😀': '%E2%98%83%F0%9F%98%80', + '/': '~1', + '~': '~0', + '~1': '~01', + }.entries) { + test('runtime keyword location for "${entry.key}"', () { + final schema = Ack.fromJsonSchema({ + 'properties': { + entry.key: {'type': 'string'}, + }, + }); + expect(schema.safeParse({entry.key: 'ok'}).isOk, isTrue); + for (final result in [ + schema.safeParse({entry.key: 42}), + schema.safeEncode({entry.key: 42}), + ]) { + final error = result.getError() as JsonSchemaValidationError; + final pointer = '#/properties/${entry.value}/type'; + expect(error.keyword, 'type'); + expect(error.pointer, pointer); + expect(error.toMap(), containsPair('pointer', pointer)); + expect(error.value, 42); + } + }); + + test('referenced false schema location for "${entry.key}"', () { + final documentUri = Uri.parse('https://example.test/defs.json'); + final schema = Ack.fromJsonSchema( + {r'$ref': 'child.json#/properties/${entry.value}'}, + baseUri: Uri.parse('https://example.test/root.json'), + documents: { + documentUri: { + r'$defs': { + 'resource': { + r'$id': 'child.json', + 'properties': {entry.key: false}, + }, + }, + }, + }, + ); + final error = + schema.safeParse(42).getError() as JsonSchemaValidationError; + expect(error.keyword, isEmpty); + expect(error.documentUri, documentUri); + expect(error.pointer, '#/\$defs/resource/properties/${entry.value}'); + expect(error.path, '#'); + }); + + test('import diagnostic location for "${entry.key}"', () { + expect( + () => Ack.fromJsonSchema({ + 'properties': { + entry.key: {'type': 'invalid'}, + }, + }), + throwsA( + isA().having( + (error) => error.diagnostics.single.pointer, + 'pointer', + '#/properties/${entry.value}/type', + ), + ), + ); + }); + } + + test('root false schema keeps the empty fragment', () { + final error = + Ack.fromJsonSchema(false).safeParse(42).getError() + as JsonSchemaValidationError; + expect(error.keyword, isEmpty); + expect(error.pointer, '#'); + }); + }); + + group('keywordLocation follows the evaluation path', () { + const draft = 'https://json-schema.org/draft/2020-12/schema'; + String keywordLocation(Map source, Object value) => + (Ack.fromJsonSchema(source).safeParse(value).getError() + as JsonSchemaValidationError) + .keywordLocation; + + test('separates propertyNames from additionalProperties', () { + final schema = Ack.fromJsonSchema({ + r'$schema': draft, + r'$defs': { + 'short': {'type': 'string', 'maxLength': 3}, + }, + 'properties': { + 'names': { + 'propertyNames': {r'$ref': r'#/$defs/short'}, + }, + 'values': { + 'additionalProperties': {r'$ref': r'#/$defs/short'}, + }, + }, + }); + JsonSchemaValidationError failure(Object value) => + schema.safeParse(value).getError() as JsonSchemaValidationError; + + final name = failure({ + 'names': {'abcd': 'x'}, + }); + final value = failure({ + 'values': {'abcd': 'abcd'}, + }); + expect( + name.keywordLocation, + r'/properties/names/propertyNames/$ref/maxLength', + ); + expect( + value.keywordLocation, r'/properties/values/additionalProperties/$ref/maxLength', ); expect(name.pointer, value.pointer); @@ -619,263 +952,62 @@ void main() { {'type': 'string'}, {'type': 'boolean'}, ], - }, - }, - ['x', 1], - ), - '/items/anyOf', - ); - }); - - test('escapes reference tokens without percent-encoding', () { - expect( - keywordLocation( - { - 'properties': { - 'a/b': { - 'properties': { - '~ %': {'type': 'string'}, - }, - }, - }, - }, - { - 'a/b': {'~ %': 1}, - }, - ), - '/properties/a~1b/properties/~0 %/type', - ); - }); - - test('is empty when the root schema is false', () { - final error = - Ack.fromJsonSchema(false).safeParse(42).getError() - as JsonSchemaValidationError; - expect(error.keywordLocation, isEmpty); - expect(error.toMap(), containsPair('keywordLocation', '')); - }); - - test('appears in toMap', () { - final error = - Ack.fromJsonSchema({ - 'properties': { - 'a': {r'$ref': r'#/$defs/s'}, - }, - r'$defs': { - 's': {'type': 'string'}, - }, - }).safeParse({'a': 1}).getError() - as JsonSchemaValidationError; - expect( - error.toMap(), - allOf( - containsPair('keywordLocation', r'/properties/a/$ref/type'), - containsPair('pointer', r'#/$defs/s/type'), - ), - ); - }); - }); - - 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); - } - }, - ); + }, + }, + ['x', 1], + ), + '/items/anyOf', + ); + }); - 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', + test('escapes reference tokens without percent-encoding', () { + expect( + keywordLocation( + { + 'properties': { + 'a/b': { 'properties': { - 'value': {'type': 'integer'}, - 'next': {r'$ref': r'#/$defs/a~1b~0c'}, + '~ %': {'type': 'string'}, }, - 'required': ['value'], - 'additionalProperties': false, }, }, }, - }, + { + 'a/b': {'~ %': 1}, + }, + ), + '/properties/a~1b/properties/~0 %/type', ); - 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('is empty when the root schema is false', () { + final error = + Ack.fromJsonSchema(false).safeParse(42).getError() + as JsonSchemaValidationError; + expect(error.keywordLocation, isEmpty); + expect(error.toMap(), containsPair('keywordLocation', '')); + }); + + test('appears in toMap', () { + final error = + Ack.fromJsonSchema({ + 'properties': { + 'a': {r'$ref': r'#/$defs/s'}, + }, + r'$defs': { + 's': {'type': 'string'}, + }, + }).safeParse({'a': 1}).getError() + as JsonSchemaValidationError; + expect( + error.toMap(), + allOf( + containsPair('keywordLocation', r'/properties/a/$ref/type'), + containsPair('pointer', r'#/$defs/s/type'), + ), + ); + }); + }); test('conditional reference cycles are nonproductive', () { for (final keyword in ['if', 'then', 'else']) { @@ -976,305 +1108,232 @@ void main() { ); test( - 'reports meta-schema references as unsupported, not as any schema', + '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( - () => Ack.fromJsonSchema({ - r'$ref': 'https://json-schema.org/draft/2020-12/schema', - }), - throwsA( - isA().having( - (error) => error.diagnostics.single.keyword, - 'keyword', - r'$ref', - ), - ), + schema.safeParse({'email': 'alice@example.com', 'extra': 123}).isFail, + isTrue, ); - }, - ); + expect(schema.safeParse({'email': 'invalid'}).isFail, isTrue); - test('strict mode reports unsupported keywords at escaped locations', () { - final document = { - 'properties': { - 'a/b': {'format': 'hostname', 'customKeyword': true}, - }, - }; - try { - Ack.fromJsonSchema(document); - fail('Expected unsupported keyword diagnostics.'); - } on JsonSchemaImportException catch (error) { + final roundTripped = Ack.fromJsonSchema(schema.toJsonSchema()); expect( - error.diagnostics.map((diagnostic) => diagnostic.pointer), - containsAll([ - '#/properties/a~1b/format', - '#/properties/a~1b/customKeyword', - ]), + 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( - 'supports multipleOf with exact decimals, tracks keywordLocation, and round-trips', + 'ImportedJsonSchema equality and hashCode include baseUri and external documents', () { - final intRoundTrip = Ack.fromJsonSchema( - Ack.integer().multipleOf(5).toJsonSchema(), - ); - expect(intRoundTrip.safeParse(15).isOk, isTrue); - expect(intRoundTrip.safeParse(0).isOk, isTrue); - expect(intRoundTrip.safeParse(-10).isOk, isTrue); - final intFail = intRoundTrip.safeParse(14).getError(); - expect( - intFail, - isA() - .having((e) => e.keyword, 'keyword', 'multipleOf') - .having((e) => e.pointer, 'pointer', '#/multipleOf') - .having( - (e) => e.keywordLocation, - 'keywordLocation', - '/multipleOf', - ), + 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 doubleRoundTrip = Ack.fromJsonSchema( - Ack.double().multipleOf(0.01).toJsonSchema(), + 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'}, + }, ); - expect(doubleRoundTrip.safeParse(0.03).isOk, isTrue); - expect(doubleRoundTrip.safeParse(12.34).isOk, isTrue); - expect(doubleRoundTrip.safeParse(0.035).isFail, isTrue); - expect(doubleRoundTrip.safeParse(1e308).isOk, isTrue); - expect(doubleRoundTrip.safeParse(-0.0).isOk, isTrue); - expect(doubleRoundTrip.safeParse(1e-9).isFail, isTrue); - - final refSchema = Ack.fromJsonSchema({ - r'$defs': { - 'step': {'type': 'number', 'multipleOf': 0.1}, + 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'}, }, - 'properties': { - 'amount': {r'$ref': r'#/$defs/step'}, + ); + 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(refSchema.safeParse({'amount': 0.3}).isOk, isTrue); - final refFail = refSchema.safeParse({'amount': 0.35}).getError(); - expect( - refFail, - isA() - .having((e) => e.keyword, 'keyword', 'multipleOf') - .having((e) => e.pointer, 'pointer', r'#/$defs/step/multipleOf') - .having( - (e) => e.keywordLocation, - 'keywordLocation', - r'/properties/amount/$ref/multipleOf', - ), ); - }, - ); - test( - 'native and imported multipleOf both divide exactly on decimal digits', - () { - final native = Ack.double().multipleOf(0.1); - final imported = Ack.fromJsonSchema(native.toJsonSchema()); - final computed = 0.1 + 0.2; - - expect(computed, 0.30000000000000004); - expect(native.safeParse(computed).isFail, isTrue); - expect(imported.safeParse(computed).isFail, isTrue); - expect(native.safeParse(0.3).isOk, isTrue); - expect(imported.safeParse(0.3).isOk, isTrue); - expect(Ack.double().multipleOf(0.5).safeParse(1e308).isOk, isTrue); + expect(docA, equals(docSameAsA)); + expect(docA.hashCode, equals(docSameAsA.hashCode)); + expect(docA, isNot(equals(docB))); + expect(docA, isNot(equals(docDifferentBase))); }, ); test( - 'accepts x-transformed and x-* vendor extensions and round-trips codecs', + 'custom vocabularies can disable applicator and unevaluated keywords', () { - final dateJson = Ack.date().toJsonSchema(); - expect(dateJson['format'], 'date'); - expect(dateJson['x-transformed'], isTrue); - final importedDate = Ack.fromJsonSchema(dateJson); - expect(importedDate.safeParse('2024-02-29').isOk, isTrue); - expect(importedDate.safeParse('2024-02-30').isFail, isTrue); - // Imported schemas export through root-scoped definitions. - final dateDefinitions = - importedDate.toJsonSchema()['definitions'] as Map; - expect(dateDefinitions.values.first, dateJson); - - final dateTimeJson = Ack.datetime().toJsonSchema(); - expect(dateTimeJson['format'], 'date-time'); - expect(dateTimeJson['x-transformed'], isTrue); - final importedDateTime = Ack.fromJsonSchema(dateTimeJson); - expect(importedDateTime.safeParse('2024-02-29T12:00:00Z').isOk, isTrue); + 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( - importedDateTime.safeParse('2024-02-30T12:00:00Z').isFail, + withoutUnevaluated.safeParse({'a': 'ok', 'extra': 1}).isOk, isTrue, ); + expect(withoutUnevaluated.safeParse({'a': 1}).isFail, isTrue); - final uriJson = Ack.uri().toJsonSchema(); - expect(uriJson['format'], 'uri'); - expect(uriJson['x-transformed'], isTrue); - final importedUri = Ack.fromJsonSchema(uriJson); - expect(importedUri.safeParse('https://example.com/path').isOk, isTrue); - expect(importedUri.safeParse('not a uri').isFail, isTrue); - - final customVendor = Ack.fromJsonSchema({ - 'type': 'string', - 'x-vendor-meta': {'tier': 'gold'}, - }); - expect(customVendor.safeParse('ok').isOk, isTrue); - final vendorDefinitions = - customVendor.toJsonSchema()['definitions'] as Map; - expect((vendorDefinitions.values.first as Map)['x-vendor-meta'], { - 'tier': 'gold', - }); + 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( - 'validates built-in string and numeric formats and round-trips native string formats', + 'export rewrites references targeting the root retrieval URI when root declares a different \$id', () { - final emailSchema = Ack.fromJsonSchema( - Ack.string().email().toJsonSchema(), - ); - expect(emailSchema.safeParse('ada@example.com').isOk, isTrue); - final emailErr = emailSchema.safeParse('not-an-email').getError(); - expect( - emailErr, - isA() - .having((e) => e.keyword, 'keyword', 'format') - .having((e) => e.keywordLocation, 'keywordLocation', '/format'), - ); + 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 uuidSchema = Ack.fromJsonSchema( - Ack.string().uuid().toJsonSchema(), - ); + final exported = schema.toJsonSchema(); expect( - uuidSchema.safeParse('550e8400-e29b-41d4-a716-446655440000').isOk, - isTrue, - ); - expect(uuidSchema.safeParse('not-a-uuid').isFail, isTrue); - - final ipv4Schema = Ack.fromJsonSchema( - Ack.string().ipv4().toJsonSchema(), - ); - expect(ipv4Schema.safeParse('192.168.1.1').isOk, isTrue); - expect(ipv4Schema.safeParse('256.0.0.1').isFail, isTrue); - expect(ipv4Schema.safeParse('01.2.3.4').isFail, isTrue); - - final ipv6Schema = Ack.fromJsonSchema( - Ack.string().ipv6().toJsonSchema(), + exported[r'$ref'], + 'https://example.test/canonical/schema.json#/\$defs/Value', ); - expect(ipv6Schema.safeParse('2001:db8::1').isOk, isTrue); - expect(ipv6Schema.safeParse('::1').isOk, isTrue); - expect(ipv6Schema.safeParse('2001:db8::1::2').isFail, isTrue); + 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({ - 'type': 'integer', 'format': 'int32', - }); + }, assertFormats: true); expect(int32Schema.safeParse(2147483647).isOk, isTrue); expect(int32Schema.safeParse(-2147483648).isOk, isTrue); expect(int32Schema.safeParse(2147483648).isFail, isTrue); - - final uint32Schema = Ack.fromJsonSchema({ - 'type': 'integer', - 'format': 'uint32', - }); - expect(uint32Schema.safeParse(0).isOk, isTrue); - expect(uint32Schema.safeParse(4294967295).isOk, isTrue); - expect(uint32Schema.safeParse(-1).isFail, isTrue); - expect(uint32Schema.safeParse(4294967296).isFail, isTrue); - - final int64Schema = Ack.fromJsonSchema({ - 'type': 'integer', - 'format': 'int64', - }); - expect(int64Schema.safeParse(9007199254740991).isOk, isTrue); + expect(int32Schema.safeParse(1.5).isFail, isTrue); + expect(int32Schema.safeParse('not-a-number').isOk, isTrue); final uint64Schema = Ack.fromJsonSchema({ - 'type': 'integer', '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({ - 'type': 'number', 'format': 'float', - }); + }, assertFormats: true); expect(floatSchema.safeParse(1.5).isOk, isTrue); - expect(floatSchema.safeParse(1e39).isFail, isTrue); + expect(floatSchema.safeParse(1e40).isFail, isTrue); + }, + ); - final doubleSchema = Ack.fromJsonSchema({ - 'type': 'number', - 'format': 'double', - }); - expect(doubleSchema.safeParse(1e300).isOk, isTrue); + test( + 'date-time format assertion enforces RFC 3339 leap-second calendar dates', + () { + final schema = Ack.fromJsonSchema({ + 'type': 'string', + 'format': 'date-time', + }, assertFormats: true); - // Unknown formats still fail closed with unsupported_keyword. - for (final unsupportedFormat in ['time', 'duration', 'hostname']) { - expect( - () => Ack.fromJsonSchema({ - 'type': 'string', - 'format': unsupportedFormat, - }), - throwsA( - isA().having( - (e) => e.diagnostics.single.code, - 'code', - 'unsupported_keyword', - ), - ), - ); - } + 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('keeps prefixItems unsupported without indexing as a schema list', () { - for (final document in [ - { - 'type': 'array', - 'prefixItems': [ - {'type': 'string'}, - ], - }, - {'type': 'array', 'prefixItems': 'not-a-list'}, - ]) { + 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( - () => Ack.fromJsonSchema(document), - throwsA( - isA().having( - (e) => e.diagnostics.single, - 'diagnostic', - isA() - .having((d) => d.code, 'code', 'unsupported_keyword') - .having((d) => d.keyword, 'keyword', 'prefixItems'), - ), - ), + reimported.safeParse({'value': 'a', 'viaExtension': 'b'}).isOk, + isTrue, ); - } - }); - - test('rejects malformed supported keywords', () { - for (final document in [ - {'type': 'made-up'}, - {'required': 'a'}, - {'minItems': -1}, - {'multipleOf': 0}, - {'multipleOf': -2}, - {'multipleOf': '2'}, - {'anyOf': []}, - {'enum': 1}, - {'items': 3}, - {'pattern': 1}, - {'pattern': '('}, - ]) { expect( - () => Ack.fromJsonSchema(document), - throwsA(isA()), - reason: '$document', + reimported.safeParse({'value': 'a', 'viaExtension': 123}).isFail, + isTrue, ); - } - }); + }, + ); }); } 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/test/models/json_maybe_test.dart b/packages/ack/test/models/json_maybe_test.dart new file mode 100644 index 00000000..db5893a9 --- /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() / nullValue() state and equality', () { + const present = JsonMaybe.value('Ada'); + const presentCopy = JsonValue('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(presentCopy)); + expect(present.hashCode, equals(presentCopy.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/test/reference_semantics_test.dart b/packages/ack/test/reference_semantics_test.dart index 77c48c16..8bc58d2b 100644 --- a/packages/ack/test/reference_semantics_test.dart +++ b/packages/ack/test/reference_semantics_test.dart @@ -96,11 +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, - 'x-transformed': true, - }); + 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 de763ed8..8afd947e 100644 --- a/packages/ack/test/runtime_dependencies_test.dart +++ b/packages/ack/test/runtime_dependencies_test.dart @@ -6,18 +6,24 @@ import 'dart:io'; import 'package:test/test.dart'; void main() { - test('ack depends only on meta and collection at runtime', () { - 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'])); - }); + 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..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,55 +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('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( + 'uses existing composition models for Draft-7 lowered imported JSON Schema', + () { + final schema = Ack.fromJsonSchema({'type': 'string', 'minLength': 2}); - expect( - schema.toJsonSchema, - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('collides with an imported definition'), + final model = schema.toSchemaModelDraft7(); + + 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 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.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( @@ -325,7 +347,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']); }, ); @@ -336,11 +358,7 @@ void main() { .max(DateTime(2026, 12, 31)); final model = schema.toSchemaModel(); - expect(model.toJsonSchema(), { - 'type': 'string', - 'format': 'date', - 'x-transformed': true, - }); + expect(model.toJsonSchema(), {'type': 'string', 'format': 'date'}); expect( model.warnings.map((warning) => warning.code), everyElement('datetime_constraint_not_draft7'), @@ -393,11 +411,7 @@ void main() { .transform((value) => value.trim()); final model = schema.toSchemaModel(); - expect(model.toJsonSchema(), { - 'type': 'string', - 'minLength': 1, - 'x-transformed': true, - }); + expect(model.toJsonSchema(), {'type': 'string', 'minLength': 1}); expect(model.warnings, isEmpty); }); @@ -412,7 +426,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/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/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/encode_error_propagation_test.dart b/packages/ack/test/schemas/encode_error_propagation_test.dart index bda62fbe..00b4a92e 100644 --- a/packages/ack/test/schemas/encode_error_propagation_test.dart +++ b/packages/ack/test/schemas/encode_error_propagation_test.dart @@ -1,7 +1,4 @@ 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:test/test.dart'; CodecSchema _throwingCodec(Object error) { @@ -19,57 +16,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) { @@ -244,17 +190,18 @@ void main() { ); }); - test('a non-codec child Exception is still attributed to #/leaf', () { - final schema = Ack.object({'leaf': const _ThrowingLeafSchema()}); + 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)); }); }); @@ -283,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/string_transformers_test.dart b/packages/ack/test/schemas/extensions/string_transformers_test.dart index b10d8417..655dcc47 100644 --- a/packages/ack/test/schemas/extensions/string_transformers_test.dart +++ b/packages/ack/test/schemas/extensions/string_transformers_test.dart @@ -325,5 +325,96 @@ 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().minLength(2).email().trim().toLowerCase(); + final jsonSchema = schema.toJsonSchema(); + + expect(jsonSchema['type'], 'string'); + expect(jsonSchema['minLength'], 2); + 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/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/schemas/schema_equality_test.dart b/packages/ack/test/schemas/schema_equality_test.dart index 73e29577..e9065564 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())), + ); }); }); @@ -211,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/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/test/typed_codecs_characterization_test.dart b/packages/ack/test/typed_codecs_characterization_test.dart index cf45c85f..08b3de6d 100644 --- a/packages/ack/test/typed_codecs_characterization_test.dart +++ b/packages/ack/test/typed_codecs_characterization_test.dart @@ -1,8 +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, Refinement, SchemaOperation, WrapperSchema; +import 'package:ack/src/schemas/schema.dart' show AnyAckSchema, WrapperSchema; import 'package:test/test.dart'; final class _Event { @@ -31,71 +30,22 @@ 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); - } +final class _RecordingConstraint extends Constraint + with Validator { + _RecordingConstraint(this.events, {required this.passes}) + : super(constraintKey: 'recording', description: 'Records validation'); - @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); - } + final List events; + final bool Function(String value) passes; @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, - ); + bool isValid(String value) { + events.add('constraint:$value'); + return passes(value); } @override - Map toJsonSchema() => const {'type': 'string'}; + String buildMessage(String value) => 'Constraint rejected $value'; } void main() { @@ -281,7 +231,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'); @@ -291,7 +241,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'); @@ -313,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'); @@ -553,35 +455,96 @@ void main() { }); }); - group('SchemaContext.operation', () { - test('parse path observes SchemaOperation.parse', () { - final parseOperations = []; - final validateOperations = []; - final schema = _OperationRecordingSchema( - 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 = _OperationRecordingSchema( - parseOperations: [], - validateOperations: validateOperations, - encodeOperations: encodeOperations, - ); + 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']); + }, + ); + }); - schema.encode('x'); + group('Sealed SchemaError exhaustiveness', () { + String classifyError(SchemaError error) => switch (error) { + TypeMismatchError() => 'type_mismatch', + SchemaConstraintsError() => 'constraints', + SchemaNestedError() => 'nested', + SchemaValidationError() => 'validation', + SchemaTransformError() => 'transform', + SchemaEncodeError() => 'encode', + }; - expect(encodeOperations, [SchemaOperation.encode]); - expect(validateOperations, [SchemaOperation.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', + ); + }, + ); }); } diff --git a/packages/ack_firebase_ai/CHANGELOG.md b/packages/ack_firebase_ai/CHANGELOG.md index b48887ce..7890114b 100644 --- a/packages/ack_firebase_ai/CHANGELOG.md +++ b/packages/ack_firebase_ai/CHANGELOG.md @@ -1,3 +1,15 @@ +## 2.0.0-alpha.1 + +### Breaking + +* Requires `ack: ^2.0.0-alpha.1` and aligns with the coordinated Ack 2.0.0-alpha.1 + release. + +### Changed + +* Regenerate Firebase AI response JSON Schema fixtures for two-way `StringSchema` + normalizer exports (`Ack.string().trim()`, `.toLowerCase()`, `.toUpperCase()`). + ## 1.7.0 ### Changed diff --git a/packages/ack_firebase_ai/README.md b/packages/ack_firebase_ai/README.md index 96329a2f..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: ^1.2.0 - ack_firebase_ai: ^1.7.0 + 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 21c969a2..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: 1.7.0 +version: 2.0.0-alpha.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace @@ -12,11 +12,12 @@ 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: ^1.2.0 + ack: ^2.0.0-alpha.1 flutter: sdk: flutter 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/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_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_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 a922f6d9..4ea69bc7 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -1,3 +1,56 @@ +## 2.0.0-alpha.1 + +### Breaking + +* 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`, `@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 + +* 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 `@AckKey('...')` directly without `package:json_annotation` (`#189`). +* Support custom schema resolution on `@Schemable()` class fields via: + 1. `@Schemable(schemas: [...])` on the class, enclosing sealed union, current + `library;` directive, or an imported `library;` directive + 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. +* Add `ack_generator:ack_json_schema` builder and `generateAckSchemaLibrary()` + 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`). +* Apply scalar `@Validate.*` constraints on `List` and `Set` fields and + parameters to the items, alongside the collection's own constraints: + `@Validate.min(1) @Validate.minItems(1) List` infers + `Ack.list(Ack.integer().min(1)).minItems(1)`. +* Resolve non-nullable `Object` fields through a class-level or union-level + `@Schemable(schemas: [...])` entry whose schema targets `Object`; `Object?` + fields still infer `Ack.any()`. + +### 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 ### Breaking diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index 229066ad..abf20830 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -1,20 +1,101 @@ # 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()`, `@AckKey()`, and the +`@Validate.*` validation 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 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({ @@ -61,56 +142,66 @@ 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'; -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) + @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()); } ``` -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;`. - -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. +### 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. **`@Schemable(schemas: [...])`** on the class, enclosing union base, current + `library;` directive, or an imported `library;` directive; +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. + +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 generated codec +with `static final schema = _accountSchema;` (or `AccountSchema.schema`). + +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`. @@ -122,25 +213,24 @@ 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 - `@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; @@ -153,64 +243,53 @@ 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 +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 +`@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) 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,40 +297,20 @@ 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 -`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. - -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()` | +`_$AccountAck` mixin, which supplies `toJson`, `safeToJson`, a typed `copyWith`, +and deep collection-aware equality. Add `static final schema = _accountSchema;` +and `static final fromJson = AccountSchema.fromJson;` when the class should +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/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/example/example.dart b/packages/ack_generator/example/example.dart index 735e68e4..c951a237 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: @@ -17,7 +16,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 +42,10 @@ final settingsSchema = Ack.object({ final class Product { const Product({required this.displayName, required this.priceCents}); - @NotEmpty() + @Validate.notEmpty() final String displayName; - @Min(0) + @Validate.min(0) final int priceCents; } diff --git a/packages/ack_generator/lib/ack_generator.dart b/packages/ack_generator/lib/ack_generator.dart index a94e1b51..613e0524 100644 --- a/packages/ack_generator/lib/ack_generator.dart +++ b/packages/ack_generator/lib/ack_generator.dart @@ -1,6 +1,11 @@ -// Export the builders for build.yaml -export 'src/builder.dart' - show ackGenerator, ackModelBuilder, ackModelJsonBuilder; - -// 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 4f94ee8f..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 @@ -1,21 +1,18 @@ -import 'package:ack/ack.dart' show 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/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'; -import 'package:build/build.dart'; -import 'package:json_annotation/json_annotation.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, @@ -27,20 +24,14 @@ typedef _ModelOptions = ({ String captureField, }); -typedef _FutureGeneratedType = ({ - String schemaExpression, - AckInferRef runtimeRef, - String? setListSchema, -}); - typedef _ClassFirstDependency = ({ClassElement target, FieldElement field}); /// 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', @@ -65,167 +56,38 @@ 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', - 'trim', - 'toLowerCase', - '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', - ); - static const _ackSchemaChecker = TypeChecker.typeNamed( - AckSchema, - inPackage: 'ack', - ); - static const _generatedJsonChecker = TypeChecker.typeNamed( - AckGeneratedJson, - 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', ); - 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', ); - final LibraryReader library; - final String? ackPrefix; AckSchemaInference get _inference => AckSchemaInference(ackPrefix: ackPrefix); - final AckModelGraph _graph = AckModelGraph(); - final Set _explicit = {}; + late final CustomSchemaResolver _customSchemas = CustomSchemaResolver(this); final Set _consumed = {}; final Map _schemaNameOwners = {}; final Map> _dependencies = {}; - ResolvedLibraryResult? _inputResolved; - final Map _resolvedByUri = {}; + ClassElement? _activeClass; 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; - _explicit.addAll(annotatedClasses); + await initInputLibrary(); for (final element in annotatedClasses) { _validateAnnotatedClass(element); } + await _customSchemas.initRegisteredAndLibrarySchemas(); for (final element in annotatedClasses.where((item) => item.isSealed)) { await _buildUnion(element); @@ -236,13 +98,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, ); @@ -251,14 +113,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, @@ -283,8 +142,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}.'); } @@ -294,22 +153,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); - } } } @@ -317,13 +167,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 ' @@ -336,18 +187,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, ); } @@ -359,7 +210,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, ); @@ -374,12 +225,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( @@ -408,7 +259,7 @@ final class ClassModelGraphBuilder { 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, ); } @@ -418,14 +269,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.', @@ -471,14 +322,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); @@ -512,6 +363,7 @@ final class ClassModelGraphBuilder { ); } valueOwners[value] = branch; + _customSchemas.unionBaseByBranch[branch] = base; _validateBranchDiscriminator(branch, discriminatorKey, value); final branchNode = await _buildObject( branch, @@ -524,12 +376,10 @@ final class ClassModelGraphBuilder { _consumed.add(branch); } - _graph.complete( + graph.complete( AckUnionModelNode( id: baseId, className: base.name!, - boundaryType: _jsonMapRef, - runtimeRef: AckExternalTypeRef(name: base.name!), discriminatorKey: discriminatorKey, branches: branches, description: _classDescription(base, baseOptions), @@ -543,14 +393,36 @@ 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); + 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, ); @@ -564,7 +436,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.', ); @@ -592,19 +464,21 @@ final class ClassModelGraphBuilder { ); } - final futureTypes = {}; + final futureTypes = {}; 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); + if (!containsInvalidType(field.type)) continue; + futureTypes[name] = await _customSchemas.futureGeneratedType( + field, + setCodec: _setCodec, + ); } 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) { @@ -625,7 +499,6 @@ final class ClassModelGraphBuilder { typeRef: futureTypes[fieldName]?.runtimeRef ?? _typeRef(parameter.type, field!), - isSuper: parameter is SuperFormalParameterElement, defaultExpression: parameter.defaultValueCode, ), ); @@ -649,7 +522,8 @@ final class ClassModelGraphBuilder { ); } captureJsonKey = - _jsonKey(extras) ?? _rename(captureFieldName, options.caseStyle); + _jsonKey(extras, parameters[captureFieldName]) ?? + _rename(captureFieldName, options.caseStyle); } final nodes = []; @@ -661,7 +535,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, ); } @@ -677,11 +551,31 @@ final class ClassModelGraphBuilder { ); } - _rejectUnsupportedStaticType(field, field.type); - _validateMapKey(field, field.type); + if (isJsonMaybeType(field.type) && isNullableType(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) ?? _rename(name, options.caseStyle); + final jsonKey = + _jsonKey(field, parameter) ?? _rename(name, options.caseStyle); final prior = ownerByJsonKey[jsonKey]; if (prior != null) { throw InvalidGenerationSource( @@ -699,20 +593,29 @@ final class ClassModelGraphBuilder { } ownerByJsonKey[jsonKey] = field; + final isJsonMaybe = + futureType?.runtimeRef is AckJsonMaybeTypeRef || + isJsonMaybeType(field.type); final dartNullable = futureType?.runtimeRef is AckNullableTypeRef || - _isNullable(field.type); - final rejectNull = _notNullChecker.hasAnnotationOfExact(field); - final acceptsNull = dartNullable && !rejectNull; + isNullableType(field.type); + final rejectNull = _hasValidateKind('notNull', field, parameter); + + 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)})' - : await _fieldSchema(field, futureType: futureType); - schema = _inference.applyDescription(schema, field); + ? '${ack('Ack')}.literal(${dartStringLiteral(discriminatorValue)})' + : await _fieldSchema( + field, + parameter: parameter, + futureType: futureType, + ); + schema = _applyDescription(schema, field, parameter); schema = _applyPresence( schema, presence: presence, @@ -740,8 +643,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) { @@ -753,13 +654,12 @@ final class ClassModelGraphBuilder { AckUnknownPropertyPolicy.capture, }, captureFieldName: captureFieldName, - captureJsonKey: captureJsonKey, unionId: unionId, discriminatorKey: discriminatorKey, discriminatorValue: discriminatorValue, description: _classDescription(element, options), ); - _graph.complete(node); + graph.complete(node); return node; } @@ -770,7 +670,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, @@ -823,38 +723,35 @@ final class ClassModelGraphBuilder { Future _fieldSchema( FieldElement field, { - _FutureGeneratedType? futureType, + FormalParameterElement? parameter, + FutureGeneratedType? futureType, }) async { - final override = _ackFieldChecker.firstAnnotationOfExact(field); - if (override != null) { - final reader = ConstantReader(override); - if (!reader.read('schema').isNull) { - final base = await _escapeHatchExpression(field, reader); - return _applySugar(base, field); - } - } if (futureType != null) { _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), - futureType.runtimeRef, + _applySugar(setListSchema, field, parameter), + effectiveRef, ); } - return _applySugar(futureType.schemaExpression, field); + 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) { final itemType = type.typeArguments.single; _rejectNullableCollectionElement(field, itemType); final item = await _schemaForType(itemType, field); - final list = _applySugar('${_ack('Ack')}.list($item)', field); - return _setCodec(list, _typeRef(type, field)); + final list = _applySugar('${ack('Ack')}.list($item)', field, parameter); + return _setCodec(list, _typeRef(type, field, allowJsonMaybe: false)); } - return _applySugar(await _schemaForType(type, field), field); + return _applySugar(await _schemaForType(type, field), field, parameter); } String _setCodec(String listSchema, AckInferRef runtimeRef) { @@ -863,7 +760,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),' @@ -884,590 +781,35 @@ final class ClassModelGraphBuilder { AckMapTypeRef(:final valueType) => AckMapTypeRef( _schemaRuntimeRef(valueType), ), + AckJsonMaybeTypeRef(:final valueType) => _schemaRuntimeRef(valueType), _ => type, }; - bool _containsInvalidType(DartType type) => - 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; - while (node != null && node is! FieldDeclaration) { - node = node.parent; - } - final annotation = node is FieldDeclaration ? node.fields.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, - ) { - 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 (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); - 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 legacyTarget = _futureAckTypeTarget( - generatedTypeName: name, - prefix: prefix, - field: field, - ); - if (legacyTarget != null) { - _rejectLegacyGeneratedType(field, name); - } - final target = _futureAckInferTarget( - className: name, - prefix: prefix, - field: field, - ); - if (target == null) return null; - final visibleName = prefix == null ? name : '$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? _futureAckInferTarget({ - required String className, - required String? prefix, - required FieldElement field, - }) { - final matches = {}; - final candidates = {}; - final imports = []; - String? hiddenDeclaration; - - Element? candidateFor(Element element) { - final declaration = _ackInferDeclaration(element); - if (declaration == null || - _generatedAckInferClassName(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? _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, - TopLevelVariableElement() => element.baseElement, - _ => null, - }; - return declaration != null && - (_ackInferChecker.hasAnnotationOfExact(declaration) || - _schemableChecker.hasAnnotationOfExact(declaration)) - ? declaration - : null; - } - - String _generatedAckInferClassName(Element declaration) { - final annotation = - _ackInferChecker.firstAnnotationOfExact(declaration) ?? - _schemableChecker.firstAnnotationOfExact(declaration)!; - final custom = ConstantReader(annotation).read('name'); - return ackInferModelClassName( - declaration.name!, - override: custom.isNull ? null : custom.stringValue, - ); - } - - 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, - ) 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); - if (runtime == null || !_sameTypeIgnoringNullability(runtime, field.type)) { - throw InvalidGenerationSource( - '$path @AckField schema function ${function.name} produces ' - '${runtime?.getDisplayString() ?? 'an untyped value'}, but the field ' - 'type is ${field.type.getDisplayString()}.', - element: field, - todo: 'Return AckSchema<..., ${field.type.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 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; - } - - /// 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, visibleTypeName: (type) => _visibleTypeName(type, field), - renderType: (type) => _renderType(_typeRef(type, field)), - resolveNamed: (type) => _namedSchemaForType(type, field), + renderType: (type) => _typeRef( + type, + field, + allowJsonMaybe: false, + ).render(ackPrefix: ackPrefix), + 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 target = interfaceType.element; - if (target is ClassElement) { - final facadeName = _classFirstFacadeName(target); - if (facadeName != null) { - final prefix = _visiblePrefix(target, field); - _validateClassFirstFacadeImport( - target, - facadeName, - prefix: prefix, - field: field, - ); - return '${prefix == null ? '' : '$prefix.'}$facadeName.schema'; - } - } - if (_generatedJsonChecker.hasAnnotationOfExact(target)) { - return '${_visibleTypeName(interfaceType, field)}.\$ack.schema'; - } - return await _typeOwnedSchema(interfaceType, field); - } - - /// 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, - ); - } - 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, - ); - } - schemas.add(await _schemaForType(argument, field)); - } - final arguments = [ - for (final argument in type.typeArguments) - _renderType(_typeArgumentRef(argument, field)), - ]; - 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)) { - 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()); } @@ -1478,10 +820,15 @@ 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) { - _nullableCollectionElementError(field, _renderType(elementType)); + _nullableCollectionElementError( + field, + elementType.render(ackPrefix: ackPrefix), + ); } _rejectNullableFutureCollectionElement(field, elementType); case AckMapTypeRef(:final valueType): @@ -1495,54 +842,92 @@ final class ClassModelGraphBuilder { 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, ); } - 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; + String _applySugar( + String schema, + 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, effectiveType); + } + var output = _inference.applyConstraints(schema, field, effectiveType); + if (parameter != null && !identical(parameter, field)) { + output = _inference.applyConstraints(output, parameter, effectiveType); + } + return output; + } + + String _applyDescription( + String schema, + FieldElement field, + FormalParameterElement? parameter, + ) { + return _inference.applyDescription( + schema, + field, + 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]) { + 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'); + } } } - 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, - ); + return null; + } + + DartObject? _firstAnnotation( + TypeChecker checker, + FieldElement field, [ + FormalParameterElement? parameter, + ]) => + checker.firstAnnotationOfExact(field) ?? + (parameter == null ? null : checker.firstAnnotationOfExact(parameter)); + + bool _hasValidateKind( + String kind, + FieldElement field, [ + FormalParameterElement? 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; } - String _applySugar(String schema, FieldElement field) => - _inference.applyConstraints(schema, field, field.type); void _rejectUnsupportedStaticType(FieldElement field, DartType type) { if (type is DynamicType || type is TypeParameterType) { _unsupportedFieldType(field, type); @@ -1555,7 +940,7 @@ final class ClassModelGraphBuilder { 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, ); } @@ -1574,8 +959,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()}.', @@ -1586,65 +970,49 @@ final class ClassModelGraphBuilder { /// 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); - - AckInferRef _typeRef(DartType type, FieldElement field) { - if (type is DynamicType || type is TypeParameterType) { - _unsupportedFieldType(field, type); - } - if (type is! InterfaceType) _unsupportedFieldType(field, type); - final interfaceType = type; - final nullable = _isNullable(interfaceType); - late final AckInferRef result; - if (interfaceType.isDartCoreList && - interfaceType.typeArguments.length == 1) { - result = AckListTypeRef( - _typeRef(interfaceType.typeArguments.single, field), - ); - } else if (interfaceType.isDartCoreSet && - interfaceType.typeArguments.length == 1) { - result = AckSetTypeRef( - _typeRef(interfaceType.typeArguments.single, field), - ); - } else if (interfaceType.isDartCoreMap && - interfaceType.typeArguments.length == 2) { - _validateMapKey(field, interfaceType); - result = AckMapTypeRef(_typeRef(interfaceType.typeArguments[1], field)); - } 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 (_generatedJsonChecker.hasAnnotationOfExact( - interfaceType.element, - )) { - result = AckModelTypeRef( - schemaId: AckSchemaId( - libraryUri: interfaceType.element.library.uri, - declarationName: interfaceType.element.name!, - ), - className: interfaceType.element.name!, - runtimeRef: _jsonMapRef, - 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), - ], + 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), ); - } - return nullable ? AckNullableTypeRef(result) : result; + + AckInferRef _typeRef( + DartType type, + FieldElement field, { + bool allowJsonMaybe = true, + }) => 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) { @@ -1670,7 +1038,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.', ); @@ -1702,71 +1070,39 @@ final class ClassModelGraphBuilder { FieldElement field, { required FormalParameterElement? parameter, required bool isDiscriminator, + bool isJsonMaybe = false, }) { final inferred = _fieldPresence(parameter); - final hasOptional = _optionalChecker.hasAnnotationOfExact(field); - final hasRequired = _requiredChecker.hasAnnotationOfExact(field); + 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, ); } - 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) { - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} @AckField() is a ' - 'no-op; set schema, description, or presence.', - element: field, - ); - } - legacyOverride = switch (presenceIndex) { - 0 => null, - 1 => AckSchemaFieldPresence.required, - 2 => AckSchemaFieldPresence.optional, - _ => throw StateError('Unknown AckFieldPresence index $presenceIndex.'), - }; + if (isJsonMaybe) { + return hasRequired + ? AckSchemaFieldPresence.required + : AckSchemaFieldPresence.optional; } - 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; - } - - if (legacyOverride != null && - annotationOverride != null && - legacyOverride != annotationOverride) { - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} has conflicting ' - 'presence declarations.', - element: field, - ); + override = null; } - final override = annotationOverride ?? legacyOverride; if (override == AckSchemaFieldPresence.optional) { 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 ' @@ -1776,14 +1112,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; } @@ -1796,9 +1124,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; } @@ -1807,8 +1135,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( @@ -1826,14 +1154,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; } @@ -1843,88 +1174,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; @@ -1939,9 +1188,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; @@ -1986,9 +1235,9 @@ 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 ' + 'Invalid ${annotationName(element)} schema facade name "$facadeName" on ' '${element.name}; schemaName must be a public UpperCamel identifier.', element: element, ); @@ -2004,20 +1253,18 @@ final class ClassModelGraphBuilder { } _schemaNameOwners[name] = element; } - _graph.setClassMetadata( + graph.setClassMetadata( id, 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) // 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(), ), @@ -2025,9 +1272,8 @@ final class ClassModelGraphBuilder { } _ModelOptions? _options(ClassElement element) { - final annotation = - _ackModelChecker.firstAnnotationOfExact(element) ?? - _schemableChecker.firstAnnotationOfExact(element); + final annotation = ModelGraphBuilderBase.schemableChecker + .firstAnnotationOfExact(element); if (annotation == null) return null; final reader = ConstantReader(annotation); final caseStyle = reader.read('caseStyle').objectValue; @@ -2049,27 +1295,12 @@ 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? _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, ); @@ -2096,67 +1327,37 @@ 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 && - (_ackModelChecker.hasAnnotationOfExact(base) || - _schemableChecker.hasAnnotationOfExact(base)); - }); - return isImplicitUnionBranch - ? ackClassSchemaFacadeName(element.name!) - : null; - } - - String? _jsonKey(FieldElement field) { - final annotation = _jsonKeyChecker.firstAnnotationOfExact(field); - if (annotation == null) return null; - 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) { + String? _jsonKey(FieldElement field, [FormalParameterElement? parameter]) { + _rejectJsonKey(field.enclosingElement as ClassElement, field); + if (parameter != null) { + _rejectJsonKey(field.enclosingElement as ClassElement, parameter); + } + final ackKey = _firstAnnotation( + ModelGraphBuilderBase.ackKeyChecker, + field, + parameter, + ); + if (ackKey == null) return null; + final nameReader = ConstantReader(ackKey).read('name'); + if (nameReader.isNull) return null; + final ackKeyName = nameReader.stringValue; + if (ackKeyName.trim().isEmpty) { 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.', + '${field.enclosingElement.name}.${field.name} @AckKey.name must ' + 'not be blank.', element: field, ); } - final value = reader.read('name'); - return value.isNull ? null : value.stringValue; + return ackKeyName; } - 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 @AckKey(...) ' + 'instead.', + element: element, ); } @@ -2198,65 +1399,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) { - final prefix = _visiblePrefix(type.element, field); - return '${prefix == null ? '' : '$prefix.'}${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.', + String _visibleTypeName(InterfaceType type, FieldElement field) => + visibleTypeNameFor( + type, + path: '${field.enclosingElement.name}.${field.name}', + context: field, ); - } - _resolvedByUri[element.uri] = result; - return result; - } void _rejectInvalidMemberName( String name, @@ -2265,7 +1413,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, @@ -2273,30 +1421,8 @@ 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', - }; - - String _ack(String symbol) { - final prefix = ackPrefix; - return prefix == null || prefix.isEmpty ? symbol : '$prefix.$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..4f7a270e --- /dev/null +++ b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart @@ -0,0 +1,971 @@ +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 '../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: ...)`, 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') && + (owner is LibraryElement || + runtime.nullabilitySuffix != NullabilitySuffix.none))) { + 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()}.', + 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: [...]).', + 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: [...]).', + 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') || + (_base.isCoreType(type, 'Object') && + type.nullabilitySuffix == NullabilitySuffix.none); + + 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.nullabilitySuffix != NullabilitySuffix.none) || + type.isDartCoreList || + type.isDartCoreSet || + type.isDartCoreMap || + _base.isJsonMaybeType(type); + + 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! FormalParameter) { + node = node.parent; + } + final annotation = switch (node) { + FieldDeclaration() => node.fields.type, + RegularFormalParameter() => 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..1dc8a126 --- /dev/null +++ b/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart @@ -0,0 +1,894 @@ +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 ackKeyChecker = TypeChecker.typeNamed( + annotations.AckKey, + 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) => [ + for (final argument in argumentList.arguments) argument.argumentExpression, + ]; + + Expression argumentExpression(Argument argument) => + argument.argumentExpression; + + ({String name, Expression expression})? namedArgument(Argument argument) => + argument is NamedArgument + ? (name: argument.name.lexeme, expression: argument.argumentExpression) + : 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_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..3564da12 --- /dev/null +++ b/packages/ack_generator/lib/src/analyzer/schema_first_names.dart @@ -0,0 +1,43 @@ +import '../utils/string_literal.dart'; + +/// 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', +}; + +/// 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) || + isDartKeyword(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..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,16 +1,15 @@ 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 'generated_companion_visibility.dart'; +import '../utils/string_literal.dart'; +import 'model_graph_builder_base.dart'; +import 'schema_first_names.dart'; final class _Declaration { const _Declaration({ @@ -26,149 +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 = { - r'$ack', - 'parse', - 'safeParse', - 'fromJson', - 'toJson', - 'safeToJson', - 'copyWith', - '_fromAckRuntime', - '_toAckRuntime', - 'hashCode', - 'noSuchMethod', - 'toString', - 'runtimeType', - }; + static const _reservedMembers = ackSchemaFirstReservedMembers; - 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', - 'trim', - 'toLowerCase', - 'toUpperCase', - }; - - 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', - ); static const _anySchemaChecker = TypeChecker.typeNamed( AnySchema, inPackage: 'ack', @@ -186,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( @@ -227,7 +78,7 @@ final class SchemaModelGraphBuilder { id: id, className: _className( declarationName, - _annotationName(element), + schemableCustomName(element), element, ), ); @@ -244,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) => @@ -264,6 +114,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( @@ -287,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: @@ -348,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( @@ -371,10 +180,10 @@ 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. ' + '${annotationName(referencedElement(chain.reference!)!)} schema. ' 'Use the original model directly.', element: declaration.element, ); @@ -417,7 +226,7 @@ final class SchemaModelGraphBuilder { } node = await _valueNode(declaration, path); } - _graph.complete(node); + graph.complete(node); } Future _valueNode( @@ -436,7 +245,7 @@ final class SchemaModelGraphBuilder { context: declaration.element, throughLazy: false, ), - description: _description(expression), + description: descriptionFromExpression(expression), ); } @@ -457,17 +266,14 @@ 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, 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, ); @@ -479,22 +285,23 @@ 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, ); } 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(...).', @@ -516,22 +323,18 @@ 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, ); } 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( @@ -552,20 +355,13 @@ final class SchemaModelGraphBuilder { context: declaration.element, throughLazy: false, ), - description: _description(entry.value), + description: descriptionFromExpression(entry.value), ), ); } - 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, @@ -575,20 +371,19 @@ final class SchemaModelGraphBuilder { ? AckUnknownPropertyPolicy.capture : AckUnknownPropertyPolicy.reject, captureFieldName: additionalProperties ? 'additionalProperties' : null, - captureJsonKey: 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': @@ -633,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, ); } @@ -649,17 +444,14 @@ final class SchemaModelGraphBuilder { ); } _unionOwnerByBranch[target.id] = declaration.id; - _graph.replace( + graph.replace( 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, @@ -674,19 +466,12 @@ 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), + description: descriptionFromExpression(declaration.expression), ); } @@ -700,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( @@ -741,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().', @@ -764,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().', @@ -783,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 && @@ -812,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; @@ -864,23 +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 (_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) { + 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, ); } @@ -891,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 " @@ -918,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 && @@ -947,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.', @@ -961,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.', @@ -982,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, ); @@ -996,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) { @@ -1012,17 +784,17 @@ final class SchemaModelGraphBuilder { runtimeRef: runtime, ); } - if (!_hasAckInfer(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, @@ -1048,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 ' @@ -1084,129 +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 = - _ackModelChecker.firstAnnotationOfExact(element) ?? - _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 && - (_ackModelChecker.hasAnnotationOfExact(base) || - _schemableChecker.hasAnnotationOfExact(base)); - }); - return isImplicitUnionBranch - ? ackClassSchemaFacadeName(element.name!) - : null; - } - _SchemaTypes _schemaTypes( Expression expression, String path, @@ -1219,282 +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), + boundary: _typeRef(ackInfer.boundary, path, context), + runtime: _typeRef(ackInfer.runtime, path, context), ); } - bool _isAckSchema(InterfaceType type) { - return _ackSchemaChecker.isExactlyType(type); - } - - 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) - _typeRef(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, - ); - } - - _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 _isCrossLibraryAckInfer(Expression expression) { - final element = _referencedElement(expression); + bool _isCrossLibrarySchemable(Expression expression) { + final element = referencedElement(expression); if (element == null) return false; if (_declarationsByElement[element.baseElement] != null) return false; - return _hasAckInfer(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 _hasAckInfer(Element element) { - final declaration = _propertyDeclaration(element); - return _ackInferChecker.hasAnnotationOfExact(declaration) || - _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? _annotationName(Element element) { - final declaration = _propertyDeclaration(element); - final annotation = - _ackInferChecker.firstAnnotationOfExact(declaration) ?? - _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( @@ -1523,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; @@ -1538,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, ); @@ -1560,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, ); @@ -1590,36 +968,22 @@ 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) { 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( + validateCopyWithHelperNames( 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, ); @@ -1627,83 +991,15 @@ 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( - '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; @@ -1721,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 && @@ -1747,7 +1043,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, @@ -1770,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.', @@ -1789,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}, ); } @@ -1847,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 ' @@ -1863,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/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..3f00fcb4 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,200 +272,173 @@ 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)), - ), - }); + const declared = ${_dataClasses.declaredKeysLiteral(declared)}; + return ${node.className}( + ${arguments.join(',\n ')}, + ); }'''; } + String _decodeParameter( + 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 = _dataClasses.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 && + parameter.defaultExpression != 'null' + ? parameter.defaultExpression + : null; + final baseType = _dataClasses.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) + _fieldRuntimeMapEntry(field), + if (discriminatorKey != null && discriminatorValue != null) + '${dartStringLiteral(discriminatorKey)}: ' + '${dartStringLiteral(discriminatorValue)}', + ]; + if (capture == null) { return ''' Map $function(${node.className} model) => - {...$jsonHelper(model)};'''; + {${fieldEntries.join(', ')}};'''; } - - final lines = []; - if (capture != null) { - lines.add('const declared = ${_keySet(_declaredKeys(node))};'); - } - 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 = ${_dataClasses.declaredKeysLiteral(_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')};', - ); + String _fieldRuntimeMapEntry(AckFieldNode field) { + if (field.runtimeRef case AckJsonMaybeTypeRef(:final valueType)) { + final baseValueType = _dataClasses.nonNullable(valueType); + final encodedValue = + baseValueType is AckScalarTypeRef || + baseValueType is AckExternalTypeRef + ? 'value' + : _dataClasses.toRuntime(AckNullableTypeRef(baseValueType), 'value'); + return 'if (model.${field.dartName} case ${_ack('JsonValue')}(:final value)) ' + '${dartStringLiteral(field.jsonKey)}: $encodedValue'; } - 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;'); - } - - return output.toString(); + return '${dartStringLiteral(field.jsonKey)}: ' + '${_omitIfNull(field) ? '?' : ''}${_encodeField(field)}'; } - 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)}', - }; + bool _omitIfNull(AckFieldNode field) => + field.nullable && !(field.isRequired && field.acceptsNull); - 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 _encodeField(AckFieldNode field) => _dataClasses.encodeField( + field.runtimeRef, + 'model.${field.dartName}', + nullable: field.nullable, + ); - 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', - }; + String _fromRuntime(AckInferRef type, String expression) => _dataClasses + .fromRuntime(type, expression, wrapImmutableCollections: true); + + String _type(AckInferRef type) => type.render(ackPrefix: ackPrefix); Set _declaredKeys(AckObjectModelNode node) => { for (final field in node.fields) field.jsonKey, 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) { @@ -475,8 +447,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 dbc0c2a1..05955e9c 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,21 +360,182 @@ ${_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', + 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 _ack(String symbol) { - final prefix = ackPrefix; - return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; + 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 3393f339..bdab8d51 100644 --- a/packages/ack_generator/lib/src/builders/model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/model_emitter.dart @@ -1,22 +1,16 @@ 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 { - 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 +53,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 +76,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 +137,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(_dataClasses.toRuntime(node.runtimeRef, 'value')), ), - ..._valueBridges(node), ]), ); } @@ -249,7 +237,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 +280,6 @@ return switch (value[${dartStringLiteral(node.discriminatorKey)}]) { leadingEntries: {discriminator: dartStringLiteral(value)}, isOverride: true, ), - ..._fieldBridges(fields), - if (node.additionalProperties) ..._additionalPropertyBridges(), ]), ); } @@ -304,9 +289,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( @@ -377,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 @@ -392,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')); @@ -423,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.', @@ -441,7 +424,9 @@ ${_ack('AckModelAdapter')}( ); if (needsCopy) { c.initializers.add( - Code('value = ${_immutableCopy(node.runtimeRef, 'value')}'), + Code( + 'value = ${_dataClasses.immutableCopy(node.runtimeRef, 'value')}', + ), ); } }); @@ -556,7 +541,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) @@ -585,76 +570,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( @@ -688,7 +609,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 +629,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' @@ -725,13 +656,10 @@ ${_ack('AckModelAdapter')}( ), ) ..body = Code(''' -const declared = ${_declaredKeysLiteral(keys)}; -return $helper({ - ...value, - 'additionalProperties': Map.fromEntries( - value.entries.where((entry) => !declared.contains(entry.key)), - ), -});'''), +const declared = ${_dataClasses.declaredKeysLiteral(keys)}; +return ${node.className}( + ${allArguments.join(',\n ')}, +);'''), ); } @@ -742,265 +670,64 @@ 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 = _dataClasses.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) { - final runtimeRef = _nonNullable(field.runtimeRef); + String _decodeField(AckFieldNode field, String rawExpression) { + final runtimeRef = _dataClasses.nonNullable(field.runtimeRef); final needsNullGuard = !field.isRequired || field.nullable; - late final String body; if (!needsNullGuard) { - body = _fromRuntime(runtimeRef, 'value'); - } else 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')},' - ' }'; + return _dataClasses.fromRuntime(runtimeRef, rawExpression); } - 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), - ); - } - - Method _toBridge(AckFieldNode field) { - final runtimeRef = _nonNullable(field.runtimeRef); - 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 (!_dataClasses.requiresRuntimeConversion(runtimeRef)) { + final type = '${_type(runtimeRef)}?'; + // The runtime map entry is already Object?. + return type == 'Object?' ? rawExpression : '$rawExpression as $type'; } - 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), - ); - } - - 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) => - '($expression as List).map((item) => ${_fromRuntime(elementType, 'item')}).toList()', - AckSetTypeRef(:final elementType) => - '($expression as Set).map((item) => ${_fromRuntime(elementType, 'item')}).toSet()', - 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, - }; + return 'switch ($rawExpression) {' + ' null => null,' + ' final fieldValue => ${_dataClasses.fromRuntime(runtimeRef, 'fieldValue')},' + ' }'; } - 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) => - '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); @@ -1022,29 +749,6 @@ return $helper({ 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) => @@ -1055,56 +759,79 @@ return $helper({ }; } - String _type(AckInferRef type) { + 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) => '${_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', + 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) => type.render(ackPrefix: ackPrefix); + 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; - 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 = ''; 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/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index 1f7b18aa..fa254bda 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -1,5 +1,4 @@ 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'; @@ -11,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. @@ -20,76 +20,16 @@ final class AckSchemaInference { final String? ackPrefix; - 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, + static const _validate = TypeChecker.typeNamed( + annotations.Validate, 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, @@ -100,6 +40,13 @@ 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') || + (_core(type, 'Object') && !_nullable(type))) { + final override = await resolveNamed(type); + if (override != null) return override; + } final scalar = _scalar(type); if (scalar != null) return scalar; if (type.element is EnumElement) { @@ -145,112 +92,275 @@ 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; + 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)) { - _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', - ); - final constraint = - '.minItems(${value.getField('count')!.toIntValue()})'; - if (isSet) { - setConstraints.add(constraint); - } else { - output = '$output$constraint'; - } - } else if (_maxItems.isExactlyType(valueType)) { - _require( - declaration, - type, - '@MaxItems', - isCollection, - 'List or Set field', - ); - final constraint = - '.maxItems(${value.getField('count')!.toIntValue()})'; - if (isSet) { - setConstraints.add(constraint); - } else { - output = '$output$constraint'; - } - } else if (_uniqueItems.isExactlyType(valueType)) { - _require( - declaration, - type, - '@UniqueItems', - isCollection, - 'List or Set field', - ); - if (isSet) { - hasUniqueConstraint = true; - setConstraints.add('.unique()'); - } else { - output = '$output.unique()'; + if (_validate.isExactlyType(valueType)) { + final kind = value.getField('kind')!.toStringValue()!; + switch (kind) { + case 'optional' || 'required' || 'notNull': + break; + case 'min': + _require( + declaration, + type, + '@Validate.min', + isNumeric, + '@Validate.minLength', + ); + addScalarConstraint( + '.min(${_number(declaration, '@Validate.min', value)})', + ); + case 'max': + _require( + declaration, + type, + '@Validate.max', + isNumeric, + '@Validate.maxLength', + ); + addScalarConstraint( + '.max(${_number(declaration, '@Validate.max', value)})', + ); + case 'multipleOf': + _require( + declaration, + type, + '@Validate.multipleOf', + isNumeric, + 'numeric field', + ); + addScalarConstraint( + '.multipleOf(${_number(declaration, '@Validate.multipleOf', value)})', + ); + case 'positive': + _require( + declaration, + type, + '@Validate.positive', + isNumeric, + 'numeric field', + ); + addScalarConstraint('.positive()'); + case 'negative': + _require( + declaration, + type, + '@Validate.negative', + isNumeric, + 'numeric field', + ); + addScalarConstraint('.negative()'); + case 'minLength': + _require( + declaration, + type, + '@Validate.minLength', + isString, + '@Validate.min', + ); + addScalarConstraint( + '.minLength(${value.getField('value')!.toIntValue()})', + ); + case 'maxLength': + _require( + declaration, + type, + '@Validate.maxLength', + isString, + '@Validate.max', + ); + addScalarConstraint( + '.maxLength(${value.getField('value')!.toIntValue()})', + ); + case 'matches': + _require( + declaration, + type, + '@Validate.matches', + isString, + 'String field', + ); + addScalarConstraint( + '.matches(${dartStringLiteral(value.getField('value')!.toStringValue()!)})', + ); + case 'notEmpty': + _require( + declaration, + type, + '@Validate.notEmpty', + isString || isCollection, + 'String, List, or Set field', + ); + if (isCollection) { + addCollectionConstraint('.notEmpty()'); + } else { + output = '$output.notEmpty()'; + } + case 'email': + _require( + declaration, + type, + '@Validate.email', + isString, + 'String field', + ); + addScalarConstraint('.email()'); + case 'url': + _require( + declaration, + type, + '@Validate.url', + isString, + 'String field', + ); + addScalarConstraint('.url()'); + case 'uri': + _require( + declaration, + type, + '@Validate.uri', + isString, + 'String field', + ); + addScalarConstraint('.uri()'); + case 'uuid': + _require( + declaration, + type, + '@Validate.uuid', + isString, + 'String field', + ); + addScalarConstraint('.uuid()'); + case 'date': + _require( + declaration, + type, + '@Validate.date', + isString, + 'String field', + ); + addScalarConstraint('.date()'); + case 'dateTime': + _require( + declaration, + type, + '@Validate.dateTime', + isString, + 'String field', + ); + addScalarConstraint('.datetime()'); + case 'ip': + _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 @Validate.ip(version: ' + '$version); version must be 4 or 6.', + element: declaration, + ); + } + addScalarConstraint('.ip(version: $version)'); + } else { + addScalarConstraint('.ip()'); + } + case 'ipv4': + _require( + declaration, + type, + '@Validate.ipv4', + isString, + 'String field', + ); + addScalarConstraint('.ipv4()'); + case 'ipv6': + _require( + declaration, + type, + '@Validate.ipv6', + isString, + 'String field', + ); + addScalarConstraint('.ipv6()'); + case 'minItems': + _require( + declaration, + type, + '@Validate.minItems', + isCollection, + 'List or Set field', + ); + addCollectionConstraint( + '.minItems(${value.getField('value')!.toIntValue()})', + ); + case 'maxItems': + _require( + declaration, + type, + '@Validate.maxItems', + isCollection, + 'List or Set field', + ); + addCollectionConstraint( + '.maxItems(${value.getField('value')!.toIntValue()})', + ); + case 'uniqueItems': + _require( + declaration, + type, + '@Validate.uniqueItems', + isCollection, + 'List or Set field', + ); + addCollectionConstraint('.unique()', isUnique: true); } } } + if (itemConstraints.isNotEmpty) { + output = _appendListItemConstraints(output, itemConstraints.join()); + } if (setConstraints.isNotEmpty) { if (isSet && !hasUniqueConstraint) { // A Set cannot retain duplicate values after decoding. Validate the @@ -262,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) { @@ -281,7 +411,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. @@ -290,9 +420,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) { @@ -301,18 +431,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)})'; @@ -342,17 +460,14 @@ 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')!; 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 +484,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/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..a614a6bf 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,14 @@ 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 +44,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..eb58e151 --- /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` `@Schemable` declarations from each +/// `.schema.json` draft 2020-12 bundle. +/// +/// 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); + + /// 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_emitter.dart b/packages/ack_generator/lib/src/json_schema/json_schema_emitter.dart new file mode 100644 index 00000000..042007a2 --- /dev/null +++ b/packages/ack_generator/lib/src/json_schema/json_schema_emitter.dart @@ -0,0 +1,195 @@ +part of 'json_schema_library.dart'; + +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: ${dartStringLiteral(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 = dartStringLiteral(documentUri.toString()); + final lookupUri = dartStringLiteral( + 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('// dart format off') + ..writeln('final Object _jsonSchemaDocument =') + ..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(${dartStringLiteral(entry.key.toString())}): ' + 'jsonDecode(${dartStringLiteral(jsonEncode(entry.value))}) as Object,', + ); + } + buffer + ..writeln('};') + ..writeln('// dart format on') + ..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': ${dartStringLiteral(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.toJsonSchema();') + ..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'); 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..88432453 --- /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'; +} + +/// 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) + : 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..75529c50 --- /dev/null +++ b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart @@ -0,0 +1,205 @@ +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 '../utils/string_literal.dart'; +import 'json_schema_generation_exception.dart'; + +part 'json_schema_emitter.dart'; +part 'json_schema_translator.dart'; + +/// How generated models treat properties a JSON Schema object doesn't declare. +enum AckUnknownPropertiesMode { + /// Follows the schema, where `additionalProperties: false` rejects unknown + /// keys. + schema, + + /// Accepts and keeps 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; + + /// Whether to assert known formats even under the standard 2020-12 dialect. + final bool assertFormats; + + /// Mode for generated models (`auto` retains typed 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 (`.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 +/// 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), + ); +} diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_translator.dart b/packages/ack_generator/lib/src/json_schema/json_schema_translator.dart new file mode 100644 index 00000000..b3c1d5d6 --- /dev/null +++ b/packages/ack_generator/lib/src/json_schema/json_schema_translator.dart @@ -0,0 +1,940 @@ +part of 'json_schema_library.dart'; + +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` 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; + didWiden = true; + } + if (node['unevaluatedProperties'] == false) { + node['unevaluatedProperties'] = true; + didWiden = true; + } + if (didWiden) 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(${dartStringLiteral(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, describe); + } + 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', root: root); + 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, pointer), + '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, + String Function(String) describe, + ) { + 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(describe(_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( + describe(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, root: root); + return union == null ? null : _Expr(describe(union), nullable: nullable); + } + + 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) { + 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: ${dartStringLiteral('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(dartStringLiteral).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(${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 && _fitsInInt64(value) && 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 (_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(); + } + + String? _integer(Map node, String pointer) { + final buffer = StringBuffer('ack.Ack.integer()'); + int? integral(Object? value) => + 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)'); + } + 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 (_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(); + } + + _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( + '${dartStringLiteral(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(${dartStringLiteral(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 _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'); + +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..3e35e817 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', @@ -49,42 +39,42 @@ final class AckModelGenerator extends Generator { @override Future generate(LibraryReader library, BuildStep buildStep) async { + _validateLibrarySchemable(library.element); final annotated = []; 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 and getters, or library directives.', + 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 +87,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 +118,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 +141,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 +196,47 @@ 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 && + element is! LibraryElement && + _schemableChecker.hasAnnotationOfExact(element); - bool _hasAckInfer(Element element) => - _ackInferChecker.hasAnnotationOfExact(element) || - (element is! ClassElement && - _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.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 +251,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 +294,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/models/schema_model_graph.dart b/packages/ack_generator/lib/src/models/schema_model_graph.dart index 3c0e9cd9..5c954cd6 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}); @@ -21,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. @@ -40,7 +41,6 @@ final class AckConstructorParameter { required this.kind, required this.fieldName, required this.typeRef, - this.isSuper = false, this.defaultExpression, }); @@ -48,7 +48,6 @@ final class AckConstructorParameter { final AckConstructorParameterKind kind; final String fieldName; final AckInferRef typeRef; - final bool isSuper; final String? defaultExpression; } @@ -58,6 +57,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 +108,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 +125,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 { @@ -130,6 +146,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({ @@ -152,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; @@ -178,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; } @@ -195,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; } @@ -212,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, @@ -230,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; @@ -249,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(...)`. @@ -260,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/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 fe2a0726..15661fc3 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 +version: 2.0.0-alpha.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace @@ -11,28 +11,30 @@ topics: - schema environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.11.0 <4.0.0' + dependencies: # Core code generation dependencies - analyzer: ">=10.0.0 <11.0.0" + analyzer: ">=13.0.0 <14.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 - json_annotation: ^4.12.0 - json_serializable: ^6.14.1 - logging: ^1.3.0 - meta: ^1.15.0 + pub_semver: ^2.1.4 # Ack packages (versions are overridden locally by Melos) - ack: ^1.7.0 + ack: ^2.0.0-alpha.1 dev_dependencies: build_runner: ^2.1.7 build_test: ^3.1.0 + json_annotation: ^4.12.0 + json_serializable: ^6.14.1 + # 6.14.1 does not compile against source_helper 1.4.0. The build fixtures + # pub get --offline, so this bound has to be in the cache. + source_helper: '>=1.3.10 <1.4.0' + logging: ^1.3.0 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/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/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..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 @@ -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) + @Validate.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,13 @@ $_imports final class Normalized with _\$NormalizedAck { const Normalized(String? value) : value = value ?? ''; - @AckField(presence: AckFieldPresence.optional) + @Validate.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 +225,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..7523e496 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}); @@ -271,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()'], @@ -319,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( @@ -335,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( @@ -351,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( @@ -370,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/ack_infer_models_test.dart b/packages/ack_generator/test/integration/ack_infer_models_test.dart index c59a53df..17be2ce9 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,9 +789,7 @@ final petSchema = Ack.discriminated( ''', }, outputs: { - 'test_pkg|lib/cat.ack.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); @@ -1006,4 +799,33 @@ 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..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 @@ -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({ @@ -589,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', @@ -600,27 +600,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']), - ); - expect( - generated['lib/models.ack.g.dart'], - contains(r'_$ExtrasFromJson'), + generated['lib/models.g.dart'], + contains('static Extras _fromAckRuntime'), ); expect( - generated['lib/models.ack.g.dart'], - contains('Extras._ackFromRuntimeName'), + generated['lib/models.g.dart'], + contains('Map _toAckRuntime()'), ); - expect(generated['lib/models.ack.dart'], contains(r'_$ExtrasFromJson')); _expectSuccess( await _run(temporary, ['analyze', '--fatal-infos']), @@ -642,11 +634,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..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 @@ -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 { @@ -72,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, @@ -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..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 @@ -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'}, @@ -449,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', @@ -463,36 +451,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..320d2d24 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( @@ -110,6 +109,9 @@ dev_dependencies: path: ${p.join(projectRoot.path, 'packages', 'ack_generator')} build_runner: ^2.15.0 json_serializable: ^6.14.1 + # json_serializable 6.14.1 does not compile against source_helper 1.4.0. + # 6.15.1 fixes that and requires Dart 3.12. + source_helper: '>=1.3.10 <1.4.0' test: ^1.29.0 dependency_overrides: ack: @@ -123,17 +125,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 +134,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()}); @@ -167,15 +151,11 @@ 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; +import 'package:json_annotation/json_annotation.dart' show JsonSerializable; 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 { @@ -200,16 +180,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, @@ -224,9 +195,8 @@ final class Profile with _$ProfileAck { final Uri? website; final String? nickname; final String role; - @UniqueItems() + @Validate.uniqueItems() final Set tags; - @AckField(schema: colorSchema) final Color color; static final fromJson = ProfileSchema.fromJson; @@ -236,7 +206,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') + @AckKey('avatar') final Uri imageUrl; static final fromJson = AccountSchema.fromJson; @@ -276,7 +246,7 @@ final class Loose with _$LooseAck { final class Normalized with _$NormalizedAck { const Normalized(String? value) : value = value ?? ''; - @Optional() + @Validate.optional() final String value; } @@ -294,12 +264,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; } @@ -312,9 +280,8 @@ final class ImmutableCollections with _$ImmutableCollectionsAck { }); final List> matrix; - @UniqueItems() + @Validate.uniqueItems() final Set labels; - @AckField(schema: groupsSchema) final Map> groups; } @@ -322,13 +289,13 @@ 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() - @NotEmpty() + @Validate.optional() + @Validate.notNull() + @Validate.notEmpty() final String? title; static final fromJson = ExampleSchema.fromJson; @@ -345,7 +312,7 @@ final class ExampleHolder with _$ExampleHolderAck { final class OptionalNullable with _$OptionalNullableAck { const OptionalNullable({this.note}); - @Optional() + @Validate.optional() final String? note; } @@ -353,27 +320,41 @@ final class OptionalNullable with _$OptionalNullableAck { final class InferredNotNull with _$InferredNotNullAck { const InferredNotNull({this.label}); - @NotNull() + @Validate.notNull() final String? label; } -AckSchema nullableNameSchema() => Ack.string().nullable(); +final class NameTag { + const NameTag(this.value); + final String value; -@Schemable() + @override + bool operator ==(Object other) => other is NameTag && other.value == value; + + @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}); - @NotNull() - @AckField(schema: nullableNameSchema) - final String? name; + @Validate.notNull() + final NameTag? name; } @Schemable() final class RequiredNotNull with _$RequiredNotNullAck { const RequiredNotNull({this.value}); - @Required() - @NotNull() + @Validate.required() + @Validate.notNull() final String? value; } @@ -382,19 +363,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, @@ -403,16 +378,12 @@ final class CapabilityBinding with _$CapabilityBindingAck { final String name; - @Optional() - @AckField(schema: parameterMapSchema) + @Validate.optional() 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({ @@ -427,16 +398,16 @@ 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; final Map labels; - @AckField(schema: scoresSchema) final Map scores; } @@ -530,8 +501,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; @@ -572,6 +542,24 @@ 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; + + @Validate.notNull() + @Validate.notEmpty() + final JsonMaybe title; + + + final JsonMaybe> tags; +} + @JsonSerializable() final class PlainJson { const PlainJson({required this.value}); @@ -643,7 +631,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(), @@ -880,9 +868,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(), {}); }); @@ -998,7 +989,7 @@ void main() { 'labels': {'env': 1}, }, { - 'scores': {'a': -1}, + 'scores': {'a': 'not-an-int'}, }, ]) { test('Envelope rejects invalid field values: $invalid', () { @@ -1154,15 +1145,63 @@ 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); }); + + 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', @@ -1170,63 +1209,49 @@ void main() { final generated = _generatedFiles(temporary); expect( 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', - ]), + containsAll(['lib/coexist.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.g.dart'], contains(r'mixin _$ProfileAck')); expect( - generated['lib/models.ack.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..61f4a98c 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'; @@ -49,8 +50,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); @@ -61,16 +61,16 @@ 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; } ''', }, 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()})", @@ -81,34 +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.ack.dart'; -part 'scores.ack.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.ack.dart': decodedMatches( - _containsCode("Ack.object({'scores': scoresSchema()})"), - ), - }, - ); - }); - test( 'collection constraints on a nested Set land on the outer set', () async { @@ -117,20 +89,19 @@ 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 { const Grid({required this.cells}); - @MinItems(2) + @Validate.minItems(2) final Set> cells; } ''', }, 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 +123,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,13 +136,58 @@ 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.')"), ), }, ); }); + test( + 'primary constructor declaring parameters support @description and @Validate', + () async { + await withEnabledExperiments( + () => _build( + { + 'primary_doc.dart': + ''' +// @dart=3.13 +$_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( @@ -180,8 +195,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 +222,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 +254,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 +270,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,20 +283,19 @@ 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 { const Escaped({required this.value}); - @AckField(description: 'A\\tB') + /// @description A\tB final String value; } ''', }, 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 +311,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 +337,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( @@ -355,9 +366,7 @@ final class Square extends Shape with _\$SquareAck { 'formats.dart': ''' $_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 { @@ -369,21 +378,21 @@ 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; } ''', }, 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 +411,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 +423,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 +444,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 { @@ -453,8 +460,9 @@ final class Envelope with _\$EnvelopeAck { }); final Object kind; - @Optional() + @Validate.optional() final Object? payload; + final List items; final Set tags; final Map values; @@ -465,7 +473,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 +490,15 @@ final class Envelope with _\$EnvelopeAck { ); }); - test('emits a codec schema, presence semantics, mixin, and bridges', () 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.ack.dart'; -part 'profile.ack.g.dart'; +part 'profile.g.dart'; @Schemable() final class Profile with _\$ProfileAck { @@ -501,94 +510,91 @@ 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; } ''', - }, - outputs: { - 'test_pkg|lib/profile.ack.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'_$ProfileFromJson'), - contains(r'Map _$ProfileToRuntime'), - contains("result['nickname'] = null"), - 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('_ackProfileFromRuntimeBio'), - contains('_ackProfileToRuntimeTags'), - ]), - ), - }, - ); - }); + 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', - () async { - await _build( - { - 'types.dart': - ''' + test('emits custom schemas and built-in recursive type coverage', () async { + await _build( + { + 'types.dart': + ''' $_imports -part 'types.ack.dart'; -part 'types.ack.g.dart'; +part 'types.g.dart'; final class Color { const Color(this.value); @@ -600,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, @@ -621,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; @@ -632,57 +630,59 @@ final class Record with _\$RecordAck { final List> names; } ''', - }, - outputs: { - 'test_pkg|lib/types.ack.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 _ackRecordFromRuntimeScores'), - contains('List> _ackRecordFromRuntimeNames'), - 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 JsonKey schema keys once', () async { + test('computes case-style and AckKey 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, + @AckKey('avatar') required this.imageUrl, + required this.handleName, + }); final String firstName; - @JsonKey(name: 'avatar') final String imageUrl; + @AckKey('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 +697,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 +715,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 +728,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 +754,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 { @@ -768,8 +765,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; } @@ -780,7 +777,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 +796,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 +823,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 +833,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,23 +855,22 @@ 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 { 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() - @NotEmpty() + @Validate.optional() + @Validate.notNull() + @Validate.notEmpty() final String? title; } @@ -883,27 +878,34 @@ final class Example with _\$ExampleAck { final class InferredNotNull with _\$InferredNotNullAck { const InferredNotNull({this.label}); - @NotNull() + @Validate.notNull() final String? label; } -AckSchema nullableNameSchema() => Ack.string().nullable(); +final class NameTag { + const NameTag(this.value); + final String value; +} + +AckSchema nullableNameSchema() => Ack.string().nullable().codec( + decode: NameTag.new, + encode: (value) => value.value, +); -@Schemable() +@Schemable(schemas: [nullableNameSchema]) final class OverrideNotNull with _\$OverrideNotNullAck { const OverrideNotNull({this.name}); - @NotNull() - @AckField(schema: nullableNameSchema) - final String? name; + @Validate.notNull() + final NameTag? name; } @Schemable() final class RequiredNotNull with _\$RequiredNotNullAck { const RequiredNotNull({this.value}); - @Required() - @NotNull() + @Validate.required() + @Validate.notNull() final String? value; } @@ -911,37 +913,22 @@ final class RequiredNotNull with _\$RequiredNotNullAck { final class Forced with _\$ForcedAck { const Forced({this.summary}); - @Required() + @Validate.required() 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; } -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, @@ -950,14 +937,14 @@ final class CapabilityBinding with _\$CapabilityBindingAck { final String name; - @Optional() - @AckField(schema: parameterMapSchema) + @Validate.optional() final Map parameters; } + ''', }, 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,9 +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("'note': Ack.string().optional().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?')), @@ -1002,8 +994,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 +1003,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>'), @@ -1024,4 +1015,184 @@ final class Account with _\$AccountAck { }, ); }); + + test( + '@Validate.* 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, + @Validate.minLength(2) @Validate.maxLength(20) required this.handle, + }); + + @Validate.email() + final String email; + + @Validate.uuid() + final String id; + + @Validate.url() + final String website; + + @Validate.uri() + final String resourceUri; + + @Validate.date() + final String birthDate; + + @Validate.dateTime() + final String createdAt; + + @Validate.ip(version: 4) + final String clientIp; + + @Validate.ipv4() + final String v4; + + @Validate.ipv6() + final String v6; + + @Validate.notEmpty() + @Validate.matches(r'^[A-Z]+\$') + final String code; + + @Validate.min(0) + @Validate.max(100) + @Validate.multipleOf(5) + @Validate.positive() + final int score; + + @Validate.negative() + final double debt; + + @Validate.notEmpty() + @Validate.minItems(1) + @Validate.maxItems(5) + @Validate.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()).notEmpty().minItems(1).maxItems(5).unique()", + ), + _containsCode( + "'handle': Ack.string().minLength(2).maxLength(20)", + ), + ]), + ), + }, + ); + }, + ); + + 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; + + @Validate.notNull() + @Validate.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'), + ]), + ), + }, + ); + }, + ); } 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..7442ff92 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() { @@ -96,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( @@ -113,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.', ], ); }); @@ -125,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( ''' @@ -137,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.', + ], ); }); @@ -160,9 +142,7 @@ final class Person with _\$PersonAck { 'Import package:test_pkg/address.dart.', ], head: _aliasHead, - allowedOutputs: { - 'test_pkg|lib/address.ack.dart': decodedMatches(anything), - }, + allowedOutputs: {'test_pkg|lib/address.g.dart': decodedMatches(anything)}, extraSources: { 'aliases.dart': ''' import 'address.dart'; @@ -174,8 +154,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 { @@ -256,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 { @@ -305,11 +273,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'], ); }); @@ -320,11 +288,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'], ); }); @@ -335,11 +303,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'], ); }); @@ -377,129 +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().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().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( ''' @@ -829,57 +674,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 @AckKey(...) instead.', ], ); }); - test('requires JsonKey name overrides on the field', () async { + test('rejects @JsonKey on constructor parameters', () async { await _expectFailure( ''' @Schemable() @@ -889,7 +702,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 @AckKey(...) instead.', + ], ); }); @@ -907,8 +723,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 +804,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 +921,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 +959,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 +977,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 +995,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 +1008,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 +1024,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 +1048,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 +1056,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 { @@ -1284,9 +1066,7 @@ final class Address with _\$AddressAck { } ''', }, - allowedOutputs: { - 'test_pkg|lib/address.ack.dart': decodedMatches(anything), - }, + allowedOutputs: {'test_pkg|lib/address.g.dart': decodedMatches(anything)}, ); }); @@ -1319,23 +1099,42 @@ 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}); + + @AckKey(' ') + final String name; +} +''', + ['User.name', '@AckKey.name must not be blank.'], + ); + }); + + test('rejects combining @AckKey(...) and @JsonKey(name: ...)', () async { await _expectFailure( ''' @Schemable() final class User with _\$UserAck { const User({required this.name}); - @AckField() + @AckKey('a') + @JsonKey(name: 'b') final String name; } ''', - ['User.name', '@AckField()', 'no-op'], + [ + 'User.name', + 'uses @JsonKey, which is not supported on @Schemable classes in Ack 2.0. Use @AckKey(...) instead.', + ], ); }); test( - 'rejects optional presence on a required constructor parameter', + 'rejects @Validate.optional() on a required constructor parameter', () async { await _expectFailure( ''' @@ -1343,7 +1142,7 @@ final class User with _\$UserAck { final class User with _\$UserAck { const User({required this.name}); - @AckField(presence: AckFieldPresence.optional) + @Validate.optional() final String name; } ''', @@ -1352,99 +1151,174 @@ final class User with _\$UserAck { }, ); - test('rejects @Optional() on a required constructor parameter', () async { - await _expectFailure( - ''' + test( + 'rejects combining @Validate.optional() and @Validate.required()', + () async { + await _expectFailure( + ''' @Schemable() final class User with _\$UserAck { - const User({required this.name}); + const User({this.name}); - @Optional() - final String name; + @Validate.optional() + @Validate.required() + final String? name; } ''', - ['User.name', 'optional', 'constructor'], - ); - }); + ['User.name', '@Validate.optional()', '@Validate.required()'], + ); + }, + ); - test('rejects combining @Optional() and @Required()', () async { + test('rejects an unmapped constructor parameter', () async { await _expectFailure( ''' @Schemable() final class User with _\$UserAck { - const User({this.name}); + const User(this.name, String extra) : label = extra; - @Optional() - @Required() - final String? name; + final String name; + final String label; } ''', - ['User.name', '@Optional()', '@Required()'], + ['User', 'extra', 'mapped'], ); }); - test('rejects conflicting legacy and new presence declarations', () async { - await _expectFailure( - ''' + test( + '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 User with _\$UserAck { - const User({this.name}); +final class Order with _\$OrderAck { + const Order({required this.address}); - @Optional() - @AckField(presence: AckFieldPresence.required) - final String? name; + final Address address; } ''', - ['User.name', 'conflicting', 'presence'], - ); - }); + 'test_pkg|lib/address.dart': ''' +import 'package:ack/ack.dart'; + +part 'address.g.dart'; - test('warns when legacy AckField presence is used', () async { - await _expectWarning( - ''' @Schemable() -final class User with _\$UserAck { - const User({this.name}); +final class Address with _\$AddressAck { + const Address({required this.city}); - @AckField(presence: AckFieldPresence.optional) - final String? name; + final String city; + + static final schema = _addressSchema; } ''', - ['User.name', '@AckField(presence:', '@Optional()', '2.0.0'], - ); - }); + }, + 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( - 'warns when matching legacy and new presence declarations coexist', + 'rejects invalid @Validate.* constraints on mismatched field types or values', () async { - await _expectWarning( + await _expectFailure( ''' @Schemable() final class User with _\$UserAck { - const User({this.name}); + const User({required this.name}); - @Optional() - @AckField(presence: AckFieldPresence.optional) - final String? name; + @Validate.min(1) + final String name; } ''', - ['User.name', '@AckField(presence:', '2.0.0'], + ['User.name', '@Validate.min', 'String', '@Validate.minLength'], ); - }, - ); + await _expectFailure( + ''' +@Schemable() +final class User with _\$UserAck { + const User({required this.ip}); - test('rejects an unmapped constructor parameter', () async { - await _expectFailure( - ''' + @Validate.ip(version: 5) + final String ip; +} +''', + ['User.ip', '@Validate.ip(version: 5)', 'version must be 4 or 6.'], + ); + await _expectFailure( + ''' @Schemable() final class User with _\$UserAck { - const User(this.name, String extra) : label = extra; + const User({@Validate.minLength(1) required this.age}); - final String name; - final String label; + final int age; } ''', - ['User', 'extra', 'mapped'], - ); + ['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({ + @AckKey('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/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..9659ab62 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']), @@ -112,52 +112,32 @@ 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'))); + 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(r'decode: _$AccountFromRuntime')); + expect(entry.value, isNot(contains('final accountSchema'))); } else { - expect(entry.value, contains('JsonSerializableGenerator')); - expect( - entry.value, - contains( - isClassFirst ? '_ackAccountFromRuntime' : '_ackFromRuntime', - ), - ); + 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( @@ -177,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: @@ -220,45 +197,42 @@ dev_dependencies: dependency_overrides: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} -''', - ); +'''); - _expectSuccess( - await _run(temporaryExample, ['pub', 'get']), - 'dart pub get', - ); + _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.ack.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/inference_library_test.dart b/packages/ack_generator/test/integration/inference_library_test.dart index e45f0815..67af42da 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, }) {} ''', @@ -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(); @@ -134,7 +107,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 +131,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'}, @@ -175,4 +148,35 @@ void widget({@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()', + ), + }, + ); + }, + ); } diff --git a/packages/ack_generator/test/integration/inference_literal_test.dart b/packages/ack_generator/test/integration/inference_literal_test.dart index 7460d69b..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'}, @@ -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..1c560b26 --- /dev/null +++ b/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart @@ -0,0 +1,318 @@ +@Tags(['integration']) +library; + +import 'dart:convert'; +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}).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); + 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'); + }); +} +'''); + + 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( + await _run(project, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); + _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..ae8ac88d 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({ @@ -75,32 +74,24 @@ 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', ); - 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 +100,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( @@ -132,6 +123,9 @@ dev_dependencies: path: ${p.join(projectRoot.path, 'packages', 'ack_generator')} build_runner: ^2.15.0 json_serializable: ^6.14.1 + # json_serializable 6.14.1 does not compile against source_helper 1.4.0. + # 6.15.1 fixes that and requires Dart 3.12. + source_helper: '>=1.3.10 <1.4.0' test: ^1.29.0 dependency_overrides: ack: @@ -141,11 +135,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 +143,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() @@ -191,7 +178,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', @@ -201,26 +191,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 +215,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 +252,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({ @@ -292,7 +276,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', @@ -302,12 +289,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/library_schema_test.dart b/packages/ack_generator/test/integration/library_schema_test.dart new file mode 100644 index 00000000..cfa489fd --- /dev/null +++ b/packages/ack_generator/test/integration/library_schema_test.dart @@ -0,0 +1,169 @@ +import 'package:test/test.dart'; + +import 'schema_resolution_support.dart'; + +void main() { + 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 generationErrors( + 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: [...]).', + ), + ), + ); + }, + ); + + test( + 'rejects an auto-detected top-level schema that uses a one-way transform', + () async { + final errors = await generationErrors( + 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.', + ), + ), + ); + }, + ); +} 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/model_generation_guide_build_test.dart b/packages/ack_generator/test/integration/model_generation_guide_build_test.dart index c9ae742f..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); @@ -114,7 +115,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/schema_resolution_support.dart b/packages/ack_generator/test/integration/schema_resolution_support.dart new file mode 100644 index 00000000..364afba4 --- /dev/null +++ b/packages/ack_generator/test/integration/schema_resolution_support.dart @@ -0,0 +1,148 @@ +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 'types.dart'; + +part 'model.g.dart'; +'''; + +const _types = r''' +import 'package:ack/ack.dart'; + +final class Slot { + const Slot(this.id); + final String id; + + static final schema = Ack.string().codec( + decode: Slot.new, + encode: (slot) => slot.id, + ); +} + +final class Token { + const Token(this.value); + final String value; + + static AckSchema get schema => Ack.string().codec( + decode: Token.new, + encode: (token) => token.value, + ); +} + +extension type const WidgetId(String value) implements Object { + static final schema = Ack.string().codec( + decode: WidgetId.new, + encode: (id) => id.value, + ); +} + +enum CompletionAction { complete, skip } + +final class Invoke { + const Invoke(this.action); + final A action; + + static AckSchema> schema( + List actions, + ) => throw UnimplementedError(); +} + +final class Trigger { + const Trigger(this.name); + final String name; + + static AckSchema> schema() => + Ack.string().codec>( + decode: Trigger.new, + encode: (trigger) => trigger.name, + ); +} + +final class Box { + const Box(this.value); + final A value; + + static AckSchema> schema( + AckSchema value, + ) => value.codec>(decode: Box.new, encode: (box) => box.value); +} + +final class Pair { + const Pair(this.first, this.second); + final A first; + final B second; + + static AckSchema> schema( + AckSchema first, + ) => throw UnimplementedError(); +} + +final class Command { + const Command(this.name); + final String name; + + static AckSchema> schema() => + Ack.string().codec>( + decode: Command.new, + encode: (command) => command.name, + ); +} +'''; + +/// Compares generated code without formatter whitespace or trailing commas. +Matcher containsCode(String code) => predicate( + (output) => _compact(output).contains(_compact(code)), + 'contains code `$code`', +); + +String _compact(String code) => + code.replaceAll(RegExp(r'\s+'), '').replaceAll(',)', ')'); + +Future expectOutput( + String body, + Matcher matcher, { + String head = _head, + String types = _types, +}) async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + { + 'test_pkg|lib/model.dart': '$head\n$body', + 'test_pkg|lib/types.dart': types, + }, + generateFor: const {'test_pkg|lib/model.dart'}, + readerWriter: readerWriter, + outputs: {'test_pkg|lib/model.g.dart': decodedMatches(matcher)}, + ); +} + +Future> generationErrors( + String body, { + String head = _head, + String types = _types, +}) 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', + 'test_pkg|lib/types.dart': types, + }, + 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; +} diff --git a/packages/ack_generator/test/integration/schemable_schemas_test.dart b/packages/ack_generator/test/integration/schemable_schemas_test.dart new file mode 100644 index 00000000..ea4215c4 --- /dev/null +++ b/packages/ack_generator/test/integration/schemable_schemas_test.dart @@ -0,0 +1,202 @@ +import 'package:test/test.dart'; + +import 'schema_resolution_support.dart'; + +void main() { + 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, +); + +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("'cellValue': cellValueSchema(),"), + containsCode("'anyJson': Ack.any().optional().nullable()"), + ), + 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 generationErrors(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.', + ), + ), + ); + + final duplicateErrors = await generationErrors( + 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.', + ), + ), + ); + }, + ); +} 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..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 { @@ -113,15 +109,47 @@ 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''' + File(p.join(temporary.path, 'lib', 'models.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; import 'types.dart'; -part 'models.ack.dart'; -part 'models.ack.g.dart'; +part 'models.g.dart'; + +final colorSchema = Ack.string().codec( + decode: Color.new, + encode: (color) => color.hex, +); @Schemable() final class Habit with _$HabitAck { @@ -129,7 +157,20 @@ final class Habit with _$HabitAck { final String name; - static final schema = HabitSchema.schema; + static final schema = _habitSchema; +} + +@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() @@ -187,13 +228,13 @@ final class Shelf with _$ShelfAck { final panelSchema = Ack.object({ 'header': Slot.schema, 'commands': Ack.list(Command.schema()), + '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'; @@ -276,37 +317,53 @@ 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')); }); + + 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); + }); } '''); - _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); - _expectSuccess( - await _run(temporary, ['run', 'build_runner', 'build']), - 'build_runner build', - ); - final generated = File( - p.join(temporary.path, 'lib', 'models.ack.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/integration/type_owned_schema_test.dart b/packages/ack_generator/test/integration/type_owned_schema_test.dart index 98939a03..b3810cdb 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_test.dart @@ -1,156 +1,10 @@ -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 'types.dart'; - -part 'model.ack.dart'; -part 'model.ack.g.dart'; -'''; - -const _types = r''' -import 'package:ack/ack.dart'; - -final class Slot { - const Slot(this.id); - final String id; - - static final schema = Ack.string().codec( - decode: Slot.new, - encode: (slot) => slot.id, - ); -} - -final class Token { - const Token(this.value); - final String value; - - static AckSchema get schema => Ack.string().codec( - decode: Token.new, - encode: (token) => token.value, - ); -} - -extension type const WidgetId(String value) implements Object { - static final schema = Ack.string().codec( - decode: WidgetId.new, - encode: (id) => id.value, - ); -} - -enum CompletionAction { complete, skip } - -final class Invoke { - const Invoke(this.action); - final A action; - - static AckSchema> schema( - List actions, - ) => throw UnimplementedError(); -} - -final class Trigger { - const Trigger(this.name); - final String name; - - static AckSchema> schema() => - Ack.string().codec>( - decode: Trigger.new, - encode: (trigger) => trigger.name, - ); -} - -final class Box { - const Box(this.value); - final A value; - - static AckSchema> schema( - AckSchema value, - ) => value.codec>(decode: Box.new, encode: (box) => box.value); -} - -final class Pair { - const Pair(this.first, this.second); - final A first; - final B second; - - static AckSchema> schema( - AckSchema first, - ) => throw UnimplementedError(); -} - -final class Command { - const Command(this.name); - final String name; - - static AckSchema> schema() => - Ack.string().codec>( - decode: Command.new, - encode: (command) => command.name, - ); -} -'''; - -/// Compares generated code without formatter whitespace or trailing commas. -Matcher _containsCode(String code) => predicate( - (output) => _compact(output).contains(_compact(code)), - 'contains code `$code`', -); - -String _compact(String code) => - code.replaceAll(RegExp(r'\s+'), '').replaceAll(',)', ')'); - -Future _expectOutput( - String body, - Matcher matcher, { - String head = _head, - String types = _types, -}) async { - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - await testBuilder( - ackModelBuilder(BuilderOptions.empty), - { - 'test_pkg|lib/model.dart': '$head\n$body', - 'test_pkg|lib/types.dart': types, - }, - generateFor: const {'test_pkg|lib/model.dart'}, - readerWriter: readerWriter, - outputs: {'test_pkg|lib/model.ack.dart': decodedMatches(matcher)}, - ); -} - -Future> _errors( - String body, { - String head = _head, - String types = _types, -}) 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', - 'test_pkg|lib/types.dart': types, - }, - 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; -} +import 'schema_resolution_support.dart'; void main() { test('a field resolves to the static schema of its type', () async { - await _expectOutput( + await expectOutput( r''' @Schemable() final class Section with _$SectionAck { @@ -168,16 +22,16 @@ final class Section with _$SectionAck { } ''', allOf( - _containsCode("'header': Slot.schema,"), - _containsCode("'footer': Slot.schema.optional().nullable(),"), - _containsCode("'token': Token.schema,"), - _containsCode("'widget': WidgetId.schema,"), + containsCode("'header': Slot.schema,"), + containsCode("'footer': Slot.schema.optional().nullable(),"), + containsCode("'token': Token.schema,"), + containsCode("'widget': WidgetId.schema,"), ), ); }); test('a type-owned schema resolves per collection item', () async { - await _expectOutput( + await expectOutput( r''' @Schemable() final class Section with _$SectionAck { @@ -193,11 +47,11 @@ final class Section with _$SectionAck { } ''', allOf( - _containsCode("'children': Ack.list(Slot.schema),"), - _containsCode( + containsCode("'children': Ack.list(Slot.schema),"), + containsCode( "'unique': Ack.list(WidgetId.schema).codec>(", ), - _containsCode("'byName': Ack.map(Slot.schema),"), + containsCode("'byName': Ack.map(Slot.schema),"), ), ); }); @@ -205,7 +59,7 @@ final class Section with _$SectionAck { test( 'a generic type resolves through a static generic schema method', () async { - await _expectOutput( + await expectOutput( r''' @Schemable() final class Menu with _$MenuAck { @@ -216,8 +70,8 @@ final class Menu with _$MenuAck { } ''', allOf( - _containsCode("'open': Command.schema(),"), - _containsCode( + containsCode("'open': Command.schema(),"), + containsCode( "'history': Ack.list(Command.schema()),", ), ), @@ -226,7 +80,7 @@ final class Menu with _$MenuAck { ); test('void, Object? and Null pass through as type arguments', () async { - await _expectOutput( + await expectOutput( r''' @Schemable() final class Panel with _$PanelAck { @@ -244,17 +98,17 @@ final class Panel with _$PanelAck { } ''', allOf( - _containsCode("'openAll': Trigger.schema(),"), - _containsCode("'anything': Trigger.schema(),"), - _containsCode("'nothing': Trigger.schema(),"), - _containsCode("'complete': Trigger.schema()"), + containsCode("'openAll': Trigger.schema(),"), + containsCode("'anything': Trigger.schema(),"), + containsCode("'nothing': Trigger.schema(),"), + containsCode("'complete': Trigger.schema()"), contains('Trigger? openAll'), ), ); }); test('a prefixed type keeps its prefix', () async { - await _expectOutput( + await expectOutput( r''' @Schemable() final class Section with _$SectionAck { @@ -263,19 +117,18 @@ final class Section with _$SectionAck { final types.Slot header; } ''', - _containsCode("Ack.object({'header': types.Slot.schema})"), + containsCode("Ack.object({'header': types.Slot.schema})"), head: ''' import 'package:ack/ack.dart'; import 'types.dart' as types; -part 'model.ack.dart'; -part 'model.ack.g.dart'; +part 'model.g.dart'; ''', ); }); test('a static schema with another runtime type is rejected', () async { - final errors = await _errors( + final errors = await generationErrors( r''' @Schemable() final class Section with _$SectionAck { @@ -308,7 +161,7 @@ final class Label { }); test('a generic type with a static schema field is rejected', () async { - final errors = await _errors( + final errors = await generationErrors( r''' @Schemable() final class Section with _$SectionAck { @@ -345,7 +198,7 @@ final class Box { }); test('a generic schema method receives the type argument schemas', () async { - await _expectOutput( + await expectOutput( r''' @Schemable() final class Row with _$RowAck { @@ -372,13 +225,13 @@ final class Screen with _$ScreenAck { } ''', allOf( - _containsCode("'row': Box.schema(RowSchema.schema),"), - _containsCode("'title': Box.schema(Ack.string()),"), - _containsCode( + containsCode("'row': Box.schema(RowSchema.schema),"), + containsCode("'title': Box.schema(Ack.string()),"), + containsCode( "'rows': Box.schema>(Ack.list(RowSchema.schema)),", ), - _containsCode("'boxes': Ack.list(Box.schema(RowSchema.schema)),"), - _containsCode( + containsCode("'boxes': Ack.list(Box.schema(RowSchema.schema)),"), + containsCode( "'nested': Box.schema>(Box.schema(Ack.string()))", ), ), @@ -386,7 +239,7 @@ final class Screen with _$ScreenAck { }); test('a schema method with too few schema parameters is rejected', () async { - final errors = await _errors(r''' + final errors = await generationErrors(r''' @Schemable() final class Section with _$SectionAck { const Section({required this.pair}); @@ -408,7 +261,7 @@ final class Section with _$SectionAck { }); test('a wrongly typed schema parameter is rejected', () async { - final errors = await _errors(r''' + final errors = await generationErrors(r''' @Schemable() final class Section with _$SectionAck { const Section({required this.run}); @@ -429,7 +282,7 @@ final class Section with _$SectionAck { }); test('a type argument without a schema is reported by name', () async { - final errors = await _errors( + final errors = await generationErrors( r''' @Schemable() final class Section with _$SectionAck { @@ -461,7 +314,7 @@ final class Box { }); test('a type without a schema explains how to provide one', () async { - final errors = await _errors( + final errors = await generationErrors( r''' @Schemable() final class Section with _$SectionAck { @@ -483,22 +336,24 @@ 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: [...]).', ), ), ); }); - 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() @@ -508,15 +363,16 @@ 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''' + final errors = await generationErrors(r''' @Schemable() final class Habit with _$HabitAck { const Habit({required this.name}); @@ -539,7 +395,7 @@ final class Habit with _$HabitAck { }); test('a schema-first model references a type-owned schema', () async { - await _expectOutput( + await expectOutput( ''' @Schemable() final class Habit with _\$HabitAck { @@ -547,7 +403,7 @@ final class Habit with _\$HabitAck { final String name; - static final schema = HabitSchema.schema; + static final schema = _habitSchema; } @Schemable() @@ -555,6 +411,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()), }); @@ -563,14 +420,88 @@ 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 generationErrors( + 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 generationErrors( + 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( + await expectOutput( r''' @Schemable() final class Task with _$TaskAck { @@ -581,8 +512,8 @@ final class Task with _$TaskAck { } ''', allOf( - _containsCode("'status': Status.schema,"), - _containsCode("'plain': Ack.enumValues(Plain.values)"), + containsCode("'status': Status.schema,"), + containsCode("'plain': Ack.enumValues(Plain.values)"), ), types: ''' import 'package:ack/ack.dart'; @@ -606,7 +537,7 @@ enum Plain { one, two } }); test('a static schema whose type does not resolve asks for a type', () async { - final errors = await _errors( + final errors = await generationErrors( r''' @Schemable() final class Section with _$SectionAck { 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..f73c6df9 --- /dev/null +++ b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart @@ -0,0 +1,88 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 +// ignore_for_file: type=lint +// coverage:ignore-file +// +// 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; + +// 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; +final Map _jsonSchemaDocuments = { + Uri.parse('package:ack_generator_fixtures/mapping.schema.json'): _jsonSchemaDocument, +}; +// dart format on + +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.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..c61b7d16 --- /dev/null +++ b/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart @@ -0,0 +1,91 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 +// ignore_for_file: type=lint +// coverage:ignore-file +// +// 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; + +// 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; +final Map _jsonSchemaDocuments = { + Uri.parse('package:ack_generator_fixtures/mapping.schema.json'): _jsonSchemaDocument, +}; +// dart format on + +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/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..4c6cc0eb --- /dev/null +++ b/packages/ack_generator/test/json_schema/json_schema_library_test.dart @@ -0,0 +1,651 @@ +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()', + ), + '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}, + '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 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': ( + { + '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()', + ), + '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': [ + {'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\u000a\\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'}, + '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': {}}, + }, + }).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', () { + 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('@ack.Schemable')); + 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('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({ + '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 = { + 'Unicode-sensitive dot pattern': {'type': 'string', 'pattern': r'^.$'}, + 'Unicode-sensitive character class': { + 'type': 'string', + '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'}, + {'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'"))); + }); + } + + 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', + '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..51bf3899 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,AckFieldPresence,AckUnknownPropertyPolicy,' - 'NotNull,Optional,Required,Schemable', + 'AckCaseStyle,AckKey,AckUnknownPropertyPolicy,Schemable,Validate', ), }, ); 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..86f8cf0e 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,33 @@ 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/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, ); - } - }); + }, + ); } 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/CHANGELOG.md b/packages/ack_json_schema_builder/CHANGELOG.md index f071de10..f8106e02 100644 --- a/packages/ack_json_schema_builder/CHANGELOG.md +++ b/packages/ack_json_schema_builder/CHANGELOG.md @@ -1,3 +1,21 @@ +## 2.0.0-alpha.1 + +### Breaking + +* 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`). +* `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 + +* Add `assertFormats` parameter to `toAckSchema()` and add + `toJsonSchemaBuilderDraft7()` on `AckSchema` (`#171`). + ## 1.7.0 ### Changed diff --git a/packages/ack_json_schema_builder/README.md b/packages/ack_json_schema_builder/README.md index 7d5c5e93..182926cf 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 - ack_json_schema_builder: ^1.7.0 + ack: ^2.0.0-alpha.1 + ack_json_schema_builder: ^2.0.0-alpha.1 json_schema_builder: ^0.1.3 ``` ### Compatibility -Requires `ack: >=1.6.0 <2.0.0` 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). @@ -95,9 +100,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 dcd542d3..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 @@ -1,7 +1,9 @@ /// 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. +/// 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 @@ -25,15 +27,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,16 +47,14 @@ 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 imported Draft 2020-12 resources by default. jsb.Schema toJsonSchemaBuilder() { return jsb.Schema.fromMap(toJsonSchema()); } -} -/// 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()); + /// 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/pubspec.yaml b/packages/ack_json_schema_builder/pubspec.yaml index 66ca49f5..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: 1.7.0 +version: 2.0.0-alpha.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace @@ -10,10 +10,11 @@ topics: - validation environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.11.0 <4.0.0' + dependencies: - ack: ^1.6.0 + ack: ^2.0.0-alpha.1 json_schema_builder: ^0.1.3 dev_dependencies: 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 e4aff202..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 @@ -19,24 +19,29 @@ void main() { expect(strict.safeParse({}).isFail, isTrue); }); - test('builder bridge retains strictness and diagnostics', () { - final model = jsb.Schema.fromMap({'format': 'hostname'}); - 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); - 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); + final asserted = model.toAckSchema(assertFormats: true); + expect(asserted.safeParse('ada@example.com').isOk, isTrue); + expect(asserted.safeParse('not an email').isFail, isTrue); }); + + 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(exported['type'], 'string'); + expect( + exported[r'$schema'], + 'https://json-schema.org/draft/2020-12/schema', + ); + expect(legacy['definitions'], isA>()); + expect(legacy[r'$schema'], isNull); + }, + ); } 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 769221b2..a0ea9b6a 100644 --- a/packages/ack_mcp_dart/CHANGELOG.md +++ b/packages/ack_mcp_dart/CHANGELOG.md @@ -1,3 +1,12 @@ +## 2.0.0-alpha.1 + +### Breaking + +* 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, ...)`. + ## 1.7.0 ### Changed diff --git a/packages/ack_mcp_dart/README.md b/packages/ack_mcp_dart/README.md index 8a6338bc..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: ^1.2.0 - ack_mcp_dart: ^1.7.0 + ack: ^2.0.0-alpha.1 + ack_mcp_dart: ^2.0.0-alpha.1 mcp_dart: ^2.4.2 ``` @@ -55,13 +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: SearchSchema.model, -callback: (args, extra) { ... })` with an `AckModelAdapter` -(such as a generated model adapter) 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 @@ -98,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 @@ -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 | @@ -141,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-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` | @@ -151,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/pubspec.yaml b/packages/ack_mcp_dart/pubspec.yaml index e6949c6b..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: 1.7.0 +version: 2.0.0-alpha.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace @@ -10,10 +10,11 @@ topics: - validation environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.11.0 <4.0.0' + dependencies: - ack: ^1.2.0 + ack: ^2.0.0-alpha.1 mcp_dart: ^2.4.2 dev_dependencies: 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..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 @@ -15,9 +16,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/test/scripts/api_check_test.dart b/test/scripts/api_check_test.dart index fd029239..f738fa63 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', '1.3.0']) { 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) {