Skip to content

Commit a950b0c

Browse files
authored
Distinguish between SQL null and JSON null in JSONB[]. (#450)
1 parent ffcd22a commit a950b0c

6 files changed

Lines changed: 145 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 3.5.11
4+
5+
- Adding `JsonbListView` with `isSqlNull(int index)` method to check if a JSONB array has SQL or JSON null value.
6+
37
## 3.5.10
48

59
- Supporting `BYTEA[]` built-in type.

lib/src/types.dart

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:core' as core;
33
import 'dart:core';
44
import 'dart:typed_data';
55

6+
import 'package:collection/collection.dart';
67
import 'package:meta/meta.dart';
78

89
import 'types/generic_type.dart';
@@ -469,3 +470,29 @@ class TypedValue<T extends Object> {
469470
return 'TypedValue($type, $value)';
470471
}
471472
}
473+
474+
/// A decoded PostgreSQL `jsonb[]` array.
475+
///
476+
/// Wraps the list of decoded JSON values and tracks which elements were SQL
477+
/// `NULL` (as opposed to the JSON `null` literal, which also decodes to Dart
478+
/// `null` but is a non-null value at the SQL level).
479+
///
480+
/// Use [isSqlNull] to distinguish between the two:
481+
/// ```dart
482+
/// final list = row[0] as JsonbListView;
483+
/// list[0]; // null — but is it SQL NULL or JSON null?
484+
/// list.isSqlNull(0); // true → SQL NULL (IS NULL in SQL)
485+
/// list.isSqlNull(1); // false → JSON null ('null'::jsonb, IS NOT NULL in SQL)
486+
/// ```
487+
class JsonbListView extends UnmodifiableListView<Object?> {
488+
final List<bool> _sqlNulls;
489+
490+
JsonbListView(super.items, List<bool> sqlNulls) : _sqlNulls = sqlNulls;
491+
492+
/// Returns `true` if the element at [index] was a SQL `NULL` in the array
493+
/// (i.e. the PostgreSQL wire protocol sent a -1 length sentinel).
494+
///
495+
/// Returns `false` for elements that carry an actual value, including the
496+
/// JSON `null` literal (`'null'::jsonb`), which also decodes to Dart `null`.
497+
bool isSqlNull(int index) => _sqlNulls[index];
498+
}

lib/src/types/binary_codec.dart

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -583,9 +583,11 @@ class PostgresBinaryEncoder {
583583
case TypeOid.jsonbArray:
584584
{
585585
if (input is List) {
586-
final objectsArray = input
587-
.map(_jsonFusedEncoding(encoding).encode)
588-
.toList();
586+
final encoder = _jsonFusedEncoding(encoding);
587+
final objectsArray = input.map((item) {
588+
if (item is TypedValue && item.isSqlNull) return null;
589+
return encoder.encode(item);
590+
}).toList();
589591
return _writeListBytes<List<int>>(
590592
objectsArray,
591593
3802,
@@ -914,15 +916,15 @@ class PostgresBinaryDecoder {
914916
return readListBytes<Uint8List>(
915917
input,
916918
(reader, length) => reader.read(length),
917-
);
919+
).items;
918920

919921
case TypeOid.uuid:
920922
return _decodeUuid(input);
921923
case TypeOid.uuidArray:
922924
return readListBytes<String>(
923925
input,
924926
(reader, _) => _decodeUuid(reader.read(16)),
925-
);
927+
).items;
926928

927929
case TypeOid.regtype:
928930
final data = input.buffer.asByteData(input.offsetInBytes, input.length);
@@ -984,53 +986,66 @@ class PostgresBinaryDecoder {
984986
return readListBytes<bool>(
985987
input,
986988
(reader, _) => reader.readUint8() != 0,
987-
);
989+
).items;
988990

989991
case TypeOid.smallIntegerArray:
990-
return readListBytes<int>(input, (reader, _) => reader.readInt16());
992+
return readListBytes<int>(
993+
input,
994+
(reader, _) => reader.readInt16(),
995+
).items;
991996
case TypeOid.integerArray:
992-
return readListBytes<int>(input, (reader, _) => reader.readInt32());
997+
return readListBytes<int>(
998+
input,
999+
(reader, _) => reader.readInt32(),
1000+
).items;
9931001
case TypeOid.bigIntegerArray:
994-
return readListBytes<int>(input, (reader, _) => reader.readInt64());
1002+
return readListBytes<int>(
1003+
input,
1004+
(reader, _) => reader.readInt64(),
1005+
).items;
9951006

9961007
case TypeOid.dateArray:
9971008
return readListBytes<DateTime>(
9981009
input,
9991010
(reader, _) =>
10001011
DateTime.utc(2000).add(Duration(days: reader.readInt32())),
1001-
);
1012+
).items;
10021013
case TypeOid.timeArray:
10031014
return readListBytes<Time>(
10041015
input,
10051016
(reader, _) => Time.fromMicroseconds(reader.readInt64()),
1006-
);
1017+
).items;
10071018
case TypeOid.timestampArray:
10081019
case TypeOid.timestampTzArray:
10091020
return readListBytes<DateTime>(
10101021
input,
10111022
(reader, _) => DateTime.utc(
10121023
2000,
10131024
).add(Duration(microseconds: reader.readInt64())),
1014-
);
1025+
).items;
10151026

