From 8b24abb7bd8ea5ee5272358c04eca43e7852dced Mon Sep 17 00:00:00 2001 From: Istvan Soos Date: Fri, 8 May 2026 17:02:30 +0200 Subject: [PATCH 1/2] Distinguish between SQL null and JSON null in JSONB[]. --- lib/src/types/binary_codec.dart | 8 +++++--- test/json_test.dart | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/lib/src/types/binary_codec.dart b/lib/src/types/binary_codec.dart index e626647..3b642ac 100644 --- a/lib/src/types/binary_codec.dart +++ b/lib/src/types/binary_codec.dart @@ -583,9 +583,11 @@ class PostgresBinaryEncoder { case TypeOid.jsonbArray: { if (input is List) { - final objectsArray = input - .map(_jsonFusedEncoding(encoding).encode) - .toList(); + final encoder = _jsonFusedEncoding(encoding); + final objectsArray = input.map((item) { + if (item is TypedValue && item.isSqlNull) return null; + return encoder.encode(item); + }).toList(); return _writeListBytes>( objectsArray, 3802, diff --git a/test/json_test.dart b/test/json_test.dart index 67b14ba..2b4f878 100644 --- a/test/json_test.dart +++ b/test/json_test.dart @@ -172,4 +172,37 @@ void main() { ]); }); }); + + withPostgresServer('JSONB array with SQL NULLs', (server) { + late Connection connection; + + setUp(() async { + connection = await server.newConnection(); + await connection.execute( + 'CREATE TEMPORARY TABLE t (j jsonb[])', + ); + }); + + tearDown(() async { + await connection.close(); + }); + + test('Can store jsonb[] with SQL NULL elements via TypedValue', () async { + final result = await connection.execute( + Sql.named('INSERT INTO t (j) VALUES (@a) RETURNING j'), + parameters: { + 'a': TypedValue(Type.jsonbArray, [ + TypedValue(Type.jsonb, null, isSqlNull: true), // SQL NULL element + null, // 'null'::jsonb + {'key': 'value'}, + ]), + }, + ); + final row = result.single; + final cell = row.single as List; + expect(cell[0], isNull); + expect(cell[1], isNull); + expect(cell[2], {'key': 'value'}); + }); + }); } From 6987a65cfd051ab77ba227dc16d1be0134c5088b Mon Sep 17 00:00:00 2001 From: Istvan Soos Date: Mon, 11 May 2026 09:22:23 +0200 Subject: [PATCH 2/2] list view --- CHANGELOG.md | 4 ++ lib/src/types.dart | 27 ++++++++++ lib/src/types/binary_codec.dart | 46 +++++++++++------ lib/src/v3/connection.dart | 9 ++-- test/json_test.dart | 18 ++++--- test/typed_value_parameter_test.dart | 76 ++++++++++++++-------------- 6 files changed, 115 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af61552..48d6da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 3.5.11 + +- Adding `JsonbListView` with `isSqlNull(int index)` method to check if a JSONB array has SQL or JSON null value. + ## 3.5.10 - Supporting `BYTEA[]` built-in type. diff --git a/lib/src/types.dart b/lib/src/types.dart index 316bb47..8811b79 100644 --- a/lib/src/types.dart +++ b/lib/src/types.dart @@ -3,6 +3,7 @@ import 'dart:core' as core; import 'dart:core'; import 'dart:typed_data'; +import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'types/generic_type.dart'; @@ -469,3 +470,29 @@ class TypedValue { return 'TypedValue($type, $value)'; } } + +/// A decoded PostgreSQL `jsonb[]` array. +/// +/// Wraps the list of decoded JSON values and tracks which elements were SQL +/// `NULL` (as opposed to the JSON `null` literal, which also decodes to Dart +/// `null` but is a non-null value at the SQL level). +/// +/// Use [isSqlNull] to distinguish between the two: +/// ```dart +/// final list = row[0] as JsonbListView; +/// list[0]; // null — but is it SQL NULL or JSON null? +/// list.isSqlNull(0); // true → SQL NULL (IS NULL in SQL) +/// list.isSqlNull(1); // false → JSON null ('null'::jsonb, IS NOT NULL in SQL) +/// ``` +class JsonbListView extends UnmodifiableListView { + final List _sqlNulls; + + JsonbListView(super.items, List sqlNulls) : _sqlNulls = sqlNulls; + + /// Returns `true` if the element at [index] was a SQL `NULL` in the array + /// (i.e. the PostgreSQL wire protocol sent a -1 length sentinel). + /// + /// Returns `false` for elements that carry an actual value, including the + /// JSON `null` literal (`'null'::jsonb`), which also decodes to Dart `null`. + bool isSqlNull(int index) => _sqlNulls[index]; +} diff --git a/lib/src/types/binary_codec.dart b/lib/src/types/binary_codec.dart index 3b642ac..5b562fb 100644 --- a/lib/src/types/binary_codec.dart +++ b/lib/src/types/binary_codec.dart @@ -916,7 +916,7 @@ class PostgresBinaryDecoder { return readListBytes( input, (reader, length) => reader.read(length), - ); + ).items; case TypeOid.uuid: return _decodeUuid(input); @@ -924,7 +924,7 @@ class PostgresBinaryDecoder { return readListBytes( input, (reader, _) => _decodeUuid(reader.read(16)), - ); + ).items; case TypeOid.regtype: final data = input.buffer.asByteData(input.offsetInBytes, input.length); @@ -986,26 +986,35 @@ class PostgresBinaryDecoder { return readListBytes( input, (reader, _) => reader.readUint8() != 0, - ); + ).items; case TypeOid.smallIntegerArray: - return readListBytes(input, (reader, _) => reader.readInt16()); + return readListBytes( + input, + (reader, _) => reader.readInt16(), + ).items; case TypeOid.integerArray: - return readListBytes(input, (reader, _) => reader.readInt32()); + return readListBytes( + input, + (reader, _) => reader.readInt32(), + ).items; case TypeOid.bigIntegerArray: - return readListBytes(input, (reader, _) => reader.readInt64()); + return readListBytes( + input, + (reader, _) => reader.readInt64(), + ).items; case TypeOid.dateArray: return readListBytes( input, (reader, _) => DateTime.utc(2000).add(Duration(days: reader.readInt32())), - ); + ).items; case TypeOid.timeArray: return readListBytes