diff --git a/CHANGELOG.md b/CHANGELOG.md index bed9bb0..2a36b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,79 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Interfaces and unions: generated as abstract classes whose `fromJson` + dispatches on `__typename`, with `Unknown` fallbacks; object types + `implements` their interfaces and unions +- Typed `Variables` classes with `toJson()` (enums, dates, custom + scalars and input types are serialized; unset optional variables are omitted) +- Public `Document` / `OperationName` constants and + `parseData` functions, usable with `watchQuery`, custom fetch + policies, etc. +- Operations with several root fields return a `Result` class +- Fragments defined in other `.graphql` files (matched by `document_paths`) +- `__typename` is added to every selection set of generated documents +- Schema extensions (`extend type`, `extend enum`, ...) are merged +- `schema_headers` config option (with `${ENV_VAR}` expansion) and an + introspection fallback (converted to SDL) for remote schemas +- New error codes `BLD101` (unknown fragment), `BLD102` (conflicting fragment), + `BLD103` (unknown root field) +- Test that compiles generated code (json_serializable + `dart analyze`) and + runs it against a fake link + +### Fixed +- Subscriptions now compile: they return `Stream`s via `GraphQLClient.subscribe` + (previously `SubscriptionOptions` were passed to `query`) +- Generated code for schemas with unions no longer fails json_serializable +- Every operation in a document is generated, not only the first one +- Aliased root fields are read from the alias key +- Dart reserved words and clashing names are escaped (`class` -> `class_`, + enum value `default` -> `default_`), keeping the GraphQL name on the wire +- Typed `*Data` methods no longer swallow errors and return `null`; + `OperationException` (with `graphqlErrors`) is thrown instead +- `@skip`/`@include` and other directives are kept when printing operations +- Files whose path contains `schema.graphql` (e.g. `get_schema.graphql`) are + no longer skipped; schema files are detected by their content +- The schema is loaded and `types.dart` generated once per build instead of + once per operation file; local schemas are read through the build system so + editing them triggers a rebuild +- Remote schema downloads have a 30s timeout; failures list every attempt +- `_dateTimeListToJson` now serializes in UTC like `DateTimeConverter` +- Builder output uses `log` instead of `print` (which build_runner reported as + warnings) +- Generated files no longer produce analyzer warnings in user projects +- `dart analyze --fatal-infos` passes again + +### Changed +- Custom scalar wrapper classes keep the raw JSON value (`raw`); `value` is the + string form (JSON-encoded for non-string values). The constructor accepts any + JSON value +- `DateTimeConverter.toJson` / `LocalDateConverter.toJson` return `dynamic` +- Unknown enum values throw `ArgumentError` instead of `Exception` +- `Decimal`, `Long`, `Byte` and custom scalar classes implement `==`/`hashCode` +- Mapped scalars (`scalar_mapping`) no longer get a generated wrapper class + +### Removed +- Placeholder `Awesome` class + ## [1.0.1] - 2025-08-31 ### Fixed - CI stability: make `dart analyze --fatal-infos` pass by removing an unused import in tests +## [1.0.2-rc.1] - 2025-09-16 + +### Fixed +- Cross-platform path handling (Windows/macOS/Linux) in builder and schema downloader using `package:path` +- Proper handling of `file://` URIs via `p.fromUri` and normalized local paths +- Robust `index.dart` export generation using `p.basename` and POSIX `/` separators +- Unified schema loading through `SchemaDownloader.downloadSchema` + +### Notes +- This is a prerelease for Windows IDE/build_runner compatibility improvements + ## [1.0.0] - 2025-06-08 ### Added diff --git a/README.md b/README.md index 17e24c2..11fa92e 100644 --- a/README.md +++ b/README.md @@ -1,440 +1,302 @@ - - -# Flutter GraphQL Codegen +# flutter_graphql_codegen [![pub package](https://img.shields.io/pub/v/flutter_graphql_codegen.svg)](https://pub.dev/packages/flutter_graphql_codegen) [![license](https://img.shields.io/github/license/ifmelate/flutter_graphql_codegen.svg)](https://github.com/ifmelate/flutter_graphql_codegen/blob/main/LICENSE) -A code generator for Flutter GraphQL applications that generates type-safe Dart classes and client extensions from GraphQL schemas and operations. +A `build_runner` builder that reads your GraphQL schema and `.graphql` operation +files and writes Dart code for [`package:graphql`](https://pub.dev/packages/graphql): +model classes for the schema types and an extension on `GraphQLClient` for each +operation. -## ✨ Features +You write this: -- 🎯 **Type-safe code generation** from GraphQL schemas and operations -- 🔧 **Automatic Dart class generation** with proper JSON serialization -- 🚀 **GraphQL client extensions** for queries, mutations, and subscriptions -- 🛡️ **Null safety** support with graceful error handling -- 📦 **Custom scalar support** (DateTime, Decimal, Long, Byte, etc.) -- 🔄 **Safe type converters** with fallback values -- 📋 **Support for complex GraphQL features**: - - Interfaces and unions - - Enums with custom values - - Input types and arguments - - Fragments and inline fragments - - Custom directives - - Nested and recursive types -- 🌐 **Multiple schema sources**: local files, HTTP/HTTPS URLs, file URLs -- 🧪 **Comprehensive test coverage** with robust error handling +```graphql +query GetUser($id: ID!) { + user(id: $id) { + id + name + } +} +``` -## 🚀 Getting Started +and call it like this: -### Installation +```dart +final user = await client.getUserData(GetUserVariables(id: '42').toJson()); +print(user?.name); +``` -Add this package to your `pubspec.yaml`: +## Installation ```yaml dependencies: - flutter_graphql_codegen: ^1.0.0 graphql: ^5.1.3 json_annotation: ^4.8.1 dev_dependencies: + flutter_graphql_codegen: ^1.0.0 build_runner: ^2.4.6 json_serializable: ^6.7.1 ``` -### Configuration +The generated model classes use `json_serializable`, so it has to be in your dev +dependencies too. -1. **Create a `build.yaml` file** in your project root: +## Configuration -```yaml -targets: - $default: - builders: - flutter_graphql_codegen: - enabled: true - options: - config_path: "graphql_codegen.yaml" -``` - -2. **Create a `graphql_codegen.yaml` configuration file**: +Put a `graphql_codegen.yaml` next to your `pubspec.yaml`: ```yaml -# Schema source - supports multiple formats -schema_url: "lib/graphql/schema.graphql" # Local file path -# schema_url: "https://api.example.com/graphql" # HTTP/HTTPS URL -# schema_url: "file:///absolute/path/to/schema.graphql" # File URL - -output_dir: "lib/graphql/generated" +schema_url: lib/graphql/schema.graphql +output_dir: lib/graphql/generated document_paths: - - "lib/graphql/documents/**/*.graphql" + - lib/graphql/documents/**/*.graphql ``` -#### Schema Source Options +The builder looks for `graphql_codegen.yaml` by default. If you keep the file +somewhere else, point to it from `build.yaml`: -The `schema_url` field supports multiple source types: - -🏠 **Local Files:** ```yaml -schema_url: "schema.graphql" # Relative path -schema_url: "lib/graphql/schema.graphql" # Relative path with directories -schema_url: "/absolute/path/to/schema.graphql" # Absolute path +targets: + $default: + builders: + flutter_graphql_codegen|graphql_codegen: + options: + config_path: config/graphql_codegen.yaml ``` -🔗 **File URLs:** -```yaml -schema_url: "file:///absolute/path/to/schema.graphql" -``` +### Where the schema comes from -🌐 **HTTP/HTTPS URLs:** -```yaml -schema_url: "https://api.example.com/graphql" # GraphQL endpoint -schema_url: "http://localhost:4000/graphql/sdl" # Direct SDL endpoint -``` +`schema_url` can be a path inside the project, an absolute path, a `file://` +URL or an HTTP(S) endpoint. + +Local schema files are tracked by `build_runner`, so editing the schema triggers +regeneration. -#### Alternative Configuration Format +For an HTTP(S) endpoint the builder tries a plain `GET` (expecting SDL), then +`GET ?sdl`, and finally a standard introspection query, which it converts to SDL. +Each request times out after 30 seconds. The remote schema is downloaded once per +`build_runner` run, so restart `watch` after the server's schema changes. -You can also use alternative field names for compatibility: +If the endpoint needs authentication, add headers. `${NAME}` is replaced with the +environment variable `NAME`, which keeps tokens out of the repository: ```yaml -schema: "lib/graphql/schema.graphql" # Instead of schema_url -documents: # Instead of document_paths - - "lib/graphql/documents/**/*.graphql" +schema_url: https://api.example.com/graphql +schema_headers: + Authorization: "Bearer ${GRAPHQL_TOKEN}" ``` -## 📖 Usage +### All options -### Basic Example +| Option | Default | Description | +|--------|---------|-------------| +| `schema_url` (or `schema`) | required | Where to load the schema from | +| `document_paths` (or `documents`) | required | Globs for operation files; also used to find shared fragments | +| `output_dir` | `lib/graphql/generated` | Where generated files go | +| `strict_nullability` | `false` | Follow `!` from the schema exactly (see below) | +| `file_naming` | `snake_case` | `snake_case`, `kebab-case`, `camelCase` or `PascalCase` | +| `emit_index` | `true` | Write an `index.dart` that exports everything | +| `scalar_mapping` | `{}` | Map a custom scalar to an existing Dart type, e.g. `Date: DateTime` | +| `schema_headers` | `{}` | HTTP headers for downloading a remote schema | +| `diagnostics_format` | `plain` | `plain` or `json` error output | -1. **Define your GraphQL operations** in `.graphql` files: - -```graphql -# lib/graphql/queries/get_user.graphql -query GetUser($id: ID!) { - user(id: $id) { - id - name - email - isActive - createdAt - } -} -``` +About `strict_nullability`: with the default `false`, list fields are always +nullable, and the typed `*Data` methods return nullable values at every level. +Turn it on for new projects. It's off by default only so existing code keeps +compiling. -2. **Run the code generator**: +## Running the generator ```bash -# First run: Generates GraphQL operation files and types.dart -dart run build_runner build --delete-conflicting-outputs - -# Second run: Generates JSON serialization code (types.g.dart) dart run build_runner build --delete-conflicting-outputs ``` -**Important:** You need to run the build command **twice**: -- **First run** generates GraphQL types and operations -- **Second run** generates JSON serialization code for the types +On a fresh checkout you need to run it twice. The first run writes `types.dart` +and the operation files; the second lets `json_serializable` generate +`types.g.dart` for the new classes. After that one run is enough unless the +schema changes. -This is because the JSON serialization generator (`json_serializable`) needs the types.dart file to exist before it can generate the corresponding .g.dart files. +## What you get -3. **Use the generated code** in your Flutter app: +`output_dir` ends up with: -```dart -import 'package:graphql/client.dart'; -import 'lib/graphql/generated/types.dart'; -import 'lib/graphql/generated/get_user.dart'; - -// Initialize GraphQL client -final client = GraphQLClient( - link: HttpLink('https://your-graphql-endpoint.com/graphql'), - cache: GraphQLCache(), -); - -// Use the generated extension method -final result = await client.getUserQuery( - variables: GetUserArguments(id: 'user-123'), -); - -if (result.hasException) { - print('Error: ${result.exception}'); -} else { - final user = result.parsedData?.user; - print('User: ${user?.name} (${user?.email})'); - print('Created: ${user?.createdAt}'); -} -``` - -### Advanced Features - -#### Custom Scalars +- `types.dart`, with a class for every object, input, interface and union in the + schema, plus enums and JSON converters +- one `_graphql.g.dart` per named operation (a file with three + operations produces three files) +- `index.dart`, which exports all of the above -The generator automatically handles custom scalars with safe converters: +For `query GetUser($id: ID!)`, the operation file contains: ```dart -// Generated safe converters with fallback values -@DateTimeConverter() -DateTime? createdAt; - -@SafeIntConverter() -int count; - -@SafeBoolConverter() -bool isActive; - -@DecimalConverter() -Decimal? price; -``` - -#### Complex Types and Operations +final getUserDocument = graphql.gql(r"""..."""); // the query, with fragments +const String getUserOperationName = 'GetUser'; -```graphql -# Schema types -type User { - id: ID! - profile: UserProfile - posts: [Post!]! - metadata: JSON - role: UserRole! -} +class GetUserVariables { ... Map toJson() } -input CreateUserInput { - name: String! - email: String! - role: UserRole = USER -} - -enum UserRole { - ADMIN - MODERATOR - USER -} +User? parseGetUserData(Map? data); // response -> model -# Mutation example -mutation CreateUser($input: CreateUserInput!) { - createUser(input: $input) { - id - name - email - role - } -} - -# Subscription example -subscription UserUpdated($id: ID!) { - userUpdated(id: $id) { - id - name - isActive - } +extension GetUserExtension on graphql.GraphQLClient { + Future>> getUser([Map? variables]); + Future getUserData([Map? variables]); } ``` -#### Generated Extensions Usage +Mutations work the same way through `client.mutate`. Subscriptions return +streams: ```dart -// Mutation with input validation -final createResult = await client.createUserMutation( - variables: CreateUserArguments( - input: CreateUserInput( - name: 'John Doe', - email: 'john@example.com', - role: UserRole.moderator, - ), - ), -); - -// Subscription with real-time updates -final subscription = client.userUpdatedSubscription( - variables: UserUpdatedArguments(id: 'user-123'), -); - -await for (final result in subscription.stream) { - if (result.data != null) { - final user = result.parsedData?.userUpdated; - print('User updated: ${user?.name}'); - } -} +client.userUpdatedData({'id': '42'}).listen((user) => print(user?.name)); ``` -### Error Handling - -The generator includes robust error handling: +If an operation selects more than one root field, `*Data` returns a small result +class with one property per field (named after the alias if there is one): ```dart -// Safe converters handle malformed data gracefully -@SafeBoolConverter() -bool isActive; // Defaults to false if null or invalid - -@SafeIntConverter() -int count; // Defaults to 0 if null or invalid - -// Custom error handling -final result = await client.getUserQuery( - variables: GetUserArguments(id: 'invalid-id'), -); - -if (result.hasException) { - // Handle GraphQL errors - final graphQLErrors = result.exception?.graphqlErrors; - final networkError = result.exception?.linkException; - - print('GraphQL Errors: $graphQLErrors'); - print('Network Error: $networkError'); -} else { - // Safe to access data - final user = result.parsedData?.user; -} +// query Dashboard { me { id } notifications { id } } +final dashboard = await client.dashboardData(); +print(dashboard?.me?.id); +print(dashboard?.notifications?.length); ``` -## ⚙️ Configuration Options - -| Option | Description | Default | Required | -|--------|-------------|---------|----------| -| `schema_url` | GraphQL schema source (URL or file path) | - | ✅ | -| `output_dir` | Output directory for generated files | `lib/generated` | ❌ | -| `document_paths` | Glob patterns for GraphQL operation files | `**/*.graphql` | ❌ | +The extension methods only cover the common case. For `watchQuery`, a specific +`fetchPolicy`, or anything else `package:graphql` offers, use the document and +the parser directly: -## 📚 API Reference - -### Generated Types +```dart +final query = client.watchQuery(WatchQueryOptions( + document: getUserDocument, + variables: GetUserVariables(id: '42').toJson(), + fetchPolicy: FetchPolicy.cacheAndNetwork, +)); +final users = query.stream.map((result) => parseGetUserData(result.data)); +``` -All GraphQL types are generated as Dart classes with: -- ✅ JSON serialization support (`fromJson`/`toJson`) -- ✅ Null safety with safe defaults -- ✅ Proper field typing based on GraphQL schema -- ✅ Custom scalar handling with type converters +### Variables -### Generated Extensions +`GetUserVariables` turns typed values into the map that `package:graphql` expects. +Enums are sent by their schema name, `DateTime` as ISO-8601 in UTC, and input +types through their `toJson()`. Optional variables you leave out are left out of +the map entirely. If you need to send an explicit `null`, pass a map by hand. -For each operation, the generator creates client extensions: -- **Queries**: `client.{operationName}Query(variables?)` -- **Mutations**: `client.{operationName}Mutation(variables?)` -- **Subscriptions**: `client.{operationName}Subscription(variables?)` +### Interfaces and unions -### Type Safety +These become abstract classes, and `fromJson` picks the concrete class by +`__typename`. The builder adds `__typename` to every selection in the generated +documents, so you don't have to. A type the generator didn't know about (say the +server added a new union member) ends up as `Unknown` instead of crashing: ```dart -// Generated with full type safety -class GetUserArguments { - final String id; - - GetUserArguments({required this.id}); -} - -class User { - final String id; - final String? name; - final String? email; - @SafeBoolConverter() - final bool isActive; - - User({ - required this.id, - this.name, - this.email, - required this.isActive, - }); +for (final result in await client.searchData({'term': 'dart'})) { + switch (result) { + case User user: + print('user ${user.name}'); + case Post post: + print('post ${post.title}'); + case UnknownSearchResult other: + print('something new: ${other.typename}'); + } } ``` -## 🔧 Troubleshooting +### Fragments -### Common Issues +Fragments can live in their own files, as long as those files match +`document_paths`. Each operation document includes only the fragments it uses. -#### "Target of URI hasn't been generated" Error +### Names that aren't valid Dart -If you see errors like: -``` -Target of URI hasn't been generated: 'package:your_app/graphql/generated/types.g.dart' -The method '_$UserFromJson' isn't defined for the type 'User' -``` +A field called `class` or an enum value called `default` gets a trailing +underscore in Dart (`class_`, `Role.default_`). JSON keys and values sent to the +server keep the original GraphQL names. -**Solution:** Run the build command twice as described above. The first run generates the GraphQL types, and the second run generates the JSON serialization code. +## Custom scalars -#### Missing JSON Serialization Methods +| GraphQL | Dart | +|---------|------| +| `DateTime` | `DateTime`, written back as UTC ISO-8601 | +| `LocalDate` | `DateTime`, written back as `yyyy-MM-dd` | +| `Decimal`, `Long` | small wrapper classes that keep the value as a string | +| `Byte`, `Short` | `Byte` wrapper / `int` | +| anything else | a generated wrapper class with `raw` (the JSON value as received) and `value` (its string form) | -If generated classes are missing `fromJson`/`toJson` methods: +To use your own type instead, add it to `scalar_mapping`. The generator doesn't +add imports, so stick to types that are always in scope (`String`, `int`, +`DateTime`, `Map`, ...). -1. Ensure you have the required dependencies: -```yaml -dependencies: - json_annotation: ^4.8.1 +Non-nullable `int`, `double` and `bool` fields have lenient converters: a missing +or malformed value becomes `0`, `0.0` or `false` instead of throwing. -dev_dependencies: - build_runner: ^2.4.6 - json_serializable: ^6.7.1 -``` - -2. Run build_runner twice: -```bash -dart run build_runner build --delete-conflicting-outputs -dart run build_runner build --delete-conflicting-outputs -``` +## Errors -#### Build Runner Conflicts +Both `getUser(...)` and `getUserData(...)` throw `OperationException` when the +response has GraphQL errors or the request failed, so you get the original +error details: -If you encounter build conflicts, use the clean option: -```bash -dart run build_runner clean -dart run build_runner build --delete-conflicting-outputs +```dart +try { + final user = await client.getUserData({'id': '42'}); +} on OperationException catch (e) { + print(e.graphqlErrors); + print(e.linkException); +} ``` -#### Schema Download Issues +Problems found during generation are reported with a code: -For HTTP/HTTPS schema URLs, ensure: -- The endpoint is accessible -- Authentication headers are set if required -- The URL returns valid GraphQL schema (SDL format) +| Code | Meaning | +|------|---------| +| `CFG001` / `CFG002` | `schema_url` / `document_paths` missing from the config | +| `CFG999` | Other config problem (bad value, unset env variable in `schema_headers`) | +| `SCH400` | The schema doesn't parse | +| `SCH404` | Schema file not found | +| `SCH500` | Remote schema download failed; the message lists every attempt | +| `BLD100` | An operation file doesn't parse | +| `BLD101` | An operation uses a fragment that isn't defined anywhere | +| `BLD102` | Two fragments share a name but differ | +| `BLD103` | An operation selects a root field the schema doesn't have | +| `BLD200` | Writing `index.dart` failed | -#### Complex Type Generation Issues +## Known limitations -If complex types aren't generating correctly: -- Check that your GraphQL schema is valid -- Ensure operation files have the correct extension (`.graphql`) -- Verify the `document_paths` configuration matches your file structure +The biggest one: the generated classes follow the schema types, not the fields +your query selects. If `User.name` is `String!` in the schema and a query asks +for `user { id }` only, parsing that `User` throws because `name` is missing. +For now, select every non-nullable field of the object types you fetch. +Per-query result types would fix this properly, but they'd change the generated +API, so they're planned for a major release. -## 🧪 Testing +Other limitations: -The package includes comprehensive test coverage: +- Anonymous operations are skipped, with a warning. Give every operation a name. +- If you rename an operation, the old generated file stays in `output_dir`. + Delete it by hand. -```bash -# Run all tests -dart test +## Troubleshooting -# Run with coverage -dart test --coverage=coverage -dart run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib -``` +**`Target of URI hasn't been generated: types.g.dart`** or +**`_$UserFromJson isn't defined`**: run `build_runner build` once more (see +[Running the generator](#running-the-generator)). -## 🤝 Contributing +**Conflicting outputs**: `dart run build_runner clean`, then build again. -Contributions are welcome! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details. +**Remote schema fails to download**: read the `SCH500` message. It says what +each attempt (SDL, `?sdl`, introspection) returned. A 401 or 403 usually means +`schema_headers` are missing; HTML in the response usually means the URL points +to a playground page instead of the API. -### Development Setup +## Development -1. Fork the repository -2. Clone your fork: `git clone https://github.com/ifmelate/flutter_graphql_codegen.git` -3. Install dependencies: `dart pub get` -4. Run tests: `dart test` -5. Make your changes -6. Submit a pull request - - - -## 🔄 Changelog - -See [CHANGELOG.md](CHANGELOG.md) for a list of changes and releases. +```bash +dart pub get +dart test # includes a ~20s test that compiles and runs generated code +dart test -x compile # skip that one +``` -## 📄 License +The compile test generates code for `test/fixtures/schema.graphql`, runs +`json_serializable` and `dart analyze` on it, and executes it against a fake +GraphQL link. If you change what the generator emits, that's the test that tells +you whether the output still works. -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +Changes are listed in [CHANGELOG.md](CHANGELOG.md). License: see [LICENSE](LICENSE). diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..8cca8bf --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + compile: + timeout: 5m diff --git a/example/flutter_graphql_codegen_example.dart b/example/flutter_graphql_codegen_example.dart index 27d1443..ec295b5 100644 --- a/example/flutter_graphql_codegen_example.dart +++ b/example/flutter_graphql_codegen_example.dart @@ -93,8 +93,7 @@ void main() async { print('3. Example usage in your Flutter app:'); print(''' // Import the generated files -import 'lib/graphql/generated/types.dart'; -import 'lib/graphql/generated/get_user.dart'; +import 'package:your_app/graphql/generated/index.dart'; // Initialize GraphQL client final client = GraphQLClient( @@ -102,18 +101,15 @@ final client = GraphQLClient( cache: GraphQLCache(), ); -// Use the generated extension -final result = await client.getUserQuery( - variables: GetUserArguments(id: 'user-123'), +// Typed data; throws OperationException on GraphQL/network errors +final user = await client.getUserData( + GetUserVariables(id: 'user-123').toJson(), ); +print('User: \${user?.name} (\${user?.email})'); +print('Posts: \${user?.posts?.length ?? 0}'); -if (result.hasException) { - print('Error: \${result.exception}'); -} else { - final user = result.parsedData?.user; - print('User: \${user?.name} (\${user?.email})'); - print('Posts: \${user?.posts?.length ?? 0}'); -} +// Raw QueryResult when you need it +final result = await client.getUser({'id': 'user-123'}); '''); print('\n4. Type-safe benefits:'); diff --git a/lib/flutter_graphql_codegen.dart b/lib/flutter_graphql_codegen.dart index 2fba34e..74fedde 100644 --- a/lib/flutter_graphql_codegen.dart +++ b/lib/flutter_graphql_codegen.dart @@ -2,10 +2,9 @@ /// /// A powerful code generation library for GraphQL operations in Flutter applications. /// Generates type-safe Dart code from GraphQL schemas and operations. -library flutter_graphql_codegen; +library; // Core functionality -export 'src/flutter_graphql_codegen_base.dart'; export 'src/builder.dart' show graphqlCodegenBuilder; export 'src/config.dart'; export 'src/generator.dart'; @@ -16,3 +15,5 @@ export 'src/schema_analyzer.dart'; export 'src/operation_analyzer.dart'; export 'src/type_generator.dart'; export 'src/client_extension_generator.dart'; +export 'src/diagnostics.dart'; +export 'src/document_utils.dart'; diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 9c3c851..09c4ad6 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -2,18 +2,40 @@ import 'dart:async'; import 'dart:io'; import 'package:build/build.dart'; +import 'package:glob/glob.dart'; +import 'package:gql/ast.dart'; import 'package:gql/language.dart' as gql_lang; import 'package:flutter_graphql_codegen/src/schema_downloader.dart'; -import 'package:flutter_graphql_codegen/src/utils.dart'; import 'package:flutter_graphql_codegen/src/type_generator.dart'; import 'package:flutter_graphql_codegen/src/client_extension_generator.dart'; import 'package:flutter_graphql_codegen/src/operation_analyzer.dart'; +import 'package:path/path.dart' as p; import 'config.dart'; +import 'config_context.dart'; +import 'diagnostics.dart'; +import 'document_utils.dart'; +import 'naming.dart'; + +/// Schema-derived data shared by all operations of a build. +class _SchemaArtifacts { + final DocumentNode schemaDoc; + final String typesCode; + final Set definedTypes; + + _SchemaArtifacts(this.schemaDoc, this.typesCode, this.definedTypes); +} class GraphQLCodegenBuilder implements Builder { final GraphQLCodegenConfig config; - static bool _typesFileGenerated = false; + + /// Remote schemas are downloaded once per builder instance (i.e. once per + /// `build_runner build`; restart `watch` to pick up remote schema changes). + final Map> _remoteSchemas = {}; + + String? _cachedSchemaText; + _SchemaArtifacts? _cachedArtifacts; + String? _lastWrittenTypes; GraphQLCodegenBuilder(this.config); @@ -26,95 +48,235 @@ class GraphQLCodegenBuilder implements Builder { return; } - // Skip schema files - if (inputId.path.contains('schema.graphql')) { + final content = await buildStep.readAsString(inputId); + final DocumentNode document; + try { + document = gql_lang.parseString(content, url: inputId.path); + } catch (e, st) { + throw _report(CodegenException( + CodegenErrorCodes.buildOperationParse, + 'Failed to parse ${inputId.path}: $e', + cause: e, + stackTrace: st, + )); + } + + // Schema files and fragment-only files produce no operation files. + final operations = + document.definitions.whereType().toList(); + if (operations.isEmpty || + document.definitions.any((d) => d is TypeSystemDefinitionNode)) { return; } - print('🔧 Processing GraphQL operation: ${inputId.path}'); + log.fine('Processing GraphQL operations in ${inputId.path}'); + + final artifacts = _artifactsFor(await _loadSchema(buildStep)); + await _writeTypesFile(artifacts.typesCode); - // Load schema (either from URL or local file) - String schema; - if (config.schemaUrl.startsWith('http://') || - config.schemaUrl.startsWith('https://')) { - // Download from URL - schema = await SchemaDownloader.downloadSchema(config.schemaUrl); - } else { - // Read from local file + final fragments = await _externalFragments(buildStep, document); + + for (final operation in operations) { + final operationName = operation.name?.value; + if (operationName == null) { + log.warning('Skipping anonymous ${operation.type.name} in ' + '${inputId.path}: name the operation to generate code for it.'); + continue; + } + + final String code; try { - final schemaFile = File(config.schemaUrl); - if (await schemaFile.exists()) { - schema = await schemaFile.readAsString(); - } else { - throw Exception('Schema file not found: ${config.schemaUrl}'); - } - } catch (e) { - print('❌ Error reading schema file: $e'); - throw Exception('Failed to read schema from ${config.schemaUrl}: $e'); + code = ClientExtensionGenerator.generateOperationFile( + operationName, + operation.type.name, + content, + artifacts.schemaDoc, + artifacts.definedTypes, + fragments: fragments, + ); + } on CodegenException catch (e) { + throw _report(e); } + + await _writeToOutputDirectory(operationName, code); + log.fine('Generated ${config.outputDir}/' + '${_generateOutputFileName(operationName)}'); } - // Read GraphQL document content - final content = await buildStep.readAsString(inputId); + // Generate index file after processing operations (if enabled) + if (config.emitIndex) { + await _generateIndexFile(); + } + } - // Extract operation name and type from document - final operationName = _extractOperationName(content); - final operationType = _extractOperationType(content); + CodegenException _report(CodegenException e) { + log.severe(e.format(format: CodegenConfigContext.diagnosticsFormat)); + return e; + } - if (operationName != null && operationType != null) { - print('📝 Generating code for: $operationName ($operationType)'); + /// Loads the schema text. + /// + /// Local schemas inside the package are read through [buildStep] so that + /// editing the schema triggers regeneration. + Future _loadSchema(BuildStep buildStep) async { + final source = config.schemaUrl; + + if (source.startsWith('http://') || source.startsWith('https://')) { + final cached = _remoteSchemas[source]; + if (cached != null) return cached; + final future = SchemaDownloader.downloadSchema(source, + headers: config.schemaHeaders); + _remoteSchemas[source] = future; + try { + return await future; + } catch (e, st) { + _remoteSchemas.remove(source); + if (e is CodegenException) throw _report(e); + throw _report(CodegenException( + CodegenErrorCodes.schemaDownloadFailed, + 'Failed to download schema from $source: $e', + cause: e, + stackTrace: st, + )); + } + } - // Generate client extension for this specific operation - final schemaDoc = gql_lang.parseString(schema); - final typesCode = TypeGenerator.generateTypesFile(schema); + final assetId = _schemaAssetId(buildStep.inputId.package); + if (assetId != null && await buildStep.canRead(assetId)) { + return buildStep.readAsString(assetId); + } - // Generate types.dart file only once - if (!_typesFileGenerated) { - await _generateTypesFileIfNeeded(typesCode); - _typesFileGenerated = true; - } + try { + return await SchemaDownloader.downloadSchema(source); + } on FileSystemException catch (e, st) { + throw _report(CodegenException( + CodegenErrorCodes.schemaFileNotFound, + 'Schema file not found: $source', + cause: e, + stackTrace: st, + )); + } on FormatException catch (e, st) { + throw _report(CodegenException( + CodegenErrorCodes.schemaInvalid, + e.message, + cause: e, + stackTrace: st, + )); + } + } - final definedTypes = OperationAnalyzer.extractDefinedTypes(typesCode); - final clientExtension = ClientExtensionGenerator.generateClientExtension( - operationName, - operationType, - content, - schemaDoc, - definedTypes, - ); + /// The schema as an asset of [package], if it is a path inside the package. + AssetId? _schemaAssetId(String package) { + final relative = _packageRelativePath(config.schemaUrl); + return relative == null ? null : AssetId(package, relative); + } - // Create operation file content - final operationFileCode = '''// AUTO-GENERATED FILE - DO NOT EDIT -// Generated by flutter_graphql_codegen -// Operation: $operationName ($operationType) + /// Converts a local path or `file://` URI into a package-relative POSIX + /// path, or returns null when it points outside the current package. + static String? _packageRelativePath(String path) { + var local = path; + if (local.startsWith('file://')) { + local = p.fromUri(Uri.parse(local)); + } + if (p.isAbsolute(local)) { + local = p.relative(local, from: Directory.current.path); + } + final posix = p.posix.normalize(local.replaceAll(r'\', '/')); + if (posix.startsWith('../') || posix == '..' || p.posix.isAbsolute(posix)) { + return null; + } + return posix; + } -import 'package:graphql/client.dart' as graphql; -import 'types.dart'; + _SchemaArtifacts _artifactsFor(String schemaText) { + final cached = _cachedArtifacts; + if (cached != null && schemaText == _cachedSchemaText) return cached; -$clientExtension -'''; + try { + final schemaDoc = gql_lang.parseString(schemaText); + final typesCode = TypeGenerator.generateTypesFile(schemaText); + final artifacts = _SchemaArtifacts( + schemaDoc, + typesCode, + OperationAnalyzer.extractDefinedTypes(typesCode), + ); + _cachedSchemaText = schemaText; + _cachedArtifacts = artifacts; + return artifacts; + } catch (e, st) { + if (e is CodegenException) throw _report(e); + throw _report(CodegenException( + CodegenErrorCodes.schemaInvalid, + 'Failed to process schema ${config.schemaUrl}: $e', + cause: e, + stackTrace: st, + )); + } + } - // Generate files directly in output directory specified in config - await _writeToOutputDirectory(operationName, operationFileCode); + /// Fragments defined in other documents (matched by `document_paths`) that + /// [document] spreads but does not define itself. + Future> _externalFragments( + BuildStep buildStep, DocumentNode document) async { + final local = DocumentUtils.collectFragments([document]); + final missing = _fragmentSpreads(document).difference(local.keys.toSet()); + if (missing.isEmpty) return const {}; + + final docs = []; + final seen = {buildStep.inputId}; + for (final pattern in config.documentPaths) { + final relative = _packageRelativePath(pattern); + if (relative == null) continue; + await for (final id in buildStep.findAssets(Glob(relative))) { + if (!seen.add(id)) continue; + final text = await buildStep.readAsString(id); + if (!text.contains('fragment')) continue; + try { + docs.add(gql_lang.parseString(text, url: id.path)); + } catch (_) { + // Parse errors are reported when that document itself is built. + } + } + } + // Missing fragments still left are reported by the generator. + return DocumentUtils.collectFragments(docs); + } - print( - '📝 Generated: ${config.outputDir}/${_generateOutputFileName(operationName)}'); + static Set _fragmentSpreads(DocumentNode document) { + final names = {}; + void visit(SelectionSetNode set) { + for (final sel in set.selections) { + if (sel is FragmentSpreadNode) { + names.add(sel.name.value); + } else if (sel is FieldNode && sel.selectionSet != null) { + visit(sel.selectionSet!); + } else if (sel is InlineFragmentNode) { + visit(sel.selectionSet); + } + } } - // Генерируем index файл после обработки всех операций - await _generateIndexFile(); + for (final def in document.definitions) { + if (def is OperationDefinitionNode) visit(def.selectionSet); + if (def is FragmentDefinitionNode) visit(def.selectionSet); + } + return names; } /// Generates index.dart file with all operations Future _generateIndexFile() async { try { - // Найти все сгенерированные .g.dart файлы + // Find all generated .g.dart files final generatedFiles = []; await _findGeneratedFiles(generatedFiles); final exports = []; final operations = []; + // Sort files deterministically by basename + generatedFiles + .sort((a, b) => p.basename(a.path).compareTo(p.basename(b.path))); + for (final file in generatedFiles) { final content = await file.readAsString(); @@ -122,25 +284,22 @@ $clientExtension continue; } - // Файлы теперь в той же папке что и index.dart, поэтому просто используем имя файла - final fileName = file.path.split('/').last; + // Files are in the same folder as index.dart, use a safe file name + final fileName = p.basename(file.path); exports.add("export '$fileName';"); - // Извлекаем имя операции из имени файла (snake_case_graphql.g.dart -> SnakeCaseGraphql) - final operationName = fileName - .replaceAll('_graphql.g.dart', '') - .split('_') - .map((word) => word[0].toUpperCase() + word.substring(1)) - .join(''); + // Extract operation name from file name, respecting the configured naming mode + final baseName = fileName.replaceAll('_graphql.g.dart', ''); + final operationName = _fileBaseNameToOperationName(baseName); operations.add('// $operationName'); } if (exports.isEmpty) { - print('📁 No generated GraphQL files found for index'); + log.fine('No generated GraphQL files found for index'); return; } - // Создаем index файл + // Create index file final indexContent = '''// AUTO-GENERATED FILE - DO NOT EDIT // Generated by flutter_graphql_codegen // GraphQL operations index @@ -156,62 +315,63 @@ ${operations.join('\n')} // Usage: // import 'package:your_app/${config.outputDir}/index.dart'; -// +// // final client = GraphQLClient(...); // final result = await client.getUserProfile({'culture': 'en'}); '''; - // Создаем директорию если не существует + // Create the directory if it does not exist final outputDir = Directory(config.outputDir); if (!outputDir.existsSync()) { await outputDir.create(recursive: true); } - // Записываем index файл - final indexFile = File('${config.outputDir}/index.dart'); - await indexFile.writeAsString(indexContent); - - print('📝 Generated index file: ${indexFile.path}'); - print('✅ Index complete! Found ${exports.length} operations'); - } catch (e) { - print('⚠️ Error generating index file: $e'); + // Write index file + final indexFile = File(p.join(config.outputDir, 'index.dart')); + await _writeIfChanged(indexFile, indexContent); + } catch (e, st) { + throw _report(CodegenException( + CodegenErrorCodes.buildIndexFailed, 'Failed to generate index.dart', + cause: e, stackTrace: st)); } } - /// Generates types.dart file - Future _generateTypesFileIfNeeded(String typesCode) async { - // Создаем директорию если не существует + /// Writes types.dart when its content changed since the last write. + Future _writeTypesFile(String typesCode) async { + if (typesCode == _lastWrittenTypes) return; + + // Create the directory if it does not exist final outputDir = Directory(config.outputDir); if (!outputDir.existsSync()) { await outputDir.create(recursive: true); } - // Всегда генерируем файл types.dart - final typesFile = File('${config.outputDir}/types.dart'); - await typesFile.writeAsString(typesCode); - print('📝 Generated types file: ${typesFile.path}'); + final typesFile = File(p.join(config.outputDir, 'types.dart')); + await _writeIfChanged(typesFile, typesCode); + _lastWrittenTypes = typesCode; + log.fine('Generated types file: ${typesFile.path}'); } /// Writes operation file to the configured output directory Future _writeToOutputDirectory( String operationName, String content) async { - // Создаем директорию если не существует + // Create the directory if it does not exist final outputDir = Directory(config.outputDir); if (!outputDir.existsSync()) { await outputDir.create(recursive: true); } - // Создаем файл в output директории + // Create the file in the output directory final fileName = _generateOutputFileName(operationName); - final filePath = '${config.outputDir}/$fileName'; + final filePath = p.join(config.outputDir, fileName); final file = File(filePath); - await file.writeAsString(content); + await _writeIfChanged(file, content); } /// Finds all generated .g.dart files Future _findGeneratedFiles(List files) async { - // Ищем файлы в output директории вместо всей lib + // Search files in the output directory instead of the entire lib final outputDir = Directory(config.outputDir); if (!outputDir.existsSync()) { return; @@ -225,31 +385,42 @@ ${operations.join('\n')} } } - /// Extracts operation name from GraphQL document content - String? _extractOperationName(String content) { - final operations = parseOperations(content); - return operations.isNotEmpty ? operations.first.name : null; - } - - /// Extracts operation type from GraphQL document content - String? _extractOperationType(String content) { - final operations = parseOperations(content); - return operations.isNotEmpty ? operations.first.type : null; + /// Converts a file base name back to a PascalCase operation name, + /// handling any configured naming mode (snake_case, kebab-case, camelCase, PascalCase). + String _fileBaseNameToOperationName(String baseName) { + // Split on underscores or hyphens to handle snake_case and kebab-case + final parts = baseName.split(RegExp(r'[_\-]')); + if (parts.length > 1) { + // snake_case or kebab-case: capitalize each part + return parts + .where((p) => p.isNotEmpty) + .map((p) => p[0].toUpperCase() + p.substring(1)) + .join(''); + } + // camelCase or PascalCase: just capitalize first letter + if (baseName.isNotEmpty) { + return baseName[0].toUpperCase() + baseName.substring(1); + } + return baseName; } /// Generates the output file name based on operation name /// Example: GetDrugList -> get_drug_list_graphql.g.dart String _generateOutputFileName(String operationName) { - final snakeCaseName = _camelToSnakeCase(operationName); - return '${snakeCaseName}_graphql.g.dart'; + // Respect configured file naming mode + final base = transformFileBaseName(operationName, config.fileNaming); + return '${base}_graphql.g.dart'; } - /// Converts camelCase to snake_case - /// Example: GetDrugList -> get_drug_list, ContinueVisit -> continue_visit - String _camelToSnakeCase(String input) { - return input - .replaceAllMapped(RegExp(r'(? '_') - .toLowerCase(); + /// Deterministic write helper: writes file only if content has changed. + Future _writeIfChanged(File file, String newContent) async { + if (await file.exists()) { + final current = await file.readAsString(); + if (current == newContent) { + return; + } + } + await file.writeAsString(newContent); } @override @@ -269,14 +440,13 @@ Builder graphqlCodegenBuilder(BuilderOptions options) { throw FileSystemException('Config file not found', configPath); } - final yamlString = configFile.readAsStringSync(); + final cfg = GraphQLCodegenConfig.fromYaml(configFile.readAsStringSync()); + // Push to global context for static generators + CodegenConfigContext.fileNaming = cfg.fileNaming; + CodegenConfigContext.strictNullability = cfg.strictNullability; + CodegenConfigContext.emitIndex = cfg.emitIndex; + CodegenConfigContext.diagnosticsFormat = cfg.diagnosticsFormat; + CodegenConfigContext.scalarMapping = Map.of(cfg.scalarMapping); - try { - final config = GraphQLCodegenConfig.fromYaml(yamlString); - return GraphQLCodegenBuilder(config); - } catch (e, stackTrace) { - print('Error parsing config: $e'); - print('Stack trace: $stackTrace'); - rethrow; - } + return GraphQLCodegenBuilder(cfg); } diff --git a/lib/src/client_extension_generator.dart b/lib/src/client_extension_generator.dart index fe63471..52752b3 100644 --- a/lib/src/client_extension_generator.dart +++ b/lib/src/client_extension_generator.dart @@ -4,301 +4,564 @@ library; import 'package:gql/ast.dart'; import 'package:gql/language.dart' as gql_lang; import 'code_utils.dart'; -import 'operation_analyzer.dart'; +import 'config_context.dart'; +import 'diagnostics.dart'; +import 'document_utils.dart'; +import 'naming.dart'; +import 'schema_analyzer.dart'; /// Utility class for generating client extensions class ClientExtensionGenerator { - /// Generates a client extension for a GraphQL operation + /// Generates a complete operation file (header, imports and the code from + /// [generateClientExtension]). + static String generateOperationFile( + String operationName, + String operationType, + String operationDocumentContent, + DocumentNode schemaDoc, + Set definedTypes, { + Map fragments = const {}, + }) { + final operation = _findOperation( + gql_lang.parseString(operationDocumentContent), operationName); + final body = generateClientExtension( + operationName, + operationType, + operationDocumentContent, + schemaDoc, + definedTypes, + fragments: fragments, + ); + return '''${CodeGenerationConstants.generatedFileWarning} +// Operation: $operationName (${operation.type.name}) +${CodeGenerationConstants.ignoreForFile} + +import 'package:graphql/client.dart' as graphql; +import 'types.dart'; + +$body'''; + } + + /// Generates the client code for a GraphQL operation: the document constant, + /// an optional variables class, a typed `parse*Data` function and an + /// extension on `GraphQLClient`. + /// + /// [operationDocumentContent] may contain several operations and fragments; + /// the operation named [operationName] is used. Fragments referenced but not + /// defined there are looked up in [fragments]. static String generateClientExtension( String operationName, String operationType, String operationDocumentContent, DocumentNode schemaDoc, - Set definedTypes, - ) { - final methodName = - operationType.toLowerCase() == 'mutation' ? 'mutate' : 'query'; - final optionsType = '${operationType.capitalize()}Options'; + Set definedTypes, { + Map fragments = const {}, + }) { final operationDoc = gql_lang.parseString(operationDocumentContent); + final operation = _findOperation(operationDoc, operationName); + final allFragments = { + ...fragments, + ...DocumentUtils.collectFragments([operationDoc]), + }; - final returnType = OperationAnalyzer.getOperationReturnType( - operationDoc, - schemaDoc, - definedTypes, + final schema = DocumentUtils.mergeSchemaExtensions(schemaDoc); + SchemaAnalyzer.extractCustomTypesAndEnums(schema); + + final context = _OperationContext( + name: operationName, + operation: operation, + schema: schema, + definedTypes: definedTypes, + strict: CodegenConfigContext.strictNullability, ); - final fieldName = OperationAnalyzer.getOperationFieldName(operationDoc); - // Analyze skippable fields - final skippableFields = OperationAnalyzer.getSkippableFields(operationDoc); + final document = gql_lang.printNode( + DocumentUtils.buildOperationDocument(operation, allFragments)); - // Check if the type is nullable - final isNullable = returnType.endsWith('?'); - // Always use Future for *Data methods - // If type is nullable in schema, keep it nullable in return type - final methodReturnType = - isNullable ? 'Future<$returnType>' : 'Future<$returnType>'; + final buffer = StringBuffer() + ..writeln('/// GraphQL document for the `$operationName` ' + '${operation.type.name}.') + ..writeln('final ${context.camel}Document = graphql.gql(' + '${_dartStringLiteral(document)});') + ..writeln() + ..writeln("const String ${context.camel}OperationName = " + "'$operationName';") + ..writeln(); - return ''' -extension ${operationName}Extension on graphql.GraphQLClient { - Future>> ${operationName.toCamelCase()}([Map? variables]) async { - final options = graphql.$optionsType>( - document: graphql.gql(r""" -$operationDocumentContent - """), - variables: variables ?? const {}, + final variables = _generateVariablesClass(context); + if (variables.isNotEmpty) buffer.writeln(variables); + + buffer.writeln(_generateParser(context)); + buffer.write(_generateExtension(context)); + return buffer.toString(); + } + + static OperationDefinitionNode _findOperation( + DocumentNode doc, String operationName) { + final operations = doc.definitions.whereType(); + if (operations.isEmpty) { + throw CodegenException( + CodegenErrorCodes.buildOperationParse, + 'No operation found in document for "$operationName"', + ); + } + return operations.firstWhere( + (op) => op.name?.value == operationName, + orElse: () => operations.first, ); + } - try { - final result = await this.$methodName(options); + // --------------------------------------------------------------------------- + // Variables + // --------------------------------------------------------------------------- - if (result.hasException) { - throw result.exception!; + static String _generateVariablesClass(_OperationContext ctx) { + final defs = ctx.operation.variableDefinitions; + if (defs.isEmpty) return ''; + + final className = '${ctx.pascal}Variables'; + final buffer = StringBuffer() + ..writeln( + '/// Variables for the `${ctx.name}` ${ctx.operation.type.name}.') + ..writeln('///') + ..writeln('/// Pass `toJson()` as the `variables` argument of the ' + 'generated methods.') + ..writeln('class $className {') + ..writeln(' const $className({'); + + final fields = [ + for (final d in defs) + ( + graphqlName: d.variable.name.value, + dartName: dartFieldName(d.variable.name.value, className: className), + type: d.type, + optional: !d.type.isNonNull || d.defaultValue?.value != null, + ), + ]; + + for (final f in fields) { + buffer.writeln(f.optional + ? ' this.${f.dartName},' + : ' required this.${f.dartName},'); + } + buffer + ..writeln(' });') + ..writeln(); + + for (final f in fields) { + final type = _inputDartType(f.type, forceNullable: f.optional); + buffer.writeln(' final $type ${f.dartName};'); + } + + buffer + ..writeln() + ..writeln(' Map toJson() => {'); + for (final f in fields) { + if (f.optional) { + var expr = _toJsonExpr(f.type, '${f.dartName}!'); + if (expr == '${f.dartName}!') expr = f.dartName; + buffer.writeln(" if (${f.dartName} != null) " + "'${f.graphqlName}': $expr,"); + } else { + buffer.writeln(" '${f.graphqlName}': " + "${_toJsonExpr(f.type, f.dartName)},"); } + } + buffer + ..writeln(' };') + ..writeln('}'); + return buffer.toString(); + } - return result; - } catch (e) { - rethrow; + /// Dart type of an input value, respecting the schema's nullability. + static String _inputDartType(TypeNode type, {bool forceNullable = false}) { + final nullable = forceNullable || !type.isNonNull; + final base = type is ListTypeNode + ? 'List<${_inputDartType(type.type)}>' + : _namedDartType((type as NamedTypeNode).name.value); + return _nullable(base, nullable); + } + + /// Expression serialising the non-null value [v] of GraphQL [type]. + static String _toJsonExpr(TypeNode type, String v) { + if (type is ListTypeNode) { + final inner = type.type; + final item = _toJsonExpr(inner, 'e'); + if (item == 'e') return v; + final itemExpr = inner.isNonNull ? item : '(e == null ? null : $item)'; + return '$v.map((e) => $itemExpr).toList()'; + } + + final name = (type as NamedTypeNode).name.value; + if (CodegenConfigContext.scalarMapping.containsKey(name)) return v; + switch (name) { + case 'Int': + case 'Float': + case 'String': + case 'Boolean': + case 'ID': + case 'Short': + return v; + case 'DateTime': + return 'const DateTimeConverter().toJson($v)'; + case 'LocalDate': + return 'const LocalDateConverter().toJson($v)'; + case 'Decimal': + return 'const SafeDecimalConverter().toJson($v)'; + case 'Long': + return 'const LongConverter().toJson($v)'; + case 'Byte': + return 'const ByteConverter().toJson($v)'; } + if (TypeRegistry.isEnum(name) || TypeRegistry.isCustomScalar(name)) { + return 'const ${name}Converter().toJson($v)'; + } + if (TypeRegistry.inputTypes.contains(name)) return '$v.toJson()'; + return v; } - $methodReturnType ${operationName.toCamelCase()}Data([Map? variables]) async { - try { - final result = await ${operationName.toCamelCase()}(variables); + // --------------------------------------------------------------------------- + // Response parsing + // --------------------------------------------------------------------------- + + static String _generateParser(_OperationContext ctx) { + final fn = 'parse${ctx.pascal}Data'; + final returnType = ctx.returnType; + final noData = returnType.endsWith('?') + ? 'return null;' + : "throw StateError('${ctx.name}: response contains no data');"; - if (result.data == null) { - ${isNullable ? 'return null;' : 'throw Exception("Error: result.data is null in $operationName");'} + final buffer = StringBuffer() + ..writeln('/// Converts the `data` of a `${ctx.name}` response into a ' + 'typed value.') + ..writeln('$returnType $fn(Map? data) {') + ..writeln(' if (data == null) {') + ..writeln(' $noData') + ..writeln(' }'); + + final fields = ctx.rootFields; + if (fields.isEmpty) { + buffer.writeln(' return data;'); + } else if (fields.length == 1) { + final f = fields.single; + buffer + ..writeln(" final value = data['${f.responseKey}'];") + ..writeln(' return ${f.fromJson('value')};'); + } else { + buffer.writeln(' return ${ctx.resultClassName}('); + for (final f in fields) { + buffer.writeln(" ${f.dartName}: " + "${f.fromJson("data['${f.responseKey}']")},"); } + buffer.writeln(' );'); + } + buffer.writeln('}'); - ${_generateDataConversion(returnType, fieldName, skippableFields)} - - } catch (e, stackTrace) { - ${isNullable ? 'return null;' : 'throw Exception("An error occurred while fetching data in $operationName: \$e");'} + if (fields.length > 1) { + buffer + ..writeln() + ..write(_generateResultClass(ctx)); + } + return buffer.toString(); + } + + static String _generateResultClass(_OperationContext ctx) { + final name = ctx.resultClassName; + final buffer = StringBuffer() + ..writeln( + '/// Typed `data` of the `${ctx.name}` ${ctx.operation.type.name}.') + ..writeln('class $name {') + ..writeln(' const $name({'); + for (final f in ctx.rootFields) { + buffer.writeln(f.dartType.endsWith('?') + ? ' this.${f.dartName},' + : ' required this.${f.dartName},'); + } + buffer + ..writeln(' });') + ..writeln(); + for (final f in ctx.rootFields) { + buffer.writeln(' final ${f.dartType} ${f.dartName};'); } + buffer.writeln('}'); + return buffer.toString(); + } + + // --------------------------------------------------------------------------- + // Client extension + // --------------------------------------------------------------------------- + + static String _generateExtension(_OperationContext ctx) { + final camel = ctx.camel; + final parse = 'parse${ctx.pascal}Data'; + final kind = ctx.operation.type; + const vars = '[Map? variables]'; + const resultType = 'graphql.QueryResult>'; + final options = ''' + document: ${camel}Document, + operationName: ${camel}OperationName, + variables: variables ?? const {},'''; + + if (kind == OperationType.subscription) { + return ''' +extension ${ctx.name}Extension on graphql.GraphQLClient { + /// Subscribes to `${ctx.name}` and emits raw results. + /// + /// Results with errors are emitted as [graphql.OperationException] errors. + Stream<$resultType> $camel($vars) { + return subscribe( + graphql.SubscriptionOptions>( +$options + ), + ).map((result) { + if (result.hasException) { + throw result.exception!; + } + return result; + }); + } + + /// Subscribes to `${ctx.name}` and emits typed data. + Stream<${ctx.returnType}> ${camel}Data($vars) { + return $camel(variables) + .where((result) => !(result.isLoading && result.data == null)) + .map((result) => $parse(result.data)); } } '''; + } + + final isMutation = kind == OperationType.mutation; + final method = isMutation ? 'mutate' : 'query'; + final optionsType = isMutation ? 'MutationOptions' : 'QueryOptions'; + return ''' +extension ${ctx.name}Extension on graphql.GraphQLClient { + /// Executes `${ctx.name}` and returns the raw result. + /// + /// Throws [graphql.OperationException] when the result has errors. + Future<$resultType> $camel($vars) async { + final result = await $method( + graphql.$optionsType>( +$options + ), + ); + if (result.hasException) { + throw result.exception!; + } + return result; } - /// Generates data conversion logic for different return types - static String _generateDataConversion( - String returnType, - String fieldName, [ - Set? skippableFields, - ]) { - final isNullable = returnType.endsWith('?'); - final baseType = isNullable - ? returnType.substring(0, returnType.length - 1) - : returnType; - - // Handle lists - if (baseType.startsWith('List<') && baseType.endsWith('>')) { - final innerType = baseType.substring(5, baseType.length - 1); - final innerIsNullable = innerType.endsWith('?'); - final innerBaseType = innerIsNullable - ? innerType.substring(0, innerType.length - 1) - : innerType; - - return """ - try { - final data = result.data!; - if (!data.containsKey('$fieldName') || data['$fieldName'] == null) { - return ${isNullable ? 'null' : '[]'}; - } - - final jsonList = data['$fieldName']; - if (jsonList is! List) { - throw Exception("Field '$fieldName' is not a list in GraphQL response"); - } - - return jsonList.map((item) { - ${_generateItemConversion(innerBaseType, innerIsNullable)} - }).toList(); - } catch (e, stackTrace) { - ${isNullable ? 'return null;' : 'throw Exception("Error converting GraphQL response to $returnType: \$e");'} - } - """; + /// Executes `${ctx.name}` and returns typed data. + Future<${ctx.returnType}> ${camel}Data($vars) async { + final result = await $camel(variables); + return $parse(result.data); + } +} +'''; + } + + // --------------------------------------------------------------------------- + // Type helpers + // --------------------------------------------------------------------------- + + /// Dart type for a GraphQL output type. In legacy (non-strict) mode every + /// level is nullable, matching previous releases. + static String _outputDartType(TypeNode type, + {required bool legacy, bool forceNullable = false}) { + final nullable = forceNullable || legacy || !type.isNonNull; + final base = type is ListTypeNode + ? 'List<${_outputDartType(type.type, legacy: legacy)}>' + : _namedDartType((type as NamedTypeNode).name.value); + return _nullable(base, nullable); + } + + static String _namedDartType(String name) { + final known = CodegenConfigContext.scalarMapping.containsKey(name) || + GraphQLConstants.scalarToDartType.containsKey(name) || + const {'Decimal', 'Long', 'Byte'}.contains(name) || + TypeRegistry.isEnum(name) || + TypeRegistry.isCustomScalar(name) || + TypeRegistry.objectTypes.contains(name) || + TypeRegistry.isAbstract(name) || + TypeRegistry.inputTypes.contains(name); + if (!known) return 'dynamic'; + return SchemaAnalyzer.getDartType( + NamedTypeNode(name: NameNode(value: name), isNonNull: true)); + } + + static String _nullable(String type, bool nullable) => + nullable && type != 'dynamic' ? '$type?' : type; + + /// Expression converting the JSON value [v] into the Dart value of [type]. + static String _fromJsonExpr(TypeNode type, String v, + {required bool legacy, + required Set definedTypes, + bool forceNullable = false}) { + final nullable = forceNullable || legacy || !type.isNonNull; + final String inner; + if (type is ListTypeNode) { + final item = _fromJsonExpr(type.type, 'e', + legacy: legacy, definedTypes: definedTypes); + inner = '($v as List).map((e) => $item).toList()'; + } else { + inner = + _namedFromJson((type as NamedTypeNode).name.value, v, definedTypes); } - // Handle primitive types - else if (baseType == 'int' || - baseType == 'bool' || - baseType == 'double' || - baseType == 'String') { - return """ - try { - final data = result.data!; - if (!data.containsKey('$fieldName') || data['$fieldName'] == null) { - ${isNullable ? 'return null;' : 'throw Exception("Field \'$fieldName\' is null in GraphQL response");'} - } - - final value = data['$fieldName']; - ${_generatePrimitiveTypeConversion(baseType, isNullable)} - } catch (e, stackTrace) { - ${isNullable ? 'return null;' : 'throw Exception("Error converting GraphQL response to $returnType: \$e");'} + return nullable ? '($v == null ? null : $inner)' : inner; + } + + static String _namedFromJson( + String name, String v, Set definedTypes) { + final mapped = CodegenConfigContext.scalarMapping[name]; + if (mapped != null) { + switch (mapped) { + case 'int': + return 'const SafeIntConverter().fromJson($v)'; + case 'double': + return 'const SafeDoubleConverter().fromJson($v)'; + case 'bool': + return 'const SafeBoolConverter().fromJson($v)'; + case 'String': + return 'const SafeStringConverter().fromJson($v)'; + case 'DateTime': + return 'const DateTimeConverter().fromJson($v)'; + default: + return '$v as $mapped'; } - """; } - // Explicit handling for dynamic baseType - else if (baseType == 'dynamic') { - return """ - try { - final data = result.data!; - if (!data.containsKey('$fieldName') || data['$fieldName'] == null) { - ${isNullable ? 'return null;' : 'throw Exception("Field \'$fieldName\' is null in GraphQL response for dynamic type");'} - } - // The field itself should be dynamic data (e.g., Map or primitive) - return data['$fieldName']; - } catch (e, stackTrace) { - ${isNullable ? 'return null;' : 'throw Exception("Error converting GraphQL response for dynamic field $fieldName: \$e");'} - } - """; + switch (name) { + case 'Int': + case 'Short': + return 'const SafeIntConverter().fromJson($v)'; + case 'Float': + return 'const SafeDoubleConverter().fromJson($v)'; + case 'Boolean': + return 'const SafeBoolConverter().fromJson($v)'; + case 'String': + case 'ID': + return 'const SafeStringConverter().fromJson($v)'; + case 'DateTime': + return 'const DateTimeConverter().fromJson($v)'; + case 'LocalDate': + return 'const LocalDateConverter().fromJson($v)'; + case 'Decimal': + return 'const SafeDecimalConverter().fromJson($v)'; + case 'Long': + return 'const LongConverter().fromJson($v)'; + case 'Byte': + return 'const ByteConverter().fromJson($v)'; } - // Handle objects - else { - // Check if there are skippable fields for this type - final hasSkippableFields = - skippableFields?.any((field) => field.startsWith('$fieldName.')) ?? - false; - - if (hasSkippableFields && baseType == 'SymptomsListDTO') { - // Special handling for SymptomsListDTO with @skip directive for repGlav - return """ - try { - final data = result.data!; - if (!data.containsKey('$fieldName') || data['$fieldName'] == null) { - ${isNullable ? 'return null;' : 'throw Exception("Field \'$fieldName\' is null in GraphQL response");'} - } - - final json = data['$fieldName']; - if (json is! Map) { - throw Exception("Field '$fieldName' is not an object in GraphQL response"); - } - - // Create modified JSON for SymptomsListDTO, setting repGlav to null if missing - final modifiedJson = Map.from(json); - if (!modifiedJson.containsKey('repGlav')) { - modifiedJson['repGlav'] = null; - } - - return $baseType.fromJson(modifiedJson); - } catch (e, stackTrace) { - ${isNullable ? 'return null;' : 'throw Exception("Error converting GraphQL response to $returnType: \$e");'} - } - """; - } else { - return """ - try { - final data = result.data!; - if (!data.containsKey('$fieldName') || data['$fieldName'] == null) { - ${isNullable ? 'return null;' : 'throw Exception("Field \'$fieldName\' is null in GraphQL response");'} - } - - final json = data['$fieldName']; - if (json is! Map) { - throw Exception("Field '$fieldName' is not an object in GraphQL response"); - } - - return $baseType.fromJson(json); - } catch (e, stackTrace) { - ${isNullable ? 'return null;' : 'throw Exception("Error converting GraphQL response to $returnType: \$e");'} - } - """; - } + if (TypeRegistry.isEnum(name)) { + return 'const ${name}Converter().fromJson($v as String)'; + } + if (TypeRegistry.isCustomScalar(name)) { + return 'const ${name}Converter().fromJson($v)'; } + if (TypeRegistry.objectTypes.contains(name) || + TypeRegistry.isAbstract(name) || + definedTypes.contains(name)) { + return '$name.fromJson($v as Map)'; + } + return v; } - /// Generates item conversion logic for list elements - static String _generateItemConversion(String type, bool isNullable) { - if (type == 'int') { - return """ - if (item is int) return item; - if (item is num) return item.toInt(); - if (item is String) { - final parsed = int.tryParse(item); - if (parsed != null) return parsed; - } - ${isNullable ? 'return null;' : 'throw Exception("Cannot convert item to int");'} - """; - } else if (type == 'bool') { - return """ - if (item is bool) return item; - if (item is String) { - if (item.toLowerCase() == 'true') return true; - if (item.toLowerCase() == 'false') return false; - } - if (item is num) return item != 0; - ${isNullable ? 'return null;' : 'throw Exception("Cannot convert item to bool");'} - """; - } else if (type == 'double') { - return """ - if (item is double) return item; - if (item is num) return item.toDouble(); - if (item is String) { - final parsed = double.tryParse(item); - if (parsed != null) return parsed; - } - ${isNullable ? 'return null;' : 'throw Exception("Cannot convert item to double");'} - """; - } else if (type == 'String') { - return """ - if (item is String) return item; - return item.toString(); - """; - } else if (type == 'dynamic') { - // Explicit handling for dynamic list elements - return """ - // Assumes 'item' already has the correct dynamic type (e.g., Map if it was an object) - return item; - """; - } else { - return """ - if (item == null) { ${isNullable ? 'return null;' : 'throw Exception("Null item in non-nullable list for type $type");'} } - if (item is! Map) { throw Exception("Item is not a map for type $type, actual: \${item.runtimeType}"); } - return $type.fromJson(item as Map); - """; + /// Returns [s] as a Dart string literal (raw triple-quoted when possible). + static String _dartStringLiteral(String s) { + if (!s.contains('"""')) return 'r"""\n$s\n"""'; + final escaped = s + .replaceAll(r'\', r'\\') + .replaceAll("'", r"\'") + .replaceAll(r'$', r'\$') + .replaceAll('\n', r'\n'); + return "'$escaped'"; + } +} + +/// A root field selected by an operation. +class _RootField { + final String responseKey; + final String dartName; + final String dartType; + final String Function(String jsonExpr) fromJson; + + _RootField(this.responseKey, this.dartName, this.dartType, this.fromJson); +} + +/// Pre-computed information about the operation being generated. +class _OperationContext { + final String name; + final OperationDefinitionNode operation; + final bool strict; + final String camel; + final String pascal; + late final List<_RootField> rootFields; + + _OperationContext({ + required this.name, + required this.operation, + required DocumentNode schema, + required Set definedTypes, + required this.strict, + }) : camel = name.toCamelCase(), + pascal = name.capitalize() { + final rootType = _rootType(schema); + rootFields = [ + for (final sel in operation.selectionSet.selections) + if (sel is FieldNode && sel.name.value != '__typename') + _rootField(sel, rootType, definedTypes), + ]; + } + + String get resultClassName => '${pascal}Result'; + + /// Return type of the generated `*Data` method / `parse*Data` function. + String get returnType { + if (rootFields.isEmpty) return 'Map?'; + if (rootFields.length == 1) return rootFields.single.dartType; + return strict ? resultClassName : '$resultClassName?'; + } + + ObjectTypeDefinitionNode _rootType(DocumentNode schema) { + try { + final root = SchemaAnalyzer.findRootType(schema, operation.type.name); + if (root != null) return root; + } on StateError { + // Reported below. } + throw CodegenException( + CodegenErrorCodes.buildOperationParse, + 'Schema has no root type for ${operation.type.name} operations ' + '(needed by "$name")', + ); } - /// Generates primitive type conversion logic - static String _generatePrimitiveTypeConversion(String type, bool isNullable) { - if (type == 'int') { - return ''' - if (value is int) return value; - if (value is num) return value.toInt(); - if (value is String) { - final parsed = int.tryParse(value); - if (parsed != null) return parsed; - } - ${isNullable ? 'return null;' : 'throw Exception("Cannot convert value to int");'} - '''; - } else if (type == 'bool') { - return ''' - if (value is bool) return value; - if (value is String) { - if (value.toLowerCase() == 'true') return true; - if (value.toLowerCase() == 'false') return false; - } - if (value is num) return value != 0; - ${isNullable ? 'return null;' : 'throw Exception("Cannot convert value to bool");'} - '''; - } else if (type == 'double') { - return ''' - if (value is double) return value; - if (value is num) return value.toDouble(); - if (value is String) { - final parsed = double.tryParse(value); - if (parsed != null) return parsed; - } - ${isNullable ? 'return null;' : 'throw Exception("Cannot convert value to double");'} - '''; - } else if (type == 'String') { - return ''' - if (value is String) return value; - return value.toString(); - '''; - } else { - return ''' - return $type.fromJson(value as Map); - '''; + _RootField _rootField(FieldNode sel, ObjectTypeDefinitionNode rootType, + Set definedTypes) { + final fieldName = sel.name.value; + final schemaField = + rootType.fields.where((f) => f.name.value == fieldName).firstOrNull; + if (schemaField == null) { + throw CodegenException( + CodegenErrorCodes.buildUnknownField, + 'Field "$fieldName" does not exist on type ' + '"${rootType.name.value}" (operation "$name")', + ); } + + // @skip/@include may remove the field from the response entirely. + final conditional = sel.directives + .any((d) => d.name.value == 'skip' || d.name.value == 'include'); + final responseKey = sel.alias?.value ?? fieldName; + final type = schemaField.type; + return _RootField( + responseKey, + dartFieldName(responseKey, className: resultClassName), + ClientExtensionGenerator._outputDartType(type, + legacy: !strict, forceNullable: conditional), + (v) => ClientExtensionGenerator._fromJsonExpr(type, v, + legacy: !strict, + definedTypes: definedTypes, + forceNullable: conditional), + ); } } diff --git a/lib/src/code_utils.dart b/lib/src/code_utils.dart index 6f8d29d..efb1be9 100644 --- a/lib/src/code_utils.dart +++ b/lib/src/code_utils.dart @@ -10,6 +10,9 @@ class CodeGenerationConstants { '''// THIS FILE IS GENERATED AUTOMATICALLY AND SHOULD NOT BE EDITED MANUALLY // Any manual changes will be overwritten during code generation // Generated by flutter_graphql_codegen'''; + + /// Silences lints in generated files (placed after [generatedFileWarning]). + static const String ignoreForFile = '// ignore_for_file: type=lint'; } /// Extension methods for string manipulation @@ -31,21 +34,15 @@ extension StringExtension on String { /// Extension for TypeNode to check if it's non-null extension TypeNodeExtensions on TypeNode { - /// Check if the type is non-null - /// This properly detects non-null types by checking for '!' suffix in GraphQL syntax + /// True if this node is marked non-null in the GraphQL AST bool get isNonNull { - final typeString = toString(); - - // Handle the case where toString() returns runtime type info instead of GraphQL syntax - if (typeString.startsWith('Instance of ')) { - // This shouldn't happen in normal cases with properly parsed GraphQL, - // but provides robustness. In this case, we cannot determine nullability - return false; + if (this is NamedTypeNode) { + return (this as NamedTypeNode).isNonNull; } - - // Check if the type string ends with '!' indicating non-null - // This works for both simple types (String!) and complex types ([String!]!) - return typeString.endsWith('!'); + if (this is ListTypeNode) { + return (this as ListTypeNode).isNonNull; + } + return false; } } @@ -76,13 +73,34 @@ class GraphQLConstants { class TypeRegistry { static final Set _customScalars = {}; static final Set _enumTypes = {}; + static final Set _objectTypes = {}; + static final Set _interfaceTypes = {}; + static final Set _unionTypes = {}; + static final Set _inputTypes = {}; + + /// Abstract type name (interface or union) -> sorted concrete object types. + static final Map> _possibleTypes = {}; + + /// Object/interface type name -> abstract types it must implement in Dart + /// (its GraphQL interfaces plus the unions it belongs to). + static final Map> _supertypes = {}; static Set get customScalars => _customScalars; static Set get enumTypes => _enumTypes; + static Set get objectTypes => _objectTypes; + static Set get interfaceTypes => _interfaceTypes; + static Set get unionTypes => _unionTypes; + static Set get inputTypes => _inputTypes; static void clear() { _customScalars.clear(); _enumTypes.clear(); + _objectTypes.clear(); + _interfaceTypes.clear(); + _unionTypes.clear(); + _inputTypes.clear(); + _possibleTypes.clear(); + _supertypes.clear(); } static void addCustomScalar(String scalar) { @@ -93,6 +111,48 @@ class TypeRegistry { _enumTypes.add(enumType); } + static void addObjectType(String typeName) { + _objectTypes.add(typeName); + } + + static void addInterfaceType(String typeName) { + _interfaceTypes.add(typeName); + } + + static void addUnionType(String typeName) { + _unionTypes.add(typeName); + } + + static void addInputType(String typeName) { + _inputTypes.add(typeName); + } + + /// Records that [concreteType] can appear where [abstractType] is expected. + static void addPossibleType(String abstractType, String concreteType) { + final list = _possibleTypes.putIfAbsent(abstractType, () => []); + if (!list.contains(concreteType)) { + list + ..add(concreteType) + ..sort(); + } + } + + /// Records that [typeName] implements [abstractType] in Dart. + static void addSupertype(String typeName, String abstractType) { + final list = _supertypes.putIfAbsent(typeName, () => []); + if (!list.contains(abstractType)) { + list + ..add(abstractType) + ..sort(); + } + } + + static List possibleTypes(String abstractType) => + _possibleTypes[abstractType] ?? const []; + + static List supertypes(String typeName) => + _supertypes[typeName] ?? const []; + static bool isEnum(String typeName) { return _enumTypes.contains(typeName); } @@ -100,4 +160,8 @@ class TypeRegistry { static bool isCustomScalar(String typeName) { return _customScalars.contains(typeName); } + + /// True for interfaces and unions. + static bool isAbstract(String typeName) => + _interfaceTypes.contains(typeName) || _unionTypes.contains(typeName); } diff --git a/lib/src/config.dart b/lib/src/config.dart index aab9d61..ade7229 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -3,19 +3,37 @@ import 'package:glob/list_local_fs.dart'; import 'package:yaml/yaml.dart'; import 'package:glob/glob.dart'; import 'package:yaml/yaml.dart' as yaml; +import 'diagnostics.dart'; class GraphQLCodegenConfig { final String schemaUrl; final String outputDir; final List documentPaths; + final String fileNaming; // snake_case | kebab-case | camelCase | pascalCase + final bool strictNullability; // if true, keep strict nullability from schema + final bool emitIndex; // whether to emit index.dart + final String diagnosticsFormat; // plain | json + final Map scalarMapping; // GraphQL scalar -> Dart type + + /// HTTP headers sent when downloading a remote schema. Values may reference + /// environment variables as `${NAME}`. + final Map schemaHeaders; GraphQLCodegenConfig({ required this.schemaUrl, required this.outputDir, required this.documentPaths, - }); + this.fileNaming = 'snake_case', + this.strictNullability = false, + this.emitIndex = true, + this.diagnosticsFormat = 'plain', + Map? scalarMapping, + Map? schemaHeaders, + }) : scalarMapping = scalarMapping ?? const {}, + schemaHeaders = schemaHeaders ?? const {}; factory GraphQLCodegenConfig.fromYaml(String yamlString) { + var diagFormat = 'plain'; try { final yamlMap = yaml.loadYaml(yamlString) as yaml.YamlMap; @@ -23,14 +41,14 @@ class GraphQLCodegenConfig { yamlMap['schema_url'] as String? ?? yamlMap['schema'] as String?; if (schemaUrl == null) { - throw ArgumentError('Missing schema_url or schema in configuration'); + throw CodegenException( + CodegenErrorCodes.cfgMissingSchema, + 'Missing schema_url or schema in configuration', + ); } - // Handle different schema source types: - // 1. HTTP/HTTPS URLs - use as is - // 2. Local file paths - can use as is (downloader will handle) - // 3. file:// URLs - use as is - print('📋 Config: schema source = $schemaUrl'); + // Schema source may be an HTTP(S) URL, a local path or a file:// URL; + // SchemaDownloader handles all of them. final outputDir = yamlMap['output_dir'] as String? ?? 'lib/graphql/generated'; @@ -39,22 +57,110 @@ class GraphQLCodegenConfig { yamlMap['documents'] as YamlList?; if (documentPaths == null) { - throw ArgumentError( - 'Missing document_paths or documents in configuration'); + throw CodegenException( + CodegenErrorCodes.cfgMissingDocuments, + 'Missing document_paths or documents in configuration', + ); + } + + // Optional settings + const validFileNamingModes = { + 'snake_case', + 'kebab-case', + 'camelCase', + 'PascalCase', + }; + final fileNaming = + (yamlMap['file_naming'] as String?)?.trim() ?? 'snake_case'; + if (!validFileNamingModes.contains(fileNaming)) { + throw CodegenException( + CodegenErrorCodes.cfgInvalid, + 'Invalid file_naming "$fileNaming". ' + 'Valid options: ${validFileNamingModes.join(', ')}', + ); + } + final strictNullability = yamlMap['strict_nullability'] as bool? ?? false; + final emitIndex = yamlMap['emit_index'] as bool? ?? true; + diagFormat = + (yamlMap['diagnostics_format'] as String?)?.trim() ?? 'plain'; + final diagnosticsFormat = diagFormat; + + // Scalar mapping + final scalarMappingMap = {}; + final yamlScalars = yamlMap['scalar_mapping']; + if (yamlScalars is yaml.YamlMap) { + for (final entry in yamlScalars.entries) { + final k = entry.key?.toString(); + final v = entry.value?.toString(); + if (k != null && v != null) { + scalarMappingMap[k] = v; + } + } + } + + // Headers for remote schema download (e.g. auth) + final schemaHeaders = {}; + final yamlHeaders = yamlMap['schema_headers']; + if (yamlHeaders is yaml.YamlMap) { + for (final entry in yamlHeaders.entries) { + final k = entry.key?.toString(); + final v = entry.value?.toString(); + if (k != null && v != null) { + schemaHeaders[k] = expandEnvironmentVariables(v); + } + } + } else if (yamlHeaders != null) { + throw CodegenException( + CodegenErrorCodes.cfgInvalid, + 'schema_headers must be a map of header name to value', + ); } return GraphQLCodegenConfig( schemaUrl: schemaUrl, outputDir: outputDir, documentPaths: documentPaths.map((e) => e as String).toList(), + fileNaming: fileNaming, + strictNullability: strictNullability, + emitIndex: emitIndex, + diagnosticsFormat: diagnosticsFormat, + scalarMapping: scalarMappingMap, + schemaHeaders: schemaHeaders, ); } catch (e) { + if (e is CodegenException) { + // Print structured error using configured diagnostics format + print(e.format(format: diagFormat)); + rethrow; + } print('❌ Error parsing GraphQL codegen config: $e'); - rethrow; + throw CodegenException(CodegenErrorCodes.cfgInvalid, e.toString(), + cause: e); } } - List resolveDocumentPaths() { + /// Replaces `${NAME}` with the value of environment variable `NAME`. + /// + /// Throws a [CodegenException] when a referenced variable is not set, so a + /// missing token fails loudly instead of sending an empty header. + static String expandEnvironmentVariables(String value, + [Map? environment]) { + final env = environment ?? Platform.environment; + return value.replaceAllMapped(RegExp(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}'), + (m) { + final name = m.group(1)!; + final resolved = env[name]; + if (resolved == null) { + throw CodegenException( + CodegenErrorCodes.cfgInvalid, + 'Environment variable "$name" referenced in schema_headers is not set', + ); + } + return resolved; + }); + } + + List resolveDocumentPaths() { return documentPaths.expand((path) { final glob = Glob(path); return glob.listSync().whereType().map((file) => file.path); diff --git a/lib/src/config_context.dart b/lib/src/config_context.dart new file mode 100644 index 0000000..847804e --- /dev/null +++ b/lib/src/config_context.dart @@ -0,0 +1,19 @@ +library; + +/// Global configuration context to be used by static generators +class CodegenConfigContext { + static String fileNaming = 'snake_case'; + static bool strictNullability = false; + static bool emitIndex = true; + static String diagnosticsFormat = 'plain'; + static Map scalarMapping = {}; + + /// Resets all fields to defaults. Useful for tests and watch-mode rebuilds. + static void reset() { + fileNaming = 'snake_case'; + strictNullability = false; + emitIndex = true; + diagnosticsFormat = 'plain'; + scalarMapping = {}; + } +} diff --git a/lib/src/diagnostics.dart b/lib/src/diagnostics.dart new file mode 100644 index 0000000..5b53f05 --- /dev/null +++ b/lib/src/diagnostics.dart @@ -0,0 +1,47 @@ +/// Diagnostics utilities and structured exceptions for code generation +library; + +import 'dart:convert'; + +/// Known error codes for the code generator +class CodegenErrorCodes { + static const String cfgMissingSchema = 'CFG001'; + static const String cfgMissingDocuments = 'CFG002'; + static const String cfgInvalid = 'CFG999'; + + static const String schemaFileNotFound = 'SCH404'; + static const String schemaInvalid = 'SCH400'; + static const String schemaDownloadFailed = 'SCH500'; + + static const String buildOperationParse = 'BLD100'; + static const String buildMissingFragment = 'BLD101'; + static const String buildDuplicateFragment = 'BLD102'; + static const String buildUnknownField = 'BLD103'; + static const String buildIndexFailed = 'BLD200'; +} + +/// Structured exception with error code and optional cause +class CodegenException implements Exception { + final String code; + final String message; + final Object? cause; + final StackTrace? stackTrace; + + CodegenException(this.code, this.message, {this.cause, this.stackTrace}); + + @override + String toString() => '[$code] $message'; + + /// Formats the error for different consumers (plain or json) + String format({String format = 'plain'}) { + if (format.toLowerCase() == 'json') { + final map = { + 'code': code, + 'message': message, + if (cause != null) 'cause': cause.toString(), + }; + return jsonEncode(map); + } + return toString(); + } +} diff --git a/lib/src/document_utils.dart b/lib/src/document_utils.dart new file mode 100644 index 0000000..6052847 --- /dev/null +++ b/lib/src/document_utils.dart @@ -0,0 +1,255 @@ +/// AST helpers for preparing GraphQL documents and schemas for generation. +library; + +import 'package:gql/ast.dart'; +import 'package:gql/language.dart' as gql_lang; + +import 'diagnostics.dart'; + +/// Utilities that operate on parsed GraphQL documents. +class DocumentUtils { + /// Collects all fragment definitions from [documents], keyed by name. + /// + /// Throws a [CodegenException] when two different fragments share a name. + static Map collectFragments( + Iterable documents) { + final fragments = {}; + for (final doc in documents) { + for (final def in doc.definitions.whereType()) { + final name = def.name.value; + final existing = fragments[name]; + if (existing != null && + gql_lang.printNode(existing) != gql_lang.printNode(def)) { + throw CodegenException( + CodegenErrorCodes.buildDuplicateFragment, + 'Fragment "$name" is defined more than once with different ' + 'selections', + ); + } + fragments[name] = def; + } + } + return fragments; + } + + /// Builds a standalone document for [operation]: the operation itself plus + /// every fragment it uses (transitively), with `__typename` added to every + /// selection set so abstract types (interfaces/unions) can be resolved. + static DocumentNode buildOperationDocument( + OperationDefinitionNode operation, + Map fragments, + ) { + final used = {}; + + void visit(SelectionSetNode set) { + for (final sel in set.selections) { + if (sel is FieldNode && sel.selectionSet != null) { + visit(sel.selectionSet!); + } else if (sel is InlineFragmentNode) { + visit(sel.selectionSet); + } else if (sel is FragmentSpreadNode) { + final name = sel.name.value; + if (used.add(name)) { + final fragment = fragments[name]; + if (fragment == null) { + throw CodegenException( + CodegenErrorCodes.buildMissingFragment, + 'Operation "${operation.name?.value ?? ''}" uses ' + 'unknown fragment "$name"', + ); + } + visit(fragment.selectionSet); + } + } + } + } + + visit(operation.selectionSet); + + final usedFragments = used.toList()..sort(); + return DocumentNode(definitions: [ + OperationDefinitionNode( + type: operation.type, + name: operation.name, + variableDefinitions: operation.variableDefinitions, + directives: operation.directives, + selectionSet: _addTypename(operation.selectionSet, isRoot: true), + ), + for (final name in usedFragments) + FragmentDefinitionNode( + name: fragments[name]!.name, + typeCondition: fragments[name]!.typeCondition, + directives: fragments[name]!.directives, + selectionSet: + _addTypename(fragments[name]!.selectionSet, isRoot: false), + ), + ]); + } + + static SelectionSetNode _addTypename(SelectionSetNode set, + {required bool isRoot}) { + final selections = [ + for (final sel in set.selections) + if (sel is FieldNode && sel.selectionSet != null) + FieldNode( + alias: sel.alias, + name: sel.name, + arguments: sel.arguments, + directives: sel.directives, + selectionSet: _addTypename(sel.selectionSet!, isRoot: false), + ) + else if (sel is InlineFragmentNode) + InlineFragmentNode( + typeCondition: sel.typeCondition, + directives: sel.directives, + // The enclosing selection set already requests __typename. + selectionSet: _addTypename(sel.selectionSet, isRoot: true), + ) + else + sel, + ]; + + final hasTypename = selections.any((s) => + s is FieldNode && s.alias == null && s.name.value == '__typename'); + if (!isRoot && !hasTypename) { + selections.add(const FieldNode(name: NameNode(value: '__typename'))); + } + return SelectionSetNode(selections: selections); + } + + /// Folds `extend type`/`extend input`/`extend enum`/... definitions into the + /// types they extend, so generators only have to look at base definitions. + static DocumentNode mergeSchemaExtensions(DocumentNode schema) { + final extensions = + schema.definitions.whereType().toList(); + if (extensions.isEmpty) return schema; + + final byName = >{}; + for (final ext in extensions) { + byName.putIfAbsent(ext.name.value, () => []).add(ext); + } + + final merged = []; + final baseNames = {}; + for (final def in schema.definitions) { + if (def is TypeExtensionNode) continue; + if (def is! TypeDefinitionNode) { + merged.add(def); + continue; + } + baseNames.add(def.name.value); + merged.add(_applyExtensions(def, byName[def.name.value] ?? const [])); + } + + // An extension without a base definition (e.g. `extend type Query` in a + // schema split across files) becomes the definition itself. + for (final entry in byName.entries) { + if (baseNames.contains(entry.key)) continue; + final first = entry.value.first; + final base = _extensionAsDefinition(first); + if (base != null) { + merged.add(_applyExtensions(base, entry.value.skip(1).toList())); + } + } + + return DocumentNode(definitions: merged); + } + + static TypeDefinitionNode _applyExtensions( + TypeDefinitionNode def, List exts) { + if (exts.isEmpty) return def; + if (def is ObjectTypeDefinitionNode) { + final e = exts.whereType(); + return ObjectTypeDefinitionNode( + name: def.name, + description: def.description, + directives: [...def.directives, for (final x in e) ...x.directives], + interfaces: [...def.interfaces, for (final x in e) ...x.interfaces], + fields: [...def.fields, for (final x in e) ...x.fields], + ); + } + if (def is InterfaceTypeDefinitionNode) { + final e = exts.whereType(); + return InterfaceTypeDefinitionNode( + name: def.name, + description: def.description, + directives: [...def.directives, for (final x in e) ...x.directives], + interfaces: [...def.interfaces, for (final x in e) ...x.interfaces], + fields: [...def.fields, for (final x in e) ...x.fields], + ); + } + if (def is InputObjectTypeDefinitionNode) { + final e = exts.whereType(); + return InputObjectTypeDefinitionNode( + name: def.name, + description: def.description, + directives: [...def.directives, for (final x in e) ...x.directives], + fields: [...def.fields, for (final x in e) ...x.fields], + ); + } + if (def is EnumTypeDefinitionNode) { + final e = exts.whereType(); + return EnumTypeDefinitionNode( + name: def.name, + description: def.description, + directives: [...def.directives, for (final x in e) ...x.directives], + values: [...def.values, for (final x in e) ...x.values], + ); + } + if (def is UnionTypeDefinitionNode) { + final e = exts.whereType(); + return UnionTypeDefinitionNode( + name: def.name, + description: def.description, + directives: [...def.directives, for (final x in e) ...x.directives], + types: [...def.types, for (final x in e) ...x.types], + ); + } + return def; + } + + static TypeDefinitionNode? _extensionAsDefinition(TypeExtensionNode ext) { + if (ext is ObjectTypeExtensionNode) { + return ObjectTypeDefinitionNode( + name: ext.name, + directives: ext.directives, + interfaces: ext.interfaces, + fields: ext.fields, + ); + } + if (ext is InterfaceTypeExtensionNode) { + return InterfaceTypeDefinitionNode( + name: ext.name, + directives: ext.directives, + interfaces: ext.interfaces, + fields: ext.fields, + ); + } + if (ext is InputObjectTypeExtensionNode) { + return InputObjectTypeDefinitionNode( + name: ext.name, + directives: ext.directives, + fields: ext.fields, + ); + } + if (ext is EnumTypeExtensionNode) { + return EnumTypeDefinitionNode( + name: ext.name, + directives: ext.directives, + values: ext.values, + ); + } + if (ext is UnionTypeExtensionNode) { + return UnionTypeDefinitionNode( + name: ext.name, + directives: ext.directives, + types: ext.types, + ); + } + if (ext is ScalarTypeExtensionNode) { + return ScalarTypeDefinitionNode( + name: ext.name, directives: ext.directives); + } + return null; + } +} diff --git a/lib/src/flutter_graphql_codegen_base.dart b/lib/src/flutter_graphql_codegen_base.dart deleted file mode 100644 index e8a6f15..0000000 --- a/lib/src/flutter_graphql_codegen_base.dart +++ /dev/null @@ -1,6 +0,0 @@ -// TODO: Put public facing types in this file. - -/// Checks if you are awesome. Spoiler: you are. -class Awesome { - bool get isAwesome => true; -} diff --git a/lib/src/generator.dart b/lib/src/generator.dart index 70f332f..de04bfa 100644 --- a/lib/src/generator.dart +++ b/lib/src/generator.dart @@ -1,70 +1,64 @@ /// This file provides the public API for generating GraphQL-related code. -/// The actual generation logic has been refactored into separate modules -/// for better maintainability and separation of concerns. +/// The actual generation logic lives in separate modules. library; +import 'package:gql/ast.dart'; import 'package:gql/language.dart' as gql_lang; -// Import the refactored modules -import 'code_utils.dart'; - +import 'client_extension_generator.dart'; import 'operation_analyzer.dart'; import 'type_generator.dart'; -import 'client_extension_generator.dart'; /// Main GraphQL code generator class /// /// This class provides the public API for generating GraphQL-related code. -/// The actual generation logic has been refactored into separate modules -/// for better maintainability and separation of concerns. class GraphQLCodeGenerator { /// Generates a complete types file with all necessary components static String generateTypesFile(String schema) { return TypeGenerator.generateTypesFile(schema); } - /// Generates code for a GraphQL operation + /// Generates code for a GraphQL operation. + /// + /// Same as [generateOperationFile]. static String generateCode( String schema, String documentContent, String operationName, String operationType, - String typesContent, - ) { - final schemaDoc = gql_lang.parseString(schema); - final definedTypes = OperationAnalyzer.extractDefinedTypes(typesContent); - final clientExtension = ClientExtensionGenerator.generateClientExtension( - operationName, operationType, documentContent, schemaDoc, definedTypes); - - return '''${CodeGenerationConstants.generatedFileWarning} - -import 'package:graphql/client.dart' as graphql; -import 'types.dart'; - -$clientExtension -'''; + String typesContent, { + Map fragments = const {}, + }) { + return generateOperationFile( + schema, + documentContent, + operationName, + operationType, + typesContent, + fragments: fragments, + ); } - /// Generates an operation file + /// Generates an operation file. + /// + /// [fragments] supplies fragment definitions that live outside + /// [documentContent] (e.g. in other `.graphql` files). static String generateOperationFile( String schema, String documentContent, String operationName, String operationType, - String typesContent, - ) { - final schemaDoc = gql_lang.parseString(schema); - final definedTypes = OperationAnalyzer.extractDefinedTypes(typesContent); - final clientExtension = ClientExtensionGenerator.generateClientExtension( - operationName, operationType, documentContent, schemaDoc, definedTypes); - - return '''${CodeGenerationConstants.generatedFileWarning} - -import 'package:graphql/client.dart' as graphql; -import 'types.dart'; - -$clientExtension -'''; + String typesContent, { + Map fragments = const {}, + }) { + return ClientExtensionGenerator.generateOperationFile( + operationName, + operationType, + documentContent, + gql_lang.parseString(schema), + OperationAnalyzer.extractDefinedTypes(typesContent), + fragments: fragments, + ); } /// Extracts defined types from types content diff --git a/lib/src/naming.dart b/lib/src/naming.dart new file mode 100644 index 0000000..65a59c1 --- /dev/null +++ b/lib/src/naming.dart @@ -0,0 +1,87 @@ +library; + +String toSnakeCase(String input) { + return input + .replaceAllMapped(RegExp(r'(? '_') + .toLowerCase(); +} + +String toKebabCase(String input) { + return input + .replaceAllMapped(RegExp(r'(? '-') + .toLowerCase(); +} + +String toCamelCaseFromPascal(String input) { + if (input.isEmpty) return input; + return input[0].toLowerCase() + input.substring(1); +} + +String toPascalCase(String input) { + if (input.isEmpty) return input; + final parts = input.split(RegExp(r'[_\-\s]+')); + return parts + .where((p) => p.isNotEmpty) + .map((p) => p[0].toUpperCase() + p.substring(1).toLowerCase()) + .join(''); +} + +/// Dart reserved words that can never be used as identifiers. +const Set dartReservedWords = { + 'assert', 'break', 'case', 'catch', 'class', 'const', 'continue', // + 'default', 'do', 'else', 'enum', 'extends', 'false', 'final', 'finally', + 'for', 'if', 'in', 'is', 'new', 'null', 'rethrow', 'return', 'super', + 'switch', 'this', 'throw', 'true', 'try', 'var', 'void', 'while', 'with', +}; + +/// Members every Dart object has; a generated field with one of these names +/// would clash with (or accidentally override) them. +const Set _objectMemberNames = { + 'hashCode', 'runtimeType', 'toString', 'noSuchMethod', // +}; + +/// Members that a generated JSON class declares itself. +const Set _generatedClassMemberNames = {'toJson', 'fromJson'}; + +/// Members every Dart enum has. +const Set _enumMemberNames = {'values', 'index', 'name'}; + +/// Returns a valid Dart identifier for a GraphQL field or variable name. +/// +/// Reserved words and names that clash with built-in members get a trailing +/// underscore (`class` -> `class_`). [className] guards against a member having +/// the same name as its enclosing class, which Dart does not allow. +String dartFieldName(String graphqlName, {String? className}) { + if (dartReservedWords.contains(graphqlName) || + _objectMemberNames.contains(graphqlName) || + _generatedClassMemberNames.contains(graphqlName) || + graphqlName == className) { + return '${graphqlName}_'; + } + return graphqlName; +} + +/// Returns a valid Dart identifier for a GraphQL enum value. +String dartEnumValueName(String graphqlValue) { + if (dartReservedWords.contains(graphqlValue) || + _objectMemberNames.contains(graphqlValue) || + _enumMemberNames.contains(graphqlValue)) { + return '${graphqlValue}_'; + } + return graphqlValue; +} + +String transformFileBaseName(String operationName, String mode) { + switch (mode) { + case 'snake_case': + return toSnakeCase(operationName); + case 'kebab-case': + return toKebabCase(operationName); + case 'camelCase': + return toCamelCaseFromPascal(operationName); + case 'PascalCase': + return toPascalCase(operationName); + default: + return toSnakeCase(operationName); + } +} diff --git a/lib/src/parser.dart b/lib/src/parser.dart index 50eceef..4afe65f 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -1,30 +1,49 @@ +import 'package:gql/ast.dart'; +import 'package:gql/language.dart' as gql_lang; + class GraphQLOperation { final String name; - final String type; - final String content; + final String type; // query | mutation | subscription + final String content; // full SDL of the operation GraphQLOperation( {required this.name, required this.type, required this.content}); } class GraphQLParser { + /// Parses GraphQL operations using the official gql AST parser for reliability static List parseOperations(String document) { - final regex = RegExp( - r'(query|mutation|subscription)\s+(\w+)?\s*($$[\s\S]*?$$)?\s*\{([\s\S]*?)\}', - multiLine: true); - final matches = regex.allMatches(document); - - return matches.map((match) { - final type = match.group(1) ?? 'query'; - final name = match.group(2) ?? 'Anonymous'; - final variables = match.group(3) ?? ''; - final content = match.group(4) ?? ''; - - return GraphQLOperation( - name: name, - type: type, - content: '$type $name$variables {\n$content\n}', - ); - }).toList(); + final doc = gql_lang.parseString(document); + final operations = []; + + for (final def in doc.definitions) { + if (def is OperationDefinitionNode) { + final typeString = _operationTypeToString(def.type); + final opName = def.name?.value ?? 'Anonymous'; + + // Print the operation back from the AST (keeps directives, aliases, + // arguments, default values, etc.) + final serialized = gql_lang.printNode(def); + + operations.add(GraphQLOperation( + name: opName, + type: typeString, + content: serialized, + )); + } + } + + return operations; + } + + static String _operationTypeToString(OperationType t) { + switch (t) { + case OperationType.query: + return 'query'; + case OperationType.mutation: + return 'mutation'; + case OperationType.subscription: + return 'subscription'; + } } } diff --git a/lib/src/schema_analyzer.dart b/lib/src/schema_analyzer.dart index fb1ab6b..5e5a257 100644 --- a/lib/src/schema_analyzer.dart +++ b/lib/src/schema_analyzer.dart @@ -3,6 +3,7 @@ library; import 'package:gql/ast.dart'; import 'code_utils.dart'; +import 'config_context.dart'; /// Utility class for analyzing GraphQL schemas class SchemaAnalyzer { @@ -15,10 +16,47 @@ class SchemaAnalyzer { final typeName = definition.name.value; if (definition is ScalarTypeDefinitionNode && !GraphQLConstants.builtInScalars.contains(typeName) && - typeName != 'Decimal') { + typeName != 'Decimal' && + // Mapped scalars use the configured Dart type, no wrapper class. + !CodegenConfigContext.scalarMapping.containsKey(typeName)) { TypeRegistry.addCustomScalar(typeName); } else if (definition is EnumTypeDefinitionNode) { TypeRegistry.addEnumType(typeName); + } else if (definition is ObjectTypeDefinitionNode) { + TypeRegistry.addObjectType(typeName); + } else if (definition is InterfaceTypeDefinitionNode) { + TypeRegistry.addInterfaceType(typeName); + } else if (definition is UnionTypeDefinitionNode) { + TypeRegistry.addUnionType(typeName); + } else if (definition is InputObjectTypeDefinitionNode) { + TypeRegistry.addInputType(typeName); + } + } + } + + // Second pass: relationships between concrete and abstract types. + for (final definition in schemaDoc.definitions) { + if (definition is ObjectTypeDefinitionNode) { + final typeName = definition.name.value; + for (final iface in definition.interfaces) { + final ifaceName = iface.name.value; + if (!TypeRegistry.interfaceTypes.contains(ifaceName)) continue; + TypeRegistry.addSupertype(typeName, ifaceName); + TypeRegistry.addPossibleType(ifaceName, typeName); + } + } else if (definition is InterfaceTypeDefinitionNode) { + for (final iface in definition.interfaces) { + final ifaceName = iface.name.value; + if (!TypeRegistry.interfaceTypes.contains(ifaceName)) continue; + TypeRegistry.addSupertype(definition.name.value, ifaceName); + } + } else if (definition is UnionTypeDefinitionNode) { + final unionName = definition.name.value; + for (final member in definition.types) { + final memberName = member.name.value; + if (!TypeRegistry.objectTypes.contains(memberName)) continue; + TypeRegistry.addSupertype(memberName, unionName); + TypeRegistry.addPossibleType(unionName, memberName); } } } @@ -44,12 +82,8 @@ class SchemaAnalyzer { static ObjectTypeDefinitionNode? findRootType( DocumentNode schemaDoc, String operationType) { operationType = operationType.toLowerCase(); - if (operationType == 'operationtype.mutation') { - operationType = 'mutation'; - } else if (operationType == 'operationtype.query') { - operationType = 'query'; - } else if (operationType == 'operationtype.subscription') { - operationType = 'subscription'; + if (operationType.startsWith('operationtype.')) { + operationType = operationType.substring('operationtype.'.length); } for (final definition in schemaDoc.definitions) { @@ -168,13 +202,31 @@ class SchemaAnalyzer { /// Gets the Dart type from a TypeNode static String getDartType(TypeNode type) { if (type is NamedTypeNode) { - final typeName = - GraphQLConstants.scalarToDartType[type.name.value] ?? type.name.value; - return type.isNonNull ? typeName : '$typeName?'; + final original = type.name.value; + // Allow scalar mapping override from config + final mapped = CodegenConfigContext.scalarMapping[original] ?? + GraphQLConstants.scalarToDartType[original] ?? + original; + + final isObjectType = TypeRegistry.objectTypes.contains(original); + + // Respect schema NonNull exactly for scalars/enums/custom scalars + if (!isObjectType) { + return type.isNonNull ? mapped : '$mapped?'; + } + + // For object types: we still respect NonNull, but mapping keeps class name + return type.isNonNull ? original : '$original?'; } else if (type is ListTypeNode) { + // Compute element Dart type first (it will include '?' if nullable) final innerType = getDartType(type.type); - // Make all lists nullable to handle null values gracefully - return 'List<$innerType>?'; + final listType = 'List<$innerType>'; + // When strictNullability is off, lists are always nullable (legacy behavior). + // When on, respect the NonNull flag from the schema exactly. + if (!CodegenConfigContext.strictNullability) { + return '$listType?'; + } + return type.isNonNull ? listType : '$listType?'; } return 'dynamic'; } diff --git a/lib/src/schema_downloader.dart b/lib/src/schema_downloader.dart index 2bb340a..3251598 100644 --- a/lib/src/schema_downloader.dart +++ b/lib/src/schema_downloader.dart @@ -1,20 +1,39 @@ -import 'package:http/http.dart' as http; +import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:build/build.dart' show log; +import 'package:gql/ast.dart'; +import 'package:gql/language.dart' as gql_lang; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; + +import 'diagnostics.dart'; + class SchemaDownloader { + /// Default timeout for every HTTP request made while fetching a schema. + static const Duration defaultTimeout = Duration(seconds: 30); + /// Downloads GraphQL schema from URL or reads from local file /// Supports: /// - HTTP/HTTPS URLs: http://example.com/graphql, https://api.example.com/graphql /// - Local file paths: schema.graphql, lib/graphql/schema.graphql /// - File URLs: file:///path/to/schema.graphql - static Future downloadSchema(String schemaSource) async { - print('📥 Loading GraphQL schema from: $schemaSource'); + /// + /// For HTTP(S) sources the SDL is requested directly first, then with a + /// `?sdl` query parameter, and finally via an introspection query whose + /// result is converted to SDL. [headers] are sent with every request. + static Future downloadSchema( + String schemaSource, { + Map headers = const {}, + Duration timeout = defaultTimeout, + }) async { + log.fine('Loading GraphQL schema from: $schemaSource'); // Handle HTTP/HTTPS URLs if (schemaSource.startsWith('http://') || schemaSource.startsWith('https://')) { - return await _downloadFromUrl(schemaSource); + return await _downloadFromUrl(schemaSource, headers, timeout); } // Handle local files (both file:// URLs and direct paths) @@ -22,164 +41,313 @@ class SchemaDownloader { } /// Downloads schema from HTTP/HTTPS URL - static Future _downloadFromUrl(String url) async { - try { - print('🌐 Downloading schema from HTTP URL: $url'); + static Future _downloadFromUrl( + String url, Map headers, Duration timeout) async { + final uri = Uri.parse(url); + final failures = []; - // Try direct SDL endpoint first - var response = await http.get(Uri.parse(url)); - - if (response.statusCode == 200 && _isValidGraphQLSchema(response.body)) { - print('✅ Successfully downloaded schema from direct SDL endpoint'); - return response.body; - } - - // If direct SDL didn't work, try with ?sdl parameter - final urlWithSdl = Uri.parse(url).replace(queryParameters: {'sdl': ''}); - response = await http.get(urlWithSdl); - - if (response.statusCode == 200) { - if (_isValidGraphQLSchema(response.body)) { - print('✅ Successfully downloaded schema with ?sdl parameter'); + Future trySdl(Uri target) async { + try { + final response = + await http.get(target, headers: headers).timeout(timeout); + if (response.statusCode == 200 && + _isValidGraphQLSchema(response.body)) { return response.body; - } else { - throw FormatException( - 'Received content does not appear to be a valid GraphQL SDL schema.', - ); } - } else { - throw Exception( - 'Failed to download schema: HTTP ${response.statusCode}. Response: ${response.body}', - ); + failures.add('GET $target -> HTTP ${response.statusCode}' + '${response.statusCode == 200 ? ' (not SDL)' : ''}'); + } on TimeoutException { + failures.add('GET $target -> timed out after ${timeout.inSeconds}s'); + } catch (e) { + failures.add('GET $target -> $e'); } + return null; + } + + final direct = await trySdl(uri); + if (direct != null) return direct; + + final withSdl = await trySdl( + uri.replace(queryParameters: {...uri.queryParameters, 'sdl': ''})); + if (withSdl != null) return withSdl; + + try { + final data = await _runIntrospection(url, headers, timeout); + log.fine('Downloaded schema via introspection from $url'); + return introspectionToSdl(data); } catch (e) { - print('❌ Error downloading schema from URL: $e'); - rethrow; + failures.add('POST introspection -> $e'); } + + throw CodegenException( + CodegenErrorCodes.schemaDownloadFailed, + 'Could not download a GraphQL schema from $url:\n' + ' ${failures.join('\n ')}', + ); } /// Reads schema from local file static Future _readFromLocalFile(String filePath) async { - try { - // Handle file:// URLs - String actualPath = filePath; - if (filePath.startsWith('file://')) { - actualPath = filePath.substring(7); // Remove 'file://' prefix - print('📂 Reading schema from file:// URL: $actualPath'); - } else { - print('📂 Reading schema from local file: $actualPath'); - } - - final file = File(actualPath); + // Handle file:// URLs and normalize cross-platform paths + final actualPath = filePath.startsWith('file://') + ? p.fromUri(Uri.parse(filePath)) + : p.normalize(filePath); - if (!await file.exists()) { - throw FileSystemException('Schema file not found', actualPath); - } + final file = File(actualPath); - final content = await file.readAsString(); + if (!await file.exists()) { + throw FileSystemException('Schema file not found', actualPath); + } - if (content.trim().isEmpty) { - throw FormatException('Schema file is empty: $actualPath'); - } + final content = await file.readAsString(); - if (!_isValidGraphQLSchema(content)) { - throw FormatException( - 'File does not contain a valid GraphQL schema: $actualPath'); - } + if (content.trim().isEmpty) { + throw FormatException('Schema file is empty: $actualPath'); + } - print('✅ Successfully read schema from local file'); - return content; - } catch (e) { - print('❌ Error reading schema from file: $e'); - rethrow; + if (!_isValidGraphQLSchema(content)) { + throw FormatException( + 'File does not contain a valid GraphQL schema: $actualPath'); } + + return content; } - /// Validates if content is a valid GraphQL schema + /// True if [content] parses as GraphQL and declares at least one type + /// system definition (an HTML error page or a JSON body does not). static bool _isValidGraphQLSchema(String content) { - final trimmedContent = content.trim(); - - if (trimmedContent.isEmpty) return false; - - // Check for common GraphQL schema keywords - return trimmedContent.contains('type ') || - trimmedContent.contains('interface ') || - trimmedContent.contains('union ') || - trimmedContent.contains('enum ') || - trimmedContent.contains('input ') || - trimmedContent.contains('scalar ') || - trimmedContent.contains('schema ') || - trimmedContent.contains('directive ') || - // Check for comments that might precede actual schema - (trimmedContent.startsWith('#') && - (trimmedContent.contains('type ') || - trimmedContent.contains('schema '))); + if (content.trim().isEmpty) return false; + try { + final doc = gql_lang.parseString(content); + return doc.definitions.any((d) => d is TypeSystemDefinitionNode); + } catch (_) { + return false; + } } + /// Runs the standard introspection query against [url] and returns the + /// result's `data` encoded as JSON. static Future downloadSchemaUsingIntrospectionQuery( - String url, - ) async { - final introspectionQuery = ''' - query IntrospectionQuery { - __schema { - queryType { name } - mutationType { name } - subscriptionType { name } - types { - ...FullType + String url, { + Map headers = const {}, + Duration timeout = defaultTimeout, + }) async { + return json.encode(await _runIntrospection(url, headers, timeout)); + } + + static Future> _runIntrospection( + String url, Map headers, Duration timeout) async { + final response = await http + .post( + Uri.parse(url), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...headers, + }, + body: json.encode({'query': introspectionQuery}), + ) + .timeout(timeout); + + if (response.statusCode != 200) { + throw HttpException( + 'HTTP ${response.statusCode}: ${_truncate(response.body)}', + uri: Uri.parse(url)); + } + + final decoded = json.decode(response.body); + if (decoded is! Map) { + throw const FormatException('Introspection response is not an object'); + } + if (decoded['errors'] != null) { + throw FormatException('Introspection errors: ${decoded['errors']}'); + } + final data = decoded['data']; + if (data is! Map || data['__schema'] == null) { + throw const FormatException('Introspection response has no __schema'); + } + return data; + } + + static String _truncate(String s) => + s.length <= 200 ? s : '${s.substring(0, 200)}...'; + + /// Converts the `data` of an introspection query result into SDL. + static String introspectionToSdl(Map data) { + final schema = data['__schema'] as Map; + final buffer = StringBuffer(); + + String? rootName(String key) => + (schema[key] as Map?)?['name'] as String?; + + final roots = { + 'query': rootName('queryType'), + 'mutation': rootName('mutationType'), + 'subscription': rootName('subscriptionType'), + }..removeWhere((_, v) => v == null); + buffer.writeln('schema {'); + roots.forEach((op, name) => buffer.writeln(' $op: $name')); + buffer.writeln('}'); + + const builtInScalars = {'String', 'Int', 'Float', 'Boolean', 'ID'}; + final types = (schema['types'] as List).cast>(); + + for (final type in types) { + final name = type['name'] as String; + if (name.startsWith('__') || builtInScalars.contains(name)) continue; + buffer.writeln(); + + switch (type['kind']) { + case 'SCALAR': + buffer.writeln('scalar $name'); + case 'OBJECT': + case 'INTERFACE': + final keyword = type['kind'] == 'OBJECT' ? 'type' : 'interface'; + final interfaces = ((type['interfaces'] as List?) ?? const []) + .map((i) => (i as Map)['name']) + .toList(); + buffer.write('$keyword $name'); + if (interfaces.isNotEmpty) { + buffer.write(' implements ${interfaces.join(' & ')}'); } - directives { - name - description - locations - args { - ...InputValue - } + final fields = ((type['fields'] as List?) ?? const []) + .cast>(); + if (fields.isEmpty) { + buffer.writeln(); + break; } - } + buffer.writeln(' {'); + for (final field in fields) { + final args = ((field['args'] as List?) ?? const []) + .cast>(); + final argsSdl = args.isEmpty + ? '' + : '(${args.map(_inputValueToSdl).join(', ')})'; + buffer.writeln(' ${field['name']}$argsSdl: ' + '${_typeRefToSdl(field['type'] as Map)}' + '${_deprecated(field)}'); + } + buffer.writeln('}'); + case 'UNION': + final members = ((type['possibleTypes'] as List?) ?? const []) + .map((t) => (t as Map)['name']) + .join(' | '); + buffer.writeln('union $name = $members'); + case 'ENUM': + buffer.writeln('enum $name {'); + for (final value in ((type['enumValues'] as List?) ?? const []) + .cast>()) { + buffer.writeln(' ${value['name']}${_deprecated(value)}'); + } + buffer.writeln('}'); + case 'INPUT_OBJECT': + final fields = ((type['inputFields'] as List?) ?? const []) + .cast>(); + if (fields.isEmpty) { + buffer.writeln('input $name'); + break; + } + buffer.writeln('input $name {'); + for (final field in fields) { + buffer.writeln(' ${_inputValueToSdl(field)}'); + } + buffer.writeln('}'); } + } - fragment FullType on __Type { - kind - name - description - fields(includeDeprecated: true) { + return buffer.toString(); + } + + static String _inputValueToSdl(Map value) { + final defaultValue = value['defaultValue']; + return '${value['name']}: ' + '${_typeRefToSdl(value['type'] as Map)}' + '${defaultValue != null ? ' = $defaultValue' : ''}'; + } + + static String _typeRefToSdl(Map ref) { + switch (ref['kind']) { + case 'NON_NULL': + return '${_typeRefToSdl(ref['ofType'] as Map)}!'; + case 'LIST': + return '[${_typeRefToSdl(ref['ofType'] as Map)}]'; + default: + return ref['name'] as String; + } + } + + static String _deprecated(Map node) { + if (node['isDeprecated'] != true) return ''; + final reason = node['deprecationReason'] as String?; + if (reason == null) return ' @deprecated'; + return ' @deprecated(reason: ${json.encode(reason)})'; + } + + /// The standard introspection query. + static const String introspectionQuery = ''' + query IntrospectionQuery { + __schema { + queryType { name } + mutationType { name } + subscriptionType { name } + types { + ...FullType + } + directives { name description + locations args { ...InputValue } - type { - ...TypeRef - } - isDeprecated - deprecationReason } - inputFields { + } + } + + fragment FullType on __Type { + kind + name + description + fields(includeDeprecated: true) { + name + description + args { ...InputValue } - interfaces { - ...TypeRef - } - enumValues(includeDeprecated: true) { - name - description - isDeprecated - deprecationReason - } - possibleTypes { + type { ...TypeRef } + isDeprecated + deprecationReason } - - fragment InputValue on __InputValue { + inputFields { + ...InputValue + } + interfaces { + ...TypeRef + } + enumValues(includeDeprecated: true) { name description - type { ...TypeRef } - defaultValue + isDeprecated + deprecationReason + } + possibleTypes { + ...TypeRef } + } - fragment TypeRef on __Type { + fragment InputValue on __InputValue { + name + description + type { ...TypeRef } + defaultValue + } + + fragment TypeRef on __Type { + kind + name + ofType { kind name ofType { @@ -200,10 +368,6 @@ class SchemaDownloader { ofType { kind name - ofType { - kind - name - } } } } @@ -211,27 +375,6 @@ class SchemaDownloader { } } } - '''; - - final response = await http.post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: json.encode({'query': introspectionQuery}), - ); - - final jsonResponse = json.decode(response.body); - - if (jsonResponse['errors'] != null) { - throw Exception( - 'Failed to download schema: ${response.statusCode}. Errors: ${jsonResponse['errors']}', - ); } - if (response.statusCode == 200) { - return json.encode(jsonResponse['data']); - } else { - throw Exception( - 'Failed to download schema: ${response.statusCode}. Response: ${response.body}', - ); - } - } + '''; } diff --git a/lib/src/type_generator.dart b/lib/src/type_generator.dart index 8ca2173..78b8d78 100644 --- a/lib/src/type_generator.dart +++ b/lib/src/type_generator.dart @@ -4,14 +4,18 @@ library; import 'package:gql/ast.dart'; import 'package:gql/language.dart' as gql_lang; import 'code_utils.dart'; +import 'document_utils.dart'; +import 'naming.dart'; import 'schema_analyzer.dart'; /// Utility class for generating types, enums, and converters class TypeGenerator { /// Generates a complete types file with all necessary components static String generateTypesFile(String schema) { - final schemaDoc = gql_lang.parseString(schema); + final schemaDoc = + DocumentUtils.mergeSchemaExtensions(gql_lang.parseString(schema)); SchemaAnalyzer.extractCustomTypesAndEnums(schemaDoc); + final needsConvert = _wrappedScalars(TypeRegistry.customScalars).isNotEmpty; final scalarConverters = _generateScalarConverters(TypeRegistry.customScalars); final enumDefinitions = _generateEnumDefinitions(schemaDoc); @@ -19,8 +23,9 @@ class TypeGenerator { final typeDefinitions = _generateAllTypeDefinitions(schemaDoc, schema); return '''${CodeGenerationConstants.generatedFileWarning} +${CodeGenerationConstants.ignoreForFile}, unused_element -import 'package:json_annotation/json_annotation.dart'; +${needsConvert ? "import 'dart:convert';\n\n" : ''}import 'package:json_annotation/json_annotation.dart'; part 'types.g.dart'; @@ -32,6 +37,12 @@ class Decimal { String toString() => value; static Decimal parse(String value) => Decimal(value); + + @override + bool operator ==(Object other) => other is Decimal && other.value == value; + + @override + int get hashCode => value.hashCode; } class Long { @@ -42,6 +53,12 @@ class Long { String toString() => value; static Long parse(String value) => Long(value); + + @override + bool operator ==(Object other) => other is Long && other.value == value; + + @override + int get hashCode => value.hashCode; } class DecimalConverter implements JsonConverter { @@ -71,7 +88,7 @@ class DateTimeConverter implements JsonConverter { } @override - String toJson(DateTime object) { + dynamic toJson(DateTime object) { // Always convert to UTC to ensure GraphQL-compatible format with timezone // GraphQL DateTime scalar requires full ISO-8601 format with timezone return object.toUtc().toIso8601String(); @@ -89,7 +106,7 @@ class LocalDateConverter implements JsonConverter { } @override - String toJson(DateTime object) => object.toIso8601String().split('T').first; + dynamic toJson(DateTime object) => object.toIso8601String().split('T').first; } class LongConverter implements JsonConverter { @@ -121,6 +138,12 @@ class Byte { if (value is double) return Byte(value.toInt()); return Byte(0); } + + @override + bool operator ==(Object other) => other is Byte && other.value == value; + + @override + int get hashCode => value.hashCode; } class ByteConverter implements JsonConverter { @@ -249,37 +272,52 @@ ${_generateListConverterHelpers(TypeRegistry.customScalars)} '''; } - /// Generates scalar converters for custom scalar types - static String _generateScalarConverters(Set customScalars) { - final buffer = StringBuffer(); - - for (final scalar in customScalars) { - if (scalar != 'Decimal' && + /// Custom scalars that get the generic generated wrapper class + converter. + static Iterable _wrappedScalars(Set customScalars) => + customScalars.where((scalar) => + scalar != 'Decimal' && scalar != 'DateTime' && scalar != 'Short' && scalar != 'Byte' && scalar != 'Long' && - scalar != 'LocalDate') { - buffer.writeln(''' + scalar != 'LocalDate'); + + /// Generates scalar converters for custom scalar types + static String _generateScalarConverters(Set customScalars) { + final buffer = StringBuffer(); + + for (final scalar in _wrappedScalars(customScalars)) { + buffer.writeln(''' +/// Wrapper for the custom GraphQL scalar `$scalar`. +/// +/// [raw] holds the JSON value exactly as received (string, number, map, ...). class $scalar { - final String value; - $scalar(this.value); + final Object? raw; + const $scalar(this.raw); + + /// String form of the value; non-string JSON values are JSON-encoded. + String get value => raw is String ? raw as String : jsonEncode(raw); @override String toString() => value; + + @override + bool operator ==(Object other) => other is $scalar && other.value == value; + + @override + int get hashCode => value.hashCode; } -class ${scalar}Converter implements JsonConverter<$scalar, String> { +class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { const ${scalar}Converter(); @override - $scalar fromJson(String json) => $scalar(json); + $scalar fromJson(dynamic json) => $scalar(json); @override - String toJson($scalar object) => object.toString(); + dynamic toJson($scalar object) => object.raw; } '''); - } } return buffer.toString(); } @@ -288,12 +326,13 @@ class ${scalar}Converter implements JsonConverter<$scalar, String> { static String _generateEnumDefinitions(DocumentNode schemaDoc) { final buffer = StringBuffer(); - for (final definition in schemaDoc.definitions) { + for (final definition in _sortedDefinitions(schemaDoc)) { if (definition is EnumTypeDefinitionNode) { final enumName = definition.name.value; buffer.writeln('enum $enumName {'); - for (final value in definition.values) { - buffer.writeln(' ${value.name.value},'); + // Sort enum values for determinism + for (final value in _sortedEnumValues(definition)) { + buffer.writeln(' ${dartEnumValueName(value.name.value)},'); } buffer.writeln('}'); buffer.writeln(); @@ -307,9 +346,10 @@ class ${scalar}Converter implements JsonConverter<$scalar, String> { static String _generateEnumConverters(DocumentNode schemaDoc) { final buffer = StringBuffer(); - for (final definition in schemaDoc.definitions) { + for (final definition in _sortedDefinitions(schemaDoc)) { if (definition is EnumTypeDefinitionNode) { final enumName = definition.name.value; + final values = _sortedEnumValues(definition); buffer.writeln( 'class ${enumName}Converter extends JsonConverter<$enumName, String> {'); buffer.writeln(' const ${enumName}Converter();'); @@ -317,18 +357,31 @@ class ${scalar}Converter implements JsonConverter<$scalar, String> { buffer.writeln(' @override'); buffer.writeln(' $enumName fromJson(String json) {'); buffer.writeln(' switch (json) {'); - for (final value in definition.values) { + for (final value in values) { buffer.writeln( - " case '${value.name.value}': return $enumName.${value.name.value};"); + " case '${value.name.value}': return $enumName.${dartEnumValueName(value.name.value)};"); } buffer.writeln( - " default: throw Exception('Unknown enum value \$json for $enumName');"); + " default: throw ArgumentError.value(json, 'json', 'Unknown $enumName value');"); buffer.writeln(' }'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' @override'); - buffer.writeln( - ' String toJson($enumName object) => object.toString().split(".").last;'); + final needsMapping = + values.any((v) => dartEnumValueName(v.name.value) != v.name.value); + if (needsMapping) { + buffer.writeln(' String toJson($enumName object) {'); + buffer.writeln(' switch (object) {'); + for (final value in values) { + buffer.writeln( + " case $enumName.${dartEnumValueName(value.name.value)}: return '${value.name.value}';"); + } + buffer.writeln(' }'); + buffer.writeln(' }'); + } else { + buffer.writeln( + ' String toJson($enumName object) => object.toString().split(".").last;'); + } buffer.writeln('}'); buffer.writeln(); } @@ -337,374 +390,234 @@ class ${scalar}Converter implements JsonConverter<$scalar, String> { return buffer.toString(); } + static List _sortedDefinitions(DocumentNode schemaDoc) { + final defs = List.from(schemaDoc.definitions); + defs.sort((a, b) { + final an = (a is TypeDefinitionNode) ? a.name.value : ''; + final bn = (b is TypeDefinitionNode) ? b.name.value : ''; + return an.compareTo(bn); + }); + return defs; + } + + static List _sortedEnumValues( + EnumTypeDefinitionNode definition) { + return [...definition.values] + ..sort((a, b) => a.name.value.compareTo(b.name.value)); + } + /// Generates all type definitions from schema static String _generateAllTypeDefinitions( DocumentNode schemaDoc, String schema) { final buffer = StringBuffer(); - for (final definition in schemaDoc.definitions) { + final interfaces = { + for (final def + in schemaDoc.definitions.whereType()) + def.name.value: def, + }; + + for (final definition in _sortedDefinitions(schemaDoc)) { if (definition is ObjectTypeDefinitionNode) { - buffer.writeln(_generateTypeDefinition(definition, schema)); + buffer.writeln(_generateTypeDefinition(definition, schema, interfaces)); } else if (definition is InputObjectTypeDefinitionNode) { buffer.writeln(_generateInputTypeDefinition(definition, schema)); + } else if (definition is InterfaceTypeDefinitionNode) { + buffer.writeln(_generateInterfaceDefinition(definition, interfaces)); + } else if (definition is UnionTypeDefinitionNode) { + buffer.writeln(_generateUnionDefinition(definition)); } } return buffer.toString(); } + /// Names of all fields declared by [typeName]'s interfaces (transitively). + static Set _inheritedFieldNames( + String typeName, + Map interfaces, [ + Set? visited, + ]) { + visited ??= {}; + final names = {}; + for (final superName in TypeRegistry.supertypes(typeName)) { + final iface = interfaces[superName]; + if (iface == null || !visited.add(superName)) continue; + names.addAll(iface.fields.map((f) => f.name.value)); + names.addAll(_inheritedFieldNames(superName, interfaces, visited)); + } + return names; + } + /// Generates a type definition for a specific object type static String _generateTypeDefinition(ObjectTypeDefinitionNode typeNode, - [String schema = '']) { + [String schema = '', + Map interfaces = const {}]) { final typeName = typeNode.name.value; - final fields = typeNode.fields; - - final buffer = StringBuffer(); - buffer.writeln( - '@JsonSerializable(includeIfNull: false, explicitToJson: true, fieldRename: FieldRename.none)'); - buffer.writeln('class $typeName {'); - - for (final field in fields) { - final fieldName = field.name.value; - var fieldType = SchemaAnalyzer.getDartType(field.type); - final baseType = fieldType - .replaceAll('?', '') - .replaceAll('List<', '') - .replaceAll('>', ''); - - // Check if this is a list field - final isListField = fieldType.startsWith('List<'); - final isNullable = fieldType.endsWith('?'); - - // Add @JsonKey annotation for field name preservation - // This ensures that camelCase field names from GraphQL are preserved - // instead of being converted to snake_case by json_serializable - final needsJsonKeyName = _needsJsonKeyForFieldName(fieldName); - - if (isListField) { - // Check if the list contains custom types that need converters - final innerType = fieldType.substring( - 5, fieldType.length - (fieldType.endsWith('>?') ? 2 : 1)); - final cleanInnerType = innerType.replaceAll('?', ''); + return _generateJsonClass( + className: typeName, + fields: [for (final f in typeNode.fields) (f.name.value, f.type)], + supertypes: TypeRegistry.supertypes(typeName), + overriddenFields: _inheritedFieldNames(typeName, interfaces), + ); + } - // Add converter annotation for custom scalar types in lists - if (TypeRegistry.isCustomScalar(cleanInnerType) && - cleanInnerType != 'Decimal' && - cleanInnerType != 'Short' && - cleanInnerType != 'Byte') { - // For lists of custom scalars, use JsonKey with converter - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [], fromJson: _${cleanInnerType.toLowerCase()}ListFromJson, toJson: _${cleanInnerType.toLowerCase()}ListToJson)'); - } else if (cleanInnerType == 'DateTime') { - // Get original GraphQL type for list element to distinguish between DateTime and LocalDate - final originalListType = getOriginalGraphQLType(field.type); - if (originalListType == 'LocalDate') { - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [], fromJson: _localDateListFromJson, toJson: _localDateListToJson)'); - } else { - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [], fromJson: _dateTimeListFromJson, toJson: _dateTimeListToJson)'); - } - } else if (cleanInnerType == 'LocalDate') { - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [], fromJson: _localDateListFromJson, toJson: _localDateListToJson)'); - } else if (cleanInnerType == 'Decimal') { - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [], fromJson: _decimalListFromJson, toJson: _decimalListToJson)'); - } else if (cleanInnerType == 'Byte') { - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [], fromJson: _byteListFromJson, toJson: _byteListToJson)'); - } else if (TypeRegistry.isEnum(cleanInnerType)) { - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [])'); - } else { - // For regular lists (including object lists), just use default value - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [])'); - } - } else if (baseType == 'DateTime') { - // Get original GraphQL type to distinguish between DateTime and LocalDate - final originalType = getOriginalGraphQLType(field.type); - print( - 'DEBUG: Field $fieldName - baseType: $baseType, originalType: $originalType'); - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - if (originalType == 'LocalDate') { - buffer.writeln(' @LocalDateConverter()'); - } else { - buffer.writeln(' @DateTimeConverter()'); - } - } else if (baseType == 'LocalDate') { - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @LocalDateConverter()'); - } else if (baseType == 'Decimal') { - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @SafeDecimalConverter()'); - } else if (baseType == 'Long') { - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @LongConverter()'); - } else if (baseType == 'Byte') { - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @ByteConverter()'); - } else if (TypeRegistry.isEnum(baseType)) { - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @${baseType}Converter()'); - } else if (TypeRegistry.isCustomScalar(baseType) && - baseType != 'Decimal' && - baseType != 'Long' && - baseType != 'Short' && - baseType != 'Byte') { - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @${baseType}Converter()'); - } else if (baseType == 'bool' && !isNullable) { - // Use safe bool converter for non-nullable boolean fields - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @SafeBoolConverter()'); - } else if (baseType == 'int' && !isNullable) { - // Use safe int converter for non-nullable integer fields - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @SafeIntConverter()'); - } else if (baseType == 'double' && !isNullable) { - // Use safe double converter for non-nullable double fields - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @SafeDoubleConverter()'); - } else if (needsJsonKeyName) { - // For any other field that needs name preservation - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } + /// Generates a type definition for a specific input type + static String _generateInputTypeDefinition( + InputObjectTypeDefinitionNode inputNode, + [String schema = '']) { + return _generateJsonClass( + className: inputNode.name.value, + fields: [for (final f in inputNode.fields) (f.name.value, f.type)], + ); + } - buffer.writeln(' $fieldType $fieldName;'); - } + /// Generates an abstract class for a GraphQL interface plus an `Unknown*` + /// fallback implementation used for unrecognised `__typename` values. + static String _generateInterfaceDefinition( + InterfaceTypeDefinitionNode node, + Map interfaces, + ) { + final name = node.name.value; + final supertypes = TypeRegistry.supertypes(name); + final inherited = _inheritedFieldNames(name, interfaces); + final unknownName = 'Unknown$name'; + final sortedFields = [...node.fields] + ..sort((a, b) => a.name.value.compareTo(b.name.value)); - buffer.writeln(); - buffer.writeln(' $typeName({'); - for (final field in fields) { + final buffer = StringBuffer(); + buffer.writeln('/// GraphQL interface `$name`.'); + final impl = + supertypes.isEmpty ? '' : ' implements ${supertypes.join(', ')}'; + buffer.writeln('abstract class $name$impl {'); + for (final field in sortedFields) { final fieldName = field.name.value; - var fieldType = SchemaAnalyzer.getDartType(field.type); - - final isNullable = fieldType.endsWith('?'); - final isListField = fieldType.startsWith('List<'); - - // Lists are now always nullable, so they don't need 'required' - // Other nullable fields also don't need 'required' - if (isListField) { - // For list fields, provide empty list as default - buffer.writeln(' this.$fieldName = const [],'); - } else if (isNullable) { - buffer.writeln(' this.$fieldName,'); - } else { - buffer.writeln(' required this.$fieldName,'); - } + if (inherited.contains(fieldName)) buffer.writeln(' @override'); + buffer.writeln( + ' ${SchemaAnalyzer.getDartType(field.type)} get ${dartFieldName(fieldName, className: name)};'); } - buffer.writeln(' });'); - buffer.writeln(); buffer.writeln( - ' factory $typeName.fromJson(Map json) => _\$${typeName}FromJson(json);'); - buffer.writeln( - ' Map toJson() => _\$${typeName}ToJson(this);'); - + ' /// Creates the concrete implementation matching `__typename`.'); + buffer.writeln(' factory $name.fromJson(Map json) {'); + buffer.write(_typenameDispatch(name, '$unknownName.fromJson(json)')); + buffer.writeln(' }'); + buffer.writeln(); + if (supertypes.isNotEmpty) buffer.writeln(' @override'); + buffer.writeln(' Map toJson();'); buffer.writeln('}'); buffer.writeln(); + buffer.writeln( + '/// Fallback for `$name` implementations unknown at generation time.'); + buffer.write(_generateJsonClass( + className: unknownName, + fields: [for (final f in node.fields) (f.name.value, f.type)], + supertypes: [name], + overriddenFields: node.fields.map((f) => f.name.value).toSet(), + fieldNameClassName: name, + )); return buffer.toString(); } - /// Determines if a field name needs explicit JsonKey annotation to preserve naming - /// This prevents json_serializable from converting camelCase to snake_case - static bool _needsJsonKeyForFieldName(String fieldName) { - // Check if the field name contains uppercase letters (indicating camelCase) - // We preserve all field names to maintain consistency with GraphQL responses - return fieldName.contains(RegExp(r'[A-Z]')) || fieldName.length > 1; - } + /// Generates an abstract class for a GraphQL union plus an `Unknown*` + /// fallback that keeps the raw JSON. + static String _generateUnionDefinition(UnionTypeDefinitionNode node) { + final name = node.name.value; + final unknownName = 'Unknown$name'; + return ''' +/// GraphQL union `$name`. +abstract class $name { + /// Creates the concrete member type matching `__typename`. + factory $name.fromJson(Map json) { +${_typenameDispatch(name, '$unknownName(json)')} } + + Map toJson(); +} - /// Generates a type definition for a specific input type - static String _generateInputTypeDefinition( - InputObjectTypeDefinitionNode inputNode, - [String schema = '']) { - final typeName = inputNode.name.value; - final fields = inputNode.fields; +/// Fallback for `$name` members unknown at generation time. +class $unknownName implements $name { + const $unknownName(this.json); - final buffer = StringBuffer(); - buffer.writeln( - '@JsonSerializable(includeIfNull: false, explicitToJson: true, fieldRename: FieldRename.none)'); - buffer.writeln('class $typeName {'); + final Map json; - for (final field in fields) { - final fieldName = field.name.value; - var fieldType = SchemaAnalyzer.getDartType(field.type); - final baseType = fieldType - .replaceAll('?', '') - .replaceAll('List<', '') - .replaceAll('>', ''); + String? get typename => json['__typename'] as String?; - // Check if this is a list field - final isListField = fieldType.startsWith('List<'); - final isNullable = fieldType.endsWith('?'); + @override + Map toJson() => json; +} - // Add @JsonKey annotation for field name preservation (same as in object types) - final needsJsonKeyName = _needsJsonKeyForFieldName(fieldName); +'''; + } - if (isListField) { - // Check if the list contains custom types that need converters - final innerType = fieldType.substring( - 5, fieldType.length - (fieldType.endsWith('>?') ? 2 : 1)); - final cleanInnerType = innerType.replaceAll('?', ''); + static String _typenameDispatch(String abstractType, String fallback) { + final buffer = StringBuffer(); + buffer.writeln(" switch (json['__typename']) {"); + for (final concrete in TypeRegistry.possibleTypes(abstractType)) { + buffer.writeln(" case '$concrete':"); + buffer.writeln(' return $concrete.fromJson(json);'); + } + buffer.writeln(' default:'); + buffer.writeln(' return $fallback;'); + buffer.writeln(' }'); + return buffer.toString(); + } - // Add converter annotation for custom scalar types in lists - if (TypeRegistry.isCustomScalar(cleanInnerType) && - cleanInnerType != 'Decimal' && - cleanInnerType != 'Short' && - cleanInnerType != 'Byte') { - // For lists of custom scalars, use JsonKey with converter - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [], fromJson: _${cleanInnerType.toLowerCase()}ListFromJson, toJson: _${cleanInnerType.toLowerCase()}ListToJson)'); - } else if (cleanInnerType == 'DateTime') { - // Get original GraphQL type for list element to distinguish between DateTime and LocalDate - final originalListType = getOriginalGraphQLType(field.type); - if (originalListType == 'LocalDate') { - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [], fromJson: _localDateListFromJson, toJson: _localDateListToJson)'); - } else { - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [], fromJson: _dateTimeListFromJson, toJson: _dateTimeListToJson)'); - } - } else if (cleanInnerType == 'LocalDate') { - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [], fromJson: _localDateListFromJson, toJson: _localDateListToJson)'); - } else if (cleanInnerType == 'Decimal') { - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [], fromJson: _decimalListFromJson, toJson: _decimalListToJson)'); - } else if (cleanInnerType == 'Byte') { - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [], fromJson: _byteListFromJson, toJson: _byteListToJson)'); - } else if (TypeRegistry.isEnum(cleanInnerType)) { - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [])'); - } else { - // For regular lists (including object lists), just use default value - buffer.writeln( - ' @JsonKey(${needsJsonKeyName ? 'name: \'$fieldName\', ' : ''}defaultValue: [])'); - } - } else if (baseType == 'DateTime') { - // Get original GraphQL type to distinguish between DateTime and LocalDate - final originalType = getOriginalGraphQLType(field.type); - print( - 'DEBUG: Field $fieldName - baseType: $baseType, originalType: $originalType'); - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - if (originalType == 'LocalDate') { - buffer.writeln(' @LocalDateConverter()'); - } else { - buffer.writeln(' @DateTimeConverter()'); - } - } else if (baseType == 'LocalDate') { - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @LocalDateConverter()'); - } else if (baseType == 'Decimal') { - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @SafeDecimalConverter()'); - } else if (baseType == 'Long') { - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @LongConverter()'); - } else if (baseType == 'Byte') { - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @ByteConverter()'); - } else if (TypeRegistry.isEnum(baseType)) { - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @${baseType}Converter()'); - } else if (TypeRegistry.isCustomScalar(baseType) && - baseType != 'Decimal' && - baseType != 'Long' && - baseType != 'Short' && - baseType != 'Byte') { - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @${baseType}Converter()'); - } else if (baseType == 'bool' && !isNullable) { - // Use safe bool converter for non-nullable boolean fields - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @SafeBoolConverter()'); - } else if (baseType == 'int' && !isNullable) { - // Use safe int converter for non-nullable integer fields - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @SafeIntConverter()'); - } else if (baseType == 'double' && !isNullable) { - // Use safe double converter for non-nullable double fields - if (needsJsonKeyName) { - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); - } - buffer.writeln(' @SafeDoubleConverter()'); - } else if (needsJsonKeyName) { - // For any other field that needs name preservation - buffer.writeln(' @JsonKey(name: \'$fieldName\')'); + /// Generates a json_serializable class with the given fields. + /// + /// [fieldNameClassName] is the class name used to detect member/class name + /// clashes; it defaults to [className] (but `Unknown*` classes must use the + /// interface name so field names match the interface getters). + static String _generateJsonClass({ + required String className, + required List<(String, TypeNode)> fields, + List supertypes = const [], + Set overriddenFields = const {}, + String? fieldNameClassName, + }) { + final buffer = StringBuffer(); + buffer.writeln( + '@JsonSerializable(includeIfNull: false, explicitToJson: true, fieldRename: FieldRename.none)'); + final impl = + supertypes.isEmpty ? '' : ' implements ${supertypes.join(', ')}'; + buffer.writeln('class $className$impl {'); + + // Sort fields deterministically by name + final sortedFields = [...fields]..sort((a, b) => a.$1.compareTo(b.$1)); + + for (final (fieldName, type) in sortedFields) { + final dartName = + dartFieldName(fieldName, className: fieldNameClassName ?? className); + if (overriddenFields.contains(fieldName)) buffer.writeln(' @override'); + for (final line in _fieldAnnotations(fieldName, dartName, type)) { + buffer.writeln(line); } - - buffer.writeln(' $fieldType $fieldName;'); + buffer.writeln(' ${SchemaAnalyzer.getDartType(type)} $dartName;'); } buffer.writeln(); - buffer.writeln(' $typeName({'); - for (final field in fields) { - final fieldName = field.name.value; - var fieldType = SchemaAnalyzer.getDartType(field.type); - - final isNullable = fieldType.endsWith('?'); - final isListField = fieldType.startsWith('List<'); - - // Lists are now always nullable, so they don't need 'required' - // Other nullable fields also don't need 'required' - if (isListField) { + buffer.writeln(' $className({'); + for (final (fieldName, type) in sortedFields) { + final dartName = + dartFieldName(fieldName, className: fieldNameClassName ?? className); + final fieldType = SchemaAnalyzer.getDartType(type); + if (fieldType.startsWith('List<')) { // For list fields, provide empty list as default - buffer.writeln(' this.$fieldName = const [],'); - } else if (isNullable) { - buffer.writeln(' this.$fieldName,'); + buffer.writeln(' this.$dartName = const [],'); + } else if (fieldType.endsWith('?')) { + buffer.writeln(' this.$dartName,'); } else { - buffer.writeln(' required this.$fieldName,'); + buffer.writeln(' required this.$dartName,'); } } buffer.writeln(' });'); buffer.writeln(); buffer.writeln( - ' factory $typeName.fromJson(Map json) => _\$${typeName}FromJson(json);'); + ' factory $className.fromJson(Map json) => _\$${className}FromJson(json);'); + if (supertypes.isNotEmpty) buffer.writeln(' @override'); buffer.writeln( - ' Map toJson() => _\$${typeName}ToJson(this);'); + ' Map toJson() => _\$${className}ToJson(this);'); buffer.writeln('}'); buffer.writeln(); @@ -712,6 +625,112 @@ class ${scalar}Converter implements JsonConverter<$scalar, String> { return buffer.toString(); } + /// Annotation lines (indented) for a generated field. + static List _fieldAnnotations( + String fieldName, String dartName, TypeNode type) { + final fieldType = SchemaAnalyzer.getDartType(type); + final baseType = fieldType + .replaceAll('?', '') + .replaceAll('List<', '') + .replaceAll('>', ''); + final isListField = fieldType.startsWith('List<'); + final isNullable = fieldType.endsWith('?'); + + // Add @JsonKey annotation for field name preservation so json_serializable + // never renames GraphQL fields (and escaped Dart names map back). + final needsJsonKeyName = + _needsJsonKeyForFieldName(fieldName) || dartName != fieldName; + final nameArg = needsJsonKeyName ? "name: '$fieldName', " : ''; + final nameKey = " @JsonKey(name: '$fieldName')"; + + List withName(String converter) => + [if (needsJsonKeyName) nameKey, converter]; + + if (isListField) { + // Check if the list contains custom types that need converters + final innerType = fieldType.substring( + 5, fieldType.length - (fieldType.endsWith('>?') ? 2 : 1)); + final cleanInnerType = innerType.replaceAll('?', ''); + + if (TypeRegistry.isCustomScalar(cleanInnerType) && + cleanInnerType != 'Decimal' && + cleanInnerType != 'Short' && + cleanInnerType != 'Byte') { + // For lists of custom scalars, use JsonKey with converter + final helper = cleanInnerType.toLowerCase(); + return [ + ' @JsonKey(${nameArg}defaultValue: const <$cleanInnerType>[], fromJson: _${helper}ListFromJson, toJson: _${helper}ListToJson)' + ]; + } + if (cleanInnerType == 'DateTime') { + // Distinguish between DateTime and LocalDate list elements + final helper = getOriginalGraphQLType(type) == 'LocalDate' + ? 'localDate' + : 'dateTime'; + return [ + ' @JsonKey(${nameArg}defaultValue: const [], fromJson: _${helper}ListFromJson, toJson: _${helper}ListToJson)' + ]; + } + if (cleanInnerType == 'LocalDate') { + return [ + ' @JsonKey(${nameArg}defaultValue: const [], fromJson: _localDateListFromJson, toJson: _localDateListToJson)' + ]; + } + if (cleanInnerType == 'Decimal') { + return [ + ' @JsonKey(${nameArg}defaultValue: const [], fromJson: _decimalListFromJson, toJson: _decimalListToJson)' + ]; + } + if (cleanInnerType == 'Byte') { + return [ + ' @JsonKey(${nameArg}defaultValue: const [], fromJson: _byteListFromJson, toJson: _byteListToJson)' + ]; + } + // Regular lists (enums, objects, built-in scalars): default value only + return [' @JsonKey(${nameArg}defaultValue: const <$cleanInnerType>[])']; + } + + if (baseType == 'DateTime') { + // Distinguish between DateTime and LocalDate + return withName(getOriginalGraphQLType(type) == 'LocalDate' + ? ' @LocalDateConverter()' + : ' @DateTimeConverter()'); + } + if (baseType == 'LocalDate') return withName(' @LocalDateConverter()'); + if (baseType == 'Decimal') return withName(' @SafeDecimalConverter()'); + if (baseType == 'Long') return withName(' @LongConverter()'); + if (baseType == 'Byte') return withName(' @ByteConverter()'); + if (TypeRegistry.isEnum(baseType)) { + return withName(' @${baseType}Converter()'); + } + if (TypeRegistry.isCustomScalar(baseType) && + baseType != 'Decimal' && + baseType != 'Long' && + baseType != 'Short' && + baseType != 'Byte') { + return withName(' @${baseType}Converter()'); + } + // Safe converters for non-nullable primitives + if (baseType == 'bool' && !isNullable) { + return withName(' @SafeBoolConverter()'); + } + if (baseType == 'int' && !isNullable) { + return withName(' @SafeIntConverter()'); + } + if (baseType == 'double' && !isNullable) { + return withName(' @SafeDoubleConverter()'); + } + return [if (needsJsonKeyName) nameKey]; + } + + /// Determines if a field name needs explicit JsonKey annotation to preserve naming + /// This prevents json_serializable from converting camelCase to snake_case + static bool _needsJsonKeyForFieldName(String fieldName) { + // Check if the field name contains uppercase letters (indicating camelCase) + // We preserve all field names to maintain consistency with GraphQL responses + return fieldName.contains(RegExp(r'[A-Z]')) || fieldName.length > 1; + } + /// Generates a class for an object type (legacy method, kept for compatibility) static String generateClassForType(ObjectTypeDefinitionNode type) { final className = type.name.value; @@ -849,7 +868,15 @@ class ${scalar}Converter implements JsonConverter<$scalar, String> { static String generateTypeDefinitions(DocumentNode schemaDoc) { final buffer = StringBuffer(); - for (final definition in schemaDoc.definitions) { + // Sort deterministically + final defs = List.from(schemaDoc.definitions); + defs.sort((a, b) { + final an = (a is TypeDefinitionNode) ? a.name.value : ''; + final bn = (b is TypeDefinitionNode) ? b.name.value : ''; + return an.compareTo(bn); + }); + + for (final definition in defs) { if (definition is ObjectTypeDefinitionNode) { buffer.writeln(generateClassForType(definition)); } @@ -863,26 +890,19 @@ class ${scalar}Converter implements JsonConverter<$scalar, String> { final buffer = StringBuffer(); // Generate helpers for each custom scalar type - for (final scalar in customScalars) { - if (scalar != 'Decimal' && - scalar != 'DateTime' && - scalar != 'Short' && - scalar != 'Byte' && - scalar != 'Long' && - scalar != 'LocalDate') { - buffer.writeln(''' + for (final scalar in _wrappedScalars(customScalars)) { + buffer.writeln(''' // Helper functions for $scalar list conversion List<$scalar>? _${scalar.toLowerCase()}ListFromJson(List? json) { if (json == null) return null; - return json.map((item) => $scalar(item.toString())).toList(); + return json.map((item) => $scalar(item)).toList(); } List? _${scalar.toLowerCase()}ListToJson(List<$scalar>? list) { if (list == null) return null; - return list.map((item) => item.toString()).toList(); + return list.map((item) => item.raw).toList(); } '''); - } } // Helper for DateTime lists @@ -895,7 +915,8 @@ List? _dateTimeListFromJson(List? json) { List? _dateTimeListToJson(List? list) { if (list == null) return null; - return list.map((item) => item.toIso8601String()).toList(); + // Same wire format as DateTimeConverter: UTC ISO-8601 with timezone. + return list.map((item) => item.toUtc().toIso8601String()).toList(); } // Helper functions for LocalDate list conversion @@ -924,8 +945,8 @@ List? _decimalListToJson(List? list) { } // Helper functions for Byte list conversion -List? _byteListFromJson(List? json) { - if (json == null) return null; +List _byteListFromJson(List? json) { + if (json == null) return const []; return json.map((item) => Byte.parse(item)).toList(); } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 246b870..4251dcf 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -1,3 +1,5 @@ +import 'parser.dart'; + class Operation { final String name; final String type; @@ -5,17 +7,14 @@ class Operation { Operation(this.name, this.type); } +/// Returns the named operations (name + `query`/`mutation`/`subscription`) +/// declared in [documentContent]. +/// +/// Uses the GraphQL parser, so keywords inside strings, comments or field +/// names are not mistaken for operations. List parseOperations(String documentContent) { - final operations = []; - final regex = RegExp(r'(query|mutation|subscription)\s+(\w+)'); - - final matches = regex.allMatches(documentContent); - - for (final match in matches) { - final type = match.group(1)!; - final name = match.group(2)!; - operations.add(Operation(name, type)); - } - - return operations; + return [ + for (final op in GraphQLParser.parseOperations(documentContent)) + if (op.name != 'Anonymous') Operation(op.name, op.type), + ]; } diff --git a/pubspec.yaml b/pubspec.yaml index 07b6e94..afbac39 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_graphql_codegen description: A powerful code generator for Flutter GraphQL applications that generates type-safe Dart classes and client extensions from GraphQL schemas and operations. -version: 1.0.1 +version: 1.0.2-rc.1 homepage: https://github.com/ifmelate/flutter_graphql_codegen repository: https://github.com/ifmelate/flutter_graphql_codegen.git issue_tracker: https://github.com/ifmelate/flutter_graphql_codegen/issues @@ -33,3 +33,4 @@ dev_dependencies: test: ^1.24.0 json_serializable: ^6.7.1 coverage: ^1.6.0 + build_test: ">=2.2.2 <4.0.0" diff --git a/test/builder_test.dart b/test/builder_test.dart new file mode 100644 index 0000000..5daf09e --- /dev/null +++ b/test/builder_test.dart @@ -0,0 +1,114 @@ +import 'dart:io'; + +import 'package:build_test/build_test.dart'; +import 'package:flutter_graphql_codegen/src/builder.dart'; +import 'package:flutter_graphql_codegen/src/config.dart'; +import 'package:flutter_graphql_codegen/src/config_context.dart'; +import 'package:flutter_graphql_codegen/src/diagnostics.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +const _schema = r''' +type Query { user(id: ID!): User version: String } +type Mutation { rename(id: ID!, name: String!): User } +type User { id: ID! name: String } +'''; + +void main() { + late Directory outDir; + late List logs; + + setUp(() async { + CodegenConfigContext.reset(); + outDir = await Directory.systemTemp.createTemp('fgc_builder_'); + logs = []; + }); + + tearDown(() async { + CodegenConfigContext.reset(); + await outDir.delete(recursive: true); + }); + + GraphQLCodegenBuilder builder() => GraphQLCodegenBuilder( + GraphQLCodegenConfig( + // Not present on disk: must be read through the BuildStep. + schemaUrl: 'lib/schema.graphql', + outputDir: outDir.path, + documentPaths: ['lib/graphql/**.graphql'], + ), + ); + + Future run(Map assets) async { + try { + await testBuilder( + builder(), + {'a|lib/schema.graphql': _schema, ...assets}, + rootPackage: 'a', + onLog: (r) => logs.add('${r.level.name}: ${r.message}'), + ); + } on CodegenException { + // build_test 2.x rethrows builder errors (3.x only logs them); the + // builder logs them as SEVERE before throwing either way. + } + } + + String? output(String name) { + final file = File(p.join(outDir.path, name)); + return file.existsSync() ? file.readAsStringSync() : null; + } + + test('generates every operation of a document, using external fragments', + () async { + await run({ + 'a|lib/graphql/fragments.graphql': + 'fragment UserBits on User { id name }', + 'a|lib/graphql/get_schema_info.graphql': r''' +query GetUser($id: ID!) { user(id: $id) { ...UserBits } } +mutation Rename($id: ID!, $name: String!) { rename(id: $id, name: $name) { id } } +query GetVersion { version } +''', + }); + + expect(logs.where((l) => l.startsWith('SEVERE')), isEmpty); + expect(output('types.dart'), contains('class User {')); + + final getUser = output('get_user_graphql.g.dart'); + expect(getUser, isNotNull); + expect(getUser, contains('fragment UserBits on User')); + expect(getUser, isNot(contains('mutation Rename'))); + expect( + output('rename_graphql.g.dart'), contains('graphql.MutationOptions')); + // Previously any path containing "schema.graphql" was skipped. + expect(output('get_version_graphql.g.dart'), isNotNull); + + final index = output('index.dart')!; + expect(index, contains("export 'get_user_graphql.g.dart';")); + expect(index, contains("export 'rename_graphql.g.dart';")); + }); + + test('skips schema and fragment-only documents', () async { + await run({ + 'a|lib/graphql/fragments.graphql': 'fragment UserBits on User { id }', + }); + expect(logs.where((l) => l.startsWith('SEVERE')), isEmpty); + expect(output('types.dart'), isNull); + }); + + test('warns about anonymous operations', () async { + await run({'a|lib/graphql/anon.graphql': '{ version }'}); + expect(logs, contains(contains('Skipping anonymous query'))); + }); + + test('reports unknown fragments with an error code', () async { + await run({ + 'a|lib/graphql/broken.graphql': + 'query Broken { user(id: 1) { ...Nope } }', + }); + expect(logs, contains(allOf(contains('BLD101'), contains('Nope')))); + }); + + test('reports unknown root fields with an error code', () async { + await run({'a|lib/graphql/broken.graphql': 'query Broken { nope }'}); + expect(logs, contains(allOf(contains('BLD103'), contains('nope')))); + }); +} diff --git a/test/comprehensive_graphql_test.dart b/test/comprehensive_graphql_test.dart index 8f3891d..c4bae61 100644 --- a/test/comprehensive_graphql_test.dart +++ b/test/comprehensive_graphql_test.dart @@ -2,12 +2,18 @@ import 'package:test/test.dart'; import 'package:flutter_graphql_codegen/src/type_generator.dart'; import 'package:flutter_graphql_codegen/src/generator.dart'; import 'package:flutter_graphql_codegen/src/code_utils.dart'; +import 'package:flutter_graphql_codegen/src/config_context.dart'; void main() { group('Comprehensive GraphQL Test Suite', () { setUp(() { // Clear type registry before each test TypeRegistry.clear(); + CodegenConfigContext.strictNullability = true; + }); + + tearDown(() { + CodegenConfigContext.reset(); }); group('Union Types', () { @@ -35,11 +41,17 @@ void main() { final generatedCode = TypeGenerator.generateTypesFile(schema); - // Should generate all types - expect(generatedCode, contains('class User {')); - expect(generatedCode, contains('class Post {')); + // Should generate all types; members implement the union + expect(generatedCode, contains('class User implements SearchResult {')); + expect(generatedCode, contains('class Post implements SearchResult {')); expect(generatedCode, contains('class Query {')); + // Union becomes an abstract class dispatching on __typename + expect(generatedCode, contains('abstract class SearchResult {')); + expect(generatedCode, contains("case 'User':")); + expect(generatedCode, contains('return User.fromJson(json);')); + expect(generatedCode, contains('class UnknownSearchResult')); + // Check that required fields are handled correctly expect(generatedCode, contains('required this.id,')); expect(generatedCode, contains('required this.name,')); @@ -77,9 +89,16 @@ void main() { final generatedCode = TypeGenerator.generateTypesFile(schema); - // Should generate all types - expect(generatedCode, contains('class Human {')); - expect(generatedCode, contains('class Droid {')); + // Should generate all types; implementations implement the interface + expect(generatedCode, contains('class Human implements Character {')); + expect(generatedCode, contains('class Droid implements Character {')); + + // Interface becomes an abstract class with getters + dispatch + expect(generatedCode, contains('abstract class Character {')); + expect(generatedCode, contains('String get id;')); + expect(generatedCode, contains('return Human.fromJson(json);')); + expect(generatedCode, + contains('class UnknownCharacter implements Character {')); // Check interface fields are required expect(generatedCode, contains('required this.id,')); @@ -128,11 +147,11 @@ void main() { expect(generatedCode, contains('class Department {')); expect(generatedCode, contains('class Employee {')); - // Check circular reference handling + // Check circular reference handling and strict list nullability expect(generatedCode, contains('Organization organization;')); expect(generatedCode, contains('Department department;')); expect(generatedCode, contains('Employee? manager;')); - expect(generatedCode, contains('List? subordinates;')); + expect(generatedCode, contains('List subordinates;')); }); }); @@ -374,7 +393,9 @@ void main() { // Should generate mutation extension expect(operationCode, contains('extension CreateUserExtension')); - expect(operationCode, contains('mutate(options)')); + expect(operationCode, contains('await mutate(')); + expect(operationCode, + contains('graphql.MutationOptions>(')); expect(operationCode, contains('CreateUserPayload')); }); }); @@ -408,9 +429,27 @@ void main() { final typesCode = TypeGenerator.generateTypesFile(schema); - // Test that types are generated - skip operation generation for subscriptions since it's not fully supported expect(typesCode, contains('class User {')); expect(typesCode, contains('class Message {')); + + final operationCode = GraphQLCodeGenerator.generateOperationFile( + schema, + r'subscription UserUpdated($id: ID!) { userUpdated(id: $id) { id } }', + 'UserUpdated', + 'subscription', + typesCode, + ); + + // Subscriptions are exposed as streams via GraphQLClient.subscribe + expect( + operationCode, + contains('Stream>> ' + 'userUpdated(')); + expect(operationCode, contains('return subscribe(')); + expect(operationCode, + contains('graphql.SubscriptionOptions>(')); + expect(operationCode, contains('Stream userUpdatedData(')); + expect(operationCode, isNot(contains('query('))); }); }); @@ -434,23 +473,22 @@ void main() { final generatedCode = TypeGenerator.generateTypesFile(schema); - // All lists should be made nullable for graceful handling - expect(generatedCode, - contains('List? requiredListRequiredItems;')); + // Lists should respect schema nullability + expect( + generatedCode, contains('List requiredListRequiredItems;')); expect(generatedCode, - contains('List? requiredListNullableItems;')); + contains('List requiredListNullableItems;')); expect(generatedCode, contains('List? nullableListRequiredItems;')); expect(generatedCode, contains('List? nullableListNullableItems;')); // Nested lists - expect(generatedCode, - contains('List?>? nestedRequiredList;')); + expect( + generatedCode, contains('List> nestedRequiredList;')); expect(generatedCode, contains('List?>? nestedNullableList;')); - expect( - generatedCode, contains('List?>? mixedNestedList;')); + expect(generatedCode, contains('List?> mixedNestedList;')); // List fields should have default values in constructor expect(generatedCode, contains('= const []')); @@ -687,8 +725,8 @@ void main() { // Should handle recursive references expect(generatedCode, contains('Category? parent;')); - expect(generatedCode, contains('List? children;')); - expect(generatedCode, contains('List? relatedProducts;')); + expect(generatedCode, contains('List children;')); + expect(generatedCode, contains('List relatedProducts;')); expect(generatedCode, contains('Category category;')); }); }); diff --git a/test/config_options_test.dart b/test/config_options_test.dart new file mode 100644 index 0000000..8099a45 --- /dev/null +++ b/test/config_options_test.dart @@ -0,0 +1,65 @@ +import 'package:test/test.dart'; +import 'package:flutter_graphql_codegen/src/config.dart'; +import 'package:flutter_graphql_codegen/src/diagnostics.dart'; + +void main() { + group('Config parsing', () { + test('parses extended options', () { + const yaml = ''' +schema_url: test/fixtures/schema.graphql +output_dir: test/generated +document_paths: + - test/fixtures/*.graphql +file_naming: kebab-case +strict_nullability: true +emit_index: false +diagnostics_format: json +scalar_mapping: + Date: DateTime + Decimal: Decimal +'''; + + final cfg = GraphQLCodegenConfig.fromYaml(yaml); + expect(cfg.fileNaming, 'kebab-case'); + expect(cfg.strictNullability, isTrue); + expect(cfg.emitIndex, isFalse); + expect(cfg.diagnosticsFormat, 'json'); + expect(cfg.scalarMapping['Date'], 'DateTime'); + }); + + test('parses schema_headers and expands environment variables', () { + expect( + GraphQLCodegenConfig.expandEnvironmentVariables( + r'Bearer ${TOKEN}', {'TOKEN': 'abc'}), + 'Bearer abc', + ); + expect( + () => GraphQLCodegenConfig.expandEnvironmentVariables( + r'Bearer ${MISSING_FGC_TOKEN}', {}), + throwsA(isA().having( + (e) => e.message, 'message', contains('MISSING_FGC_TOKEN'))), + ); + + const yaml = ''' +schema_url: https://example.com/graphql +document_paths: + - lib/**.graphql +schema_headers: + X-Api-Key: plain-value +'''; + final cfg = GraphQLCodegenConfig.fromYaml(yaml); + expect(cfg.schemaHeaders, {'X-Api-Key': 'plain-value'}); + }); + + test('rejects non-map schema_headers', () { + const yaml = ''' +schema_url: https://example.com/graphql +document_paths: + - lib/**.graphql +schema_headers: nope +'''; + expect(() => GraphQLCodegenConfig.fromYaml(yaml), + throwsA(isA())); + }); + }); +} diff --git a/test/datetime_converter_test.dart b/test/datetime_converter_test.dart index 98f114e..45534da 100644 --- a/test/datetime_converter_test.dart +++ b/test/datetime_converter_test.dart @@ -110,7 +110,7 @@ void main() { expect( generatedTypes, contains( - 'String toJson(DateTime object) => object.toIso8601String().split(\'T\').first;')); + 'dynamic toJson(DateTime object) => object.toIso8601String().split(\'T\').first;')); }); test('should fix GraphQL DateTime parsing error scenario', () { diff --git a/test/determinism_test.dart b/test/determinism_test.dart new file mode 100644 index 0000000..e6b31cb --- /dev/null +++ b/test/determinism_test.dart @@ -0,0 +1,31 @@ +import 'package:test/test.dart'; +import 'package:flutter_graphql_codegen/src/type_generator.dart'; + +void main() { + group('Deterministic generation', () { + test('same schema produces identical output', () { + const schema = ''' + enum E { B, A, C } + type T { b: String, a: Int!, c: Float } + '''; + + final a = TypeGenerator.generateTypesFile(schema); + final b = TypeGenerator.generateTypesFile(schema); + + expect(a, equals(b)); + }); + + test('enum values are sorted for stability', () { + const schema = 'enum Status { PENDING, ACTIVE, DELETED }'; + final code = TypeGenerator.generateTypesFile(schema); + final idx = code.indexOf('enum Status'); + final slice = code.substring(idx, idx + 120); + expect(slice, contains('ACTIVE')); + expect(slice, contains('DELETED')); + expect(slice, contains('PENDING')); + // Order check by index positions + expect(slice.indexOf('ACTIVE') < slice.indexOf('DELETED'), isTrue); + expect(slice.indexOf('DELETED') < slice.indexOf('PENDING'), isTrue); + }); + }); +} diff --git a/test/diagnostics_test.dart b/test/diagnostics_test.dart new file mode 100644 index 0000000..548cf0d --- /dev/null +++ b/test/diagnostics_test.dart @@ -0,0 +1,28 @@ +import 'package:test/test.dart'; +import 'package:flutter_graphql_codegen/src/config.dart'; +import 'package:flutter_graphql_codegen/src/diagnostics.dart'; + +void main() { + group('Diagnostics and error codes', () { + test('missing schema triggers CFG001', () { + const yaml = ''' +output_dir: out +document_paths: ["test/fixtures/*.graphql"] +'''; + expect( + () => GraphQLCodegenConfig.fromYaml(yaml), + throwsA(isA() + .having((e) => e.code, 'code', CodegenErrorCodes.cfgMissingSchema)), + ); + }); + + test('missing documents triggers CFG002', () { + const yaml = 'schema_url: test/fixtures/schema.graphql'; + expect( + () => GraphQLCodegenConfig.fromYaml(yaml), + throwsA(isA().having( + (e) => e.code, 'code', CodegenErrorCodes.cfgMissingDocuments)), + ); + }); + }); +} diff --git a/test/document_utils_test.dart b/test/document_utils_test.dart new file mode 100644 index 0000000..92906cc --- /dev/null +++ b/test/document_utils_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter_graphql_codegen/src/diagnostics.dart'; +import 'package:flutter_graphql_codegen/src/document_utils.dart'; +import 'package:flutter_graphql_codegen/src/naming.dart'; +import 'package:gql/ast.dart'; +import 'package:gql/language.dart' as gql_lang; +import 'package:test/test.dart'; + +OperationDefinitionNode _op(String source) => gql_lang + .parseString(source) + .definitions + .whereType() + .single; + +void main() { + group('buildOperationDocument', () { + test('adds __typename to nested selections but not the root', () { + final doc = DocumentUtils.buildOperationDocument( + _op('query Q { user { id posts { title } } }'), const {}); + final printed = gql_lang.printNode(doc); + + expect('__typename'.allMatches(printed).length, 2); + expect(printed, startsWith('query Q {\n user {')); + }); + + test('does not duplicate an existing __typename', () { + final doc = DocumentUtils.buildOperationDocument( + _op('query Q { user { __typename id } }'), const {}); + expect('__typename'.allMatches(gql_lang.printNode(doc)).length, 1); + }); + + test('keeps directives, aliases and arguments', () { + final doc = DocumentUtils.buildOperationDocument( + _op(r'query Q($s: Boolean!) { me: user(id: "1") @skip(if: $s) { id } }'), + const {}); + final printed = gql_lang.printNode(doc); + expect(printed, contains('me: user(id: "1") @skip(if: \$s)')); + }); + + test('includes only fragments used, transitively and sorted', () { + final fragments = DocumentUtils.collectFragments([ + gql_lang.parseString(''' + fragment B on User { id ...A } + fragment A on User { name } + fragment Unused on User { id } + '''), + ]); + final doc = DocumentUtils.buildOperationDocument( + _op('query Q { user { ...B } }'), fragments); + final names = doc.definitions + .whereType() + .map((f) => f.name.value); + expect(names, ['A', 'B']); + }); + + test('throws BLD101 for unknown fragments', () { + expect( + () => DocumentUtils.buildOperationDocument( + _op('query Q { user { ...Missing } }'), const {}), + throwsA(isA().having( + (e) => e.code, 'code', CodegenErrorCodes.buildMissingFragment)), + ); + }); + }); + + test('collectFragments rejects conflicting duplicates', () { + expect( + () => DocumentUtils.collectFragments([ + gql_lang.parseString('fragment F on User { id }'), + gql_lang.parseString('fragment F on User { name }'), + ]), + throwsA(isA().having( + (e) => e.code, 'code', CodegenErrorCodes.buildDuplicateFragment)), + ); + }); + + test('mergeSchemaExtensions folds extensions into base types', () { + final merged = DocumentUtils.mergeSchemaExtensions(gql_lang.parseString(''' + type Query { a: String } + extend type Query { b: String } + enum Color { RED } + extend enum Color { BLUE } + extend type Mutation { c: String } + ''')); + + final query = merged.definitions + .whereType() + .firstWhere((d) => d.name.value == 'Query'); + expect(query.fields.map((f) => f.name.value), ['a', 'b']); + + final color = merged.definitions.whereType().single; + expect(color.values.map((v) => v.name.value), ['RED', 'BLUE']); + + expect( + merged.definitions + .whereType() + .any((d) => d.name.value == 'Mutation'), + isTrue); + expect(merged.definitions.whereType(), isEmpty); + }); + + group('identifier escaping', () { + test('escapes reserved words and clashing members', () { + expect(dartFieldName('class'), 'class_'); + expect(dartFieldName('hashCode'), 'hashCode_'); + expect(dartFieldName('toJson'), 'toJson_'); + expect(dartFieldName('User', className: 'User'), 'User_'); + expect(dartFieldName('name'), 'name'); + }); + + test('escapes enum values clashing with Enum members', () { + expect(dartEnumValueName('default'), 'default_'); + expect(dartEnumValueName('values'), 'values_'); + expect(dartEnumValueName('name'), 'name_'); + expect(dartEnumValueName('ACTIVE'), 'ACTIVE'); + }); + }); +} diff --git a/test/flutter_graphql_codegen_test.dart b/test/flutter_graphql_codegen_test.dart index 3e47d6b..360480f 100644 --- a/test/flutter_graphql_codegen_test.dart +++ b/test/flutter_graphql_codegen_test.dart @@ -67,20 +67,12 @@ void main() { }); }); - group('Legacy Tests', () { - final awesome = Awesome(); - - setUp(() { - // Additional setup goes here. - }); - - test('First Test', () { - expect(awesome.isAwesome, isTrue); + group('Builder factory', () { + test('creates a builder from the config file', () { final builderOptions = BuilderOptions({ 'config_path': 'test/fixtures/graphql_codegen.yaml', }); - graphqlCodegenBuilder(builderOptions); - /* builder.build(BuildStep()); */ + expect(graphqlCodegenBuilder(builderOptions), isA()); }); }); } diff --git a/test/generated_code_compiles_test.dart b/test/generated_code_compiles_test.dart new file mode 100644 index 0000000..06875fe --- /dev/null +++ b/test/generated_code_compiles_test.dart @@ -0,0 +1,259 @@ +// Verifies generated code end to end: it must survive json_serializable, +// pass `dart analyze` without errors and behave correctly at runtime. +// +// Slow (creates a package and runs build_runner); skip locally with +// `dart test -x compile`. +@Tags(['compile']) +@Timeout(Duration(minutes: 5)) +library; + +import 'dart:io'; + +import 'package:flutter_graphql_codegen/src/config_context.dart'; +import 'package:flutter_graphql_codegen/src/document_utils.dart'; +import 'package:flutter_graphql_codegen/src/generator.dart'; +import 'package:flutter_graphql_codegen/src/type_generator.dart'; +import 'package:gql/ast.dart'; +import 'package:gql/language.dart' as gql_lang; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import 'support/compile_harness.dart'; + +/// Generates types.dart + one file per operation into `lib//`. +Map _generate( + String dir, + String schema, + List documents, { + required bool strict, +}) { + CodegenConfigContext.reset(); + CodegenConfigContext.strictNullability = strict; + try { + final types = TypeGenerator.generateTypesFile(schema); + final files = {'lib/$dir/types.dart': types}; + final fragments = + DocumentUtils.collectFragments(documents.map(gql_lang.parseString)); + for (final doc in documents) { + final ops = gql_lang + .parseString(doc) + .definitions + .whereType(); + for (final op in ops) { + final name = op.name!.value; + files['lib/$dir/$name.g.dart'] = + GraphQLCodeGenerator.generateOperationFile( + schema, doc, name, op.type.name, types, + fragments: fragments); + } + } + return files; + } finally { + CodegenConfigContext.reset(); + } +} + +const _smokeSchema = r''' +scalar JSON +scalar DateTime + +interface Node { id: ID! } + +type User implements Node { + id: ID! + name: String! + role: Role! + meta: JSON + class: String +} + +type Post implements Node { id: ID! title: String! } + +union SearchResult = User | Post + +enum Role { ADMIN default } + +input UserFilter { role: Role, since: DateTime, tags: [String!] } + +type Query { + node(id: ID!): Node + search(q: String!): [SearchResult!]! + me: User! + users(filter: UserFilter, first: Int = 10): [User!]! +} + +type Mutation { rename(id: ID!, name: String!): User } + +type Subscription { userChanged: User! } + +extend type Query { version: String! } +'''; + +const _smokeDocuments = [ + r''' +query Search($q: String!) { + search(q: $q) { + ... on User { id name role meta class } + ... on Post { id title } + } +} +''', + r''' +query Combined($id: ID!) { + me { ...UserBits } + node(id: $id) { id ...UserBits } + version +} +''', + r''' +fragment UserBits on User { id name role } +''', + r''' +query Users($filter: UserFilter, $first: Int) { users(filter: $filter, first: $first) { ...UserBits } } +mutation Rename($id: ID!, $name: String!) { renamed: rename(id: $id, name: $name) { ...UserBits } } +subscription UserChanged { userChanged { ...UserBits } } +''', +]; + +const _smokeMain = r''' +import 'dart:async'; + +import 'package:fgc_compile_check/smoke/Combined.g.dart'; +import 'package:fgc_compile_check/smoke/Rename.g.dart'; +import 'package:fgc_compile_check/smoke/Search.g.dart'; +import 'package:fgc_compile_check/smoke/UserChanged.g.dart'; +import 'package:fgc_compile_check/smoke/Users.g.dart'; +import 'package:fgc_compile_check/smoke/types.dart'; +import 'package:graphql/client.dart'; + +final requests = []; + +class FakeLink extends Link { + @override + Stream request(Request request, [NextLink? forward]) async* { + requests.add(request); + final data = responses[request.operation.operationName]; + if (data == null) { + yield Response( + errors: const [GraphQLError(message: 'boom')], + response: const {}, + ); + return; + } + yield Response(data: data, response: const {}); + } +} + +const user = { + '__typename': 'User', 'id': '1', 'name': 'Ann', 'role': 'default', +}; + +final responses = >{ + 'Search': { + 'search': [ + {...user, 'meta': {'a': 1}, 'class': 'x'}, + {'__typename': 'Post', 'id': '2', 'title': 'Hello'}, + {'__typename': 'Ad', 'id': '3'}, + ], + }, + 'Combined': { + 'me': user, + 'node': user, + 'version': '1.0', + }, + 'Rename': {'renamed': {...user, 'name': 'Bob'}}, + 'UserChanged': {'userChanged': user}, + 'Users': {'users': [user]}, +}; + +void check(bool condition, String message) { + if (!condition) throw StateError('Check failed: $message'); +} + +Future main() async { + final client = GraphQLClient( + link: FakeLink(), + cache: GraphQLCache(), + defaultPolicies: DefaultPolicies( + query: Policies(fetch: FetchPolicy.noCache), + mutate: Policies(fetch: FetchPolicy.noCache), + subscribe: Policies(fetch: FetchPolicy.noCache), + ), + ); + + // Unions dispatch on __typename, unknown members fall back. + final results = await client.searchData( + const SearchVariables(q: 'a').toJson()); + check(results.length == 3, 'three search results'); + final first = results[0] as User; + check(first.role == Role.default_, 'escaped enum value'); + check(first.class_ == 'x', 'escaped field name'); + check(first.meta!.raw is Map, 'JSON scalar keeps raw map'); + check((results[1] as Post).title == 'Hello', 'post member'); + check((results[2] as UnknownSearchResult).typename == 'Ad', 'fallback'); + check(requests.last.operation.operationName == 'Search', 'operationName'); + + // Multiple root fields, interface dispatch, extended schema field. + final combined = await client.combinedData({'id': '1'}); + check(combined.me.name == 'Ann', 'first root field'); + check(combined.node is User, 'interface dispatch'); + check(combined.version == '1.0', 'field from extend type'); + + // Typed variables serialize enums/dates and omit unset optionals. + final vars = UsersVariables( + filter: UserFilter(role: Role.default_, since: DateTime.utc(2024)), + ).toJson(); + check(!vars.containsKey('first'), 'unset optional omitted'); + final filter = vars['filter'] as Map; + check(filter['role'] == 'default', 'enum serialized with schema name'); + check(filter['since'] == '2024-01-01T00:00:00.000Z', 'DateTime serialized'); + check((await client.usersData(vars)).single.id == '1', 'users query'); + + // Aliased root field. + final renamed = await client.renameData( + const RenameVariables(id: '1', name: 'Bob').toJson()); + check(renamed!.name == 'Bob', 'alias used as response key'); + + // Subscriptions are streams. + final changed = await client.userChangedData().first; + check(changed.id == '1', 'subscription data'); + + // Errors propagate as OperationException instead of being swallowed. + responses.remove('Combined'); + try { + await client.combinedData({'id': '1'}); + throw StateError('Check failed: expected OperationException'); + } on OperationException catch (e) { + check(e.graphqlErrors.single.message == 'boom', 'graphql error kept'); + } + + print('SMOKE OK'); +} +'''; + +void main() { + test('generated code compiles and works', () async { + final fixtureDir = p.join('test', 'fixtures'); + final schema = + File(p.join(fixtureDir, 'schema.graphql')).readAsStringSync(); + final documents = Directory(fixtureDir) + .listSync() + .whereType() + .where((f) => + f.path.endsWith('.graphql') && !f.path.endsWith('schema.graphql')) + .map((f) => f.readAsStringSync()) + .toList() + ..sort(); + + final files = { + ..._generate('legacy', schema, documents, strict: false), + ..._generate('strict', schema, documents, strict: true), + ..._generate('smoke', _smokeSchema, _smokeDocuments, strict: true), + 'bin/smoke.dart': _smokeMain, + }; + + final result = + await compileGenerated(files, runScripts: ['bin/smoke.dart']); + expect(result.errors, isEmpty, reason: result.log); + }); +} diff --git a/test/local_date_converter_test.dart b/test/local_date_converter_test.dart index 0b7ce6c..1d320aa 100644 --- a/test/local_date_converter_test.dart +++ b/test/local_date_converter_test.dart @@ -1,8 +1,16 @@ import 'package:test/test.dart'; import 'package:flutter_graphql_codegen/src/type_generator.dart'; +import 'package:flutter_graphql_codegen/src/config_context.dart'; void main() { group('LocalDate Converter Tests', () { + setUp(() { + CodegenConfigContext.strictNullability = true; + }); + + tearDown(() { + CodegenConfigContext.reset(); + }); test('should generate LocalDateConverter for LocalDate scalar fields', () { const schema = ''' scalar LocalDate @@ -51,7 +59,7 @@ void main() { expect( generatedTypes, contains( - 'String toJson(DateTime object) => object.toIso8601String().split(\'T\').first;')); + 'dynamic toJson(DateTime object) => object.toIso8601String().split(\'T\').first;')); // Check that FinanceOperationsInput uses LocalDateConverter for dateFrom and dateTo expect(generatedTypes, contains('@LocalDateConverter()')); @@ -100,7 +108,7 @@ void main() { expect( generatedTypes, contains( - 'String toJson(DateTime object) => object.toIso8601String().split(\'T\').first;')); + 'dynamic toJson(DateTime object) => object.toIso8601String().split(\'T\').first;')); // Verify that proper converters are applied to fields final lines = generatedTypes.split('\n'); @@ -208,7 +216,7 @@ void main() { expect(generatedTypes, contains('DateTime? dateTo;')); expect(generatedTypes, contains('DateTime createdAt;')); expect(generatedTypes, contains('DateTime? updatedAt;')); - expect(generatedTypes, contains('List? timestamps;')); + expect(generatedTypes, contains('List timestamps;')); expect(generatedTypes, contains('List? dates;')); // Check that proper converters are applied diff --git a/test/schema_downloader_test.dart b/test/schema_downloader_test.dart new file mode 100644 index 0000000..ce8b273 --- /dev/null +++ b/test/schema_downloader_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter_graphql_codegen/src/schema_downloader.dart'; +import 'package:gql/ast.dart'; +import 'package:gql/language.dart' as gql_lang; +import 'package:test/test.dart'; + +Map _named(String kind, String name) => + {'kind': kind, 'name': name, 'ofType': null}; + +Map _nonNull(Map of) => + {'kind': 'NON_NULL', 'name': null, 'ofType': of}; + +Map _list(Map of) => + {'kind': 'LIST', 'name': null, 'ofType': of}; + +void main() { + test('introspectionToSdl produces a parseable equivalent schema', () { + final data = { + '__schema': { + 'queryType': {'name': 'Query'}, + 'mutationType': null, + 'subscriptionType': null, + 'types': [ + { + 'kind': 'OBJECT', + 'name': 'Query', + 'interfaces': [], + 'fields': [ + { + 'name': 'search', + 'args': [ + { + 'name': 'first', + 'type': _named('SCALAR', 'Int'), + 'defaultValue': '10', + }, + ], + 'type': _nonNull(_list(_nonNull(_named('UNION', 'Result')))), + 'isDeprecated': false, + }, + { + 'name': 'old', + 'args': [], + 'type': _named('SCALAR', 'String'), + 'isDeprecated': true, + 'deprecationReason': 'Use "search"', + }, + ], + }, + { + 'kind': 'INTERFACE', + 'name': 'Node', + 'interfaces': [], + 'fields': [ + { + 'name': 'id', + 'args': [], + 'type': _nonNull(_named('SCALAR', 'ID')), + }, + ], + }, + { + 'kind': 'OBJECT', + 'name': 'User', + 'interfaces': [_named('INTERFACE', 'Node')], + 'fields': [ + { + 'name': 'id', + 'args': [], + 'type': _nonNull(_named('SCALAR', 'ID')), + }, + { + 'name': 'role', + 'args': [], + 'type': _named('ENUM', 'Role'), + }, + ], + }, + { + 'kind': 'UNION', + 'name': 'Result', + 'possibleTypes': [_named('OBJECT', 'User')], + }, + { + 'kind': 'ENUM', + 'name': 'Role', + 'enumValues': [ + {'name': 'ADMIN', 'isDeprecated': false}, + ], + }, + { + 'kind': 'INPUT_OBJECT', + 'name': 'Filter', + 'inputFields': [ + {'name': 'q', 'type': _named('SCALAR', 'String')}, + ], + }, + {'kind': 'SCALAR', 'name': 'DateTime'}, + {'kind': 'SCALAR', 'name': 'String'}, + {'kind': 'OBJECT', 'name': '__Type', 'fields': []}, + ], + }, + }; + + final sdl = SchemaDownloader.introspectionToSdl(data); + final doc = gql_lang.parseString(sdl); + + expect(sdl, contains('search(first: Int = 10): [Result!]!')); + expect(sdl, contains('type User implements Node {')); + expect(sdl, contains('union Result = User')); + expect( + sdl, contains('old: String @deprecated(reason: "Use \\"search\\"")')); + expect(sdl, contains('scalar DateTime')); + expect(sdl, isNot(contains('scalar String'))); + expect(sdl, isNot(contains('__Type'))); + expect(doc.definitions.whereType().length, 7); + }); +} diff --git a/test/schema_validation_test.dart b/test/schema_validation_test.dart index 08b8525..71664a3 100644 --- a/test/schema_validation_test.dart +++ b/test/schema_validation_test.dart @@ -1,11 +1,17 @@ import 'package:test/test.dart'; import 'package:flutter_graphql_codegen/src/type_generator.dart'; import 'package:flutter_graphql_codegen/src/code_utils.dart'; +import 'package:flutter_graphql_codegen/src/config_context.dart'; void main() { group('Schema Validation and Edge Cases', () { setUp(() { TypeRegistry.clear(); + CodegenConfigContext.strictNullability = true; + }); + + tearDown(() { + CodegenConfigContext.reset(); }); group('Schema Parsing Edge Cases', () { @@ -104,8 +110,8 @@ void main() { expect(generatedCode, contains('NestedType requiredNested;')); expect(generatedCode, contains('NestedType? nullableNested;')); - // All lists should be nullable for graceful handling - expect(generatedCode, contains('List? requiredList;')); + // Lists should respect schema nullability + expect(generatedCode, contains('List requiredList;')); expect(generatedCode, contains('List? nullableList;')); expect(generatedCode, contains('List? mixedList;')); expect(generatedCode, diff --git a/test/support/compile_harness.dart b/test/support/compile_harness.dart new file mode 100644 index 0000000..bb2f56a --- /dev/null +++ b/test/support/compile_harness.dart @@ -0,0 +1,97 @@ +/// Helpers for verifying that generated code actually compiles. +/// +/// Creates a throwaway Dart package, writes generated sources into it, runs +/// json_serializable via build_runner and then `dart analyze`. +library; + +import 'dart:io'; + +import 'package:path/path.dart' as p; + +class CompileResult { + final bool ok; + final List errors; + final String log; + + CompileResult(this.ok, this.errors, this.log); +} + +/// Writes [files] (package-relative path -> content) into a fresh package, +/// runs build_runner and dart analyze and returns analyzer errors. +/// +/// Each of [runScripts] (package-relative paths) is then executed with +/// `dart run`; a non-zero exit code is reported as an error. +Future compileGenerated( + Map files, { + Directory? workDir, + List runScripts = const [], +}) async { + final dir = workDir ?? await Directory.systemTemp.createTemp('fgc_compile_'); + final log = StringBuffer(); + + File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync(''' +name: fgc_compile_check +publish_to: none +environment: + sdk: ^3.2.0 +dependencies: + graphql: ^5.1.3 + json_annotation: ^4.8.1 +dev_dependencies: + build_runner: ^2.4.6 + json_serializable: ^6.7.1 +'''); + File(p.join(dir.path, 'analysis_options.yaml')).writeAsStringSync(''); + + for (final entry in files.entries) { + final file = File(p.join(dir.path, entry.key)); + file.parent.createSync(recursive: true); + file.writeAsStringSync(entry.value); + } + + Future run(List args) async { + final r = await Process.run(Platform.resolvedExecutable, args, + workingDirectory: dir.path); + log + ..writeln('\$ dart ${args.join(' ')} (exit ${r.exitCode})') + ..writeln(r.stdout) + ..writeln(r.stderr); + return r; + } + + var r = await run(['pub', 'get', '--offline']); + if (r.exitCode != 0) r = await run(['pub', 'get']); + if (r.exitCode != 0) { + return CompileResult(false, ['pub get failed'], log.toString()); + } + + r = await run( + ['run', 'build_runner', 'build', '--delete-conflicting-outputs']); + final buildErrors = []; + if (r.exitCode != 0) { + buildErrors.add('build_runner failed'); + } + + r = await run(['analyze', '--no-fatal-warnings', '.']); + final errors = [ + ...buildErrors, + ...'${r.stdout}' + .split('\n') + .map((l) => l.trim()) + .where((l) => l.startsWith('error -')), + ]; + + if (errors.isEmpty) { + for (final script in runScripts) { + r = await run(['run', script]); + if (r.exitCode != 0) { + errors.add('$script failed: ${r.stdout}\n${r.stderr}'); + } + } + } + + if (workDir == null && errors.isEmpty) { + await dir.delete(recursive: true); + } + return CompileResult(errors.isEmpty, errors, log.toString()); +} diff --git a/test/type_nullability_test.dart b/test/type_nullability_test.dart index 2fb9b4e..a526289 100644 --- a/test/type_nullability_test.dart +++ b/test/type_nullability_test.dart @@ -2,6 +2,7 @@ import 'package:test/test.dart'; import 'package:gql/ast.dart'; import 'package:gql/language.dart' as gql_lang; import 'package:flutter_graphql_codegen/src/code_utils.dart'; +import 'package:flutter_graphql_codegen/src/config_context.dart'; import 'package:flutter_graphql_codegen/src/schema_analyzer.dart'; import 'package:flutter_graphql_codegen/src/type_generator.dart'; @@ -10,6 +11,12 @@ void main() { setUp(() { // Clear type registry before each test TypeRegistry.clear(); + // These tests verify strict nullability behavior + CodegenConfigContext.strictNullability = true; + }); + + tearDown(() { + CodegenConfigContext.reset(); }); test('isNonNull extension should correctly detect non-null types', () { @@ -95,8 +102,8 @@ void main() { reason: 'String! should map to String (non-nullable)'); expect(nullableStringType, equals('String?'), reason: 'String should map to String? (nullable)'); - expect(requiredListType, equals('List?'), - reason: 'All lists are made nullable for graceful null handling'); + expect(requiredListType, equals('List'), + reason: 'Non-null list with non-null items should be List'); expect(nullableListType, equals('List?'), reason: 'Nullable list with nullable items'); }); @@ -188,11 +195,11 @@ void main() { reason: 'optionalSymptom (RepertorySymptomDTO) should not be required'); - // Check list types - all lists are made nullable for graceful handling + // Check list types - respect schema nullability strictly expect( - generatedCode, contains('List? childSymptoms;'), + generatedCode, contains('List childSymptoms;'), reason: - 'childSymptoms should be List? even if defined as non-null list'); + 'childSymptoms should be List for [RepertorySymptomDTO!]!'); expect(generatedCode, contains('List? optionalChildSymptoms;'), reason: