From 941b955f83ca88d349cade960d7bd044856250fb Mon Sep 17 00:00:00 2001 From: Mark Shvabenlandt <17984008+ifmelate@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:08:06 +0300 Subject: [PATCH 1/6] feat: format generated files with dart_style Files are formatted for the language version of the package being built, so they pass `dart format --set-exit-if-changed` in user projects. --- lib/src/builder.dart | 41 ++++++++++++++++++++++++++++++++++++--- lib/src/formatting.dart | 17 ++++++++++++++++ pubspec.yaml | 4 +++- test/builder_test.dart | 19 ++++++++++++++++++ test/formatting_test.dart | 16 +++++++++++++++ 5 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 lib/src/formatting.dart create mode 100644 test/formatting_test.dart diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 58d4960..7c4170f 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 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/pubspec.yaml b/pubspec.yaml index ccdbfa5..13290b6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,9 @@ environment: # 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/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'); + }); +} From 9801133386be5d98e9aecf3d65f88f9b1b4a9e31 Mon Sep 17 00:00:00 2001 From: Mark Shvabenlandt <17984008+ifmelate@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:08:06 +0300 Subject: [PATCH 2/6] feat: possibleTypes map, escape schema type names that shadow Dart names - types.dart exports `possibleTypes` for GraphQLCache(possibleTypes: ...) - Schema types named like identifiers the generated code uses (Map, String, Object, Future, Set, JsonKey, Decimal, ...) get a trailing underscore in Dart; __typename dispatch and possibleTypes keep the GraphQL names - Fix: non-null lists of custom scalars, DateTime, LocalDate and Decimal did not compile with strict_nullability (list helpers returned nullable lists) - Compile test covers a schema full of colliding names --- lib/src/client_extension_generator.dart | 17 ++- lib/src/code_utils.dart | 20 ++++ lib/src/naming.dart | 13 +++ lib/src/schema_analyzer.dart | 33 +++--- lib/src/type_generator.dart | 139 ++++++++++++++---------- test/generated_code_compiles_test.dart | 84 +++++++++++++- test/local_date_converter_test.dart | 2 +- test/possible_types_test.dart | 30 +++++ test/type_name_collision_test.dart | 93 ++++++++++++++++ 9 files changed, 353 insertions(+), 78 deletions(-) create mode 100644 test/possible_types_test.dart create mode 100644 test/type_name_collision_test.dart diff --git a/lib/src/client_extension_generator.dart b/lib/src/client_extension_generator.dart index a62b99d..2ffb520 100644 --- a/lib/src/client_extension_generator.dart +++ b/lib/src/client_extension_generator.dart @@ -198,7 +198,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 +220,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 +435,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 +459,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/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/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/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(')); + }); +} From 5665b74e4fefe1ba8c5417fe9e95422691f86137 Mon Sep 17 00:00:00 2001 From: Mark Shvabenlandt <17984008+ifmelate@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:12:36 +0300 Subject: [PATCH 3/6] feat: validate operations against the schema Unknown fields at any depth (BLD103), unknown or missing required arguments (BLD105), undeclared, unused or wrongly typed variables (BLD106) and missing or unexpected selection sets and invalid fragment types (BLD107) are reported together, with path and line. `validate_documents: false` turns it off. --- lib/src/builder.dart | 1 + lib/src/client_extension_generator.dart | 4 + lib/src/config.dart | 9 +- lib/src/config_context.dart | 2 + lib/src/diagnostics.dart | 3 + lib/src/document_validator.dart | 350 ++++++++++++++++++++++++ test/config_options_test.dart | 1 + test/document_validation_test.dart | 216 +++++++++++++++ 8 files changed, 585 insertions(+), 1 deletion(-) create mode 100644 lib/src/document_validator.dart create mode 100644 test/document_validation_test.dart diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 7c4170f..3387c2e 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -552,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 2ffb520..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, 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/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, + ); + }); +} From c5c35c6d5f6875e4ad92b37f430effe5e17c3e91 Mon Sep 17 00:00:00 2001 From: Mark Shvabenlandt <17984008+ifmelate@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:12:36 +0300 Subject: [PATCH 4/6] docs: formatting, possibleTypes, type name escaping and validation --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ README.md | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6229dd..120be10 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 +- 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 `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 From bcc2b60ea0a94ad464673dab1142421caed242ca Mon Sep 17 00:00:00 2001 From: Mark Shvabenlandt <17984008+ifmelate@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:14:51 +0300 Subject: [PATCH 5/6] build: require Dart 3.3 (dart_style >=2.3.7 needs it) --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 ++ pubspec.yaml | 2 +- test/support/compile_harness.dart | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dfe04c..26c7528 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.3.0", "stable"] steps: - name: Checkout repository diff --git a/CHANGELOG.md b/CHANGELOG.md index 120be10..b83c203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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.3 or newer (Flutter 3.19+), because `dart_style` needs it; + projects on Dart 3.2 keep resolving 2.1.0 - Requires `build` 2.4.0 or newer; adds `dart_style` and `pub_semver` dependencies diff --git a/pubspec.yaml b/pubspec.yaml index 13290b6..67b8735 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ topics: - type-safety environment: - sdk: ^3.2.0 + sdk: ^3.3.0 # Add regular dependencies here. dependencies: diff --git a/test/support/compile_harness.dart b/test/support/compile_harness.dart index 1f72656..f17b434 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.3.0 dependencies: graphql: ^5.1.3 json_annotation: ^4.8.1 From ed7df1d262302ceba90677ae6c57eea852745a53 Mon Sep 17 00:00:00 2001 From: Mark Shvabenlandt <17984008+ifmelate@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:18:28 +0300 Subject: [PATCH 6/6] build: require Dart 3.4 (dart_style's analyzer needs it) --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 4 ++-- pubspec.yaml | 2 +- test/support/compile_harness.dart | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26c7528..7dc5a06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - dart-version: ["3.3.0", "stable"] + dart-version: ["3.4.0", "stable"] steps: - name: Checkout repository diff --git a/CHANGELOG.md b/CHANGELOG.md index b83c203..3738adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,8 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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.3 or newer (Flutter 3.19+), because `dart_style` needs it; - projects on Dart 3.2 keep resolving 2.1.0 +- 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 diff --git a/pubspec.yaml b/pubspec.yaml index 67b8735..9e4fefe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ topics: - type-safety environment: - sdk: ^3.3.0 + sdk: ^3.4.0 # Add regular dependencies here. dependencies: diff --git a/test/support/compile_harness.dart b/test/support/compile_harness.dart index f17b434..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.3.0 + sdk: ^3.4.0 dependencies: graphql: ^5.1.3 json_annotation: ^4.8.1