diff --git a/CHANGELOG.md b/CHANGELOG.md index 07c5b20..d67dcbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ 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). +## [3.0.1] - 2026-10-22 + +Fixes found by running generated code against public GraphQL APIs. + +### Fixed +- A variable with a default value that isn't given is now sent with that + default instead of being left out. The server applied the default anyway, + but the normalized cache didn't: with `@include`/`@skip` on such a variable + the query failed with `PartialDataException`, and `read` missed results + cached under the same variables +- A nullable list field of an input class now defaults to `null` (left out) + instead of `[]`, in both result type modes. Filters like + `{code: {eq: "DE"}}` were sent as `{eq: "DE", in: [], nin: []}`, which + servers read as "none of these" and returned nothing. Non-null lists still + default to `[]` +- A schema type named `Duration` no longer hides `dart:core`'s `Duration` + used by `pollInterval`; it gets a trailing underscore like other reserved + names + ## [3.0.0] - 2026-10-21 Results follow the selections of each operation instead of the schema types. diff --git a/lib/src/client_extension_generator.dart b/lib/src/client_extension_generator.dart index 8b8bd18..19a2118 100644 --- a/lib/src/client_extension_generator.dart +++ b/lib/src/client_extension_generator.dart @@ -331,8 +331,7 @@ ${request(writeIdFields)}, dartName: dartFieldName(d.variable.name.value, className: className), type: d.type, optional: !d.type.isNonNull || d.defaultValue?.value != null, - // An explicit null would override the default on the server. - hasDefault: d.defaultValue?.value != null, + defaultValue: d.defaultValue?.value, ), ]; @@ -357,7 +356,17 @@ ${request(writeIdFields)}, if (f.optional) { var expr = JsonExprs.toJsonExpr(f.type, '${f.dartName}!'); if (expr == '${f.dartName}!') expr = f.dartName; - if (CodegenConfigContext.includeNullInputs && !f.hasDefault) { + final defaultValue = f.defaultValue; + if (defaultValue != null) { + // The default is sent explicitly: the server would apply it anyway, + // but the normalized cache doesn't, and @include/@skip on a missing + // variable would make it expect fields the server left out. + final literal = _valueLiteral(defaultValue); + buffer.writeln(expr == f.dartName + ? " '${f.graphqlName}': ${f.dartName} ?? $literal," + : " '${f.graphqlName}': ${f.dartName} == null " + '? $literal : $expr,'); + } else if (CodegenConfigContext.includeNullInputs) { final value = expr == f.dartName ? expr : '${f.dartName} == null ? null : $expr'; @@ -377,6 +386,30 @@ ${request(writeIdFields)}, return buffer.toString(); } + /// Dart expression for the JSON form of a GraphQL constant [value] (a + /// variable's default value). + static String _valueLiteral(ValueNode value) => switch (value) { + IntValueNode(:final value) => value, + FloatValueNode(:final value) => value, + BooleanValueNode(:final value) => '$value', + NullValueNode() => 'null', + StringValueNode(:final value) => _stringLiteral(value), + EnumValueNode(:final name) => _stringLiteral(name.value), + ListValueNode(:final values) => + '[${values.map(_valueLiteral).join(', ')}]', + ObjectValueNode(:final fields) => '{${fields.map( + (f) => '${_stringLiteral(f.name.value)}: ' + '${_valueLiteral(f.value)}', + ).join(', ')}}', + _ => throw CodegenException( + CodegenErrorCodes.buildOperationParse, + 'Unsupported default value: ${gql_lang.printNode(value)}', + ), + }; + + static String _stringLiteral(String s) => + "'${s.replaceAll(r'\', r'\\').replaceAll("'", r"\'").replaceAll(r'$', r'\$').replaceAll('\n', r'\n')}'"; + // --------------------------------------------------------------------------- // Response parsing // --------------------------------------------------------------------------- diff --git a/lib/src/naming.dart b/lib/src/naming.dart index af365af..876b0bc 100644 --- a/lib/src/naming.dart +++ b/lib/src/naming.dart @@ -42,6 +42,8 @@ const Set dartReservedWords = { const Set reservedTypeNames = { 'Map', 'List', 'Set', 'String', 'Object', 'int', 'double', 'num', 'bool', // 'dynamic', 'DateTime', 'Future', 'Stream', 'ArgumentError', 'StateError', + // Typed options take a `Duration? pollInterval`. + 'Duration', // `enum Enum` would also get a converter named like the built-in one. 'Enum', 'Deprecated', 'override', 'JsonConverter', 'JsonSerializable', 'JsonKey', diff --git a/lib/src/type_generator.dart b/lib/src/type_generator.dart index 447fce3..f92c917 100644 --- a/lib/src/type_generator.dart +++ b/lib/src/type_generator.dart @@ -674,6 +674,7 @@ class ${name}Converter implements JsonConverter<$name, dynamic> { // With include_null_inputs a null field is sent as null, so the server // can tell "clear it" from "leave it". includeIfNull: CodegenConfigContext.includeNullInputs, + isInput: true, ); } @@ -722,7 +723,9 @@ class ${name}Converter implements JsonConverter<$name, dynamic> { buffer.writeln(); buffer.writeln(' const $className({'); for (final f in fields) { - if (f.type.startsWith('List<')) { + // A nullable list stays null when not given: an empty list sent to the + // server means "none of these", not "no filter". + if (f.type.startsWith('List<') && f.node.type.isNonNull) { buffer.writeln(' this.${f.name} = const [],'); } else if (f.type.endsWith('?') || f.type == 'dynamic') { buffer.writeln(' this.${f.name},'); @@ -739,8 +742,8 @@ class ${name}Converter implements JsonConverter<$name, dynamic> { for (final f in fields) { final v = "json['${f.key}']"; final type = f.node.type; - // Like json_serializable's `defaultValue`, a missing list is empty. - final expr = type is ListTypeNode + // A missing non-null list is empty, a missing nullable one null. + final expr = type is ListTypeNode && type.isNonNull ? '$v == null ? const <${_listElementType(f.type)}>[] : ' '${_inputFromJson(type, v, nullable: false)}' : _inputFromJson(type, v, parenthesize: false); @@ -992,6 +995,7 @@ class $unknownName implements $name { Set overriddenFields = const {}, String? fieldNameClassName, bool includeIfNull = false, + bool isInput = false, bool rawEnumFields = false, }) { final buffer = StringBuffer(); @@ -1030,8 +1034,9 @@ class $unknownName implements $name { final dartName = dartFieldName(field.name, className: fieldNameClassName ?? className); final fieldType = SchemaAnalyzer.getDartType(field.type); - if (fieldType.startsWith('List<')) { - // For list fields, provide empty list as default + // Lists default to empty, except nullable input lists: an empty list + // sent to the server means "none of these", not "no filter". + if (fieldType.startsWith('List<') && (!isInput || field.type.isNonNull)) { buffer.writeln(' this.$dartName = const [],'); } else if (fieldType.endsWith('?')) { buffer.writeln(' this.$dartName,'); diff --git a/pubspec.yaml b/pubspec.yaml index 5469f2c..eadb835 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_graphql_codegen description: A powerful code generator for Flutter GraphQL applications that generates type-safe Dart classes and client extensions from GraphQL schemas and operations. -version: 3.0.0 +version: 3.0.1 homepage: https://github.com/ifmelate/flutter_graphql_codegen repository: https://github.com/ifmelate/flutter_graphql_codegen.git issue_tracker: https://github.com/ifmelate/flutter_graphql_codegen/issues diff --git a/test/generated_code_compiles_test.dart b/test/generated_code_compiles_test.dart index 2c75857..d796d08 100644 --- a/test/generated_code_compiles_test.dart +++ b/test/generated_code_compiles_test.dart @@ -643,7 +643,7 @@ Future main() async { '''; // include_null_inputs: null input fields and variables are sent as null, -// except variables with a default value (null would override it). +// except variables with a default value, which are sent with the default. const _nullsSchema = r''' input Patch { name: String, note: String } type Query { ok: Boolean } @@ -674,7 +674,7 @@ void main() { check(patch['name'] == 'x', 'set field sent'); check(patch.containsKey('note') == $sendsNulls, 'null input field'); check(vars.containsKey('tag') == $sendsNulls, 'null variable'); - check(!vars.containsKey('limit'), 'variable with a default is omitted'); + check(vars['limit'] == 5, 'variable with a default is sent with it'); check(UpdateVariables(id: '1', patch: Patch(), limit: 3).toJson()['limit'] == 3, 'variable with a default is sent when set'); print('NULLS OK'); @@ -903,13 +903,14 @@ void main() { check(json.containsKey('old') == nulls, 'unset field'); check(json.keys.length == (nulls ? 23 : 22), 'all fields written'); - // Unset optional fields: left out unless include_null_inputs; lists - // default to empty as in json_serializable classes. + // Unset optional fields: left out unless include_null_inputs. A non-null + // list defaults to empty, a nullable one to null (`[]` would mean "none"). final empty = const ProductFilter(name: 'n').toJson(); check(empty.containsKey('int') == nulls && empty.containsKey('page') == nulls, 'null fields'); - check(graphqlDeepEquals(empty['tags'], []) && graphqlDeepEquals(empty['pages'], []), - 'lists default to empty'); + check(graphqlDeepEquals(empty['tags'], []), 'non-null list defaults to empty'); + check(empty.containsKey('pages') == nulls && empty['pages'] == null, + 'nullable list defaults to null'); if (nulls) check(empty['int'] == null, 'null written as null'); // fromJson round trip. diff --git a/test/operation_input_types_test.dart b/test/operation_input_types_test.dart index 6d38df5..100c979 100644 --- a/test/operation_input_types_test.dart +++ b/test/operation_input_types_test.dart @@ -83,7 +83,9 @@ void main() { expect(types, contains(' const UserFilter({')); expect(types, contains(' required this.role,')); expect(types, contains(' this.name,')); - expect(types, contains(' this.roles = const [],')); + // A nullable list stays null: `[]` would mean "none of these". + expect(types, contains(' this.roles,')); + expect(types, isNot(contains(' this.roles = const [],'))); expect(types, contains(' factory UserFilter.fromJson(')); expect(types, contains(' Map toJson() => {')); }); diff --git a/test/public_api_regressions_test.dart b/test/public_api_regressions_test.dart new file mode 100644 index 0000000..dd27de7 --- /dev/null +++ b/test/public_api_regressions_test.dart @@ -0,0 +1,103 @@ +// Problems found by running generated code against public GraphQL APIs. +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''' +scalar Duration + +enum Sort { NEWEST OLDEST } + +input Filter { name: String tags: [String!] ids: [ID!]! } + +type Item { id: ID! name: String! parts: [String!]! timeout: Duration } + +type Query { + items(filter: Filter, first: Int, after: String, sort: Sort): [Item!]! +} +'''; + +const _document = r''' +query Items( + $filter: Filter = {name: "it's", tags: ["a"], ids: []} + $first: Int = 3 + $sort: Sort = NEWEST + $full: Boolean = false + $after: String +) { + items(filter: $filter, first: $first, after: $after, sort: $sort) { + id + parts @include(if: $full) + } +} +'''; + +String _op() { + final types = TypeGenerator.generateTypesFile(_schema); + return GraphQLCodeGenerator.generateOperationFile( + _schema, _document, 'Items', 'query', types); +} + +void main() { + setUp(() { + CodegenConfigContext.reset(); + CodegenConfigContext.strictNullability = true; + }); + tearDown(CodegenConfigContext.reset); + + group('variables with a default value', () { + // The normalized cache doesn't apply defaults, so a left-out variable + // used by @include made it expect fields the server didn't send. + test('are sent with their default when not given', () { + final code = _op(); + expect(code, contains("'first': first ?? 3,")); + expect(code, contains("'sort': sort == null ? 'NEWEST' :")); + expect(code, contains("'full': full ?? false,")); + expect( + code, + contains("'filter': filter == null ? {" + r"'name': 'it\'s', 'tags': ['a'], 'ids': []}")); + }); + + test('without a default are still left out when null', () { + expect(_op(), contains("if (after != null) 'after': after,")); + }); + + test('with include_null_inputs too', () { + CodegenConfigContext.includeNullInputs = true; + final code = _op(); + expect(code, contains("'full': full ?? false,")); + expect(code, contains("'after': after,")); + }); + }); + + group('nullable input lists', () { + // `[]` sent for a filter list means "none of these". + test('default to null in operation mode', () { + final types = TypeGenerator.generateTypesFile(_schema); + expect(types, contains(' this.tags,')); + expect(types, contains(' this.ids = const [],')); + }); + + test('default to null in schema mode', () { + CodegenConfigContext.resultTypes = 'schema'; + final types = TypeGenerator.generateTypesFile(_schema); + final filter = types.substring(types.indexOf('class Filter ')); + final ctor = filter.substring(0, filter.indexOf('});')); + expect(ctor, contains(' this.tags,')); + expect(ctor, contains(' this.ids = const [],')); + // Result lists keep their empty default. + final item = types.substring(types.indexOf('class Item ')); + expect(item.substring(0, item.indexOf('});')), + contains(' this.parts = const [],')); + }); + }); + + test('a schema scalar named Duration does not hide dart:core Duration', () { + final types = TypeGenerator.generateTypesFile(_schema); + expect(types, contains('class Duration_ {')); + expect(types, isNot(contains('class Duration {'))); + expect(_op(), contains('Duration? pollInterval')); + }); +}