diff --git a/CHANGELOG.md b/CHANGELOG.md index 44f498c..bab6245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: ` +- `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 diff --git a/README.md b/README.md index a70dd17..0a9057f 100644 --- a/README.md +++ b/README.md @@ -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: ` +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 @@ -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 diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 09c4ad6..58d4960 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -36,6 +36,7 @@ class GraphQLCodegenBuilder implements Builder { String? _cachedSchemaText; _SchemaArtifacts? _cachedArtifacts; String? _lastWrittenTypes; + bool _loggedConfigWarnings = false; GraphQLCodegenBuilder(this.config); @@ -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 { @@ -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 = {}; for (final operation in operations) { final operationName = operation.name?.value; if (operationName == null) { @@ -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( @@ -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 _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() + .map((op) => op.name?.value) + .whereType() + .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; @@ -264,7 +325,7 @@ class GraphQLCodegenBuilder implements Builder { } /// Generates index.dart file with all operations - Future _generateIndexFile() async { + Future _generateIndexFile(String package) async { try { // Find all generated .g.dart files final generatedFiles = []; @@ -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 @@ -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); @@ -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 _writeTypesFile(String typesCode) async { if (typesCode == _lastWrittenTypes) return; @@ -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); } diff --git a/lib/src/client_extension_generator.dart b/lib/src/client_extension_generator.dart index 52752b3..a62b99d 100644 --- a/lib/src/client_extension_generator.dart +++ b/lib/src/client_extension_generator.dart @@ -14,6 +14,9 @@ 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, @@ -21,6 +24,7 @@ class ClientExtensionGenerator { DocumentNode schemaDoc, Set definedTypes, { Map fragments = const {}, + String? sourcePath, }) { final operation = _findOperation( gql_lang.parseString(operationDocumentContent), operationName); @@ -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'; diff --git a/lib/src/config.dart b/lib/src/config.dart index ade7229..0f1f897 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -15,10 +15,30 @@ class GraphQLCodegenConfig { final String diagnosticsFormat; // plain | json final Map 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 schemaHeaders; + /// Problems in the configuration that do not stop generation (e.g. unknown + /// keys); the builder logs them. + final List warnings; + + /// Keys understood in `graphql_codegen.yaml`. + static const Set 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, @@ -27,8 +47,11 @@ class GraphQLCodegenConfig { this.strictNullability = false, this.emitIndex = true, this.diagnosticsFormat = 'plain', + this.unknownEnumFallback = false, + this.deprecatedAnnotations = false, Map? scalarMapping, Map? schemaHeaders, + this.warnings = const [], }) : scalarMapping = scalarMapping ?? const {}, schemaHeaders = schemaHeaders ?? const {}; @@ -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; @@ -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) { @@ -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.generate(b.length + 1, (i) => i); + for (var i = 1; i <= a.length; i++) { + final current = [i, ...List.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 diff --git a/lib/src/config_context.dart b/lib/src/config_context.dart index 847804e..9139abe 100644 --- a/lib/src/config_context.dart +++ b/lib/src/config_context.dart @@ -7,6 +7,8 @@ class CodegenConfigContext { static bool emitIndex = true; static String diagnosticsFormat = 'plain'; static Map scalarMapping = {}; + static bool unknownEnumFallback = false; + static bool deprecatedAnnotations = false; /// Resets all fields to defaults. Useful for tests and watch-mode rebuilds. static void reset() { @@ -15,5 +17,7 @@ class CodegenConfigContext { emitIndex = true; diagnosticsFormat = 'plain'; scalarMapping = {}; + unknownEnumFallback = false; + deprecatedAnnotations = false; } } diff --git a/lib/src/diagnostics.dart b/lib/src/diagnostics.dart index 5b53f05..47a33b8 100644 --- a/lib/src/diagnostics.dart +++ b/lib/src/diagnostics.dart @@ -17,6 +17,7 @@ class CodegenErrorCodes { static const String buildMissingFragment = 'BLD101'; static const String buildDuplicateFragment = 'BLD102'; static const String buildUnknownField = 'BLD103'; + static const String buildDuplicateOperation = 'BLD104'; static const String buildIndexFailed = 'BLD200'; } diff --git a/lib/src/generator.dart b/lib/src/generator.dart index de04bfa..ff96e08 100644 --- a/lib/src/generator.dart +++ b/lib/src/generator.dart @@ -21,6 +21,7 @@ class GraphQLCodeGenerator { /// Generates code for a GraphQL operation. /// /// Same as [generateOperationFile]. + @Deprecated('Use generateOperationFile; will be removed in 3.0.') static String generateCode( String schema, String documentContent, diff --git a/lib/src/operation_analyzer.dart b/lib/src/operation_analyzer.dart index 2601dab..e3c532d 100644 --- a/lib/src/operation_analyzer.dart +++ b/lib/src/operation_analyzer.dart @@ -7,6 +7,7 @@ import 'schema_analyzer.dart'; /// Utility class for analyzing GraphQL operations class OperationAnalyzer { /// Gets the return type for a GraphQL operation + @Deprecated('Not used by the generator; will be removed in 3.0.') static String getOperationReturnType( DocumentNode operationDoc, DocumentNode schemaDoc, @@ -42,6 +43,7 @@ class OperationAnalyzer { } /// Gets the field name from the first selection in an operation + @Deprecated('Not used by the generator; will be removed in 3.0.') static String getOperationFieldName(DocumentNode operationDoc) { for (final definition in operationDoc.definitions) { if (definition is OperationDefinitionNode) { @@ -57,11 +59,13 @@ class OperationAnalyzer { } /// Checks if a field has a @skip directive + @Deprecated('Not used by the generator; will be removed in 3.0.') static bool hasSkipDirective(FieldNode field) { return field.directives.any((directive) => directive.name.value == 'skip'); } /// Analyzes operation and finds fields with @skip directives + @Deprecated('Not used by the generator; will be removed in 3.0.') static Set getSkippableFields(DocumentNode operationDoc) { final skippableFields = {}; diff --git a/lib/src/schema_analyzer.dart b/lib/src/schema_analyzer.dart index 5e5a257..1713a39 100644 --- a/lib/src/schema_analyzer.dart +++ b/lib/src/schema_analyzer.dart @@ -63,6 +63,7 @@ class SchemaAnalyzer { } /// Extracts custom scalars from the schema document + @Deprecated('Not used by the generator; will be removed in 3.0.') static Set extractCustomScalars(DocumentNode schemaDoc) { final customScalars = {}; for (final definition in schemaDoc.definitions) { @@ -110,6 +111,7 @@ class SchemaAnalyzer { } /// Gets the schema type string from a TypeNode + @Deprecated('Not used by the generator; will be removed in 3.0.') static String getSchemaType(TypeNode type) { if (type is NamedTypeNode) { return '${type.name.value}?'; @@ -120,6 +122,7 @@ class SchemaAnalyzer { } /// Maps schema type to Dart type + @Deprecated('Not used by the generator; will be removed in 3.0.') static String mapSchemaTypeToDartType( String schemaType, Set definedTypes) { final isNullable = schemaType.endsWith('?'); @@ -163,6 +166,7 @@ class SchemaAnalyzer { } /// Checks if a type is a scalar type + @Deprecated('Not used by the generator; will be removed in 3.0.') static bool isScalarType(String typeName) { final scalarTypes = [ 'Int', @@ -180,6 +184,7 @@ class SchemaAnalyzer { } /// Maps GraphQL type to Dart type for basic types + @Deprecated('Not used by the generator; will be removed in 3.0.') static String mapGraphQLTypeToDartType(String graphqlType) { switch (graphqlType) { case 'Int': diff --git a/lib/src/type_generator.dart b/lib/src/type_generator.dart index 78b8d78..2fbbc27 100644 --- a/lib/src/type_generator.dart +++ b/lib/src/type_generator.dart @@ -4,10 +4,33 @@ library; import 'package:gql/ast.dart'; import 'package:gql/language.dart' as gql_lang; import 'code_utils.dart'; +import 'config_context.dart'; import 'document_utils.dart'; import 'naming.dart'; import 'schema_analyzer.dart'; +/// A field of a generated JSON class. +typedef _Field = ({ + String name, + TypeNode type, + StringValueNode? description, + List directives, +}); + +_Field _fieldOf(FieldDefinitionNode f) => ( + name: f.name.value, + type: f.type, + description: f.description, + directives: f.directives, + ); + +_Field _inputFieldOf(InputValueDefinitionNode f) => ( + name: f.name.value, + type: f.type, + description: f.description, + directives: f.directives, + ); + /// Utility class for generating types, enums, and converters class TypeGenerator { /// Generates a complete types file with all necessary components @@ -23,7 +46,7 @@ class TypeGenerator { final typeDefinitions = _generateAllTypeDefinitions(schemaDoc, schema); return '''${CodeGenerationConstants.generatedFileWarning} -${CodeGenerationConstants.ignoreForFile}, unused_element +${CodeGenerationConstants.ignoreForFile}, unused_element, deprecated_member_use_from_same_package ${needsConvert ? "import 'dart:convert';\n\n" : ''}import 'package:json_annotation/json_annotation.dart'; @@ -329,10 +352,26 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { for (final definition in _sortedDefinitions(schemaDoc)) { if (definition is EnumTypeDefinitionNode) { final enumName = definition.name.value; + buffer.write(_docComment(definition.description)); buffer.writeln('enum $enumName {'); // Sort enum values for determinism for (final value in _sortedEnumValues(definition)) { - buffer.writeln(' ${dartEnumValueName(value.name.value)},'); + final graphqlName = value.name.value; + final dartName = dartEnumValueName(graphqlName); + buffer.write(_docComment(value.description, + directives: value.directives, indent: ' ')); + buffer.write(_deprecation(value.directives, indent: ' ')); + // json_serializable decodes enum lists without our converter; the + // JsonValue keeps escaped values mapped to their GraphQL names. + if (dartName != graphqlName) { + buffer.writeln(" @JsonValue('$graphqlName')"); + } + buffer.writeln(' $dartName,'); + } + if (CodegenConfigContext.unknownEnumFallback) { + buffer.writeln(' /// A value added to the schema after this code ' + 'was generated.'); + buffer.writeln(' \$unknown,'); } buffer.writeln('}'); buffer.writeln(); @@ -361,21 +400,28 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { buffer.writeln( " case '${value.name.value}': return $enumName.${dartEnumValueName(value.name.value)};"); } - buffer.writeln( - " default: throw ArgumentError.value(json, 'json', 'Unknown $enumName value');"); + final fallback = CodegenConfigContext.unknownEnumFallback; + buffer.writeln(fallback + ? ' default: return $enumName.\$unknown;' + : " default: throw ArgumentError.value(json, 'json', 'Unknown $enumName value');"); buffer.writeln(' }'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' @override'); final needsMapping = values.any((v) => dartEnumValueName(v.name.value) != v.name.value); - if (needsMapping) { + if (needsMapping || fallback) { 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}';"); } + if (fallback) { + buffer + .writeln(' case $enumName.\$unknown: throw ArgumentError(' + "'$enumName.\\\$unknown cannot be sent to the server');"); + } buffer.writeln(' }'); buffer.writeln(' }'); } else { @@ -456,7 +502,8 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { final typeName = typeNode.name.value; return _generateJsonClass( className: typeName, - fields: [for (final f in typeNode.fields) (f.name.value, f.type)], + description: typeNode.description, + fields: typeNode.fields.map(_fieldOf).toList(), supertypes: TypeRegistry.supertypes(typeName), overriddenFields: _inheritedFieldNames(typeName, interfaces), ); @@ -468,7 +515,8 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { [String schema = '']) { return _generateJsonClass( className: inputNode.name.value, - fields: [for (final f in inputNode.fields) (f.name.value, f.type)], + description: inputNode.description, + fields: inputNode.fields.map(_inputFieldOf).toList(), ); } @@ -486,13 +534,17 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { ..sort((a, b) => a.name.value.compareTo(b.name.value)); final buffer = StringBuffer(); - buffer.writeln('/// GraphQL interface `$name`.'); + buffer.write( + _docComment(node.description, fallback: '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; + buffer.write(_docComment(field.description, + directives: field.directives, indent: ' ')); if (inherited.contains(fieldName)) buffer.writeln(' @override'); + buffer.write(_deprecation(field.directives, indent: ' ')); buffer.writeln( ' ${SchemaAnalyzer.getDartType(field.type)} get ${dartFieldName(fieldName, className: name)};'); } @@ -512,7 +564,7 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { '/// 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)], + fields: node.fields.map(_fieldOf).toList(), supertypes: [name], overriddenFields: node.fields.map((f) => f.name.value).toSet(), fieldNameClassName: name, @@ -526,8 +578,7 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { final name = node.name.value; final unknownName = 'Unknown$name'; return ''' -/// GraphQL union `$name`. -abstract class $name { +${_docComment(node.description, fallback: 'GraphQL union `$name`.')}abstract class $name { /// Creates the concrete member type matching `__typename`. factory $name.fromJson(Map json) { ${_typenameDispatch(name, '$unknownName(json)')} } @@ -570,12 +621,14 @@ class $unknownName implements $name { /// interface name so field names match the interface getters). static String _generateJsonClass({ required String className, - required List<(String, TypeNode)> fields, + required List<_Field> fields, + StringValueNode? description, List supertypes = const [], Set overriddenFields = const {}, String? fieldNameClassName, }) { final buffer = StringBuffer(); + buffer.write(_docComment(description)); buffer.writeln( '@JsonSerializable(includeIfNull: false, explicitToJson: true, fieldRename: FieldRename.none)'); final impl = @@ -583,12 +636,17 @@ class $unknownName implements $name { buffer.writeln('class $className$impl {'); // Sort fields deterministically by name - final sortedFields = [...fields]..sort((a, b) => a.$1.compareTo(b.$1)); + final sortedFields = [...fields]..sort((a, b) => a.name.compareTo(b.name)); - for (final (fieldName, type) in sortedFields) { + for (final field in sortedFields) { + final fieldName = field.name; + final type = field.type; final dartName = dartFieldName(fieldName, className: fieldNameClassName ?? className); + buffer.write(_docComment(field.description, + directives: field.directives, indent: ' ')); if (overriddenFields.contains(fieldName)) buffer.writeln(' @override'); + buffer.write(_deprecation(field.directives, indent: ' ')); for (final line in _fieldAnnotations(fieldName, dartName, type)) { buffer.writeln(line); } @@ -597,10 +655,10 @@ class $unknownName implements $name { buffer.writeln(); buffer.writeln(' $className({'); - for (final (fieldName, type) in sortedFields) { + for (final field in sortedFields) { final dartName = - dartFieldName(fieldName, className: fieldNameClassName ?? className); - final fieldType = SchemaAnalyzer.getDartType(type); + dartFieldName(field.name, className: fieldNameClassName ?? className); + final fieldType = SchemaAnalyzer.getDartType(field.type); if (fieldType.startsWith('List<')) { // For list fields, provide empty list as default buffer.writeln(' this.$dartName = const [],'); @@ -625,6 +683,59 @@ class $unknownName implements $name { return buffer.toString(); } + /// `///` lines for a schema [description] (or [fallback] when there is + /// none), each ending with a newline; empty when there is nothing to write. + /// + /// Without `deprecated_annotations`, a `@deprecated` directive among + /// [directives] is mentioned here instead of becoming `@Deprecated`. + static String _docComment(StringValueNode? description, + {List directives = const [], + String indent = '', + String? fallback}) { + var text = description?.value.trim() ?? ''; + if (text.isEmpty) text = fallback ?? ''; + final reason = _deprecationReason(directives); + if (reason != null && !CodegenConfigContext.deprecatedAnnotations) { + text = + text.isEmpty ? 'Deprecated: $reason' : '$text\n\nDeprecated: $reason'; + } + if (text.isEmpty) return ''; + return [ + for (final line in text.split('\n')) + line.trimRight().isEmpty + ? '$indent///\n' + : '$indent/// ${line.trimRight()}\n' + ].join(); + } + + /// A `@Deprecated(...)` line for a GraphQL `@deprecated` directive when + /// `deprecated_annotations` is on, otherwise ''. + static String _deprecation(List directives, + {String indent = ''}) { + final reason = _deprecationReason(directives); + if (reason == null || !CodegenConfigContext.deprecatedAnnotations) { + return ''; + } + final escaped = reason + .replaceAll(r'\', r'\\') + .replaceAll("'", r"\'") + .replaceAll(r'$', r'\$') + .replaceAll('\n', r'\n'); + return "$indent@Deprecated('$escaped')\n"; + } + + /// The reason of a `@deprecated` directive, or null when there is none. + static String? _deprecationReason(List directives) { + final directive = + directives.where((d) => d.name.value == 'deprecated').firstOrNull; + if (directive == null) return null; + final reason = directive.arguments + .where((a) => a.name.value == 'reason') + .firstOrNull + ?.value; + return reason is StringValueNode ? reason.value : 'No longer supported'; + } + /// Annotation lines (indented) for a generated field. static List _fieldAnnotations( String fieldName, String dartName, TypeNode type) { @@ -687,7 +798,13 @@ class $unknownName implements $name { ]; } // Regular lists (enums, objects, built-in scalars): default value only - return [' @JsonKey(${nameArg}defaultValue: const <$cleanInnerType>[])']; + final unknownArg = CodegenConfigContext.unknownEnumFallback && + TypeRegistry.isEnum(cleanInnerType) + ? ', unknownEnumValue: $cleanInnerType.\$unknown' + : ''; + return [ + ' @JsonKey(${nameArg}defaultValue: const <$cleanInnerType>[]$unknownArg)' + ]; } if (baseType == 'DateTime') { @@ -732,6 +849,7 @@ class $unknownName implements $name { } /// Generates a class for an object type (legacy method, kept for compatibility) + @Deprecated('Not used by the generator; will be removed in 3.0.') static String generateClassForType(ObjectTypeDefinitionNode type) { final className = type.name.value; final fields = type.fields; @@ -865,6 +983,7 @@ class $unknownName implements $name { } /// Generates type definitions for older API compatibility + @Deprecated('Not used by the generator; will be removed in 3.0.') static String generateTypeDefinitions(DocumentNode schemaDoc) { final buffer = StringBuffer(); @@ -972,6 +1091,7 @@ List? _byteListToJson(List? list) { } /// Gets the Dart type from a TypeNode but preserves original type name for converter logic + @Deprecated('Not used by the generator; will be removed in 3.0.') static String getDartTypeFromOriginal( String originalType, bool isNullable, bool isList) { String dartType; diff --git a/test/builder_test.dart b/test/builder_test.dart index 5daf09e..fc6f023 100644 --- a/test/builder_test.dart +++ b/test/builder_test.dart @@ -29,19 +29,22 @@ void main() { await outDir.delete(recursive: true); }); - GraphQLCodegenBuilder builder() => GraphQLCodegenBuilder( + GraphQLCodegenBuilder builder({List warnings = const []}) => + GraphQLCodegenBuilder( GraphQLCodegenConfig( // Not present on disk: must be read through the BuildStep. schemaUrl: 'lib/schema.graphql', outputDir: outDir.path, documentPaths: ['lib/graphql/**.graphql'], + warnings: warnings, ), ); - Future run(Map assets) async { + Future run(Map assets, + {List warnings = const []}) async { try { await testBuilder( - builder(), + builder(warnings: warnings), {'a|lib/schema.graphql': _schema, ...assets}, rootPackage: 'a', onLog: (r) => logs.add('${r.level.name}: ${r.message}'), @@ -111,4 +114,83 @@ query GetVersion { version } await run({'a|lib/graphql/broken.graphql': 'query Broken { nope }'}); expect(logs, contains(allOf(contains('BLD103'), contains('nope')))); }); + + test('index.dart import hint uses the package URI of output_dir', () { + expect(GraphQLCodegenBuilder.packageImportOf('app', 'lib/graphql/gen'), + 'package:app/graphql/gen/index.dart'); + expect(GraphQLCodegenBuilder.packageImportOf('app', './lib/gen/'), + 'package:app/gen/index.dart'); + expect(GraphQLCodegenBuilder.packageImportOf('app', 'generated'), isNull); + }); + + test('index.dart has no made-up usage example', () async { + await run({'a|lib/graphql/a.graphql': 'query A { version }'}); + final index = output('index.dart')!; + expect(index, isNot(contains('your_app'))); + expect(index, isNot(contains('getUserProfile'))); + }); + + test('logs config warnings', () async { + await run({ + 'a|lib/graphql/a.graphql': 'query A { version }', + 'a|lib/graphql/b.graphql': 'query B { version }', + }, warnings: [ + 'Unknown option "x"' + ]); + // Only presence is checked: how often build_test hands a record to onLog + // differs between environments (CI delivered it once per input). + expect(logs, + contains(allOf(startsWith('WARNING'), contains('Unknown option "x"')))); + }); + + group('duplicate operations', () { + Matcher duplicateError(String name) => + contains(allOf(contains('BLD104'), contains(name))); + + test('in two documents are reported with both paths', () async { + await run({ + 'a|lib/graphql/a.graphql': 'query GetVersion { version }', + 'a|lib/graphql/b.graphql': 'query GetVersion { version }', + }); + expect( + logs, + contains(allOf( + contains('BLD104'), + contains('lib/graphql/a.graphql'), + contains('lib/graphql/b.graphql'), + ))); + }); + + test('in one document are reported', () async { + await run({ + 'a|lib/graphql/a.graphql': + 'query GetVersion { version }\nquery GetVersion { version }', + }); + expect(logs, duplicateError('GetVersion')); + }); + + test('whose names map to the same file are reported', () async { + await run({ + 'a|lib/graphql/a.graphql': 'query GetVersion { version }', + 'a|lib/graphql/b.graphql': 'query getVersion { version }', + }); + expect(logs, duplicateError('get_version_graphql.g.dart')); + }); + + test('are not reported when a document is regenerated', () async { + await run({'a|lib/graphql/a.graphql': 'query GetVersion { version }'}); + await run({'a|lib/graphql/a.graphql': 'query GetVersion { version }'}); + expect(logs.where((l) => l.startsWith('SEVERE')), isEmpty); + }); + + test('are not reported when an operation moved to another document', + () async { + await run({'a|lib/graphql/a.graphql': 'query GetVersion { version }'}); + await run({ + 'a|lib/graphql/a.graphql': 'query Other { version }', + 'a|lib/graphql/b.graphql': 'query GetVersion { version }', + }); + expect(logs.where((l) => l.startsWith('SEVERE')), isEmpty); + }); + }); } diff --git a/test/config_options_test.dart b/test/config_options_test.dart index 8099a45..b620248 100644 --- a/test/config_options_test.dart +++ b/test/config_options_test.dart @@ -25,6 +25,48 @@ scalar_mapping: expect(cfg.emitIndex, isFalse); expect(cfg.diagnosticsFormat, 'json'); expect(cfg.scalarMapping['Date'], 'DateTime'); + expect(cfg.deprecatedAnnotations, isFalse); + expect( + GraphQLCodegenConfig.fromYaml('${yaml}deprecated_annotations: true\n') + .deprecatedAnnotations, + isTrue, + ); + }); + + test('warns about unknown keys and suggests the closest option', () { + final cfg = GraphQLCodegenConfig.fromYaml(''' +schema_url: schema.graphql +document_paths: [a.graphql] +strict_nulability: true +something_else: 1 +'''); + expect(cfg.strictNullability, isFalse); + expect(cfg.warnings, hasLength(2)); + expect( + cfg.warnings, + contains(allOf(contains('strict_nulability'), + contains('did you mean "strict_nullability"')))); + expect( + cfg.warnings, + contains(allOf( + contains('something_else'), isNot(contains('did you mean'))))); + }); + + test('accepts every documented key without warnings', () { + final cfg = GraphQLCodegenConfig.fromYaml(''' +schema: schema.graphql +documents: [a.graphql] +output_dir: out +strict_nullability: true +file_naming: snake_case +emit_index: true +scalar_mapping: {} +schema_headers: {} +diagnostics_format: plain +unknown_enum_fallback: true +deprecated_annotations: true +'''); + expect(cfg.warnings, isEmpty); }); test('parses schema_headers and expands environment variables', () { diff --git a/test/enum_fallback_test.dart b/test/enum_fallback_test.dart new file mode 100644 index 0000000..4a00adc --- /dev/null +++ b/test/enum_fallback_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_graphql_codegen/src/config.dart'; +import 'package:flutter_graphql_codegen/src/config_context.dart'; +import 'package:flutter_graphql_codegen/src/type_generator.dart'; +import 'package:test/test.dart'; + +const _schema = r''' +enum Role { ADMIN GUEST } +type User { role: Role! roles: [Role!]! } +type Query { me: User } +'''; + +void main() { + tearDown(CodegenConfigContext.reset); + + test('unknown_enum_fallback is off by default and parsed from config', () { + const base = ''' +schema_url: schema.graphql +document_paths: [a.graphql] +'''; + expect(GraphQLCodegenConfig.fromYaml(base).unknownEnumFallback, isFalse); + expect( + GraphQLCodegenConfig.fromYaml('${base}unknown_enum_fallback: true\n') + .unknownEnumFallback, + isTrue, + ); + }); + + test('without the option unknown values throw', () { + final types = TypeGenerator.generateTypesFile(_schema); + expect(types, isNot(contains(r'$unknown'))); + expect(types, contains("throw ArgumentError.value(json, 'json'")); + }); + + group('with unknown_enum_fallback', () { + late String types; + + setUp(() { + CodegenConfigContext.unknownEnumFallback = true; + types = TypeGenerator.generateTypesFile(_schema); + }); + + test(r'adds a $unknown value', () { + expect(types, contains(r' $unknown,' '\n}')); + }); + + test(r'the converter maps unknown values to $unknown', () { + expect(types, contains(r'default: return Role.$unknown;')); + }); + + test(r'the converter refuses to serialize $unknown', () { + expect(types, contains(r'case Role.$unknown: throw ArgumentError')); + }); + + test(r'enum lists fall back to $unknown', () { + expect(types, contains(r'unknownEnumValue: Role.$unknown')); + }); + }); +} diff --git a/test/generated_code_compiles_test.dart b/test/generated_code_compiles_test.dart index 06875fe..980d337 100644 --- a/test/generated_code_compiles_test.dart +++ b/test/generated_code_compiles_test.dart @@ -26,9 +26,13 @@ Map _generate( String schema, List documents, { required bool strict, + bool unknownEnumFallback = false, + bool deprecatedAnnotations = false, }) { CodegenConfigContext.reset(); CodegenConfigContext.strictNullability = strict; + CodegenConfigContext.unknownEnumFallback = unknownEnumFallback; + CodegenConfigContext.deprecatedAnnotations = deprecatedAnnotations; try { final types = TypeGenerator.generateTypesFile(schema); final files = {'lib/$dir/types.dart': types}; @@ -59,10 +63,16 @@ scalar DateTime interface Node { id: ID! } +""" +A person. +""" type User implements Node { + "Stable id." id: ID! name: String! + nick: String @deprecated(reason: "Use 'name'") role: Role! + roles: [Role!]! meta: JSON class: String } @@ -71,7 +81,7 @@ type Post implements Node { id: ID! title: String! } union SearchResult = User | Post -enum Role { ADMIN default } +enum Role { ADMIN "Old." default @deprecated } input UserFilter { role: Role, since: DateTime, tags: [String!] } @@ -93,7 +103,7 @@ const _smokeDocuments = [ r''' query Search($q: String!) { search(q: $q) { - ... on User { id name role meta class } + ... on User { id name nick role roles meta class } ... on Post { id title } } } @@ -151,7 +161,7 @@ const user = { final responses = >{ 'Search': { 'search': [ - {...user, 'meta': {'a': 1}, 'class': 'x'}, + {...user, 'nick': 'A', 'roles': ['ADMIN', 'default'], 'meta': {'a': 1}, 'class': 'x'}, {'__typename': 'Post', 'id': '2', 'title': 'Hello'}, {'__typename': 'Ad', 'id': '3'}, ], @@ -188,6 +198,9 @@ Future main() async { final first = results[0] as User; check(first.role == Role.default_, 'escaped enum value'); check(first.class_ == 'x', 'escaped field name'); + check(first.nick == 'A', 'deprecated field still parsed'); + check(first.roles.length == 2 && first.roles[1] == Role.default_, + 'escaped enum value in a list'); 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'); @@ -231,6 +244,37 @@ Future main() async { } '''; +const _fallbackMain = r''' +import 'package:fgc_compile_check/fallback/types.dart'; + +void check(bool condition, String message) { + if (!condition) throw StateError('Check failed: $message'); +} + +void main() { + final user = User.fromJson({ + 'id': '1', + 'name': 'Ann', + 'role': 'OWNER', + 'roles': ['ADMIN', 'OWNER', 'default'], + }); + check(user.role == Role.$unknown, 'unknown enum value'); + check(user.roles[0] == Role.ADMIN, 'known value in list'); + check(user.roles[1] == Role.$unknown, 'unknown value in list'); + // ignore: deprecated_member_use_from_same_package + check(user.roles[2] == Role.default_, 'escaped value in list'); + check(UserFilter(role: Role.ADMIN).toJson()['role'] == 'ADMIN', + 'known value serialized'); + try { + UserFilter(role: Role.$unknown).toJson(); + throw StateError(r'Check failed: $unknown must not be serialized'); + } on ArgumentError { + // expected + } + print('FALLBACK OK'); +} +'''; + void main() { test('generated code compiles and works', () async { final fixtureDir = p.join('test', 'fixtures'); @@ -249,11 +293,20 @@ void main() { ..._generate('legacy', schema, documents, strict: false), ..._generate('strict', schema, documents, strict: true), ..._generate('smoke', _smokeSchema, _smokeDocuments, strict: true), + ..._generate('fallback', _smokeSchema, _smokeDocuments, + strict: true, unknownEnumFallback: true, deprecatedAnnotations: true), 'bin/smoke.dart': _smokeMain, + 'bin/fallback.dart': _fallbackMain, }; - final result = - await compileGenerated(files, runScripts: ['bin/smoke.dart']); + final result = await compileGenerated( + files, + runScripts: ['bin/smoke.dart', 'bin/fallback.dart'], + // json_serializable's output uses deprecated members when they are + // annotated; that trade-off of deprecated_annotations is documented. + mustBeClean: (path) => + path.startsWith('lib/') && path != 'lib/fallback/types.g.dart', + ); expect(result.errors, isEmpty, reason: result.log); }); } diff --git a/test/schema_docs_test.dart b/test/schema_docs_test.dart new file mode 100644 index 0000000..8bca174 --- /dev/null +++ b/test/schema_docs_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_graphql_codegen/src/config_context.dart'; +import 'package:flutter_graphql_codegen/src/type_generator.dart'; +import 'package:test/test.dart'; + +const _schema = r''' +""" +A person using the app. + +Second paragraph. +""" +type User implements Node { + "Stable identifier." + id: ID! + name: String + nick: String @deprecated(reason: "Use `name`.") + login: String @deprecated +} + +"Anything with an id." +interface Node { + "Stable identifier." + id: ID! +} + +"What users can see." +enum Role { + "Full access." + ADMIN + GUEST @deprecated(reason: "Removed in v3") +} + +"Search hits." +union Hit = User + +"Filter for users." +input UserFilter { + "Exact name." + name: String + old: String @deprecated(reason: "Ignored") +} + +type Query { me: User } +'''; + +void main() { + late String types; + + setUpAll(() { + CodegenConfigContext.reset(); + types = TypeGenerator.generateTypesFile(_schema); + }); + + test('type descriptions become doc comments', () { + expect( + types, + contains('/// A person using the app.\n' + '///\n' + '/// Second paragraph.\n' + '@JsonSerializable(')); + expect(types, contains('/// Anything with an id.\nabstract class Node')); + expect(types, contains('/// What users can see.\nenum Role {')); + expect(types, contains('/// Search hits.\nabstract class Hit {')); + expect(types, contains('/// Filter for users.\n@JsonSerializable(')); + }); + + test('field and enum value descriptions become doc comments', () { + expect(types, contains(' /// Stable identifier.\n @override\n')); + expect(types, contains(' /// Stable identifier.\n String get id;')); + expect(types, contains(' /// Exact name.\n')); + expect(types, contains(' /// Full access.\n ADMIN,')); + }); + + test('@deprecated is documented by default, without annotations', () { + expect(types, isNot(contains('@Deprecated('))); + expect(types, contains(' /// Deprecated: Use `name`.\n')); + expect(types, contains(' /// Deprecated: No longer supported\n')); + expect( + types, + contains(' /// Full access.\n ADMIN,\n' + ' /// Deprecated: Removed in v3\n GUEST,')); + }); + + test('a deprecation follows the description in the doc comment', () { + final out = TypeGenerator.generateTypesFile(r''' +type Query { "Old field." a: String @deprecated(reason: "Gone") } +'''); + expect(out, contains(' /// Old field.\n ///\n /// Deprecated: Gone\n')); + }); + + test('types.dart ignores uses of its own deprecated members', () { + expect(types, contains('deprecated_member_use_from_same_package')); + }); + + group('with deprecated_annotations', () { + setUp(() => CodegenConfigContext.deprecatedAnnotations = true); + tearDown(CodegenConfigContext.reset); + + test('@deprecated becomes @Deprecated with the reason', () { + final out = TypeGenerator.generateTypesFile(_schema); + expect(out, contains(" @Deprecated('Use `name`.')\n")); + expect(out, contains(" @Deprecated('No longer supported')\n")); + expect(out, contains(" @Deprecated('Removed in v3')\n GUEST,")); + expect(out, contains(" @Deprecated('Ignored')\n")); + }); + + test('quotes in deprecation reasons are escaped', () { + final out = TypeGenerator.generateTypesFile(r''' +type Query { a: String @deprecated(reason: "Don't use $a\\b") } +'''); + expect(out, contains(r"@Deprecated('Don\'t use \$a\\b')")); + }); + }); +} diff --git a/test/support/compile_harness.dart b/test/support/compile_harness.dart index bb2f56a..1f72656 100644 --- a/test/support/compile_harness.dart +++ b/test/support/compile_harness.dart @@ -19,13 +19,18 @@ class CompileResult { /// Writes [files] (package-relative path -> content) into a fresh package, /// runs build_runner and dart analyze and returns analyzer errors. /// +/// Analyzer infos and warnings are reported as errors too for files where +/// [mustBeClean] returns true (by default everything under `lib/`). +/// /// 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 [], + bool Function(String path)? mustBeClean, }) async { + final clean = mustBeClean ?? (path) => path.startsWith('lib/'); final dir = workDir ?? await Directory.systemTemp.createTemp('fgc_compile_'); final log = StringBuffer(); @@ -73,12 +78,14 @@ dev_dependencies: } r = await run(['analyze', '--no-fatal-warnings', '.']); + final issue = RegExp(r'^(?:info|warning) - (\S+?):\d+'); final errors = [ ...buildErrors, - ...'${r.stdout}' - .split('\n') - .map((l) => l.trim()) - .where((l) => l.startsWith('error -')), + ...'${r.stdout}'.split('\n').map((l) => l.trim()).where((l) { + if (l.startsWith('error -')) return true; + final path = issue.firstMatch(l)?.group(1); + return path != null && clean(path.replaceAll(r'\', '/')); + }), ]; if (errors.isEmpty) {