10161027
case TypeOid.varCharArray:
10171028
case TypeOid.textArray:
10181029
return readListBytes<String>(input, (reader, length) {
10191030
return context.encoding.decode(length > 0 ? reader.read(length) : []);
1020-
});
1031+
}).items;
10211032

10221033
case TypeOid.doubleArray:
10231034
return readListBytes<double>(
10241035
input,
10251036
(reader, _) => reader.readFloat64(),
1026-
);
1037+
).items;
10271038

10281039
case TypeOid.jsonbArray:
1029-
return readListBytes<dynamic>(input, (reader, length) {
1040+
final (:items, :sqlNulls) = readListBytes<dynamic>(input, (
1041+
reader,
1042+
length,
1043+
) {
10301044
reader.read(1);
10311045
final bytes = reader.read(length - 1);
10321046
return _jsonFusedEncoding(context.encoding).decode(bytes);
10331047
});
1048+
return JsonbListView(items, sqlNulls);
10341049

10351050
case TypeOid.integerRange:
10361051
final range = _decodeRange(context, buffer, input, TypeOid.integer);
@@ -1076,12 +1091,12 @@ class PostgresBinaryDecoder {
10761091
);
10771092
}
10781093

1079-
static List<V?> readListBytes<V>(
1094+
static ({List<V?> items, List<bool> sqlNulls}) readListBytes<V>(
10801095
Uint8List data,
10811096
V Function(ByteDataReader reader, int length) valueDecoder,
10821097
) {
10831098
if (data.length < 16) {
1084-
return [];
1099+
return (items: [], sqlNulls: []);
10851100
}
10861101

10871102
final reader = ByteDataReader()..add(data);
@@ -1092,20 +1107,23 @@ class PostgresBinaryDecoder {
10921107

10931108
reader.read(4); // index
10941109

1110+
final sqlNulls = <bool>[];
10951111
bool hasNull = false;
10961112
for (var i = 0; i < size; i++) {
10971113
final len = reader.readInt32();
10981114
if (len == -1) {
10991115
decoded.add(null);
1116+
sqlNulls.add(true);
11001117
hasNull = true;
11011118
} else {
11021119
final v = valueDecoder(reader, len);
11031120
decoded.add(v);
1121+
sqlNulls.add(false);
11041122
hasNull = hasNull || (v == null);
11051123
}
11061124
}
11071125

1108-
return hasNull ? decoded : decoded.cast<V>();
1126+
return (items: hasNull ? decoded : decoded.cast<V>(), sqlNulls: sqlNulls);
11091127
}
11101128

11111129
/// Decode numeric / decimal to String without loosing precision.

lib/src/v3/connection.dart

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1410,15 +1410,14 @@ List<int?>? _mergeTypeOids(
14101410
final length = paramTypes?.length ?? fallbackTypes.length;
14111411
final result = <int?>[];
14121412
for (var i = 0; i < length; i++) {
1413-
final fromAnnotation =
1414-
(paramTypes != null && i < paramTypes.length) ? paramTypes[i]?.oid : null;
1413+
final fromAnnotation = (paramTypes != null && i < paramTypes.length)
1414+
? paramTypes[i]?.oid
1415+
: null;
14151416
if (fromAnnotation != null) {
14161417
result.add(fromAnnotation);
14171418
} else {
14181419
final type = i < fallbackTypes.length ? fallbackTypes[i].type : null;
1419-
result.add(
1420-
(type != null && type != Type.unspecified) ? type.oid : null,
1421-
);
1420+
result.add((type != null && type != Type.unspecified) ? type.oid : null);
14221421
}
14231422
}
14241423
return result;

test/json_test.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,39 @@ void main() {
172172
]);
173173
});
174174
});
175+
176+
withPostgresServer('JSONB array with SQL NULLs', (server) {
177+
late Connection connection;
178+
179+
setUp(() async {
180+
connection = await server.newConnection();
181+
await connection.execute('CREATE TEMPORARY TABLE t (j jsonb[])');
182+
});
183+
184+
tearDown(() async {
185+
await connection.close();
186+
});
187+
188+
test('Can store jsonb[] with SQL NULL elements via TypedValue', () async {
189+
final result = await connection.execute(
190+
Sql.named('INSERT INTO t (j) VALUES (@a) RETURNING j'),
191+
parameters: {
192+
'a': TypedValue(Type.jsonbArray, [
193+
TypedValue(Type.jsonb, null, isSqlNull: true), // SQL NULL element
194+
null, // 'null'::jsonb
195+
{'key': 'value'},
196+
]),
197+
},
198+
);
199+
final list = result.single.single as JsonbListView;
200+
// Both SQL NULL and JSON null decode to Dart null...
201+
expect(list[0], isNull);
202+
expect(list[1], isNull);
203+
expect(list[2], {'key': 'value'});
204+
// ...but isSqlNull distinguishes them.
205+
expect(list.isSqlNull(0), isTrue); // SQL NULL
206+
expect(list.isSqlNull(1), isFalse); // JSON null ('null'::jsonb)
207+
expect(list.isSqlNull(2), isFalse); // real value
208+
});
209+
});
175210
}

