From 1aea3bbc91e17574a7925f47d96d2fa77effb945 Mon Sep 17 00:00:00 2001 From: Mark Shvabenlandt <17984008+ifmelate@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:34:01 +0300 Subject: [PATCH 1/4] feat: typed options for query, watchQuery, mutate and subscribe Each operation file gets functions building package:graphql options with typed variables and the generated parser as parserFn, so QueryResult.parsedData and ObservableQuery results are typed: getUserQueryOptions, getUserWatchQueryOptions, renameMutationOptions, userChangedSubscriptionOptions. Query extensions also get typed read/write cache helpers. --- lib/src/client_extension_generator.dart | 160 +++++++++++++++++++++++- test/generated_code_compiles_test.dart | 24 ++++ test/typed_options_test.dart | 112 +++++++++++++++++ 3 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 test/typed_options_test.dart diff --git a/lib/src/client_extension_generator.dart b/lib/src/client_extension_generator.dart index f4f39e1..91b1abb 100644 --- a/lib/src/client_extension_generator.dart +++ b/lib/src/client_extension_generator.dart @@ -100,6 +100,7 @@ $body'''; if (variables.isNotEmpty) buffer.writeln(variables); buffer.writeln(_generateParser(context)); + buffer.writeln(_generateOptions(context)); buffer.write(_generateExtension(context)); return buffer.toString(); } @@ -366,7 +367,146 @@ $options final result = await $camel(variables); return $parse(result.data); } -} +${isMutation ? '' : _generateCacheHelpers(ctx)}} +'''; + } + + // --------------------------------------------------------------------------- + // Typed options and cache helpers + // --------------------------------------------------------------------------- + + /// Top-level functions building `package:graphql` options whose + /// `parserFn` is the generated parser, so `QueryResult.parsedData` (and + /// `ObservableQuery` results) are typed. + static String _generateOptions(_OperationContext ctx) { + final common = [ + ('graphql.FetchPolicy? fetchPolicy', 'fetchPolicy: fetchPolicy'), + ('graphql.ErrorPolicy? errorPolicy', 'errorPolicy: errorPolicy'), + ( + 'graphql.CacheRereadPolicy? cacheRereadPolicy', + 'cacheRereadPolicy: cacheRereadPolicy' + ), + ('graphql.Context? context', 'context: context'), + ('Object? optimisticResult', 'optimisticResult: optimisticResult'), + ]; + const poll = ('Duration? pollInterval', 'pollInterval: pollInterval'); + final t = ctx.returnType; + final buffer = StringBuffer(); + switch (ctx.operation.type) { + case OperationType.query: + buffer + ..writeln(_optionsFunction(ctx, 'QueryOptions', 'Query', + 'GraphQLClient.query', [...common, poll])) + ..write(_optionsFunction(ctx, 'WatchQueryOptions', 'WatchQuery', + 'GraphQLClient.watchQuery', [ + ...common, + poll, + ('bool fetchResults = false', 'fetchResults: fetchResults'), + ( + 'bool carryForwardDataOnException = true', + 'carryForwardDataOnException: carryForwardDataOnException' + ), + ( + 'bool? eagerlyFetchResults', + 'eagerlyFetchResults: eagerlyFetchResults' + ), + ])); + case OperationType.mutation: + buffer.write(_optionsFunction( + ctx, 'MutationOptions', 'Mutation', 'GraphQLClient.mutate', [ + ...common, + ('graphql.OnMutationUpdate<$t>? update', 'update: update'), + ( + 'graphql.OnMutationCompleted? onCompleted', + 'onCompleted: onCompleted' + ), + ('graphql.OnError? onError', 'onError: onError'), + ])); + case OperationType.subscription: + buffer.write(_optionsFunction(ctx, 'SubscriptionOptions', + 'Subscription', 'GraphQLClient.subscribe', common)); + } + return buffer.toString(); + } + + static String _optionsFunction( + _OperationContext ctx, + String optionsClass, + String suffix, + String clientMethod, + List<(String, String)> params, + ) { + final t = ctx.returnType; + final vars = '${ctx.pascal}Variables'; + final buffer = StringBuffer() + ..writeln('/// Options for running `${ctx.name}` with ' + '[graphql.$clientMethod]; the result\'s `parsedData` is typed.') + ..write('graphql.$optionsClass<$t> ${ctx.camel}${suffix}Options('); + switch (ctx.variablesKind) { + case _VariablesKind.required: + buffer.write('\n $vars variables, {\n'); + case _VariablesKind.optional: + buffer.write('{\n $vars? variables,\n'); + case _VariablesKind.none: + buffer.write('{\n'); + } + for (final (param, _) in params) { + buffer.writeln(' $param,'); + } + buffer + ..writeln('}) =>') + ..writeln(' graphql.$optionsClass<$t>(') + ..writeln(' document: ${ctx.camel}Document,') + ..writeln(' operationName: ${ctx.camel}OperationName,') + ..writeln(' variables: ${ctx.variablesJson},'); + for (final (_, arg) in params) { + buffer.writeln(' $arg,'); + } + buffer + ..writeln(' parserFn: parse${ctx.pascal}Data,') + ..writeln(' );'); + return buffer.toString(); + } + + /// `read*` / `write*` methods for the normalized cache of a query. + static String _generateCacheHelpers(_OperationContext ctx) { + final vars = '${ctx.pascal}Variables'; + final t = + ctx.returnType.endsWith('?') ? ctx.returnType : '${ctx.returnType}?'; + final options = '${ctx.camel}QueryOptions'; + final (readParams, writeParams, args) = switch (ctx.variablesKind) { + _VariablesKind.required => ( + '$vars variables, {bool optimistic = true}', + '$vars variables, Map data, ' + '{bool broadcast = true}', + 'variables', + ), + _VariablesKind.optional => ( + '{$vars? variables, bool optimistic = true}', + 'Map data, ' + '{$vars? variables, bool broadcast = true}', + 'variables: variables', + ), + _VariablesKind.none => ( + '{bool optimistic = true}', + 'Map data, {bool broadcast = true}', + '', + ), + }; + return ''' + + /// Reads `${ctx.name}` from the cache; null when it isn't cached. + $t read${ctx.pascal}($readParams) { + final Map? data = + readQuery($options($args).asRequest, optimistic: optimistic); + return data == null ? null : parse${ctx.pascal}Data(data); + } + + /// Writes raw `data` for `${ctx.name}` into the cache, shaped as the server + /// returns it, including `__typename` on every object (also the root). + void write${ctx.pascal}($writeParams) { + writeQuery($options($args).asRequest, data: data, broadcast: broadcast); + } '''; } @@ -490,6 +630,8 @@ $options } } +enum _VariablesKind { none, required, optional } + /// A root field selected by an operation. class _RootField { final String responseKey; @@ -527,6 +669,22 @@ class _OperationContext { String get resultClassName => '${pascal}Result'; + /// Whether the operation has variables, and whether any is required. + _VariablesKind get variablesKind { + final defs = operation.variableDefinitions; + if (defs.isEmpty) return _VariablesKind.none; + return defs.any((d) => d.type.isNonNull && d.defaultValue?.value == null) + ? _VariablesKind.required + : _VariablesKind.optional; + } + + /// Expression for the variables map in generated options functions. + String get variablesJson => switch (variablesKind) { + _VariablesKind.required => 'variables.toJson()', + _VariablesKind.optional => 'variables?.toJson() ?? const {}', + _VariablesKind.none => 'const {}', + }; + /// Return type of the generated `*Data` method / `parse*Data` function. String get returnType { if (rootFields.isEmpty) return 'Map?'; diff --git a/test/generated_code_compiles_test.dart b/test/generated_code_compiles_test.dart index 6f309d0..9bbc3e8 100644 --- a/test/generated_code_compiles_test.dart +++ b/test/generated_code_compiles_test.dart @@ -234,6 +234,30 @@ Future main() async { final changed = await client.userChangedData().first; check(changed.id == '1', 'subscription data'); + // Typed options: parsedData is typed for query, mutate and subscribe. + final typed = + await client.query(searchQueryOptions(const SearchVariables(q: 'a'))); + check(typed.parsedData!.length == 3, 'typed QueryOptions'); + final ObservableQuery watched = client.watchQuery( + combinedWatchQueryOptions(const CombinedVariables(id: '1'))); + watched.close(); + final renamedTyped = await client.mutate( + renameMutationOptions(const RenameVariables(id: '1', name: 'Bob'))); + check(renamedTyped.parsedData!.name == 'Bob', 'typed MutationOptions'); + final changedTyped = + await client.subscribe(userChangedSubscriptionOptions()).first; + check(changedTyped.parsedData!.id == '1', 'typed SubscriptionOptions'); + + // Cache helpers: write raw data, read it back typed. + const cacheVars = UsersVariables(first: 1); + check(client.readUsers(variables: cacheVars) == null, 'cache miss is null'); + client.writeUsers({ + '__typename': 'Query', + 'users': [user], + }, variables: cacheVars); + check(client.readUsers(variables: cacheVars)!.single.name == 'Ann', + 'cache round trip'); + // Errors propagate as OperationException instead of being swallowed. responses.remove('Combined'); try { diff --git a/test/typed_options_test.dart b/test/typed_options_test.dart new file mode 100644 index 0000000..cf601c1 --- /dev/null +++ b/test/typed_options_test.dart @@ -0,0 +1,112 @@ +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'; + +const _schema = r''' +type User { id: ID! name: String } +type Query { user(id: ID!): User users(first: Int): [User!]! version: String } +type Mutation { rename(id: ID!, name: String!): User } +type Subscription { userChanged: User! } +'''; + +String _generate(String document, String name, String type) { + final types = TypeGenerator.generateTypesFile(_schema); + return GraphQLCodeGenerator.generateOperationFile( + _schema, document, name, type, types); +} + +void main() { + setUp(() { + CodegenConfigContext.reset(); + CodegenConfigContext.strictNullability = true; + }); + tearDown(CodegenConfigContext.reset); + + group('queries', () { + late String code; + setUp(() => code = _generate( + r'query GetUser($id: ID!) { user(id: $id) { id name } }', + 'GetUser', + 'query')); + + test('get typed QueryOptions with required variables', () { + expect( + code, + contains('graphql.QueryOptions getUserQueryOptions(\n' + ' GetUserVariables variables, {\n')); + expect(code, contains(' graphql.FetchPolicy? fetchPolicy,\n')); + expect(code, contains(' Duration? pollInterval,\n')); + expect(code, contains(' variables: variables.toJson(),\n')); + expect(code, contains(' parserFn: parseGetUserData,\n')); + }); + + test('get typed WatchQueryOptions', () { + expect( + code, + contains( + 'graphql.WatchQueryOptions getUserWatchQueryOptions(\n' + ' GetUserVariables variables, {\n')); + expect(code, contains(' bool fetchResults = false,\n')); + }); + + test('get typed cache helpers', () { + expect( + code, + contains(' User? readGetUser(GetUserVariables variables, ' + '{bool optimistic = true}) {\n')); + expect( + code, + contains(' void writeGetUser(GetUserVariables variables, ' + 'Map data, {bool broadcast = true}) {\n')); + }); + }); + + test('optional variables become an optional named parameter', () { + final code = _generate( + r'query Users($first: Int) { users(first: $first) { id } }', + 'Users', + 'query'); + expect( + code, + contains('graphql.QueryOptions> usersQueryOptions({\n' + ' UsersVariables? variables,\n')); + expect(code, contains(' variables: variables?.toJson() ?? const {},\n')); + expect( + code, + contains(' List? readUsers(' + '{UsersVariables? variables, bool optimistic = true}) {\n')); + }); + + test('operations without variables take no variables parameter', () { + final code = _generate('query Version { version }', 'Version', 'query'); + expect( + code, + contains('graphql.QueryOptions versionQueryOptions({\n' + ' graphql.FetchPolicy? fetchPolicy,\n')); + expect(code, contains(' variables: const {},\n')); + }); + + test('mutations get typed MutationOptions and no cache helpers', () { + final code = _generate( + r'mutation Rename($id: ID!, $name: String!) ' + r'{ rename(id: $id, name: $name) { id } }', + 'Rename', + 'mutation'); + expect( + code, + contains('graphql.MutationOptions renameMutationOptions(\n' + ' RenameVariables variables, {\n')); + expect(code, contains(' graphql.OnMutationUpdate? update,\n')); + expect(code, isNot(contains('readRename'))); + }); + + test('subscriptions get typed SubscriptionOptions', () { + final code = _generate('subscription UserChanged { userChanged { id } }', + 'UserChanged', 'subscription'); + expect( + code, + contains('graphql.SubscriptionOptions ' + 'userChangedSubscriptionOptions({\n')); + }); +} From 53c658d938ddc24e793c23ed0fa045d36c2955c5 Mon Sep 17 00:00:00 2001 From: Mark Shvabenlandt <17984008+ifmelate@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:39:21 +0300 Subject: [PATCH 2/4] feat: scalar_mapping with imports and converters, strict_scalars - scalar_mapping entries can be `{type, import, converter}`; the import is added to types.dart and operation files, the converter is used on fields, list elements, variables and root fields - Mapped types without a converter are read with fromJson and written with toJson unless they are JSON-native - strict_scalars fails generation (SCH401) for custom scalars without a mapping - Fix: variables of a scalar mapped to DateTime were sent as DateTime objects --- lib/src/builder.dart | 3 + lib/src/client_extension_generator.dart | 27 +++- lib/src/config.dart | 57 ++++++++- lib/src/config_context.dart | 6 + lib/src/diagnostics.dart | 1 + lib/src/type_generator.dart | 41 +++++- test/config_options_test.dart | 1 + test/generated_code_compiles_test.dart | 125 +++++++++++++++++- test/scalar_mapping_test.dart | 163 ++++++++++++++++++++++++ 9 files changed, 412 insertions(+), 12 deletions(-) create mode 100644 test/scalar_mapping_test.dart diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 3387c2e..adb04e9 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -553,6 +553,9 @@ Builder graphqlCodegenBuilder(BuilderOptions options) { CodegenConfigContext.unknownEnumFallback = cfg.unknownEnumFallback; CodegenConfigContext.deprecatedAnnotations = cfg.deprecatedAnnotations; CodegenConfigContext.validateDocuments = cfg.validateDocuments; + CodegenConfigContext.scalarImports = Map.of(cfg.scalarImports); + CodegenConfigContext.scalarConverters = Map.of(cfg.scalarConverters); + CodegenConfigContext.strictScalars = cfg.strictScalars; return GraphQLCodegenBuilder(cfg); } diff --git a/lib/src/client_extension_generator.dart b/lib/src/client_extension_generator.dart index 91b1abb..24983c2 100644 --- a/lib/src/client_extension_generator.dart +++ b/lib/src/client_extension_generator.dart @@ -10,6 +10,7 @@ import 'document_utils.dart'; import 'document_validator.dart'; import 'naming.dart'; import 'schema_analyzer.dart'; +import 'type_generator.dart'; /// Utility class for generating client extensions class ClientExtensionGenerator { @@ -29,6 +30,7 @@ class ClientExtensionGenerator { }) { final operation = _findOperation( gql_lang.parseString(operationDocumentContent), operationName); + final scalarImports = TypeGenerator.scalarImportDirectives(); final body = generateClientExtension( operationName, operationType, @@ -39,11 +41,11 @@ class ClientExtensionGenerator { ); return '''${CodeGenerationConstants.generatedFileWarning} // Operation: $operationName (${operation.type.name}) -${sourcePath == null ? '' : '// Source: $sourcePath\n'}${CodeGenerationConstants.ignoreForFile} +${sourcePath == null ? '' : '// Source: $sourcePath\n'}${CodeGenerationConstants.ignoreForFile}${scalarImports.isEmpty ? '' : ', unused_import'} import 'package:graphql/client.dart' as graphql; import 'types.dart'; - +$scalarImports $body'''; } @@ -202,7 +204,13 @@ $body'''; } final name = (type as NamedTypeNode).name.value; - if (CodegenConfigContext.scalarMapping.containsKey(name)) return v; + final mapped = CodegenConfigContext.scalarMapping[name]; + if (mapped != null) { + final converter = CodegenConfigContext.scalarConverters[name]; + if (converter != null) return 'const $converter().toJson($v)'; + if (mapped == 'DateTime') return 'const DateTimeConverter().toJson($v)'; + return _isJsonNative(mapped) ? v : '$v.toJson()'; + } // 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) { @@ -539,6 +547,13 @@ ${isMutation ? '' : _generateCacheHelpers(ctx)}} NamedTypeNode(name: NameNode(value: name), isNonNull: true)); } + /// Dart types a JSON value can be used as directly. + static bool _isJsonNative(String type) => + const {'String', 'int', 'double', 'num', 'bool', 'Object', 'dynamic'} + .contains(type.replaceAll('?', '')) || + type.startsWith('Map<') || + type.startsWith('List<'); + static String _nullable(String type, bool nullable) => nullable && type != 'dynamic' ? '$type?' : type; @@ -564,6 +579,8 @@ ${isMutation ? '' : _generateCacheHelpers(ctx)}} String name, String v, Set definedTypes) { final mapped = CodegenConfigContext.scalarMapping[name]; if (mapped != null) { + final converter = CodegenConfigContext.scalarConverters[name]; + if (converter != null) return 'const $converter().fromJson($v)'; switch (mapped) { case 'int': return 'const SafeIntConverter().fromJson($v)'; @@ -576,7 +593,9 @@ ${isMutation ? '' : _generateCacheHelpers(ctx)}} case 'DateTime': return 'const DateTimeConverter().fromJson($v)'; default: - return '$v as $mapped'; + return _isJsonNative(mapped) + ? '$v as $mapped' + : '$mapped.fromJson($v)'; } } // A schema type named like a built-in scalar (e.g. `type Decimal`) is diff --git a/lib/src/config.dart b/lib/src/config.dart index 31bfcec..0c9cf76 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -15,6 +15,17 @@ class GraphQLCodegenConfig { final String diagnosticsFormat; // plain | json final Map scalarMapping; // GraphQL scalar -> Dart type + /// GraphQL scalar -> library to import for its mapped Dart type. + final Map scalarImports; + + /// GraphQL scalar -> `JsonConverter` class (with a const constructor) for + /// its mapped Dart type. + final Map scalarConverters; + + /// Fails generation for custom scalars without a `scalar_mapping` entry + /// (instead of generating a wrapper class for them). + final bool strictScalars; + /// Adds a `$unknown` value to every enum for values the server sends that /// were not in the schema at generation time (instead of throwing). final bool unknownEnumFallback; @@ -40,7 +51,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', 'validate_documents', + 'deprecated_annotations', 'validate_documents', 'strict_scalars', }; GraphQLCodegenConfig({ @@ -55,9 +66,14 @@ class GraphQLCodegenConfig { this.deprecatedAnnotations = false, this.validateDocuments = true, Map? scalarMapping, + Map? scalarImports, + Map? scalarConverters, + this.strictScalars = false, Map? schemaHeaders, this.warnings = const [], }) : scalarMapping = scalarMapping ?? const {}, + scalarImports = scalarImports ?? const {}, + scalarConverters = scalarConverters ?? const {}, schemaHeaders = schemaHeaders ?? const {}; factory GraphQLCodegenConfig.fromYaml(String yamlString) { @@ -118,18 +134,45 @@ class GraphQLCodegenConfig { (yamlMap['diagnostics_format'] as String?)?.trim() ?? 'plain'; final diagnosticsFormat = diagFormat; - // Scalar mapping + // Scalar mapping: `Scalar: DartType` or + // `Scalar: {type: DartType, import: uri, converter: ConverterClass}`. final scalarMappingMap = {}; + final scalarImports = {}; + final scalarConverters = {}; + final mappingWarnings = []; final yamlScalars = yamlMap['scalar_mapping']; if (yamlScalars is yaml.YamlMap) { for (final entry in yamlScalars.entries) { final k = entry.key?.toString(); - final v = entry.value?.toString(); - if (k != null && v != null) { - scalarMappingMap[k] = v; + final v = entry.value; + if (k == null || v == null) continue; + if (v is! yaml.YamlMap) { + scalarMappingMap[k] = v.toString(); + continue; + } + final type = v['type']?.toString(); + if (type == null) { + throw CodegenException( + CodegenErrorCodes.cfgInvalid, + 'scalar_mapping entry "$k" needs a type', + ); + } + scalarMappingMap[k] = type; + if (v['import'] case final Object import) { + scalarImports[k] = import.toString(); + } + if (v['converter'] case final Object converter) { + scalarConverters[k] = converter.toString(); + } + for (final key in v.keys.map((key) => '$key')) { + if (!const {'type', 'import', 'converter'}.contains(key)) { + mappingWarnings.add('Unknown key "$key" in scalar_mapping entry ' + '"$k" (expected type, import or converter); it is ignored'); + } } } } + final strictScalars = yamlMap['strict_scalars'] as bool? ?? false; // Headers for remote schema download (e.g. auth) final schemaHeaders = {}; @@ -161,10 +204,14 @@ class GraphQLCodegenConfig { deprecatedAnnotations: deprecatedAnnotations, validateDocuments: validateDocuments, scalarMapping: scalarMappingMap, + scalarImports: scalarImports, + scalarConverters: scalarConverters, + strictScalars: strictScalars, schemaHeaders: schemaHeaders, warnings: [ for (final key in yamlMap.keys.map((k) => '$k')) if (!knownKeys.contains(key)) _unknownKeyWarning(key), + ...mappingWarnings, ], ); } catch (e) { diff --git a/lib/src/config_context.dart b/lib/src/config_context.dart index 4903601..238283b 100644 --- a/lib/src/config_context.dart +++ b/lib/src/config_context.dart @@ -10,6 +10,9 @@ class CodegenConfigContext { static bool unknownEnumFallback = false; static bool deprecatedAnnotations = false; static bool validateDocuments = true; + static Map scalarImports = {}; + static Map scalarConverters = {}; + static bool strictScalars = false; /// Resets all fields to defaults. Useful for tests and watch-mode rebuilds. static void reset() { @@ -21,5 +24,8 @@ class CodegenConfigContext { unknownEnumFallback = false; deprecatedAnnotations = false; validateDocuments = true; + scalarImports = {}; + scalarConverters = {}; + strictScalars = false; } } diff --git a/lib/src/diagnostics.dart b/lib/src/diagnostics.dart index 29a6bb4..d0416df 100644 --- a/lib/src/diagnostics.dart +++ b/lib/src/diagnostics.dart @@ -11,6 +11,7 @@ class CodegenErrorCodes { static const String schemaFileNotFound = 'SCH404'; static const String schemaInvalid = 'SCH400'; + static const String schemaUnmappedScalar = 'SCH401'; static const String schemaDownloadFailed = 'SCH500'; static const String buildOperationParse = 'BLD100'; diff --git a/lib/src/type_generator.dart b/lib/src/type_generator.dart index 459b77d..46fbfda 100644 --- a/lib/src/type_generator.dart +++ b/lib/src/type_generator.dart @@ -5,6 +5,7 @@ import 'package:gql/ast.dart'; import 'package:gql/language.dart' as gql_lang; import 'code_utils.dart'; import 'config_context.dart'; +import 'diagnostics.dart'; import 'document_utils.dart'; import 'naming.dart'; import 'schema_analyzer.dart'; @@ -38,6 +39,7 @@ class TypeGenerator { final schemaDoc = DocumentUtils.mergeSchemaExtensions(gql_lang.parseString(schema)); SchemaAnalyzer.extractCustomTypesAndEnums(schemaDoc); + _checkStrictScalars(); final needsConvert = _wrappedScalars(TypeRegistry.customScalars).isNotEmpty; final scalarConverters = _generateScalarConverters(TypeRegistry.customScalars); @@ -46,10 +48,10 @@ class TypeGenerator { final typeDefinitions = _generateAllTypeDefinitions(schemaDoc, schema); return '''${CodeGenerationConstants.generatedFileWarning} -${CodeGenerationConstants.ignoreForFile}, unused_element, deprecated_member_use_from_same_package +${CodeGenerationConstants.ignoreForFile}, unused_element, unused_import, deprecated_member_use_from_same_package ${needsConvert ? "import 'dart:convert';\n\n" : ''}import 'package:json_annotation/json_annotation.dart'; - +${scalarImportDirectives()} part 'types.g.dart'; class Decimal { @@ -297,6 +299,28 @@ ${_generateListConverterHelpers(TypeRegistry.customScalars)} '''; } + /// `import` lines for the libraries of mapped scalar types. + static String scalarImportDirectives() { + final uris = CodegenConfigContext.scalarImports.values.toSet().toList() + ..sort(); + return uris.map((uri) => "import '$uri';\n").join(); + } + + /// With `strict_scalars`, custom scalars must have a `scalar_mapping`. + static void _checkStrictScalars() { + if (!CodegenConfigContext.strictScalars) return; + final unmapped = TypeRegistry.customScalars + .where(TypeRegistry.isWrappedScalar) + .toList() + ..sort(); + if (unmapped.isEmpty) return; + throw CodegenException( + CodegenErrorCodes.schemaUnmappedScalar, + 'Custom scalars without a scalar_mapping entry (strict_scalars is on): ' + '${unmapped.join(', ')}', + ); + } + /// The concrete object types of every interface and union, in the shape /// `GraphQLCache(possibleTypes: ...)` expects for matching fragments. static String _generatePossibleTypes() { @@ -781,6 +805,10 @@ class $unknownName implements $name { final isNullable = fieldType.endsWith('?'); // Registry lookups use the GraphQL name; Dart names may be escaped. final graphqlBase = getOriginalGraphQLType(type); + final mappedConverter = + CodegenConfigContext.scalarMapping.containsKey(graphqlBase) + ? CodegenConfigContext.scalarConverters[graphqlBase] + : null; // Add @JsonKey annotation for field name preservation so json_serializable // never renames GraphQL fields (and escaped Dart names map back). @@ -798,6 +826,14 @@ class $unknownName implements $name { 5, fieldType.length - (fieldType.endsWith('>?') ? 2 : 1)); final cleanInnerType = innerType.replaceAll('?', ''); + // json_serializable applies a field's converter to list elements. + if (mappedConverter != null) { + return [ + ' @JsonKey(${nameArg}defaultValue: const <$innerType>[])', + ' @$mappedConverter()', + ]; + } + if (TypeRegistry.isWrappedScalar(graphqlBase)) { // For lists of custom scalars, use JsonKey with converter final helper = cleanInnerType.toLowerCase(); @@ -839,6 +875,7 @@ class $unknownName implements $name { ]; } + if (mappedConverter != null) return withName(' @$mappedConverter()'); if (baseType == 'DateTime') { // Distinguish between DateTime and LocalDate return withName(getOriginalGraphQLType(type) == 'LocalDate' diff --git a/test/config_options_test.dart b/test/config_options_test.dart index c3a9452..af5f7b0 100644 --- a/test/config_options_test.dart +++ b/test/config_options_test.dart @@ -66,6 +66,7 @@ diagnostics_format: plain unknown_enum_fallback: true deprecated_annotations: true validate_documents: true +strict_scalars: false '''); expect(cfg.warnings, isEmpty); }); diff --git a/test/generated_code_compiles_test.dart b/test/generated_code_compiles_test.dart index 9bbc3e8..f2c111f 100644 --- a/test/generated_code_compiles_test.dart +++ b/test/generated_code_compiles_test.dart @@ -28,11 +28,17 @@ Map _generate( required bool strict, bool unknownEnumFallback = false, bool deprecatedAnnotations = false, + Map scalarMapping = const {}, + Map scalarImports = const {}, + Map scalarConverters = const {}, }) { CodegenConfigContext.reset(); CodegenConfigContext.strictNullability = strict; CodegenConfigContext.unknownEnumFallback = unknownEnumFallback; CodegenConfigContext.deprecatedAnnotations = deprecatedAnnotations; + CodegenConfigContext.scalarMapping = Map.of(scalarMapping); + CodegenConfigContext.scalarImports = Map.of(scalarImports); + CodegenConfigContext.scalarConverters = Map.of(scalarConverters); try { final types = TypeGenerator.generateTypesFile(schema); final files = {'lib/$dir/types.dart': types}; @@ -377,6 +383,102 @@ void main() { } '''; +// scalar_mapping with imports: a converter (Money), a type with +// fromJson/toJson (Cents) and the short form (Date: DateTime). +const _mappedLibrary = r''' +import 'package:json_annotation/json_annotation.dart'; + +class Money { + const Money(this.cents); + final int cents; +} + +// A non-nullable JSON type lets json_serializable handle nulls. +class MoneyConverter implements JsonConverter { + const MoneyConverter(); + + @override + Money fromJson(int json) => Money(json); + + @override + int toJson(Money object) => object.cents; +} + +class Cents { + const Cents(this.value); + factory Cents.fromJson(Object? json) => Cents(json as int); + final int value; + Object? toJson() => value; +} +'''; + +const _mappedSchema = r''' +scalar Money +scalar Cents +scalar Date +type Item { + price: Money! + prices: [Money!]! + maybe: [Money] + cents: Cents! + day: Date! +} +input ItemInput { price: Money! cents: Cents day: Date } +type Query { item(day: Date!, price: Money, input: ItemInput): Item! total: Money! } +'''; + +const _mappedDocuments = [ + r''' +query GetItem($day: Date!, $price: Money, $input: ItemInput) { + item(day: $day, price: $price, input: $input) { price prices maybe cents day } + total +} +''', +]; + +const _mappedMain = r''' +import 'package:fgc_compile_check/mapped/GetItem.g.dart'; +import 'package:fgc_compile_check/mapped/types.dart'; +import 'package:fgc_compile_check/scalars/money.dart'; + +void check(bool condition, String message) { + if (!condition) throw StateError('Check failed: $message'); +} + +void main() { + final data = parseGetItemData({ + 'item': { + '__typename': 'Item', + 'price': 150, + 'prices': [1, 2], + 'maybe': [3, null], + 'cents': 7, + 'day': '2024-05-01T00:00:00.000Z', + }, + 'total': 999, + }); + check(data.item.price.cents == 150, 'converter on a field'); + check(data.item.prices[1].cents == 2, 'converter on list elements'); + check(data.item.maybe![0]!.cents == 3 && data.item.maybe![1] == null, + 'converter on nullable list elements'); + check(data.item.cents.value == 7, 'type with fromJson'); + check(data.item.day.year == 2024, 'short form mapping'); + check(data.total.cents == 999, 'root field through the converter'); + check(data.item.toJson()['price'] == 150, 'converter toJson'); + + final vars = GetItemVariables( + day: DateTime.utc(2024, 5, 1), + price: const Money(5), + input: ItemInput(price: const Money(6), cents: const Cents(7)), + ).toJson(); + check(vars['day'] == '2024-05-01T00:00:00.000Z', 'mapped DateTime variable'); + check(vars['price'] == 5, 'converter variable'); + final input = vars['input'] as Map; + check(input['price'] == 6 && input['cents'] == 7, 'input with mapped scalars'); + print('MAPPED OK'); +} +'''; + void main() { test('generated code compiles and works', () async { final fixtureDir = p.join('test', 'fixtures'); @@ -398,6 +500,22 @@ void main() { ..._generate('fallback', _smokeSchema, _smokeDocuments, strict: true, unknownEnumFallback: true, deprecatedAnnotations: true), ..._generate('collide', _collideSchema, _collideDocuments, strict: true), + ..._generate('mapped', _mappedSchema, _mappedDocuments, + strict: true, + scalarMapping: { + 'Money': 'Money', + 'Cents': 'Cents', + 'Date': 'DateTime' + }, + scalarImports: { + 'Money': 'package:fgc_compile_check/scalars/money.dart', + 'Cents': 'package:fgc_compile_check/scalars/money.dart', + }, + scalarConverters: { + 'Money': 'MoneyConverter' + }), + 'lib/scalars/money.dart': _mappedLibrary, + 'bin/mapped.dart': _mappedMain, 'bin/smoke.dart': _smokeMain, 'bin/fallback.dart': _fallbackMain, 'bin/collide.dart': _collideMain, @@ -405,7 +523,12 @@ void main() { final result = await compileGenerated( files, - runScripts: ['bin/smoke.dart', 'bin/fallback.dart', 'bin/collide.dart'], + runScripts: [ + 'bin/smoke.dart', + 'bin/fallback.dart', + 'bin/collide.dart', + 'bin/mapped.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/scalar_mapping_test.dart b/test/scalar_mapping_test.dart new file mode 100644 index 0000000..0a272dd --- /dev/null +++ b/test/scalar_mapping_test.dart @@ -0,0 +1,163 @@ +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/generator.dart'; +import 'package:flutter_graphql_codegen/src/type_generator.dart'; +import 'package:test/test.dart'; + +const _base = 'schema_url: s.graphql\ndocument_paths: [a.graphql]\n'; + +const _schema = r''' +scalar Money +scalar Cents +scalar Date +type Item { price: Money! prices: [Money!]! cents: Cents! day: Date! } +input ItemInput { price: Money! cents: Cents day: Date } +type Query { item(day: Date!, price: Money, input: ItemInput): Item! } +'''; + +void _configure() { + CodegenConfigContext.scalarMapping = { + 'Money': 'Money', + 'Cents': 'Cents', + 'Date': 'DateTime', + }; + CodegenConfigContext.scalarImports = { + 'Money': 'package:app/money.dart', + 'Cents': 'package:app/money.dart', + }; + CodegenConfigContext.scalarConverters = {'Money': 'MoneyConverter'}; +} + +void main() { + setUp(CodegenConfigContext.reset); + tearDown(CodegenConfigContext.reset); + + group('config', () { + test('accepts the long form with import and converter', () { + final cfg = GraphQLCodegenConfig.fromYaml(''' +${_base}scalar_mapping: + Date: DateTime + Money: + type: Money + import: package:app/money.dart + converter: MoneyConverter +'''); + expect(cfg.scalarMapping, {'Date': 'DateTime', 'Money': 'Money'}); + expect(cfg.scalarImports, {'Money': 'package:app/money.dart'}); + expect(cfg.scalarConverters, {'Money': 'MoneyConverter'}); + expect(cfg.warnings, isEmpty); + }); + + test('the long form needs a type', () { + expect( + () => GraphQLCodegenConfig.fromYaml( + '${_base}scalar_mapping:\n Money:\n import: package:a/b.dart\n'), + throwsA(isA() + .having((e) => e.code, 'code', CodegenErrorCodes.cfgInvalid) + .having((e) => e.message, 'message', + allOf(contains('Money'), contains('type')))), + ); + }); + + test('unknown keys in the long form are warned about', () { + final cfg = GraphQLCodegenConfig.fromYaml( + '${_base}scalar_mapping:\n Money:\n type: Money\n imprt: x\n'); + expect(cfg.warnings, contains(contains('imprt'))); + }); + + test('strict_scalars defaults to false', () { + expect(GraphQLCodegenConfig.fromYaml(_base).strictScalars, isFalse); + expect( + GraphQLCodegenConfig.fromYaml('${_base}strict_scalars: true\n') + .strictScalars, + isTrue); + }); + }); + + group('types.dart', () { + late String types; + setUp(() { + _configure(); + types = TypeGenerator.generateTypesFile(_schema); + }); + + test('imports the mapped types once', () { + expect("import 'package:app/money.dart';".allMatches(types).length, 1); + }); + + test('uses the converter on fields and lists', () { + expect(types, contains(' @MoneyConverter()\n Money price;')); + expect(types, contains(' @MoneyConverter()\n List? prices;')); + }); + + test('generates no wrapper for mapped scalars', () { + expect(types, isNot(contains('class Money'))); + expect(types, isNot(contains('class Cents'))); + }); + }); + + group('operation files', () { + late String code; + setUp(() { + _configure(); + final types = TypeGenerator.generateTypesFile(_schema); + code = GraphQLCodeGenerator.generateOperationFile( + _schema, + r'query GetItem($day: Date!, $price: Money, $input: ItemInput) ' + r'{ item(day: $day, price: $price, input: $input) { price cents } }', + 'GetItem', + 'query', + types); + }); + + test('import the mapped types', () { + expect(code, contains("import 'package:app/money.dart';")); + }); + + test('serialize variables through converters and toJson', () { + expect(code, contains("'day': const DateTimeConverter().toJson(day)")); + expect(code, contains('const MoneyConverter().toJson(price!)')); + }); + + test('parse root fields through the converter', () { + CodegenConfigContext.scalarMapping = {'Money': 'Money'}; + CodegenConfigContext.scalarConverters = {'Money': 'MoneyConverter'}; + const schema = 'scalar Money\ntype Query { total: Money! cents: Cents }' + '\nscalar Cents'; + CodegenConfigContext.scalarMapping['Cents'] = 'Cents'; + final types = TypeGenerator.generateTypesFile(schema); + final out = GraphQLCodeGenerator.generateOperationFile( + schema, 'query T { total cents }', 'T', 'query', types); + expect(out, contains("const MoneyConverter().fromJson(data['total'])")); + expect(out, contains("Cents.fromJson(data['cents'])")); + }); + }); + + group('strict_scalars', () { + test('fails for custom scalars without a mapping', () { + CodegenConfigContext.strictScalars = true; + expect( + () => TypeGenerator.generateTypesFile( + 'scalar JSON\nscalar Url\nscalar DateTime\n' + 'type Query { a: JSON b: Url c: DateTime }'), + throwsA(isA() + .having((e) => e.code, 'code', 'SCH401') + .having( + (e) => e.message, + 'message', + allOf(contains('JSON'), contains('Url'), + isNot(contains('DateTime'))))), + ); + }); + + test('accepts mapped and built-in scalars', () { + CodegenConfigContext.strictScalars = true; + CodegenConfigContext.scalarMapping = {'JSON': 'Map'}; + expect( + TypeGenerator.generateTypesFile( + 'scalar JSON\nscalar Decimal\ntype Query { a: JSON b: Decimal }'), + contains('Map? a;')); + }); + }); +} From eeb66c0653cd8b65b123b15067ae3e0749f8db66 Mon Sep 17 00:00:00 2001 From: Mark Shvabenlandt <17984008+ifmelate@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:43:18 +0300 Subject: [PATCH 3/4] feat: fetch_schema command to keep the schema in the repository `dart run flutter_graphql_codegen:fetch_schema` downloads the schema from schema_endpoint (new config key) with schema_headers into schema_url, with --config, --endpoint, --output and --header overrides. --- bin/fetch_schema.dart | 8 ++ lib/src/config.dart | 7 ++ lib/src/fetch_schema_command.dart | 147 ++++++++++++++++++++++++++++++ pubspec.yaml | 1 + test/config_options_test.dart | 1 + test/fetch_schema_test.dart | 119 ++++++++++++++++++++++++ 6 files changed, 283 insertions(+) create mode 100644 bin/fetch_schema.dart create mode 100644 lib/src/fetch_schema_command.dart create mode 100644 test/fetch_schema_test.dart diff --git a/bin/fetch_schema.dart b/bin/fetch_schema.dart new file mode 100644 index 0000000..b75cfef --- /dev/null +++ b/bin/fetch_schema.dart @@ -0,0 +1,8 @@ +import 'dart:io'; + +import 'package:flutter_graphql_codegen/src/fetch_schema_command.dart'; + +/// `dart run flutter_graphql_codegen:fetch_schema --help` +Future main(List args) async { + exitCode = await runFetchSchema(args); +} diff --git a/lib/src/config.dart b/lib/src/config.dart index 0c9cf76..659cd3e 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -7,6 +7,10 @@ import 'diagnostics.dart'; class GraphQLCodegenConfig { final String schemaUrl; + + /// Endpoint `dart run flutter_graphql_codegen:fetch_schema` downloads the + /// schema from (into [schemaUrl]); not used by the builder. + final String? schemaEndpoint; final String outputDir; final List documentPaths; final String fileNaming; // snake_case | kebab-case | camelCase | pascalCase @@ -52,10 +56,12 @@ class GraphQLCodegenConfig { 'strict_nullability', 'file_naming', 'emit_index', 'scalar_mapping', 'schema_headers', 'diagnostics_format', 'unknown_enum_fallback', 'deprecated_annotations', 'validate_documents', 'strict_scalars', + 'schema_endpoint', }; GraphQLCodegenConfig({ required this.schemaUrl, + this.schemaEndpoint, required this.outputDir, required this.documentPaths, this.fileNaming = 'snake_case', @@ -194,6 +200,7 @@ class GraphQLCodegenConfig { return GraphQLCodegenConfig( schemaUrl: schemaUrl, + schemaEndpoint: yamlMap['schema_endpoint'] as String?, outputDir: outputDir, documentPaths: documentPaths.map((e) => e as String).toList(), fileNaming: fileNaming, diff --git a/lib/src/fetch_schema_command.dart b/lib/src/fetch_schema_command.dart new file mode 100644 index 0000000..fc269ee --- /dev/null +++ b/lib/src/fetch_schema_command.dart @@ -0,0 +1,147 @@ +/// `dart run flutter_graphql_codegen:fetch_schema`: downloads the schema from +/// an endpoint into a local file, so builds work offline and the schema is +/// versioned with the code. +library; + +import 'dart:io' as io; + +import 'package:args/args.dart'; +import 'package:gql/ast.dart'; +import 'package:gql/language.dart' as gql_lang; +import 'package:path/path.dart' as p; + +import 'config.dart'; +import 'diagnostics.dart'; +import 'schema_downloader.dart'; + +/// Exit code for wrong usage or configuration. +const int _usageError = 64; + +/// Runs the command with [args]; returns the process exit code. +Future runFetchSchema( + List args, { + StringSink? stdout, + StringSink? stderr, +}) async { + final out = stdout ?? io.stdout; + final err = stderr ?? io.stderr; + + final parser = ArgParser() + ..addOption('config', + defaultsTo: 'graphql_codegen.yaml', + help: 'Config file with schema_endpoint, schema_url and ' + 'schema_headers.') + ..addOption('endpoint', + help: 'GraphQL endpoint to download from (overrides ' + 'schema_endpoint).') + ..addOption('output', + help: 'File to write the schema to (overrides schema_url).') + ..addMultiOption('header', + help: 'Extra HTTP header as "Name: value"; \${ENV_VAR} is expanded.', + splitCommas: false) + ..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.'); + + void usage(String message) { + err + ..writeln(message) + ..writeln() + ..writeln('Usage: dart run flutter_graphql_codegen:fetch_schema ' + '[options]') + ..writeln(parser.usage); + } + + final ArgResults options; + try { + options = parser.parse(args); + } on FormatException catch (e) { + usage(e.message); + return _usageError; + } + if (options['help'] as bool) { + out + ..writeln('Downloads the GraphQL schema into a local file.') + ..writeln() + ..writeln(parser.usage); + return 0; + } + + final configPath = options['config'] as String; + GraphQLCodegenConfig? config; + if (io.File(configPath).existsSync()) { + try { + config = + GraphQLCodegenConfig.fromYaml(io.File(configPath).readAsStringSync()); + } on CodegenException catch (e) { + err.writeln(e.toString()); + return _usageError; + } + } + + final configUrl = config?.schemaUrl; + final configIsRemote = configUrl != null && _isHttp(configUrl); + final endpoint = options['endpoint'] as String? ?? + config?.schemaEndpoint ?? + (configIsRemote ? configUrl : null); + final output = + options['output'] as String? ?? (configIsRemote ? null : configUrl); + + if (endpoint == null) { + usage(config == null + ? 'No endpoint: $configPath not found and --endpoint not given.' + : 'No endpoint: set schema_endpoint in $configPath or pass ' + '--endpoint.'); + return _usageError; + } + if (!_isHttp(endpoint)) { + usage('The endpoint must be an http(s) URL: $endpoint'); + return _usageError; + } + if (output == null) { + usage(config == null + ? 'No output file: pass --output.' + : 'No output file: schema_url in $configPath is a URL; pass --output ' + 'or point schema_url to a local file and set schema_endpoint.'); + return _usageError; + } + + final headers = {...?config?.schemaHeaders}; + for (final header in options['header'] as List) { + final colon = header.indexOf(':'); + if (colon <= 0) { + usage('Invalid --header "$header": expected "Name: value".'); + return _usageError; + } + try { + headers[header.substring(0, colon).trim()] = + GraphQLCodegenConfig.expandEnvironmentVariables( + header.substring(colon + 1).trim()); + } on CodegenException catch (e) { + usage(e.message); + return _usageError; + } + } + + final String schema; + try { + schema = await SchemaDownloader.downloadSchema(endpoint, headers: headers); + } on CodegenException catch (e) { + err.writeln(e.toString()); + return 1; + } + + final file = io.File( + output.startsWith('file://') ? p.fromUri(Uri.parse(output)) : output); + file.parent.createSync(recursive: true); + file.writeAsStringSync(schema.endsWith('\n') ? schema : '$schema\n'); + + final types = gql_lang + .parseString(schema) + .definitions + .whereType() + .length; + out.writeln('Wrote the schema from $endpoint to $output ($types types).'); + return 0; +} + +bool _isHttp(String url) => + url.startsWith('http://') || url.startsWith('https://'); diff --git a/pubspec.yaml b/pubspec.yaml index e48a372..3496f1e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ environment: # Add regular dependencies here. dependencies: path: ^1.8.1 + args: ^2.4.0 build: ^2.4.0 dart_style: ">=2.3.7 <4.0.0" pub_semver: ^2.1.0 diff --git a/test/config_options_test.dart b/test/config_options_test.dart index af5f7b0..6498ea9 100644 --- a/test/config_options_test.dart +++ b/test/config_options_test.dart @@ -67,6 +67,7 @@ unknown_enum_fallback: true deprecated_annotations: true validate_documents: true strict_scalars: false +schema_endpoint: https://example.com/graphql '''); expect(cfg.warnings, isEmpty); }); diff --git a/test/fetch_schema_test.dart b/test/fetch_schema_test.dart new file mode 100644 index 0000000..7461f14 --- /dev/null +++ b/test/fetch_schema_test.dart @@ -0,0 +1,119 @@ +import 'dart:io'; + +import 'package:flutter_graphql_codegen/src/fetch_schema_command.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +const _sdl = 'type Query { hello: String }\n'; + +void main() { + late HttpServer server; + late Directory dir; + late List requests; + late StringBuffer out; + late StringBuffer err; + late String endpoint; + var status = 200; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('fgc_fetch_'); + requests = []; + out = StringBuffer(); + err = StringBuffer(); + status = 200; + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + endpoint = 'http://127.0.0.1:${server.port}/graphql'; + server.listen((request) { + requests.add(request.headers); + request.response + ..statusCode = status + ..write(status == 200 ? _sdl : 'nope') + ..close(); + }); + }); + + tearDown(() async { + await server.close(force: true); + await dir.delete(recursive: true); + }); + + String writeConfig(String body) { + final path = p.join(dir.path, 'graphql_codegen.yaml'); + File(path).writeAsStringSync(body); + return path; + } + + Future run(List args) => + runFetchSchema(args, stdout: out, stderr: err); + + test('downloads from schema_endpoint into schema_url with schema_headers', + () async { + final output = p.join(dir.path, 'lib', 'schema.graphql'); + final config = writeConfig(''' +schema_url: $output +schema_endpoint: $endpoint +schema_headers: + Authorization: Bearer abc +document_paths: [lib/**.graphql] +'''); + + expect(await run(['--config', config]), 0, reason: '$err'); + expect(File(output).readAsStringSync(), _sdl); + expect(requests.single.value('authorization'), 'Bearer abc'); + expect(out.toString(), allOf(contains(endpoint), contains(output))); + }); + + test('--endpoint, --output and --header work without a config file', + () async { + final output = p.join(dir.path, 'schema.graphql'); + expect( + await run([ + '--config', p.join(dir.path, 'missing.yaml'), // + '--endpoint', endpoint, + '--output', output, + '--header', 'X-Api-Key: secret', + ]), + 0, + reason: '$err', + ); + expect(File(output).readAsStringSync(), _sdl); + expect(requests.single.value('x-api-key'), 'secret'); + }); + + test('fails with usage help when there is no endpoint', () async { + final config = writeConfig(''' +schema_url: ${p.join(dir.path, 'schema.graphql')} +document_paths: [lib/**.graphql] +'''); + expect(await run(['--config', config]), 64); + expect(err.toString(), contains('schema_endpoint')); + }); + + test('fails when schema_url is a URL and there is no --output', () async { + final config = writeConfig(''' +schema_url: $endpoint +document_paths: [lib/**.graphql] +'''); + expect(await run(['--config', config]), 64); + expect(err.toString(), contains('--output')); + }); + + test('reports download failures', () async { + status = 500; + final output = p.join(dir.path, 'schema.graphql'); + expect(await run(['--endpoint', endpoint, '--output', output]), 1); + expect(err.toString(), contains('SCH500')); + expect(File(output).existsSync(), isFalse); + }); + + test('rejects malformed --header values', () async { + expect( + await run([ + '--endpoint', endpoint, // + '--output', p.join(dir.path, 's.graphql'), + '--header', 'no-colon', + ]), + 64); + expect(err.toString(), contains('no-colon')); + }); +} From b7c28261b5631f098272c040f2d9115b64e034d2 Mon Sep 17 00:00:00 2001 From: Mark Shvabenlandt <17984008+ifmelate@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:43:18 +0300 Subject: [PATCH 4/4] docs: typed options, cache helpers, scalar_mapping long form, fetch_schema --- CHANGELOG.md | 25 ++++++++++++ README.md | 105 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da1d7e..892f7cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ 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 +- Typed options for `package:graphql`: every operation file has + `QueryOptions` / `WatchQueryOptions` (queries), + `MutationOptions` and `SubscriptionOptions`. They take + typed variables and the full set of options (fetch and error policies, + polling, optimistic results, mutation `update`, ...), and use the generated + parser as `parserFn`, so `parsedData` and `ObservableQuery` results are typed +- `read` / `write` cache helpers on the client extension + of queries +- `scalar_mapping` entries can be `{type, import, converter}`: the import is + added to the generated files, and the converter is used for fields, list + elements, variables and results. Mapped types without a converter are read + with `fromJson` and written with `toJson` +- `strict_scalars`: fail with `SCH401` for custom scalars without a mapping +- `dart run flutter_graphql_codegen:fetch_schema` downloads the schema from + `schema_endpoint` (new config key) into `schema_url`, so the schema can live + in the repository and builds don't need the network + +### Fixed +- Variables of a scalar mapped to `DateTime` (`scalar_mapping: {Date: + DateTime}`) were put into the request as `DateTime` objects, which can't be + sent as JSON. They are now serialized like `DateTime` scalars + ## [2.2.0] - 2026-10-17 ### Added diff --git a/README.md b/README.md index 7283a22..005005b 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,29 @@ schema_headers: Authorization: "Bearer ${GRAPHQL_TOKEN}" ``` +#### Keeping the schema in the repository + +Downloading during every build makes builds depend on the network and on the +server's current schema. The alternative: keep the schema as a file in the +project and refresh it with a command when the server changes. + +```yaml +schema_url: lib/graphql/schema.graphql # the build reads this file +schema_endpoint: https://api.example.com/graphql # fetch_schema downloads from here +schema_headers: + Authorization: "Bearer ${GRAPHQL_TOKEN}" +``` + +```bash +dart run flutter_graphql_codegen:fetch_schema +``` + +It downloads the schema the same way the builder does (SDL, `?sdl`, +introspection) with `schema_headers`, and writes it to `schema_url`. Options: +`--config `, `--endpoint `, `--output ` and +`--header "Name: value"` (repeatable). It exits with 1 when the download fails +and 64 on wrong usage, so it works in scripts and CI. + ### All options | Option | Default | Description | @@ -103,6 +126,8 @@ schema_headers: | `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)) | +| `strict_scalars` | `false` | Fail when a custom scalar has no `scalar_mapping` entry (see [Custom scalars](#custom-scalars)) | +| `schema_endpoint` | none | Where `fetch_schema` downloads the schema from (see above) | A misspelled key is ignored with a warning that suggests the closest option. @@ -169,9 +194,15 @@ class GetUserVariables { ... Map toJson() } User? parseGetUserData(Map? data); // response -> model +// package:graphql options with typed variables and parsedData +QueryOptions getUserQueryOptions(GetUserVariables variables, {fetchPolicy, ...}); +WatchQueryOptions getUserWatchQueryOptions(GetUserVariables variables, {...}); + extension GetUserExtension on graphql.GraphQLClient { Future>> getUser([Map? variables]); Future getUserData([Map? variables]); + User? readGetUser(GetUserVariables variables); // from the cache + void writeGetUser(GetUserVariables variables, Map data); } ``` @@ -192,19 +223,39 @@ print(dashboard?.me?.id); print(dashboard?.notifications?.length); ``` -The extension methods only cover the common case. For `watchQuery`, a specific -`fetchPolicy`, or anything else `package:graphql` offers, use the document and -the parser directly: +### Options for `query`, `watchQuery`, `mutate` and `subscribe` + +The extension methods cover the common case. For `watchQuery`, a fetch or +error policy, polling, optimistic results or a mutation `update` callback, use +the generated options functions. They take typed variables, and their +`parserFn` is the generated parser, so `parsedData` is typed: ```dart -final query = client.watchQuery(WatchQueryOptions( - document: getUserDocument, - variables: GetUserVariables(id: '42').toJson(), - fetchPolicy: FetchPolicy.cacheAndNetwork, +final result = await client.query(getUserQueryOptions( + const GetUserVariables(id: '42'), + fetchPolicy: FetchPolicy.networkOnly, )); -final users = query.stream.map((result) => parseGetUserData(result.data)); +final User? user = result.parsedData; + +final ObservableQuery query = client.watchQuery( + getUserWatchQueryOptions(const GetUserVariables(id: '42'), + fetchPolicy: FetchPolicy.cacheAndNetwork), +); +query.stream.listen((result) => print(result.parsedData?.name)); ``` +There are `QueryOptions` and `WatchQueryOptions` for +queries, `MutationOptions` for mutations and +`SubscriptionOptions` for subscriptions. Operations without +variables take no variables argument; operations whose variables are all +optional take `variables:` as a named argument. Unlike the extension methods, +`client.query(...)` doesn't throw on errors: check `result.hasException`. + +For queries, `read` returns the cached result, typed, or null when +it isn't cached, and `write` puts data into the cache. Write data in +the shape the server returns, with `__typename` on every object including the +root. + ### Variables `GetUserVariables` turns typed values into the map that `package:graphql` expects. @@ -270,9 +321,40 @@ becomes `class Map_`, `scalar Object` becomes `Object_`. `__typename` and | `Byte`, `Short` | `Byte` wrapper / `int` | | anything else | a generated wrapper class with `raw` (the JSON value as received) and `value` (its string form) | -To use your own type instead, add it to `scalar_mapping`. The generator doesn't -add imports, so stick to types that are always in scope (`String`, `int`, -`DateTime`, `Map`, ...). +To use your own type instead, add it to `scalar_mapping`. The short form works +for types that are always in scope (`String`, `int`, `DateTime`, +`Map`, ...): + +```yaml +scalar_mapping: + Date: DateTime + JSON: Map +``` + +For a type from another library, use the long form with `import`. The import +is added to `types.dart` and to the operation files: + +```yaml +scalar_mapping: + Decimal: + type: Decimal + import: package:decimal/decimal.dart + Money: + type: Money + import: package:my_app/money.dart + converter: MoneyConverter +``` + +Without `converter`, the type needs `fromJson` and `toJson`, like `Decimal` from +`package:decimal`. With `converter`, give the name of a `JsonConverter` class +with a `const` constructor, exported by the same `import`. It's used for fields, +list elements, variables and results. Give it a non-nullable JSON type +(`JsonConverter`, not ``), so null values are +handled for you instead of being passed to `fromJson`. + +With `strict_scalars: true`, a custom scalar that isn't mapped (and isn't one of +the built-in ones above) fails the build with `SCH401`, instead of silently +becoming a wrapper class. Non-nullable `int`, `double` and `bool` fields have lenient converters: a missing or malformed value becomes `0`, `0.0` or `false` instead of throwing. @@ -299,6 +381,7 @@ Problems found during generation are reported with a code: | `CFG001` / `CFG002` | `schema_url` / `document_paths` missing from the config | | `CFG999` | Other config problem (bad value, unset env variable in `schema_headers`) | | `SCH400` | The schema doesn't parse | +| `SCH401` | A custom scalar has no `scalar_mapping` entry and `strict_scalars` is on | | `SCH404` | Schema file not found | | `SCH500` | Remote schema download failed; the message lists every attempt | | `BLD100` | An operation file doesn't parse |