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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,37 @@ 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
- Schema descriptions become doc comments on generated classes, fields, enums
and enum values; `@deprecated` is mentioned there as `Deprecated: <reason>`
- `deprecated_annotations` option: emit Dart `@Deprecated` for schema
`@deprecated` fields and enum values
- `unknown_enum_fallback` option: enums get a `$unknown` value that unknown
server values parse to (including in lists) instead of throwing
- Error code `BLD104` for duplicate operation names, in one document or across
documents, and for names that map to the same output file; generated
operation files record their source document in a `// Source:` line
- Unknown keys in `graphql_codegen.yaml` are reported as warnings, with a
suggestion for likely typos

### Fixed
- Lists of enums with escaped values (e.g. `[Role!]` containing `default`)
failed to parse: `json_serializable` didn't know the GraphQL names.
Escaped values now carry `@JsonValue`
- The usage hint in `index.dart` showed an invalid `package:your_app/lib/...`
import and a made-up operation; it now shows the real `package:` URI

### Deprecated
- Public helpers the generator no longer uses, to be removed in 3.0:
`TypeGenerator.generateClassForType`, `generateTypeDefinitions` and
`getDartTypeFromOriginal`; `OperationAnalyzer.getOperationReturnType`,
`getOperationFieldName`, `hasSkipDirective` and `getSkippableFields`;
`SchemaAnalyzer.extractCustomScalars`, `getSchemaType`,
`mapSchemaTypeToDartType`, `isScalarType` and `mapGraphQLTypeToDartType`;
`GraphQLCodeGenerator.generateCode` (use `generateOperationFile`)

## [2.0.0] - 2026-10-10

### Breaking changes and migration
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,39 @@ schema_headers:
| `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 |
| `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) |

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

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.

About `unknown_enum_fallback`: by default an enum value the generator doesn't
know (the server added it after you generated the code) throws while parsing.
That is risky for mobile apps, where old versions stay installed for a long
time. With the option on, every enum gets an extra `$unknown` value, and unknown
values parse to it, also inside lists. Sending `$unknown` back to the server
throws `ArgumentError`. It's off by default because the extra value makes your
existing exhaustive `switch` statements over enums incomplete.

About `deprecated_annotations`: descriptions from the schema always become doc
comments, and a `@deprecated` field or enum value gets a `Deprecated: <reason>`
line there. With the option on it also gets a real `@Deprecated` annotation, so
the IDE strikes it out. The catch: `types.g.dart` (written by
`json_serializable`) uses those members too, so `dart analyze`/`flutter analyze`
report `deprecated_member_use_from_same_package` there. Exclude generated files
from analysis if you turn it on:

```yaml
# analysis_options.yaml
analyzer:
exclude:
- "**/*.g.dart"
```

## Running the generator

```bash
Expand Down Expand Up @@ -256,6 +283,7 @@ Problems found during generation are reported with a code:
| `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 |
| `BLD104` | Two operations have the same name (or names that map to the same file) |
| `BLD200` | Writing `index.dart` failed |