test/typed_value_parameter_test.dart

Lines changed: 39 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,37 +15,37 @@ void main() {
1515
await conn.close();
1616
});
1717

18-
test('daterange @> TypedValue(Type.date) without inline annotation',
19-
() async {
20-
final result = await conn.execute(
21-
Sql.named(
22-
"SELECT daterange('2026-01-01','2026-01-10','[)') @> @d",
23-
),
24-
parameters: {'d': TypedValue(Type.date, DateTime.utc(2026, 1, 5))},
25-
);
26-
expect(result.single.single, isTrue);
27-
});
18+
test(
19+
'daterange @> TypedValue(Type.date) without inline annotation',
20+
() async {
21+
final result = await conn.execute(
22+
Sql.named("SELECT daterange('2026-01-01','2026-01-10','[)') @> @d"),
23+
parameters: {'d': TypedValue(Type.date, DateTime.utc(2026, 1, 5))},
24+
);
25+
expect(result.single.single, isTrue);
26+
},
27+
);
2828

2929
test('TypedValue date outside range returns false', () async {
3030
final result = await conn.execute(
31-
Sql.named(
32-
"SELECT daterange('2026-01-01','2026-01-10','[)') @> @d",
33-
),
31+
Sql.named("SELECT daterange('2026-01-01','2026-01-10','[)') @> @d"),
3432
parameters: {'d': TypedValue(Type.date, DateTime.utc(2026, 1, 20))},
3533
);
3634
expect(result.single.single, isFalse);
3735
});
3836

39-
test('integerArray && TypedValue(_int4) without inline annotation',
40-
() async {
41-
final result = await conn.execute(
42-
Sql.named("SELECT ARRAY[1,2,3] && @arr"),
43-
parameters: {
44-
'arr': TypedValue(Type.integerArray, [2, 5]),
45-
},
46-
);
47-
expect(result.single.single, isTrue);
48-
});
37+
test(
38+
'integerArray && TypedValue(_int4) without inline annotation',
39+
() async {
40+
final result = await conn.execute(
41+
Sql.named('SELECT ARRAY[1,2,3] && @arr'),
42+
parameters: {
43+
'arr': TypedValue(Type.integerArray, [2, 5]),
44+
},
45+
);
46+
expect(result.single.single, isTrue);
47+
},
48+
);
4949

5050
test('inline annotation takes precedence over TypedValue type', () async {
5151
// :date annotation wins even though we pass TypedValue(Type.date, ...)
@@ -79,24 +79,26 @@ void main() {
7979
// PostgreSQL cannot resolve anyelement polymorphic parameters when the
8080
// driver sends OID 0 ("unknown") in the Parse message. TypedValue must
8181
// propagate its type to Parse so the function can be resolved.
82-
test('anyelement polymorphic function resolves with TypedValue type',
83-
() async {
84-
await conn.execute(r'''
82+
test(
83+
'anyelement polymorphic function resolves with TypedValue type',
84+
() async {
85+
await conn.execute(r'''
8586
CREATE OR REPLACE FUNCTION pg_temp.identity(anyelement)
8687
RETURNS anyelement LANGUAGE sql AS $$ SELECT $1 $$
8788
''');
8889

89-
final intResult = await conn.execute(
90-
Sql.named('SELECT pg_temp.identity(@v)'),
91-
parameters: {'v': TypedValue(Type.integer, 42)},
92-
);
93-
expect(intResult.single.single, 42);
90+
final intResult = await conn.execute(
91+
Sql.named('SELECT pg_temp.identity(@v)'),
92+
parameters: {'v': TypedValue(Type.integer, 42)},
93+
);
94+
expect(intResult.single.single, 42);
9495

95-
final boolResult = await conn.execute(
96-
Sql.named('SELECT pg_temp.identity(@v)'),
97-
parameters: {'v': TypedValue(Type.boolean, true)},
98-
);
99-
expect(boolResult.single.single, isTrue);
100-
});
96+
final boolResult = await conn.execute(
97+
Sql.named('SELECT pg_temp.identity(@v)'),
98+
parameters: {'v': TypedValue(Type.boolean, true)},
99+
);
100+
expect(boolResult.single.single, isTrue);
101+
},
102+
);
101103
});
102104
}

0 commit comments

Comments
 (0)