Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:

strategy:
matrix:
dart-version: ["3.2.0", "stable"]
dart-version: ["3.4.0", "stable"]

steps:
- name: Checkout repository
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,39 @@ 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
- Generated files are formatted with `dart format` for the language version of
your package, so format checks in CI pass without excluding them
- `types.dart` exports `possibleTypes` for
`GraphQLCache(possibleTypes: possibleTypes)`, so fragments on interfaces and
unions are matched when reading from the cache
- Operations are checked against the schema before generation: unknown fields
at any depth (`BLD103`, previously root fields only), unknown or missing
required arguments (`BLD105`), undeclared, unused or wrongly typed variables
(`BLD106`) and missing or unexpected selection sets and invalid fragment
types (`BLD107`). All problems are reported together with path and line.
`validate_documents: false` turns the checks off

### Fixed
- Schema types named like identifiers the generated code uses (`Map`,
`String`, `Object`, `Future`, `Set`, `JsonKey`, object types named
`Decimal`/`Long`/`Byte`, ...) produced code that didn't compile. Their Dart
names now get a trailing underscore (`type Map` -> `class Map_`)
- With `strict_nullability`, non-null lists of custom scalars, `DateTime`,
`LocalDate` and `Decimal` (`[JSON!]!`) didn't compile: the list helpers
returned nullable lists

### Changed
- A build now fails for operations the schema rejects (they used to generate
code that failed at runtime). Set `validate_documents: false` to keep the old
behavior while fixing them
- Requires Dart 3.4 or newer (Flutter 3.22+), because `dart_style` needs it;
projects on older SDKs keep resolving 2.1.0
- Requires `build` 2.4.0 or newer; adds `dart_style` and `pub_semver`
dependencies

## [2.1.0] - 2026-10-17

