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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
27 changes: 27 additions & 0 deletions lib/src/types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -469,3 +470,29 @@ class TypedValue<T extends Object> {
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<Object?> {
final List<bool> _sqlNulls;

JsonbListView(super.items, List<bool> 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];
}
54 changes: 36 additions & 18 deletions lib/src/types/binary_codec.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<int>>(
objectsArray,
3802,
Expand Down Expand Up @@ -914,15 +916,15 @@ class PostgresBinaryDecoder {
return readListBytes<Uint8List>(
input,
(reader, length) => reader.read(length),
);
).items;

case TypeOid.uuid:
return _decodeUuid(input);
case TypeOid.uuidArray:
return readListBytes<String>(
input,
(reader, _) => _decodeUuid(reader.read(16)),
);
).items;

case TypeOid.regtype:
final data = input.buffer.asByteData(input.offsetInBytes, input.length);
Expand Down Expand Up @@ -984,53 +986,66 @@ class PostgresBinaryDecoder {
return readListBytes<bool>(
input,
(reader, _) => reader.readUint8() != 0,
);
).items;

case TypeOid.smallIntegerArray:
return readListBytes<int>(input, (reader, _) => reader.readInt16());
return readListBytes<int>(
input,
(reader, _) => reader.readInt16(),
).items;
case TypeOid.integerArray:
return readListBytes<int>(input, (reader, _) => reader.readInt32());
return readListBytes<int>(
input,
(reader, _) => reader.readInt32(),
).items;
case TypeOid.bigIntegerArray:
return readListBytes<int>(input, (reader, _) => reader.readInt64());
return readListBytes<int>(
input,
(reader, _) => reader.readInt64(),
).items;

case TypeOid.dateArray:
return readListBytes<DateTime>(
input,
(reader, _) =>
DateTime.utc(2000).add(Duration(days: reader.readInt32())),
);
).items;
case TypeOid.timeArray:
return readListBytes<Time>(
input,
(reader, _) => Time.fromMicroseconds(reader.readInt64()),
);
).items;
case TypeOid.timestampArray:
case TypeOid.timestampTzArray:
return readListBytes<DateTime>(
input,
(reader, _) => DateTime.utc(
2000,
).add(Duration(microseconds: reader.readInt64())),
);
).items;

case TypeOid.varCharArray:
case TypeOid.textArray:
return readListBytes<String>(input, (reader, length) {
return context.encoding.decode(length > 0 ? reader.read(length) : []);
});
}).items;

case TypeOid.doubleArray:
return readListBytes<double>(
input,
(reader, _) => reader.readFloat64(),
);
).items;

