diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dfe04c..7dc5a06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - dart-version: ["3.2.0", "stable"] + dart-version: ["3.4.0", "stable"] steps: - name: Checkout repository diff --git a/CHANGELOG.md b/CHANGELOG.md index e6229dd..3738adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 28d3948..94bb6e4 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 `_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 @@ -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 @@ -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 | @@ -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 diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 58d4960..3387c2e 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -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. @@ -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 @@ -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); @@ -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', @@ -404,6 +417,28 @@ $usage'''; return 'package:$package/${inLib.isEmpty ? '' : '$inLib/'}index.dart'; } + static Future _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 _writeTypesFile(String typesCode) async { if (typesCode == _lastWrittenTypes) return; @@ -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}'); } @@ -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 @@ -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); } diff --git a/lib/src/client_extension_generator.dart b/lib/src/client_extension_generator.dart index a62b99d..f4f39e1 100644 --- a/lib/src/client_extension_generator.dart +++ b/lib/src/client_extension_generator.dart @@ -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'; @@ -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, @@ -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': @@ -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; @@ -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)'; @@ -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)'; + return '$dartName.fromJson($v as Map)'; } return v; } diff --git a/lib/src/code_utils.dart b/lib/src/code_utils.dart index efb1be9..b57a4eb 100644 --- a/lib/src/code_utils.dart +++ b/lib/src/code_utils.dart @@ -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 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); } diff --git a/lib/src/config.dart b/lib/src/config.dart index 0f1f897..31bfcec 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -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 schemaHeaders; @@ -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({ @@ -49,6 +53,7 @@ class GraphQLCodegenConfig { this.diagnosticsFormat = 'plain', this.unknownEnumFallback = false, this.deprecatedAnnotations = false, + this.validateDocuments = true, Map? scalarMapping, Map? schemaHeaders, this.warnings = const [], @@ -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; @@ -153,6 +159,7 @@ class GraphQLCodegenConfig { diagnosticsFormat: diagnosticsFormat, unknownEnumFallback: unknownEnumFallback, deprecatedAnnotations: deprecatedAnnotations, + validateDocuments: validateDocuments, scalarMapping: scalarMappingMap, schemaHeaders: schemaHeaders, warnings: [ diff --git a/lib/src/config_context.dart b/lib/src/config_context.dart index 9139abe..4903601 100644 --- a/lib/src/config_context.dart +++ b/lib/src/config_context.dart @@ -9,6 +9,7 @@ class CodegenConfigContext { static Map scalarMapping = {}; 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() { @@ -19,5 +20,6 @@ class CodegenConfigContext { scalarMapping = {}; unknownEnumFallback = false; deprecatedAnnotations = false; + validateDocuments = true; } } diff --git a/lib/src/diagnostics.dart b/lib/src/diagnostics.dart index 47a33b8..29a6bb4 100644 --- a/lib/src/diagnostics.dart +++ b/lib/src/diagnostics.dart @@ -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'; } diff --git a/lib/src/document_validator.dart b/lib/src/document_validator.dart new file mode 100644 index 0000000..86fbd2b --- /dev/null +++ b/lib/src/document_validator.dart @@ -0,0 +1,350 @@ +/// Validation of operations against the schema. +library; + +import 'package:gql/ast.dart'; + +import 'code_utils.dart'; +import 'diagnostics.dart'; +import 'schema_analyzer.dart'; + +/// A problem found in an operation. +class ValidationProblem { + final String code; + final String message; + + const ValidationProblem(this.code, this.message); + + @override + String toString() => '[$code] $message'; +} + +/// Checks operations against a schema: fields, arguments, variables and +/// selection sets. +/// +/// Only definite errors are reported (documents a GraphQL server rejects); +/// argument value types and field merging are not checked. +class DocumentValidator { + DocumentValidator(DocumentNode schema) + : _schema = schema, + _types = { + for (final def in schema.definitions.whereType()) + def.name.value: def, + }; + + final DocumentNode _schema; + final Map _types; + + /// Returns the problems in [operation]; [fragments] are the fragment + /// definitions it may spread (missing ones are reported elsewhere). + List validate( + OperationDefinitionNode operation, + Map fragments, + ) { + final ObjectTypeDefinitionNode? root; + try { + root = SchemaAnalyzer.findRootType(_schema, operation.type.name); + } on StateError { + return const []; // reported by the generator + } + if (root == null) return const []; + + final run = _Run(this, operation, fragments); + run.selectionSet(operation.selectionSet, root, '', null); + run.checkVariables(); + return run.problems; + } + + /// Throws a [CodegenException] listing every problem in [operation]. + void check( + OperationDefinitionNode operation, + Map fragments, + ) { + final problems = validate(operation, fragments); + if (problems.isEmpty) return; + final name = operation.name?.value ?? ''; + throw CodegenException( + problems.first.code, + 'Invalid operation "$name":\n${problems.map((p) => ' $p').join('\n')}', + ); + } + + static bool _isComposite(TypeDefinitionNode? type) => + type is ObjectTypeDefinitionNode || + type is InterfaceTypeDefinitionNode || + type is UnionTypeDefinitionNode; + + static String _namedType(TypeNode type) => type is ListTypeNode + ? _namedType(type.type) + : (type as NamedTypeNode).name.value; +} + +/// State of validating one operation. +class _Run { + _Run(this.validator, this.operation, this.fragments); + + final DocumentValidator validator; + final OperationDefinitionNode operation; + final Map fragments; + final problems = []; + final _usedVariables = {}; + final _visitedFragments = {}; + + Map get _types => validator._types; + + void selectionSet(SelectionSetNode set, TypeDefinitionNode parent, + String path, String? fragment) { + for (final selection in set.selections) { + if (selection is FieldNode) { + _field(selection, parent, path, fragment); + } else if (selection is InlineFragmentNode) { + _directives(selection.directives); + final condition = selection.typeCondition?.on.name.value; + final target = condition == null + ? parent + : _fragmentType(condition, selection, path, fragment); + if (target != null) { + selectionSet(selection.selectionSet, target, path, fragment); + } + } else if (selection is FragmentSpreadNode) { + _directives(selection.directives); + final name = selection.name.value; + final definition = fragments[name]; + if (definition == null || !_visitedFragments.add(name)) continue; + _directives(definition.directives); + final target = _fragmentType( + definition.typeCondition.on.name.value, definition, '', name); + if (target != null) { + selectionSet(definition.selectionSet, target, '', name); + } + } + } + } + + void _field(FieldNode field, TypeDefinitionNode parent, String path, + String? fragment) { + final name = field.name.value; + final key = field.alias?.value ?? name; + final fieldPath = path.isEmpty ? key : '$path.$key'; + for (final argument in field.arguments) { + _variablesIn(argument.value); + } + _directives(field.directives); + + final parentName = parent.name.value; + if (name == '__typename') { + if (field.selectionSet != null) { + _report( + CodegenErrorCodes.buildInvalidSelection, + 'Field "__typename" cannot have a selection set', + field, + fieldPath, + fragment); + } + return; + } + // Introspection fields: their types are not part of the SDL. + if (name == '__schema' || name == '__type') return; + + if (parent is UnionTypeDefinitionNode) { + _report( + CodegenErrorCodes.buildUnknownField, + 'Field "$name" cannot be selected on union "$parentName"; use an ' + 'inline fragment (... on Type)', + field, + fieldPath, + fragment); + return; + } + final fields = parent is ObjectTypeDefinitionNode + ? parent.fields + : (parent as InterfaceTypeDefinitionNode).fields; + final definition = fields.where((f) => f.name.value == name).firstOrNull; + if (definition == null) { + _report( + CodegenErrorCodes.buildUnknownField, + 'Field "$name" does not exist on type "$parentName"', + field, + fieldPath, + fragment); + return; + } + + _arguments(field, definition, '$parentName.$name', fieldPath, fragment); + + final typeName = DocumentValidator._namedType(definition.type); + final type = _types[typeName]; + if (DocumentValidator._isComposite(type)) { + final set = field.selectionSet; + if (set == null) { + _report( + CodegenErrorCodes.buildInvalidSelection, + 'Field "$name" of type "$typeName" needs a selection set', + field, + fieldPath, + fragment); + } else { + selectionSet(set, type!, fieldPath, fragment); + } + } else if (field.selectionSet != null) { + _report( + CodegenErrorCodes.buildInvalidSelection, + 'Field "$name" of type "$typeName" cannot have a selection set', + field, + fieldPath, + fragment); + } + } + + void _arguments(FieldNode field, FieldDefinitionNode definition, + String fieldName, String path, String? fragment) { + final declared = {for (final a in definition.args) a.name.value: a}; + final given = {}; + for (final argument in field.arguments) { + final name = argument.name.value; + given.add(name); + if (!declared.containsKey(name)) { + _report( + CodegenErrorCodes.buildInvalidArgument, + 'Unknown argument "$name" on field "$fieldName"', + argument, + path, + fragment); + } + } + for (final arg in definition.args) { + final required = arg.type.isNonNull && arg.defaultValue == null; + if (required && !given.contains(arg.name.value)) { + _report( + CodegenErrorCodes.buildInvalidArgument, + 'Missing required argument "${arg.name.value}" on field ' + '"$fieldName"', + field, + path, + fragment); + } + } + } + + TypeDefinitionNode? _fragmentType( + String typeName, Node node, String path, String? fragment) { + final type = _types[typeName]; + if (type == null) { + _report( + CodegenErrorCodes.buildInvalidSelection, + 'Fragment type "$typeName" does not exist in the schema', + node, + path, + fragment); + return null; + } + if (!DocumentValidator._isComposite(type)) { + _report( + CodegenErrorCodes.buildInvalidSelection, + 'Fragment on "$typeName" is not allowed: fragments need an object, ' + 'interface or union type', + node, + path, + fragment); + return null; + } + return type; + } + + void _directives(List directives) { + for (final directive in directives) { + for (final argument in directive.arguments) { + _variablesIn(argument.value); + } + } + } + + void _variablesIn(ValueNode value) { + if (value is VariableNode) { + _usedVariables.putIfAbsent(value.name.value, () => value); + } else if (value is ListValueNode) { + value.values.forEach(_variablesIn); + } else if (value is ObjectValueNode) { + for (final field in value.fields) { + _variablesIn(field.value); + } + } + } + + /// Reports undeclared, unused and wrongly typed variables. + void checkVariables() { + final declared = { + for (final d in operation.variableDefinitions) d.variable.name.value: d, + }; + for (final entry in _usedVariables.entries) { + if (!declared.containsKey(entry.key)) { + _report( + CodegenErrorCodes.buildInvalidVariable, + 'Variable "\$${entry.key}" is used but not declared by the ' + 'operation', + entry.value, + '', + null); + } + } + for (final entry in declared.entries) { + final name = entry.key; + final definition = entry.value; + if (!_usedVariables.containsKey(name)) { + _report( + CodegenErrorCodes.buildInvalidVariable, + 'Variable "\$$name" is declared but never used', + definition, + '', + null); + } + final typeName = DocumentValidator._namedType(definition.type); + if (GraphQLConstants.builtInScalars.contains(typeName)) continue; + final type = _types[typeName]; + if (type == null) { + _report( + CodegenErrorCodes.buildInvalidVariable, + 'Variable "\$$name" has unknown type "$typeName"', + definition, + '', + null); + } else if (type is! ScalarTypeDefinitionNode && + type is! EnumTypeDefinitionNode && + type is! InputObjectTypeDefinitionNode) { + _report( + CodegenErrorCodes.buildInvalidVariable, + 'Variable "\$$name" has type "$typeName", which is not an input ' + 'type', + definition, + '', + null); + } + } + } + + /// 1-based line of [node]; the gql parser only records spans of names. + static int? _line(Node node) { + final name = switch (node) { + FieldNode(:final alias, :final name) => alias ?? name, + ArgumentNode(:final name) => name, + FragmentSpreadNode(:final name) => name, + FragmentDefinitionNode(:final name) => name, + InlineFragmentNode(:final typeCondition) => typeCondition?.on.name, + VariableNode(:final name) => name, + VariableDefinitionNode(:final variable) => variable.name, + _ => null, + }; + final span = name?.span ?? node.span; + return span == null ? null : span.start.line + 1; + } + + void _report( + String code, String message, Node node, String path, String? fragment) { + final where = [ + fragment == null ? null : 'fragment $fragment', + if (path.isNotEmpty) 'path $path', + if (_line(node) case final line?) 'line $line', + ].whereType(); + problems.add(ValidationProblem( + code, where.isEmpty ? message : '$message (${where.join(', ')})')); + } +} diff --git a/lib/src/formatting.dart b/lib/src/formatting.dart new file mode 100644 index 0000000..726c640 --- /dev/null +++ b/lib/src/formatting.dart @@ -0,0 +1,17 @@ +/// Formatting of generated Dart code. +library; + +import 'package:dart_style/dart_style.dart'; +import 'package:pub_semver/pub_semver.dart'; + +/// Formats generated [code] with `dart format` rules for [languageVersion] +/// (the language version of the package the code is written into, so the +/// result matches what `dart format` produces there; defaults to the +/// formatter's latest). +/// +/// Throws [FormatterException] when [code] is not valid Dart. +String formatGeneratedCode(String code, {Version? languageVersion}) => + DartFormatter( + languageVersion: + languageVersion ?? DartFormatter.latestLanguageVersion) + .format(code); diff --git a/lib/src/naming.dart b/lib/src/naming.dart index 65a59c1..dc79ca6 100644 --- a/lib/src/naming.dart +++ b/lib/src/naming.dart @@ -34,6 +34,19 @@ const Set dartReservedWords = { 'switch', 'this', 'throw', 'true', 'try', 'var', 'void', 'while', 'with', }; +/// Names the generated code refers to without a prefix (dart:core and +/// json_annotation types, built-in helper classes). A schema type with one of +/// these names would shadow them, so its Dart name gets a trailing underscore. +const Set reservedTypeNames = { + 'Map', 'List', 'Set', 'String', 'Object', 'int', 'double', 'num', 'bool', // + 'dynamic', 'DateTime', 'Future', 'Stream', 'ArgumentError', 'StateError', + 'Deprecated', 'override', 'JsonConverter', 'JsonSerializable', 'JsonKey', + 'JsonValue', 'FieldRename', 'Decimal', 'Long', 'Byte', 'EnumConverter', + 'DecimalConverter', 'DateTimeConverter', 'LocalDateConverter', + 'LongConverter', 'ByteConverter', 'SafeStringConverter', 'SafeBoolConverter', + 'SafeIntConverter', 'SafeDoubleConverter', 'SafeDecimalConverter', +}; + /// Members every Dart object has; a generated field with one of these names /// would clash with (or accidentally override) them. const Set _objectMemberNames = { diff --git a/lib/src/schema_analyzer.dart b/lib/src/schema_analyzer.dart index 1713a39..ade0eaa 100644 --- a/lib/src/schema_analyzer.dart +++ b/lib/src/schema_analyzer.dart @@ -4,6 +4,7 @@ library; import 'package:gql/ast.dart'; import 'code_utils.dart'; import 'config_context.dart'; +import 'naming.dart'; /// Utility class for analyzing GraphQL schemas class SchemaAnalyzer { @@ -204,24 +205,30 @@ class SchemaAnalyzer { } } + /// The Dart name of a type the generator declares for schema type + /// [graphqlName]: names in [reservedTypeNames] get a trailing underscore. + static String dartTypeName(String graphqlName) => + TypeRegistry.declaresType(graphqlName) && + reservedTypeNames.contains(graphqlName) + ? '${graphqlName}_' + : graphqlName; + /// Gets the Dart type from a TypeNode static String getDartType(TypeNode type) { if (type is NamedTypeNode) { 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?'; + final String mapped; + if (TypeRegistry.objectTypes.contains(original)) { + mapped = dartTypeName(original); + } else { + // Allow scalar mapping override from config + mapped = CodegenConfigContext.scalarMapping[original] ?? + (TypeRegistry.declaresType(original) + ? dartTypeName(original) + : GraphQLConstants.scalarToDartType[original] ?? original); } - - // For object types: we still respect NonNull, but mapping keeps class name - return type.isNonNull ? original : '$original?'; + // Respect schema NonNull exactly + return type.isNonNull ? mapped : '$mapped?'; } else if (type is ListTypeNode) { // Compute element Dart type first (it will include '?' if nullable) final innerType = getDartType(type.type); diff --git a/lib/src/type_generator.dart b/lib/src/type_generator.dart index 2fbbc27..459b77d 100644 --- a/lib/src/type_generator.dart +++ b/lib/src/type_generator.dart @@ -290,33 +290,56 @@ $enumConverters $typeDefinitions +${_generatePossibleTypes()} + // Helper functions for list conversions ${_generateListConverterHelpers(TypeRegistry.customScalars)} '''; } + /// The concrete object types of every interface and union, in the shape + /// `GraphQLCache(possibleTypes: ...)` expects for matching fragments. + static String _generatePossibleTypes() { + final abstractTypes = { + ...TypeRegistry.interfaceTypes, + ...TypeRegistry.unionTypes, + }.toList() + ..sort(); + final buffer = StringBuffer() + ..writeln('/// Concrete types of every interface and union, for ' + '`GraphQLCache(possibleTypes: possibleTypes)`.'); + if (abstractTypes.isEmpty) { + buffer.write('const Map> possibleTypes = {};'); + return buffer.toString(); + } + buffer.writeln('const Map> possibleTypes = {'); + for (final name in abstractTypes) { + final members = TypeRegistry.possibleTypes(name).map((t) => "'$t'"); + buffer.writeln(members.isEmpty + ? " '$name': {}," + : " '$name': {${members.join(', ')}},"); + } + buffer.write('};'); + return buffer.toString(); + } + /// 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'); + customScalars.where(TypeRegistry.isWrappedScalar); /// Generates scalar converters for custom scalar types static String _generateScalarConverters(Set customScalars) { final buffer = StringBuffer(); for (final scalar in _wrappedScalars(customScalars)) { + final name = SchemaAnalyzer.dartTypeName(scalar); buffer.writeln(''' /// Wrapper for the custom GraphQL scalar `$scalar`. /// /// [raw] holds the JSON value exactly as received (string, number, map, ...). -class $scalar { +class $name { final Object? raw; - const $scalar(this.raw); + const $name(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); @@ -325,20 +348,20 @@ class $scalar { String toString() => value; @override - bool operator ==(Object other) => other is $scalar && other.value == value; + bool operator ==(Object other) => other is $name && other.value == value; @override int get hashCode => value.hashCode; } -class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { - const ${scalar}Converter(); +class ${name}Converter implements JsonConverter<$name, dynamic> { + const ${name}Converter(); @override - $scalar fromJson(dynamic json) => $scalar(json); + $name fromJson(dynamic json) => $name(json); @override - dynamic toJson($scalar object) => object.raw; + dynamic toJson($name object) => object.raw; } '''); } @@ -351,7 +374,7 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { for (final definition in _sortedDefinitions(schemaDoc)) { if (definition is EnumTypeDefinitionNode) { - final enumName = definition.name.value; + final enumName = SchemaAnalyzer.dartTypeName(definition.name.value); buffer.write(_docComment(definition.description)); buffer.writeln('enum $enumName {'); // Sort enum values for determinism @@ -387,7 +410,7 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { for (final definition in _sortedDefinitions(schemaDoc)) { if (definition is EnumTypeDefinitionNode) { - final enumName = definition.name.value; + final enumName = SchemaAnalyzer.dartTypeName(definition.name.value); final values = _sortedEnumValues(definition); buffer.writeln( 'class ${enumName}Converter extends JsonConverter<$enumName, String> {'); @@ -501,10 +524,10 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { Map interfaces = const {}]) { final typeName = typeNode.name.value; return _generateJsonClass( - className: typeName, + className: SchemaAnalyzer.dartTypeName(typeName), description: typeNode.description, fields: typeNode.fields.map(_fieldOf).toList(), - supertypes: TypeRegistry.supertypes(typeName), + supertypes: _dartSupertypes(typeName), overriddenFields: _inheritedFieldNames(typeName, interfaces), ); } @@ -514,7 +537,7 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { InputObjectTypeDefinitionNode inputNode, [String schema = '']) { return _generateJsonClass( - className: inputNode.name.value, + className: SchemaAnalyzer.dartTypeName(inputNode.name.value), description: inputNode.description, fields: inputNode.fields.map(_inputFieldOf).toList(), ); @@ -526,16 +549,17 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { InterfaceTypeDefinitionNode node, Map interfaces, ) { - final name = node.name.value; - final supertypes = TypeRegistry.supertypes(name); - final inherited = _inheritedFieldNames(name, interfaces); - final unknownName = 'Unknown$name'; + final graphqlName = node.name.value; + final name = SchemaAnalyzer.dartTypeName(graphqlName); + final supertypes = _dartSupertypes(graphqlName); + final inherited = _inheritedFieldNames(graphqlName, interfaces); + final unknownName = 'Unknown$graphqlName'; final sortedFields = [...node.fields] ..sort((a, b) => a.name.value.compareTo(b.name.value)); final buffer = StringBuffer(); - buffer.write( - _docComment(node.description, fallback: 'GraphQL interface `$name`.')); + buffer.write(_docComment(node.description, + fallback: 'GraphQL interface `$graphqlName`.')); final impl = supertypes.isEmpty ? '' : ' implements ${supertypes.join(', ')}'; buffer.writeln('abstract class $name$impl {'); @@ -552,7 +576,7 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { buffer.writeln( ' /// Creates the concrete implementation matching `__typename`.'); buffer.writeln(' factory $name.fromJson(Map json) {'); - buffer.write(_typenameDispatch(name, '$unknownName.fromJson(json)')); + buffer.write(_typenameDispatch(graphqlName, '$unknownName.fromJson(json)')); buffer.writeln(' }'); buffer.writeln(); if (supertypes.isNotEmpty) buffer.writeln(' @override'); @@ -561,7 +585,8 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { buffer.writeln(); buffer.writeln( - '/// Fallback for `$name` implementations unknown at generation time.'); + '/// Fallback for `$graphqlName` implementations unknown at generation ' + 'time.'); buffer.write(_generateJsonClass( className: unknownName, fields: node.fields.map(_fieldOf).toList(), @@ -575,18 +600,19 @@ class ${scalar}Converter implements JsonConverter<$scalar, dynamic> { /// 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'; + final graphqlName = node.name.value; + final name = SchemaAnalyzer.dartTypeName(graphqlName); + final unknownName = 'Unknown$graphqlName'; return ''' -${_docComment(node.description, fallback: 'GraphQL union `$name`.')}abstract class $name { +${_docComment(node.description, fallback: 'GraphQL union `$graphqlName`.')}abstract class $name { /// Creates the concrete member type matching `__typename`. factory $name.fromJson(Map json) { -${_typenameDispatch(name, '$unknownName(json)')} } +${_typenameDispatch(graphqlName, '$unknownName(json)')} } Map toJson(); } -/// Fallback for `$name` members unknown at generation time. +/// Fallback for `$graphqlName` members unknown at generation time. class $unknownName implements $name { const $unknownName(this.json); @@ -601,12 +627,19 @@ class $unknownName implements $name { '''; } + /// Dart names of the interfaces and unions [typeName] implements. + static List _dartSupertypes(String typeName) => + TypeRegistry.supertypes(typeName) + .map(SchemaAnalyzer.dartTypeName) + .toList(); + 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( + ' return ${SchemaAnalyzer.dartTypeName(concrete)}.fromJson(json);'); } buffer.writeln(' default:'); buffer.writeln(' return $fallback;'); @@ -746,6 +779,8 @@ class $unknownName implements $name { .replaceAll('>', ''); final isListField = fieldType.startsWith('List<'); final isNullable = fieldType.endsWith('?'); + // Registry lookups use the GraphQL name; Dart names may be escaped. + final graphqlBase = getOriginalGraphQLType(type); // Add @JsonKey annotation for field name preservation so json_serializable // never renames GraphQL fields (and escaped Dart names map back). @@ -763,10 +798,7 @@ class $unknownName implements $name { 5, fieldType.length - (fieldType.endsWith('>?') ? 2 : 1)); final cleanInnerType = innerType.replaceAll('?', ''); - if (TypeRegistry.isCustomScalar(cleanInnerType) && - cleanInnerType != 'Decimal' && - cleanInnerType != 'Short' && - cleanInnerType != 'Byte') { + if (TypeRegistry.isWrappedScalar(graphqlBase)) { // For lists of custom scalars, use JsonKey with converter final helper = cleanInnerType.toLowerCase(); return [ @@ -799,7 +831,7 @@ class $unknownName implements $name { } // Regular lists (enums, objects, built-in scalars): default value only final unknownArg = CodegenConfigContext.unknownEnumFallback && - TypeRegistry.isEnum(cleanInnerType) + TypeRegistry.isEnum(graphqlBase) ? ', unknownEnumValue: $cleanInnerType.\$unknown' : ''; return [ @@ -817,14 +849,8 @@ class $unknownName implements $name { 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') { + if (TypeRegistry.isEnum(graphqlBase) || + TypeRegistry.isWrappedScalar(graphqlBase)) { return withName(' @${baseType}Converter()'); } // Safe converters for non-nullable primitives @@ -1010,14 +1036,15 @@ class $unknownName implements $name { // Generate helpers for each custom scalar type for (final scalar in _wrappedScalars(customScalars)) { + final name = SchemaAnalyzer.dartTypeName(scalar); 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)).toList(); +List<$name> _${name.toLowerCase()}ListFromJson(List? json) { + if (json == null) return const []; + return json.map((item) => $name(item)).toList(); } -List? _${scalar.toLowerCase()}ListToJson(List<$scalar>? list) { +List? _${name.toLowerCase()}ListToJson(List<$name>? list) { if (list == null) return null; return list.map((item) => item.raw).toList(); } @@ -1027,8 +1054,8 @@ List? _${scalar.toLowerCase()}ListToJson(List<$scalar>? list) { // Helper for DateTime lists buffer.writeln(''' // Helper functions for DateTime list conversion -List? _dateTimeListFromJson(List? json) { - if (json == null) return null; +List _dateTimeListFromJson(List? json) { + if (json == null) return const []; return json.map((item) => DateTime.parse(item.toString())).toList(); } @@ -1039,8 +1066,8 @@ List? _dateTimeListToJson(List? list) { } // Helper functions for LocalDate list conversion -List? _localDateListFromJson(List? json) { - if (json == null) return null; +List _localDateListFromJson(List? json) { + if (json == null) return const []; return json.map((item) => DateTime.parse(item.toString())).toList(); } @@ -1053,8 +1080,8 @@ List? _localDateListToJson(List? list) { // Helper for Decimal lists buffer.writeln(''' // Helper functions for Decimal list conversion -List? _decimalListFromJson(List? json) { - if (json == null) return null; +List _decimalListFromJson(List? json) { + if (json == null) return const []; return json.map((item) => Decimal(item.toString())).toList(); } diff --git a/pubspec.yaml b/pubspec.yaml index ccdbfa5..9e4fefe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,12 +14,14 @@ topics: - type-safety environment: - sdk: ^3.2.0 + sdk: ^3.4.0 # Add regular dependencies here. dependencies: path: ^1.8.1 - build: ^2.0.0 + build: ^2.4.0 + dart_style: ">=2.3.7 <4.0.0" + pub_semver: ^2.1.0 yaml: ^3.1.0 http: ^1.1.2 graphql: ^5.1.3 diff --git a/test/builder_test.dart b/test/builder_test.dart index fc6f023..a650349 100644 --- a/test/builder_test.dart +++ b/test/builder_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:build_test/build_test.dart'; +import 'package:dart_style/dart_style.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'; @@ -89,6 +90,24 @@ query GetVersion { version } expect(index, contains("export 'rename_graphql.g.dart';")); }); + test('writes dart-formatted files', () async { + await run({ + 'a|lib/graphql/a.graphql': r''' +query GetUser($id: ID!) { user(id: $id) { id name } } +''', + }); + final formatter = + DartFormatter(languageVersion: DartFormatter.latestLanguageVersion); + for (final name in [ + 'types.dart', + 'get_user_graphql.g.dart', + 'index.dart', + ]) { + final code = output(name)!; + expect(formatter.format(code), code, reason: '$name is not formatted'); + } + }); + test('skips schema and fragment-only documents', () async { await run({ 'a|lib/graphql/fragments.graphql': 'fragment UserBits on User { id }', diff --git a/test/config_options_test.dart b/test/config_options_test.dart index b620248..c3a9452 100644 --- a/test/config_options_test.dart +++ b/test/config_options_test.dart @@ -65,6 +65,7 @@ schema_headers: {} diagnostics_format: plain unknown_enum_fallback: true deprecated_annotations: true +validate_documents: true '''); expect(cfg.warnings, isEmpty); }); diff --git a/test/document_validation_test.dart b/test/document_validation_test.dart new file mode 100644 index 0000000..786106e --- /dev/null +++ b/test/document_validation_test.dart @@ -0,0 +1,216 @@ +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: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:test/test.dart'; + +const _schema = r''' +scalar DateTime +interface Node { id: ID! } +type User implements Node { + id: ID! + name: String + posts(first: Int = 10, after: String): [Post!]! + avatar(size: Int!): String +} +type Post implements Node { id: ID! title: String! author: User! } +union Hit = User | Post +enum Order { ASC DESC } +input PostFilter { author: ID, since: DateTime, order: Order } +type Query { + me: User + node(id: ID!): Node + search(term: String!): [Hit!]! + posts(filter: PostFilter, order: Order): [Post!]! +} +type Mutation { rename(id: ID!, name: String!): User } +'''; + +/// Generates [operation] from [document]; returns the error or null. +CodegenException? _generate(String document, {String? operation}) { + final types = TypeGenerator.generateTypesFile(_schema); + final doc = gql_lang.parseString(document); + final name = operation ?? + doc.definitions.whereType().first.name!.value; + try { + GraphQLCodeGenerator.generateOperationFile( + _schema, + document, + name, + 'query', + types, + fragments: DocumentUtils.collectFragments([doc]), + ); + return null; + } on CodegenException catch (e) { + return e; + } +} + +Matcher _problem(String code, List parts) => + isA().having((e) => e.message, 'message', + allOf([code, ...parts].map(contains).toList())); + +void main() { + setUp(CodegenConfigContext.reset); + tearDown(CodegenConfigContext.reset); + + test('accepts a valid document using every kind of selection', () { + expect( + _generate(r''' +query Everything($id: ID!, $term: String!, $withName: Boolean!, $f: PostFilter) { + __typename + me { ...UserBits avatar(size: 32) } + node(id: $id) { id ... on User { name @include(if: $withName) } } + search(term: $term) { + __typename + ... on User { id } + ... on Post { title author { id } } + } + latest: posts(filter: $f, order: DESC) { id } +} +fragment UserBits on User { id posts { id } } +'''), + isNull, + ); + }); + + group('BLD103 unknown fields', () { + test('are reported at any depth with path and line', () { + expect( + _generate(''' +query Q { + me { + posts { titel } + } +} +'''), + _problem('BLD103', ['"titel"', '"Post"', 'me.posts.titel', 'line 3']), + ); + }); + + test('are reported inside fragments', () { + expect( + _generate('query Q { me { ...Bits } }\nfragment Bits on User { nope }'), + _problem('BLD103', ['"nope"', '"User"', 'fragment Bits']), + ); + }); + + test('include plain fields selected on a union', () { + expect( + _generate(r'query Q($t: String!) { search(term: $t) { id } }'), + _problem('BLD103', ['"id"', 'union "Hit"']), + ); + }); + }); + + group('BLD105 arguments', () { + test('unknown arguments are reported', () { + expect( + _generate('query Q { me { posts(last: 1) { id } } }'), + _problem('BLD105', ['"last"', 'User.posts']), + ); + }); + + test('missing required arguments are reported', () { + expect( + _generate('query Q { me { avatar } }'), + _problem('BLD105', ['"size"', 'User.avatar', 'required']), + ); + }); + }); + + group('BLD106 variables', () { + test('undeclared variables are reported, also in nested values', () { + expect( + _generate(r'query Q { posts(filter: {author: $who}) { id } }'), + _problem('BLD106', [r'$who', 'not declared']), + ); + expect( + _generate(r'query Q { me { name @skip(if: $hide) } }'), + _problem('BLD106', [r'$hide', 'not declared']), + ); + }); + + test('unused variables are reported', () { + expect( + _generate(r'query Q($id: ID!) { me { id } }'), + _problem('BLD106', [r'$id', 'never used']), + ); + }); + + test('variables used only in a fragment count as used', () { + expect( + _generate(r''' +query Q($size: Int!) { me { ...Bits } } +fragment Bits on User { avatar(size: $size) } +'''), + isNull, + ); + }); + + test('variables of unknown or output types are reported', () { + expect( + _generate(r'query Q($u: Usr) { node(id: $u) { id } }'), + _problem('BLD106', [r'$u', '"Usr"']), + ); + expect( + _generate(r'query Q($u: User) { node(id: $u) { id } }'), + _problem('BLD106', [r'$u', '"User"', 'input type']), + ); + }); + }); + + group('BLD107 selections', () { + test('a scalar with a selection set is reported', () { + expect( + _generate('query Q { me { name { x } } }'), + _problem('BLD107', ['"name"', 'String']), + ); + }); + + test('an object without a selection set is reported', () { + expect( + _generate('query Q { me }'), + _problem('BLD107', ['"me"', 'User']), + ); + }); + + test('fragments on unknown or leaf types are reported', () { + expect( + _generate('query Q { me { ... on Usr { id } } }'), + _problem('BLD107', ['"Usr"']), + ); + expect( + _generate('query Q { me { ...Bits } }\nfragment Bits on Order { x }'), + _problem('BLD107', ['"Order"', 'fragment Bits']), + ); + }); + }); + + test('all problems of a document are reported together', () { + final error = _generate(r'query Q($x: Int) { me { nope avatar } }'); + expect(error, _problem('BLD103', ['nope'])); + expect(error!.message, contains('BLD105')); + expect(error.message, contains('BLD106')); + }); + + test('validate_documents: false skips validation', () { + CodegenConfigContext.validateDocuments = false; + expect(_generate('query Q { me { nope } }'), isNull); + }); + + test('validate_documents is on by default and read from config', () { + const base = 'schema_url: s.graphql\ndocument_paths: [a.graphql]\n'; + expect(GraphQLCodegenConfig.fromYaml(base).validateDocuments, isTrue); + expect( + GraphQLCodegenConfig.fromYaml('${base}validate_documents: false\n') + .validateDocuments, + isFalse, + ); + }); +} diff --git a/test/formatting_test.dart b/test/formatting_test.dart new file mode 100644 index 0000000..5b1e13a --- /dev/null +++ b/test/formatting_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_graphql_codegen/src/formatting.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +void main() { + test('formats generated code', () { + expect(formatGeneratedCode('class A{int x=1;}'), + 'class A {\n int x = 1;\n}\n'); + }); + + test('a language version newer than the formatter knows is accepted', () { + expect( + formatGeneratedCode('class A{}', languageVersion: Version(99, 0, 0)), + 'class A {}\n'); + }); +} diff --git a/test/generated_code_compiles_test.dart b/test/generated_code_compiles_test.dart index 980d337..6f309d0 100644 --- a/test/generated_code_compiles_test.dart +++ b/test/generated_code_compiles_test.dart @@ -183,7 +183,7 @@ void check(bool condition, String message) { Future main() async { final client = GraphQLClient( link: FakeLink(), - cache: GraphQLCache(), + cache: GraphQLCache(possibleTypes: possibleTypes), defaultPolicies: DefaultPolicies( query: Policies(fetch: FetchPolicy.noCache), mutate: Policies(fetch: FetchPolicy.noCache), @@ -191,6 +191,9 @@ Future main() async { ), ); + check(possibleTypes['SearchResult']!.containsAll(['Post', 'User']), + 'possibleTypes lists union members'); + // Unions dispatch on __typename, unknown members fall back. final results = await client.searchData( const SearchVariables(q: 'a').toJson()); @@ -275,6 +278,81 @@ void main() { } '''; +// Schema types named like identifiers the generated code relies on. +const _collideSchema = r''' +scalar Object +scalar DateTime +type Map { key: String! tags: [Object!]! at: [DateTime!]! } +type Decimal { amount: String! } +type User { id: ID! } +union Set = Map | User +enum Future { NOW LATER } +input Stream { from: Future } +type Query { + maps(filter: Stream): [Map!]! + obj: Object + set: [Set!]! + when: Future! + decimal: Decimal +} +type Subscription { ticks: Map! } +'''; + +const _collideDocuments = [ + r''' +query Everything($s: Stream) { + maps(filter: $s) { key tags at } + obj + set { ... on Map { key } ... on User { id } } + when + decimal { amount } +} +subscription Ticks { ticks { key } } +''', +]; + +const _collideMain = r''' +import 'package:fgc_compile_check/collide/Everything.g.dart'; +import 'package:fgc_compile_check/collide/types.dart'; + +void check(bool condition, String message) { + if (!condition) throw StateError('Check failed: $message'); +} + +void main() { + final data = parseEverythingData({ + 'maps': [ + { + '__typename': 'Map', + 'key': 'k', + 'tags': ['a', 1], + 'at': ['2024-01-01T00:00:00Z'], + }, + ], + 'obj': {'any': 'thing'}, + 'set': [ + {'__typename': 'Map', 'key': 'm'}, + {'__typename': 'User', 'id': '1'}, + ], + 'when': 'LATER', + 'decimal': {'__typename': 'Decimal', 'amount': '1.5'}, + }); + check(data.maps.single.key == 'k', 'Map_ parsed'); + check(data.maps.single.tags[1].raw == 1, 'Object_ list parsed'); + check(data.maps.single.at.single.year == 2024, 'DateTime list parsed'); + check(data.obj!.raw is Map, 'Object_ scalar keeps the raw JSON'); + check(data.set[0] is Map_ && data.set[1] is User, 'Set_ dispatch'); + check(data.when == Future_.LATER, 'Future_ enum parsed'); + check(data.decimal!.amount == '1.5', 'Decimal_ object parsed'); + check(Decimal('2.50').value == '2.50', 'built-in Decimal still there'); + check(EverythingVariables(s: Stream_(from: Future_.NOW)) + .toJson()['s']['from'] == 'NOW', + 'Stream_ input serialized'); + check(possibleTypes['Set']!.contains('Map'), 'GraphQL names in possibleTypes'); + print('COLLIDE OK'); +} +'''; + void main() { test('generated code compiles and works', () async { final fixtureDir = p.join('test', 'fixtures'); @@ -295,13 +373,15 @@ void main() { ..._generate('smoke', _smokeSchema, _smokeDocuments, strict: true), ..._generate('fallback', _smokeSchema, _smokeDocuments, strict: true, unknownEnumFallback: true, deprecatedAnnotations: true), + ..._generate('collide', _collideSchema, _collideDocuments, strict: true), 'bin/smoke.dart': _smokeMain, 'bin/fallback.dart': _fallbackMain, + 'bin/collide.dart': _collideMain, }; final result = await compileGenerated( files, - runScripts: ['bin/smoke.dart', 'bin/fallback.dart'], + runScripts: ['bin/smoke.dart', 'bin/fallback.dart', 'bin/collide.dart'], // json_serializable's output uses deprecated members when they are // annotated; that trade-off of deprecated_annotations is documented. mustBeClean: (path) => diff --git a/test/local_date_converter_test.dart b/test/local_date_converter_test.dart index 1d320aa..51bd62b 100644 --- a/test/local_date_converter_test.dart +++ b/test/local_date_converter_test.dart @@ -151,7 +151,7 @@ void main() { expect( generatedTypes, contains( - 'List? _localDateListFromJson(List? json)')); + 'List _localDateListFromJson(List? json)')); expect(generatedTypes, contains('List? _localDateListToJson(List? list)')); diff --git a/test/possible_types_test.dart b/test/possible_types_test.dart new file mode 100644 index 0000000..6555863 --- /dev/null +++ b/test/possible_types_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_graphql_codegen/src/config_context.dart'; +import 'package:flutter_graphql_codegen/src/type_generator.dart'; +import 'package:test/test.dart'; + +void main() { + setUp(CodegenConfigContext.reset); + + test('lists the concrete types of every interface and union', () { + final types = TypeGenerator.generateTypesFile(''' +interface Node { id: ID! } +type User implements Node { id: ID! } +type Post implements Node { id: ID! } +type Ad { id: ID! } +union Hit = Post | Ad | User +type Query { node: Node } +'''); + expect( + types, + contains('const Map> possibleTypes = {\n' + " 'Hit': {'Ad', 'Post', 'User'},\n" + " 'Node': {'Post', 'User'},\n" + '};')); + }); + + test('is empty when the schema has no abstract types', () { + final types = TypeGenerator.generateTypesFile('type Query { a: String }'); + expect( + types, contains('const Map> possibleTypes = {};')); + }); +} diff --git a/test/support/compile_harness.dart b/test/support/compile_harness.dart index 1f72656..5c5a3e9 100644 --- a/test/support/compile_harness.dart +++ b/test/support/compile_harness.dart @@ -38,7 +38,7 @@ Future compileGenerated( name: fgc_compile_check publish_to: none environment: - sdk: ^3.2.0 + sdk: ^3.4.0 dependencies: graphql: ^5.1.3 json_annotation: ^4.8.1 diff --git a/test/type_name_collision_test.dart b/test/type_name_collision_test.dart new file mode 100644 index 0000000..6598263 --- /dev/null +++ b/test/type_name_collision_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_graphql_codegen/src/config_context.dart'; +import 'package:flutter_graphql_codegen/src/generator.dart'; +import 'package:flutter_graphql_codegen/src/type_generator.dart'; +import 'package:test/test.dart'; + +// Schema types named like identifiers the generated code uses (dart:core, +// json_annotation, built-in helpers) get a trailing underscore in Dart. +const _schema = r''' +scalar Object +type Map { key: String! } +type Decimal { amount: String! } +type User { id: ID! } +union Set = Map | User +enum Future { NOW LATER } +input Stream { from: Future } +type Query { + map: Map + maps(filter: Stream): [Map!]! + obj: Object + objs: [Object!]! + set: Set + when: Future + user: User +} +'''; + +void main() { + late String types; + + setUpAll(() { + CodegenConfigContext.reset(); + types = TypeGenerator.generateTypesFile(_schema); + }); + + test('object, input, enum and union types are escaped', () { + expect(types, contains('class Map_ implements Set_ {')); + expect(types, contains('factory Map_.fromJson(')); + expect(types, contains('class Decimal_ {')); + expect(types, contains('abstract class Set_ {')); + expect(types, contains('class UnknownSet implements Set_ {')); + expect(types, contains('enum Future_ {')); + expect(types, contains('class Future_Converter ')); + expect(types, contains('class Stream_ {')); + }); + + test('fields refer to the escaped names', () { + expect(types, contains(' Map_? map;')); + expect(types, contains(' Set_? set;')); + expect(types, contains(' @Future_Converter()\n Future_? when;')); + expect(types, contains(' @Future_Converter()\n Future_? from;')); + }); + + test('custom scalar wrappers are escaped', () { + expect(types, contains('class Object_ {')); + expect(types, contains('class Object_Converter ')); + expect(types, contains(' @Object_Converter()\n Object_? obj;')); + expect(types, contains('_object_ListFromJson')); + }); + + test('__typename dispatch and possibleTypes keep the GraphQL names', () { + expect(types, contains("case 'Map':\n return Map_.fromJson(json);")); + expect(types, contains("'Set': {'Map', 'User'},")); + }); + + test('ordinary names are left alone', () { + expect(types, contains('class User implements Set_ {')); + }); + + test('built-in scalars are not escaped', () { + final out = TypeGenerator.generateTypesFile(''' +scalar Decimal +scalar Long +type Price { value: Decimal! cents: Long } +type Query { price: Price } +'''); + expect(out, contains('class Decimal {')); + expect(out, contains(' @SafeDecimalConverter()\n Decimal value;')); + expect(out, contains(' @LongConverter()\n Long? cents;')); + expect(out, isNot(contains('Decimal_'))); + }); + + test('operation files use the escaped names', () { + final code = GraphQLCodeGenerator.generateOperationFile( + _schema, + r'query GetMap($f: Stream) { maps(filter: $f) { key } when }', + 'GetMap', + 'query', + types); + expect(code, contains('final Stream_? f;')); + expect(code, contains('Map_.fromJson(')); + expect(code, contains('const Future_Converter().fromJson(')); + }); +}