### Added
Expand Down
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ schema_headers:
| `diagnostics_format` | `plain` | `plain` or `json` error output |
| `unknown_enum_fallback` | `false` | Add a `$unknown` value to every enum instead of throwing on values the server added later (see below) |
| `deprecated_annotations` | `false` | Turn schema `@deprecated` into Dart `@Deprecated` (see below) |
| `validate_documents` | `true` | Check operations against the schema before generating (see [Errors](#errors)) |

A misspelled key is ignored with a warning that suggests the closest option.

Expand Down Expand Up @@ -149,11 +150,15 @@ schema changes.
`output_dir` ends up with:

- `types.dart`, with a class for every object, input, interface and union in the
schema, plus enums and JSON converters
schema, plus enums, JSON converters and a `possibleTypes` map
- one `<operation>_graphql.g.dart` per named operation (a file with three
operations produces three files)
- `index.dart`, which exports all of the above

The files are formatted with `dart format` for your package's language version,
so a `dart format --set-exit-if-changed` check in CI passes without excluding
them.

For `query GetUser($id: ID!)`, the operation file contains:

```dart
Expand Down Expand Up @@ -227,6 +232,16 @@ for (final result in await client.searchData({'term': 'dart'})) {
}
```

Pass `possibleTypes` to the cache, so fragments on interfaces and unions are
matched correctly when results are read back from it:

```dart
final client = GraphQLClient(
link: link,
cache: GraphQLCache(possibleTypes: possibleTypes),
);
```

### Fragments

Fragments can live in their own files, as long as those files match
Expand All @@ -238,6 +253,13 @@ 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.

The same goes for schema types named like something the generated code relies
on: `dart:core` types (`Map`, `String`, `Object`, `Future`, `Set`, ...),
`json_annotation` classes (`JsonKey`, ...) and the built-in helpers (`Decimal`,
`Long`, `Byte` when they are object types rather than scalars). `type Map`
becomes `class Map_`, `scalar Object` becomes `Object_`. `__typename` and
`possibleTypes` keep the GraphQL names.

## Custom scalars

| GraphQL | Dart |
Expand Down Expand Up @@ -282,10 +304,19 @@ Problems found during generation are reported with a code:
| `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 |
| `BLD103` | An operation selects a field the schema doesn't have (at any depth), or a plain field on a union |
| `BLD104` | Two operations have the same name (or names that map to the same file) |
| `BLD105` | Unknown argument, or a required argument is missing |
| `BLD106` | A variable is used but not declared, declared but never used, or has an unknown or non-input type |
| `BLD107` | A selection set is missing (object field) or not allowed (scalar field), or a fragment is on an unknown or non-composite type |
| `BLD200` | Writing `index.dart` failed |

`BLD103` and `BLD105`–`BLD107` come from checking each operation against the
schema. All problems of an operation are reported together, with the path and
line of each one. These are documents a GraphQL server would reject anyway. If a
check gets in the way while you fix an existing project, turn it off with
`validate_documents: false`.

## Known limitations

The biggest one: the generated classes follow the schema types, not the fields
Expand Down
42 changes: 39 additions & 3 deletions lib/src/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ import 'package:flutter_graphql_codegen/src/schema_downloader.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:dart_style/dart_style.dart' show FormatterException;
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';

import 'config.dart';
import 'config_context.dart';
import 'diagnostics.dart';
import 'document_utils.dart';
import 'formatting.dart';
import 'naming.dart';

/// Schema-derived data shared by all operations of a build.
Expand All @@ -38,6 +41,11 @@ class GraphQLCodegenBuilder implements Builder {
String? _lastWrittenTypes;
bool _loggedConfigWarnings = false;

/// Language version generated code is formatted for (that of the package
/// being built); null means the formatter's latest.
Version? _languageVersion;
bool _languageVersionResolved = false;

GraphQLCodegenBuilder(this.config);

@override
Expand All @@ -49,6 +57,11 @@ class GraphQLCodegenBuilder implements Builder {
return;
}

if (!_languageVersionResolved) {
_languageVersionResolved = true;
_languageVersion = await _packageLanguageVersion(buildStep);
}

if (!_loggedConfigWarnings) {
_loggedConfigWarnings = true;
config.warnings.forEach(log.warning);
Expand Down Expand Up @@ -386,7 +399,7 @@ $usage''';

// Write index file
final indexFile = File(p.join(config.outputDir, 'index.dart'));
await _writeIfChanged(indexFile, indexContent);
await _writeIfChanged(indexFile, _format(indexContent, 'index.dart'));
} catch (e, st) {
throw _report(CodegenException(
CodegenErrorCodes.buildIndexFailed, 'Failed to generate index.dart',
Expand All @@ -404,6 +417,28 @@ $usage''';
return 'package:$package/${inLib.isEmpty ? '' : '$inLib/'}index.dart';
}

static Future<Version?> _packageLanguageVersion(BuildStep buildStep) async {
try {
final config = await buildStep.packageConfig;
final version = config[buildStep.inputId.package]?.languageVersion;
return version == null ? null : Version(version.major, version.minor, 0);
} catch (_) {
return null;
}
}

/// Formats generated [code]; if that fails (a generator bug), the code is
/// written unformatted so it can still be inspected.
String _format(String code, String fileName) {
try {
return formatGeneratedCode(code, languageVersion: _languageVersion);
} on FormatterException catch (e) {
log.warning('Could not format generated $fileName, writing it '
'unformatted. Please report this: ${e.message()}');
return code;
}
}

/// Writes types.dart when its content changed since the last write.
Future<void> _writeTypesFile(String typesCode) async {
if (typesCode == _lastWrittenTypes) return;
Expand All @@ -415,7 +450,7 @@ $usage''';
}

final typesFile = File(p.join(config.outputDir, 'types.dart'));
await _writeIfChanged(typesFile, typesCode);
await _writeIfChanged(typesFile, _format(typesCode, 'types.dart'));
_lastWrittenTypes = typesCode;
log.fine('Generated types file: ${typesFile.path}');
}
Expand All @@ -434,7 +469,7 @@ $usage''';
final filePath = p.join(config.outputDir, fileName);
final file = File(filePath);

await _writeIfChanged(file, content);
await _writeIfChanged(file, _format(content, fileName));
}

/// Finds all generated .g.dart files
Expand Down Expand Up @@ -517,6 +552,7 @@ Builder graphqlCodegenBuilder(BuilderOptions options) {
CodegenConfigContext.scalarMapping = Map.of(cfg.scalarMapping);
CodegenConfigContext.unknownEnumFallback = cfg.unknownEnumFallback;
CodegenConfigContext.deprecatedAnnotations = cfg.deprecatedAnnotations;
CodegenConfigContext.validateDocuments = cfg.validateDocuments;

return GraphQLCodegenBuilder(cfg);
}
21 changes: 15 additions & 6 deletions lib/src/client_extension_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'code_utils.dart';
import 'config_context.dart';
import 'diagnostics.dart';
import 'document_utils.dart';
import 'document_validator.dart';
import 'naming.dart';
import 'schema_analyzer.dart';

Expand Down Expand Up @@ -70,6 +71,9 @@ $body''';

final schema = DocumentUtils.mergeSchemaExtensions(schemaDoc);
SchemaAnalyzer.extractCustomTypesAndEnums(schema);
if (CodegenConfigContext.validateDocuments) {
DocumentValidator(schema).check(operation, allFragments);
}

final context = _OperationContext(
name: operationName,
Expand Down Expand Up @@ -198,7 +202,9 @@ $body''';

final name = (type as NamedTypeNode).name.value;
if (CodegenConfigContext.scalarMapping.containsKey(name)) return v;
switch (name) {
// A schema type named like a built-in scalar (e.g. `type Decimal`) is
// handled as the type the generator declares for it.
switch (TypeRegistry.declaresType(name) ? '' : name) {
case 'Int':
case 'Float':
case 'String':
Expand All @@ -218,7 +224,7 @@ $body''';
return 'const ByteConverter().toJson($v)';
}
if (TypeRegistry.isEnum(name) || TypeRegistry.isCustomScalar(name)) {
return 'const ${name}Converter().toJson($v)';
return 'const ${SchemaAnalyzer.dartTypeName(name)}Converter().toJson($v)';
}
if (TypeRegistry.inputTypes.contains(name)) return '$v.toJson()';
return v;
Expand Down Expand Up @@ -433,7 +439,9 @@ $options
return '$v as $mapped';
}
}
switch (name) {
// A schema type named like a built-in scalar (e.g. `type Decimal`) is
// handled as the type the generator declares for it.
switch (TypeRegistry.declaresType(name) ? '' : name) {
case 'Int':
case 'Short':
return 'const SafeIntConverter().fromJson($v)';
Expand All @@ -455,16 +463,17 @@ $options
case 'Byte':
return 'const ByteConverter().fromJson($v)';
}
final dartName = SchemaAnalyzer.dartTypeName(name);
if (TypeRegistry.isEnum(name)) {
return 'const ${name}Converter().fromJson($v as String)';
return 'const ${dartName}Converter().fromJson($v as String)';
}
if (TypeRegistry.isCustomScalar(name)) {
return 'const ${name}Converter().fromJson($v)';
return 'const ${dartName}Converter().fromJson($v)';
}
if (TypeRegistry.objectTypes.contains(name) ||
TypeRegistry.isAbstract(name) ||
definedTypes.contains(name)) {
return '$name.fromJson($v as Map<String, dynamic>)';
return '$dartName.fromJson($v as Map<String, dynamic>)';
}
return v;
}
Expand Down
20 changes: 20 additions & 0 deletions lib/src/code_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,24 @@ class TypeRegistry {
/// True for interfaces and unions.
static bool isAbstract(String typeName) =>
_interfaceTypes.contains(typeName) || _unionTypes.contains(typeName);

/// Scalars with a built-in Dart representation instead of a generated
/// wrapper class.
static const Set<String> builtInScalarTypes = {
'Decimal', 'DateTime', 'Short', 'Byte', 'Long', 'LocalDate', //
};

/// True for custom scalars that get a generated wrapper class.
static bool isWrappedScalar(String typeName) =>
_customScalars.contains(typeName) &&
!builtInScalarTypes.contains(typeName);

/// True when the generator declares a Dart type for schema type [typeName]
/// (object, input, interface, union, enum or wrapped custom scalar).
static bool declaresType(String typeName) =>
_objectTypes.contains(typeName) ||
_inputTypes.contains(typeName) ||
_enumTypes.contains(typeName) ||
isAbstract(typeName) ||
isWrappedScalar(typeName);
}
9 changes: 8 additions & 1 deletion lib/src/config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ class GraphQLCodegenConfig {
/// (otherwise the deprecation is only mentioned in the doc comment).
final bool deprecatedAnnotations;

/// Checks operations against the schema (fields, arguments, variables,
/// selections) before generating code for them.
final bool validateDocuments;

/// HTTP headers sent when downloading a remote schema. Values may reference
/// environment variables as `${NAME}`.
final Map<String, String> schemaHeaders;
Expand All @@ -36,7 +40,7 @@ class GraphQLCodegenConfig {
'schema_url', 'schema', 'document_paths', 'documents', 'output_dir', //
'strict_nullability', 'file_naming', 'emit_index', 'scalar_mapping',
'schema_headers', 'diagnostics_format', 'unknown_enum_fallback',
'deprecated_annotations',
'deprecated_annotations', 'validate_documents',
};

GraphQLCodegenConfig({
Expand All @@ -49,6 +53,7 @@ class GraphQLCodegenConfig {
this.diagnosticsFormat = 'plain',
this.unknownEnumFallback = false,
this.deprecatedAnnotations = false,
this.validateDocuments = true,
Map<String, String>? scalarMapping,
Map<String, String>? schemaHeaders,
this.warnings = const [],
Expand Down Expand Up @@ -108,6 +113,7 @@ class GraphQLCodegenConfig {
yamlMap['unknown_enum_fallback'] as bool? ?? false;
final deprecatedAnnotations =
yamlMap['deprecated_annotations'] as bool? ?? false;
final validateDocuments = yamlMap['validate_documents'] as bool? ?? true;
diagFormat =
(yamlMap['diagnostics_format'] as String?)?.trim() ?? 'plain';
final diagnosticsFormat = diagFormat;
Expand Down Expand Up @@ -153,6 +159,7 @@ class GraphQLCodegenConfig {
diagnosticsFormat: diagnosticsFormat,
unknownEnumFallback: unknownEnumFallback,
deprecatedAnnotations: deprecatedAnnotations,
validateDocuments: validateDocuments,
scalarMapping: scalarMappingMap,
schemaHeaders: schemaHeaders,
warnings: [
Expand Down
2 changes: 2 additions & 0 deletions lib/src/config_context.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class CodegenConfigContext {
static Map<String, String> scalarMapping = <String, String>{};
static bool unknownEnumFallback = false;
static bool deprecatedAnnotations = false;
static bool validateDocuments = true;

/// Resets all fields to defaults. Useful for tests and watch-mode rebuilds.
static void reset() {
Expand All @@ -19,5 +20,6 @@ class CodegenConfigContext {
scalarMapping = <String, String>{};
unknownEnumFallback = false;
deprecatedAnnotations = false;
validateDocuments = true;
}
}
3 changes: 3 additions & 0 deletions lib/src/diagnostics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ class CodegenErrorCodes {
static const String buildDuplicateFragment = 'BLD102';
static const String buildUnknownField = 'BLD103';
static const String buildDuplicateOperation = 'BLD104';
static const String buildInvalidArgument = 'BLD105';
static const String buildInvalidVariable = 'BLD106';
static const String buildInvalidSelection = 'BLD107';
static const String buildIndexFailed = 'BLD200';
}

Expand Down
Loading
Loading