Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Op>` 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.
Expand Down
39 changes: 36 additions & 3 deletions lib/src/client_extension_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
];

Expand All @@ -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';
Expand All @@ -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) =>
'<dynamic>[${values.map(_valueLiteral).join(', ')}]',
ObjectValueNode(:final fields) => '<String, dynamic>{${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
// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions lib/src/naming.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const Set<String> dartReservedWords = {
const Set<String> 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',
Expand Down
15 changes: 10 additions & 5 deletions lib/src/type_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

Expand Down Expand Up @@ -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},');
Expand All @@ -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);
Expand Down Expand Up @@ -992,6 +995,7 @@ class $unknownName implements $name {
Set<String> overriddenFields = const {},
String? fieldNameClassName,
bool includeIfNull = false,
bool isInput = false,
bool rawEnumFields = false,
}) {
final buffer = StringBuffer();
Expand Down Expand Up @@ -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,');
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 7 additions & 6 deletions test/generated_code_compiles_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,7 @@ Future<void> 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 }
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion test/operation_input_types_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> toJson() => {'));
});
Expand Down
103 changes: 103 additions & 0 deletions test/public_api_regressions_test.dart
Original file line number Diff line number Diff line change
@@ -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 ? <String, dynamic>{"
r"'name': 'it\'s', 'tags': <dynamic>['a'], 'ids': <dynamic>[]}"));
});

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'));
});
}
Loading