case TypeOid.jsonbArray:
return readListBytes<dynamic>(input, (reader, length) {
final (:items, :sqlNulls) = readListBytes<dynamic>(input, (
reader,
length,
) {
reader.read(1);
final bytes = reader.read(length - 1);
return _jsonFusedEncoding(context.encoding).decode(bytes);
});
return JsonbListView(items, sqlNulls);

case TypeOid.integerRange:
final range = _decodeRange(context, buffer, input, TypeOid.integer);
Expand Down Expand Up @@ -1076,12 +1091,12 @@ class PostgresBinaryDecoder {
);
}

static List<V?> readListBytes<V>(
static ({List<V?> items, List<bool> sqlNulls}) readListBytes<V>(
Uint8List data,
V Function(ByteDataReader reader, int length) valueDecoder,
) {
if (data.length < 16) {
return [];
return (items: [], sqlNulls: []);
}

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

reader.read(4); // index

final sqlNulls = <bool>[];
bool hasNull = false;
for (var i = 0; i < size; i++) {
final len = reader.readInt32();
if (len == -1) {
decoded.add(null);
sqlNulls.add(true);
hasNull = true;
} else {
final v = valueDecoder(reader, len);
decoded.add(v);
sqlNulls.add(false);
hasNull = hasNull || (v == null);
}
}

return hasNull ? decoded : decoded.cast<V>();
return (items: hasNull ? decoded : decoded.cast<V>(), sqlNulls: sqlNulls);
}

/// Decode numeric / decimal to String without loosing precision.
Expand Down
9 changes: 4 additions & 5 deletions lib/src/v3/connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1410,15 +1410,14 @@ List<int?>? _mergeTypeOids(
final length = paramTypes?.length ?? fallbackTypes.length;
final result = <int?>[];
for (var i = 0; i < length; i++) {
final fromAnnotation =
(paramTypes != null && i < paramTypes.length) ? paramTypes[i]?.oid : null;
final fromAnnotation = (paramTypes != null && i < paramTypes.length)
? paramTypes[i]?.oid
: null;
if (fromAnnotation != null) {
result.add(fromAnnotation);
} else {
final type = i < fallbackTypes.length ? fallbackTypes[i].type : null;
result.add(
(type != null && type != Type.unspecified) ? type.oid : null,
);
result.add((type != null && type != Type.unspecified) ? type.oid : null);
}
}
return result;
Expand Down
35 changes: 35 additions & 0 deletions test/json_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,39 @@ 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 list = result.single.single as JsonbListView;
// Both SQL NULL and JSON null decode to Dart null...
expect(list[0], isNull);
expect(list[1], isNull);
expect(list[2], {'key': 'value'});
// ...but isSqlNull distinguishes them.
expect(list.isSqlNull(0), isTrue); // SQL NULL
expect(list.isSqlNull(1), isFalse); // JSON null ('null'::jsonb)
expect(list.isSqlNull(2), isFalse); // real value
});
});
}
76 changes: 39 additions & 37 deletions test/typed_value_parameter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,37 +15,37 @@ void main() {
await conn.close();
});

test('daterange @> TypedValue(Type.date) without inline annotation',
() async {
final result = await conn.execute(
Sql.named(
"SELECT daterange('2026-01-01','2026-01-10','[)') @> @d",
),
parameters: {'d': TypedValue(Type.date, DateTime.utc(2026, 1, 5))},
);
expect(result.single.single, isTrue);
});
test(
'daterange @> TypedValue(Type.date) without inline annotation',
() async {
final result = await conn.execute(
Sql.named("SELECT daterange('2026-01-01','2026-01-10','[)') @> @d"),
parameters: {'d': TypedValue(Type.date, DateTime.utc(2026, 1, 5))},
);
expect(result.single.single, isTrue);
},
);

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

test('integerArray && TypedValue(_int4) without inline annotation',
() async {
final result = await conn.execute(
Sql.named("SELECT ARRAY[1,2,3] && @arr"),
parameters: {
'arr': TypedValue(Type.integerArray, [2, 5]),
},
);
expect(result.single.single, isTrue);
});
test(
'integerArray && TypedValue(_int4) without inline annotation',
() async {
final result = await conn.execute(
Sql.named('SELECT ARRAY[1,2,3] && @arr'),
parameters: {
'arr': TypedValue(Type.integerArray, [2, 5]),
},
);
expect(result.single.single, isTrue);
},
);

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

final intResult = await conn.execute(
Sql.named('SELECT pg_temp.identity(@v)'),
parameters: {'v': TypedValue(Type.integer, 42)},
);
expect(intResult.single.single, 42);
final intResult = await conn.execute(
Sql.named('SELECT pg_temp.identity(@v)'),
parameters: {'v': TypedValue(Type.integer, 42)},
);
expect(intResult.single.single, 42);

final boolResult = await conn.execute(
Sql.named('SELECT pg_temp.identity(@v)'),
parameters: {'v': TypedValue(Type.boolean, true)},
);
expect(boolResult.single.single, isTrue);
});
final boolResult = await conn.execute(
Sql.named('SELECT pg_temp.identity(@v)'),
parameters: {'v': TypedValue(Type.boolean, true)},
);
expect(boolResult.single.single, isTrue);
},
);
});
}
Loading