## Known limitations
Expand Down
92 changes: 81 additions & 11 deletions lib/src/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class GraphQLCodegenBuilder implements Builder {
String? _cachedSchemaText;
_SchemaArtifacts? _cachedArtifacts;
String? _lastWrittenTypes;
bool _loggedConfigWarnings = false;

GraphQLCodegenBuilder(this.config);

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

if (!_loggedConfigWarnings) {
_loggedConfigWarnings = true;
config.warnings.forEach(log.warning);
}

final content = await buildStep.readAsString(inputId);
final DocumentNode document;
try {
Expand Down Expand Up @@ -76,6 +82,8 @@ class GraphQLCodegenBuilder implements Builder {

final fragments = await _externalFragments(buildStep, document);

// Output file name -> operation that generates it, for this document.
final outputs = <String, String>{};
for (final operation in operations) {
final operationName = operation.name?.value;
if (operationName == null) {
Expand All @@ -84,6 +92,21 @@ class GraphQLCodegenBuilder implements Builder {
continue;
}

final fileName = _generateOutputFileName(operationName);
final previous = outputs[fileName];
if (previous != null) {
throw _report(CodegenException(
CodegenErrorCodes.buildDuplicateOperation,
previous == operationName
? 'Operation "$operationName" is defined more than once in '
'${inputId.path}'
: 'Operations "$previous" and "$operationName" in '
'${inputId.path} both generate $fileName',
));
}
outputs[fileName] = operationName;
await _checkOutputOwner(buildStep, fileName, operationName);

final String code;
try {
code = ClientExtensionGenerator.generateOperationFile(
Expand All @@ -93,22 +116,60 @@ class GraphQLCodegenBuilder implements Builder {
artifacts.schemaDoc,
artifacts.definedTypes,
fragments: fragments,
sourcePath: inputId.path,
);
} on CodegenException catch (e) {
throw _report(e);
}

await _writeToOutputDirectory(operationName, code);
log.fine('Generated ${config.outputDir}/'
'${_generateOutputFileName(operationName)}');
log.fine('Generated ${config.outputDir}/$fileName');
}

// Generate index file after processing operations (if enabled)
if (config.emitIndex) {
await _generateIndexFile();
await _generateIndexFile(inputId.package);
}
}

/// Throws when [fileName] was generated from another document that still
/// defines an operation writing to the same file.
Future<void> _checkOutputOwner(
BuildStep buildStep, String fileName, String operationName) async {
final file = File(p.join(config.outputDir, fileName));
if (!await file.exists()) return;
final source = RegExp(r'^// Source: (.+)$', multiLine: true)
.firstMatch(await file.readAsString())
?.group(1);
if (source == null || source == buildStep.inputId.path) return;

final sourceId = AssetId(buildStep.inputId.package, source);
if (!await buildStep.canRead(sourceId)) return;
final DocumentNode other;
try {
other = gql_lang.parseString(await buildStep.readAsString(sourceId));
} catch (_) {
// Parse errors are reported when that document itself is built.
return;
}
final clash = other.definitions
.whereType<OperationDefinitionNode>()
.map((op) => op.name?.value)
.whereType<String>()
.where((name) => _generateOutputFileName(name) == fileName)
.firstOrNull;
if (clash == null) return;

throw _report(CodegenException(
CodegenErrorCodes.buildDuplicateOperation,
clash == operationName
? 'Operation "$operationName" is defined in both $source and '
'${buildStep.inputId.path} (output file $fileName)'
: 'Operation "$clash" in $source and operation "$operationName" in '
'${buildStep.inputId.path} both generate $fileName',
));
}

CodegenException _report(CodegenException e) {
log.severe(e.format(format: CodegenConfigContext.diagnosticsFormat));
return e;
Expand Down Expand Up @@ -264,7 +325,7 @@ class GraphQLCodegenBuilder implements Builder {
}

/// Generates index.dart file with all operations
Future<void> _generateIndexFile() async {
Future<void> _generateIndexFile(String package) async {
try {
// Find all generated .g.dart files
final generatedFiles = <File>[];
Expand Down Expand Up @@ -299,6 +360,9 @@ class GraphQLCodegenBuilder implements Builder {
return;
}

final import = packageImportOf(package, config.outputDir);
final usage = import == null ? '' : "\n// Usage: import '$import';\n";

// Create index file
final indexContent = '''// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by flutter_graphql_codegen
Expand All @@ -312,13 +376,7 @@ ${exports.join('\n')}

// Available operations:
${operations.join('\n')}

// Usage:
// import 'package:your_app/${config.outputDir}/index.dart';
//
// final client = GraphQLClient(...);
// final result = await client.getUserProfile({'culture': 'en'});
''';
$usage''';

// Create the directory if it does not exist
final outputDir = Directory(config.outputDir);
Expand All @@ -336,6 +394,16 @@ ${operations.join('\n')}
}
}

/// The `package:` URI of `index.dart` in [outputDir], or null when
/// [outputDir] is not inside `lib/` of [package].
static String? packageImportOf(String package, String outputDir) {
final relative = _packageRelativePath(outputDir);
if (relative == null || !relative.startsWith('lib/')) return null;
final inLib =
relative.substring('lib/'.length).replaceAll(RegExp(r'/+$'), '');
return 'package:$package/${inLib.isEmpty ? '' : '$inLib/'}index.dart';
}

/// Writes types.dart when its content changed since the last write.
Future<void> _writeTypesFile(String typesCode) async {
if (typesCode == _lastWrittenTypes) return;
Expand Down Expand Up @@ -447,6 +515,8 @@ Builder graphqlCodegenBuilder(BuilderOptions options) {
CodegenConfigContext.emitIndex = cfg.emitIndex;
CodegenConfigContext.diagnosticsFormat = cfg.diagnosticsFormat;
CodegenConfigContext.scalarMapping = Map.of(cfg.scalarMapping);
CodegenConfigContext.unknownEnumFallback = cfg.unknownEnumFallback;
CodegenConfigContext.deprecatedAnnotations = cfg.deprecatedAnnotations;

return GraphQLCodegenBuilder(cfg);
}
6 changes: 5 additions & 1 deletion lib/src/client_extension_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ import 'schema_analyzer.dart';
class ClientExtensionGenerator {
/// Generates a complete operation file (header, imports and the code from
/// [generateClientExtension]).
///
/// [sourcePath] is recorded in the header so the builder can tell which
/// document a file was generated from.
static String generateOperationFile(
String operationName,
String operationType,
String operationDocumentContent,
DocumentNode schemaDoc,
Set<String> definedTypes, {
Map<String, FragmentDefinitionNode> fragments = const {},
String? sourcePath,
}) {
final operation = _findOperation(
gql_lang.parseString(operationDocumentContent), operationName);
Expand All @@ -34,7 +38,7 @@ class ClientExtensionGenerator {
);
return '''${CodeGenerationConstants.generatedFileWarning}
// Operation: $operationName (${operation.type.name})
${CodeGenerationConstants.ignoreForFile}
${sourcePath == null ? '' : '// Source: $sourcePath\n'}${CodeGenerationConstants.ignoreForFile}

import 'package:graphql/client.dart' as graphql;
import 'types.dart';
Expand Down
64 changes: 64 additions & 0 deletions lib/src/config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,30 @@ class GraphQLCodegenConfig {
final String diagnosticsFormat; // plain | json
final Map<String, String> scalarMapping; // GraphQL scalar -> Dart type

/// Adds a `$unknown` value to every enum for values the server sends that
/// were not in the schema at generation time (instead of throwing).
final bool unknownEnumFallback;

/// Emits `@Deprecated` for schema `@deprecated` fields and enum values
/// (otherwise the deprecation is only mentioned in the doc comment).
final bool deprecatedAnnotations;

/// HTTP headers sent when downloading a remote schema. Values may reference
/// environment variables as `${NAME}`.
final Map<String, String> schemaHeaders;

/// Problems in the configuration that do not stop generation (e.g. unknown
/// keys); the builder logs them.
final List<String> warnings;

/// Keys understood in `graphql_codegen.yaml`.
static const Set<String> knownKeys = {
'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',
};

GraphQLCodegenConfig({
required this.schemaUrl,
required this.outputDir,
Expand All @@ -27,8 +47,11 @@ class GraphQLCodegenConfig {
this.strictNullability = false,
this.emitIndex = true,
this.diagnosticsFormat = 'plain',
this.unknownEnumFallback = false,
this.deprecatedAnnotations = false,
Map<String, String>? scalarMapping,
Map<String, String>? schemaHeaders,
this.warnings = const [],
}) : scalarMapping = scalarMapping ?? const {},
schemaHeaders = schemaHeaders ?? const {};

Expand Down Expand Up @@ -81,6 +104,10 @@ class GraphQLCodegenConfig {
}
final strictNullability = yamlMap['strict_nullability'] as bool? ?? false;
final emitIndex = yamlMap['emit_index'] as bool? ?? true;
final unknownEnumFallback =
yamlMap['unknown_enum_fallback'] as bool? ?? false;
final deprecatedAnnotations =
yamlMap['deprecated_annotations'] as bool? ?? false;
diagFormat =
(yamlMap['diagnostics_format'] as String?)?.trim() ?? 'plain';
final diagnosticsFormat = diagFormat;
Expand Down Expand Up @@ -124,8 +151,14 @@ class GraphQLCodegenConfig {
strictNullability: strictNullability,
emitIndex: emitIndex,
diagnosticsFormat: diagnosticsFormat,
unknownEnumFallback: unknownEnumFallback,
deprecatedAnnotations: deprecatedAnnotations,
scalarMapping: scalarMappingMap,
schemaHeaders: schemaHeaders,
warnings: [
for (final key in yamlMap.keys.map((k) => '$k'))
if (!knownKeys.contains(key)) _unknownKeyWarning(key),
],
);
} catch (e) {
if (e is CodegenException) {
Expand All @@ -139,6 +172,37 @@ class GraphQLCodegenConfig {
}
}

static String _unknownKeyWarning(String key) {
String? closest;
var best = 4; // suggest only near misses
for (final known in knownKeys) {
final distance = _editDistance(key, known);
if (distance < best) {
best = distance;
closest = known;
}
}
return 'Unknown option "$key" in graphql_codegen.yaml'
'${closest == null ? '' : ' (did you mean "$closest"?)'}; it is ignored';
}

static int _editDistance(String a, String b) {
var previous = List<int>.generate(b.length + 1, (i) => i);
for (var i = 1; i <= a.length; i++) {
final current = [i, ...List<int>.filled(b.length, 0)];
for (var j = 1; j <= b.length; j++) {
final cost = a[i - 1] == b[j - 1] ? 0 : 1;
current[j] = [
previous[j] + 1,
current[j - 1] + 1,
previous[j - 1] + cost,
].reduce((x, y) => x < y ? x : y);
}
previous = current;
}
return previous[b.length];
}

/// Replaces `${NAME}` with the value of environment variable `NAME`.
///
/// Throws a [CodegenException] when a referenced variable is not set, so a
Expand Down
Loading
Loading