From a37840a629b2b04b0c1ad73ebfef3af77c6fc6be Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 21 Oct 2026 16:10:01 +0100 Subject: [PATCH 1/2] fix: Allow null trait values --- lib/src/core/model/trait.dart | 18 ++++++++++++++---- test/core/models/trait_test.dart | 22 ++++++++++++++++++++++ test/fg/flagsmith_traits_test.dart | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/lib/src/core/model/trait.dart b/lib/src/core/model/trait.dart index 67275ce..039a57a 100644 --- a/lib/src/core/model/trait.dart +++ b/lib/src/core/model/trait.dart @@ -47,14 +47,19 @@ class Trait { } else { return jsonValue.toDouble(); // Treat as double if it's a decimal } - } else if (jsonValue is String || jsonValue is bool) { + } else if (jsonValue == null || jsonValue is String || jsonValue is bool) { return jsonValue; } throw ArgumentError('Invalid value type'); } static dynamic _toJson(dynamic value) { - if (value is double || value is int || value is String || value is bool) { + // A null value deletes the trait in Flagsmith. + if (value == null || + value is double || + value is int || + value is String || + value is bool) { return value; } throw ArgumentError('Invalid value type'); @@ -104,14 +109,19 @@ class TraitWithIdentity { } else { return jsonValue.toDouble(); // Treat as double if it's a decimal } - } else if (jsonValue is String || jsonValue is bool) { + } else if (jsonValue == null || jsonValue is String || jsonValue is bool) { return jsonValue; } throw ArgumentError('Invalid value type'); } static dynamic _toJson(dynamic value) { - if (value is double || value is int || value is String || value is bool) { + // A null value deletes the trait in Flagsmith. + if (value == null || + value is double || + value is int || + value is String || + value is bool) { return value; } throw ArgumentError('Invalid value type'); diff --git a/test/core/models/trait_test.dart b/test/core/models/trait_test.dart index 31d5400..3bc0db6 100644 --- a/test/core/models/trait_test.dart +++ b/test/core/models/trait_test.dart @@ -104,6 +104,28 @@ void main() { expect(mapped['trait_value'], 'value'); }); }); + group('[Trait] - null value', () { + test('When trait value is null, then it is parsed as null', () { + final trait = Trait.fromJson({'trait_key': 'plan', 'trait_value': null}); + expect(trait.value, isNull); + }); + + test('When trait value is null, then it is serialised as null', () { + final mapped = Trait(key: 'plan', value: null).toJson(); + expect(mapped, {'trait_key': 'plan', 'trait_value': null}); + expect(Trait.fromJson(jsonDecode(jsonEncode(mapped))).value, isNull); + }); + + test('When trait with identity value is null, then it round-trips', () { + final trait = TraitWithIdentity( + identity: Identity(identifier: 'user'), key: 'plan', value: null); + final mapped = trait.toJson(); + expect(mapped['trait_value'], isNull); + expect(mapped.containsKey('trait_value'), isTrue); + expect(TraitWithIdentity.fromJson(mapped).value, isNull); + }); + }); + group('[Trait] - copyWith', () { test('as a developer want to use with new data', () { final trait = Trait.fromJson(decodedTraitStringValue); diff --git a/test/fg/flagsmith_traits_test.dart b/test/fg/flagsmith_traits_test.dart index 198e0d9..ac0a0e5 100644 --- a/test/fg/flagsmith_traits_test.dart +++ b/test/fg/flagsmith_traits_test.dart @@ -269,6 +269,24 @@ void main() { expect(result, isNotEmpty); }); + test('When get flags with a null trait, then it is sent as null', () async { + var user = Identity(identifier: 'test_another_user'); + fs = await setupClientAdapter(StorageType.inMemory); + setupEmptyAdapter(fs, cb: (config, adapter) { + adapter.onPost(fs.config.identitiesURI, (server) { + return server.reply(200, jsonDecode(identitiesResponseData)); + }, data: { + 'identifier': 'test_another_user', + 'traits': [ + {'trait_key': 'plan', 'trait_value': null}, + ], + }); + }); + final result = await fs.getFeatureFlags( + user: user, traits: [Trait(key: 'plan', value: null)]); + expect(result, isNotEmpty); + }); + test('When get flags for transient identity request expected', () async { var user = Identity(identifier: 'test_another_user', transient: true); fs = await setupClientAdapter(StorageType.inMemory); From f0611ed72d0a595f17de2357a4ec8d306e98ced8 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 21 Oct 2026 17:05:24 +0100 Subject: [PATCH 2/2] fix: Let copyWith set a null trait value --- lib/src/core/model/trait.dart | 11 +++++++---- test/core/models/trait_test.dart | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/lib/src/core/model/trait.dart b/lib/src/core/model/trait.dart index 039a57a..f444ac6 100644 --- a/lib/src/core/model/trait.dart +++ b/lib/src/core/model/trait.dart @@ -5,6 +5,9 @@ import 'package:json_annotation/json_annotation.dart'; import 'identity.dart'; part 'trait.g.dart'; +// Default for copyWith's value, so that an explicit null can be told apart. +const _unset = Object(); + @JsonSerializable() class Trait { @JsonKey(includeIfNull: false) @@ -32,12 +35,12 @@ class Trait { Trait copyWith({ int? id, String? key, - dynamic value, + dynamic value = _unset, }) => Trait( id: id ?? this.id, key: key ?? this.key, - value: value ?? this.value, + value: identical(value, _unset) ? this.value : value, ); static dynamic _fromJson(dynamic jsonValue) { @@ -94,12 +97,12 @@ class TraitWithIdentity { TraitWithIdentity copyWith({ Identity? identity, String? key, - dynamic value, + dynamic value = _unset, }) => TraitWithIdentity( identity: identity ?? this.identity, key: key ?? this.key, - value: value ?? this.value, + value: identical(value, _unset) ? this.value : value, ); static dynamic _fromJson(dynamic jsonValue) { diff --git a/test/core/models/trait_test.dart b/test/core/models/trait_test.dart index 3bc0db6..32dc9e8 100644 --- a/test/core/models/trait_test.dart +++ b/test/core/models/trait_test.dart @@ -124,6 +124,23 @@ void main() { expect(mapped.containsKey('trait_value'), isTrue); expect(TraitWithIdentity.fromJson(mapped).value, isNull); }); + + test('When copyWith sets value to null, then value is null', () { + final trait = Trait(key: 'plan', value: 'premium'); + expect(trait.copyWith(value: null).value, isNull); + expect(trait.copyWith(key: 'tier').value, 'premium'); + }); + + test( + 'When trait with identity copyWith sets value to null, then value ' + 'is null', () { + final trait = TraitWithIdentity( + identity: Identity(identifier: 'user'), + key: 'plan', + value: 'premium'); + expect(trait.copyWith(value: null).value, isNull); + expect(trait.copyWith(key: 'tier').value, 'premium'); + }); }); group('[Trait] - copyWith', () {