diff --git a/CHANGELOG.md b/CHANGELOG.md index cac79d3..af61552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 3.5.10 - Supporting `BYTEA[]` built-in type. +- Fix TypedValue parameter propagation. ## 3.5.9 diff --git a/lib/src/v3/connection.dart b/lib/src/v3/connection.dart index 4d8e27c..7bbe034 100644 --- a/lib/src/v3/connection.dart +++ b/lib/src/v3/connection.dart @@ -180,7 +180,7 @@ abstract class _PgSessionBase implements Session { } else { // The simple query protocol does not support variables. So when we have // parameters, we need an explicit prepare. - final prepared = await _prepare(description); + final prepared = await _prepare(description, variables); try { return await prepared.run(variables, timeout: timeout); } finally { @@ -195,7 +195,10 @@ abstract class _PgSessionBase implements Session { return await _prepare(query); } - Future<_PreparedStatement> _prepare(Object query) async { + Future<_PreparedStatement> _prepare( + Object query, [ + List? fallbackTypes, + ]) async { final stackTrace = StackTrace.current; final trace = Trace.from(stackTrace); final conn = _connection; @@ -209,7 +212,7 @@ abstract class _PgSessionBase implements Session { ParseMessage( description.transformedSql, statementName: name, - typeOids: description.parameterTypes?.map((e) => e?.oid).toList(), + typeOids: _mergeTypeOids(description.parameterTypes, fallbackTypes), ), stackTrace: stackTrace, ); @@ -1391,6 +1394,36 @@ class _AuthenticationProcedure extends _PendingOperation { } } +/// Merges inline SQL type annotations with runtime [TypedValue] types for use +/// in a [ParseMessage]. +/// +/// Inline annotations (from `:type` syntax) take precedence. For positions +/// without an annotation, the [TypedValue.type] is used as a hint so that +/// PostgreSQL can resolve polymorphic operators (e.g. `@>`, `&&`, `<@`). +List? _mergeTypeOids( + List? paramTypes, + List? fallbackTypes, +) { + if (fallbackTypes == null || fallbackTypes.isEmpty) { + return paramTypes?.map((e) => e?.oid).toList(); + } + final length = paramTypes?.length ?? fallbackTypes.length; + final result = []; + for (var i = 0; i < length; i++) { + 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, + ); + } + } + return result; +} + extension on PgException { bool get willAbortConnection { return severity == Severity.fatal || severity == Severity.panic; diff --git a/test/byte_array_array_test.dart b/test/byte_array_array_test.dart new file mode 100644 index 0000000..d6f4990 --- /dev/null +++ b/test/byte_array_array_test.dart @@ -0,0 +1,164 @@ +import 'dart:typed_data'; + +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +import 'docker.dart'; + +void main() { + withPostgresServer('byteArrayArray (_bytea)', (server) { + late Connection conn; + + setUp(() async { + conn = await server.newConnection(); + }); + + tearDown(() async { + await conn.close(); + }); + + test('round-trips via SELECT', () async { + Future check(List?> value) async { + final result = await conn.execute( + Sql(r'SELECT $1', types: [Type.byteArrayArray]), + parameters: [value], + ); + final returned = result.single.single as List; + expect(returned.length, value.length); + for (var i = 0; i < value.length; i++) { + if (value[i] == null) { + expect(returned[i], isNull); + } else { + expect(returned[i], value[i]); + } + } + } + + await check([]); + await check([ + [0], + ]); + await check([ + [1, 2, 3], + ]); + await check([ + [255, 254, 253], + ]); + await check([ + [0], + [1, 2, 3], + [255, 254, 253], + ]); + await check([null]); + await check([ + null, + [1, 2, 3], + null, + ]); + }); + + test('round-trips via named parameter', () async { + Future check(List?> value) async { + final result = await conn.execute( + Sql.named('SELECT @v:_bytea'), + parameters: {'v': value}, + ); + final returned = result.single.single as List; + expect(returned.length, value.length); + for (var i = 0; i < value.length; i++) { + if (value[i] == null) { + expect(returned[i], isNull); + } else { + expect(returned[i], value[i]); + } + } + } + + await check([]); + await check([ + [42], + ]); + await check([ + null, + [1, 2], + [3, 4, 5], + ]); + }); + + test('round-trips through a table column', () async { + await conn.execute('CREATE TEMPORARY TABLE t (v bytea[])'); + + final values = [ + ?>[], + [ + [0], + ], + [ + [1, 2, 3], + [255, 254, 253], + ], + [ + null, + [10, 20], + null, + ], + ]; + + for (final value in values) { + await conn.execute( + Sql.named('INSERT INTO t (v) VALUES (@v:_bytea)'), + parameters: {'v': value}, + ); + } + + final result = await conn.execute('SELECT v FROM t ORDER BY ctid'); + expect(result.length, values.length); + + for (var i = 0; i < values.length; i++) { + final returned = result[i][0] as List; + final expected = values[i]; + expect(returned.length, expected.length); + for (var j = 0; j < expected.length; j++) { + if (expected[j] == null) { + expect(returned[j], isNull); + } else { + expect(returned[j], expected[j]); + } + } + } + }); + + test('SQL NULL round-trips as null', () async { + final result = await conn.execute( + Sql.named('SELECT @v:_bytea'), + parameters: {'v': null}, + ); + expect(result.single.single, isNull); + }); + + test('decoded elements are Uint8List', () async { + final result = await conn.execute( + Sql(r'SELECT $1', types: [Type.byteArrayArray]), + parameters: [ + [ + [1, 2, 3], + ], + ], + ); + final list = result.single.single as List; + expect(list.single, isA()); + }); + + test('rejects wrong element type', () async { + await expectLater( + () => conn.execute( + Sql.named('SELECT @v:_bytea'), + parameters: { + 'v': ['not-a-list'], + }, + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/typed_value_parameter_test.dart b/test/typed_value_parameter_test.dart new file mode 100644 index 0000000..97a0e7e --- /dev/null +++ b/test/typed_value_parameter_test.dart @@ -0,0 +1,79 @@ +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +import 'docker.dart'; + +void main() { + withPostgresServer('TypedValue parameter type propagation', (server) { + late Connection conn; + + setUp(() async { + conn = await server.newConnection(); + }); + + tearDown(() async { + 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('TypedValue date outside range returns false', () 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, 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('inline annotation takes precedence over TypedValue type', () async { + // :date annotation wins even though we pass TypedValue(Type.date, ...) + final result = await conn.execute( + Sql.named( + "SELECT daterange('2026-01-01','2026-01-10','[)') @> @d:date", + ), + parameters: {'d': TypedValue(Type.date, DateTime.utc(2026, 1, 5))}, + ); + expect(result.single.single, isTrue); + }); + + test('positional TypedValue without explicit types list', () async { + final result = await conn.execute( + Sql(r"SELECT daterange('2026-01-01','2026-01-10','[)') @> $1"), + parameters: [TypedValue(Type.date, DateTime.utc(2026, 1, 5))], + ); + expect(result.single.single, isTrue); + }); + + test('unspecified TypedValue still infers type from value', () async { + // Type.unspecified means the driver should fall back to text encoding, + // which PostgreSQL can handle for simple equality checks. + final result = await conn.execute( + Sql.named('SELECT @v::int = 42'), + parameters: {'v': TypedValue(Type.unspecified, 42)}, + ); + expect(result.single.single, isTrue); + }); + }); +}