Repository: isoos/postgresql-dart

Issue:
UnimplementedError with a column that has an interval type
Hi, when I try to select a table that has a column type with a default value, I'm always getting this error:
```
Unhandled exception:
UnimplementedError
#0      PostgresBinaryDecoder.convert (package:postgres/src/binary_codec.dart:498:40)
#1      Query.addRow.<anonymous closure> (package:postgres/src/query.dart:173:41)
#2      MappedListIterable.elementAt (dart:_internal/iterable.dart:415:31)
....
```

Here's a small reproductible example:
```dart
import 'package:postgres/postgres.dart';

void main(List<String> args) async {
  final connection = PostgreSQLConnection('localhost', 5432, 'db', username: 'username', password: 'pwd');
  await connection.open();

  await connection.query(
    '''
      CREATE TABLE IF NOT EXISTS users
      (id SERIAL PRIMARY KEY, name TEXT NOT NULL, max_age INTERVAL DEFAULT ('1 day'::interval) NOT NULL)
    ''',
  );

  await connection.query("INSERT INTO users (name) VALUES ('Alice')");

  final result = await connection.mappedResultsQuery('SELECT * FROM users');

  for (final row in result) {
    print(row);
  }
}
```
Not sure why it's happening though...



Discussion: Hm, looks like the current implementation is limited to 8-bytes long microseconds, and doesn't handle days (or larger) components of the 16-bytes Postgres `interval`. I think the correct thing to do is to stop using `Duration` for this, and introduce an `Interval` data type.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c47fba6f..8bab1cf7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,7 @@ Notable breaking behaviour changes:
     removed from the new API.
   - Queries are not cached implicitly, explicit prepared statements can be
     used instead.
+  - `interval` values are returned as `Interval` type instead of `Duration`.
   - Types, fields and parameter names may have been renamed to be more
     consistent or more aligned with the Dart naming guides.
 
diff --git a/lib/src/binary_codec.dart b/lib/src/binary_codec.dart
index 16abbf4c..9e470369 100644
--- a/lib/src/binary_codec.dart
+++ b/lib/src/binary_codec.dart
@@ -166,6 +166,13 @@ class PostgresBinaryEncoder<T extends Object> {
 
       case Type.interval:
         {
+          if (input is Interval) {
+            final bd = ByteData(16);
+            bd.setInt64(0, input.microseconds);
+            bd.setInt32(8, input.days);
+            bd.setInt32(12, input.months);
+            return bd.buffer.asUint8List();
+          }
           if (input is Duration) {
             final bd = ByteData(16);
             bd.setInt64(0, input.inMicroseconds);
@@ -173,7 +180,7 @@ class PostgresBinaryEncoder<T extends Object> {
             return bd.buffer.asUint8List();
           }
           throw FormatException(
-              'Invalid type for parameter value. Expected: Duration Got: ${input.runtimeType}');
+              'Invalid type for parameter value. Expected: Interval Got: ${input.runtimeType}');
         }
 
       case Type.numeric:
@@ -538,10 +545,11 @@ class PostgresBinaryDecoder<T> {
             .add(Duration(microseconds: buffer.getInt64(0))) as T;
 
       case Type.interval:
-        {
-          if (buffer.getInt64(8) != 0) throw UnimplementedError();
-          return Duration(microseconds: buffer.getInt64(0)) as T;
-        }
+        return Interval(
+          microseconds: buffer.getInt64(0),
+          days: buffer.getInt32(8),
+          months: buffer.getInt32(12),
+        ) as T;
 
       case Type.numeric:
         return _decodeNumeric(input) as T;
diff --git a/lib/src/types.dart b/lib/src/types.dart
index e1252055..b7df8ea0 100644
--- a/lib/src/types.dart
+++ b/lib/src/types.dart
@@ -5,6 +5,50 @@ import 'package:meta/meta.dart';
 
 import 'exceptions.dart' show PgException;
 
+/// In Postgresql `interval` values are stored as [months], [days], and [microseconds].
+/// This is done because the number of days in a month varies, and a day can
+/// have 23 or 25 hours if a daylight savings time adjustment is involved.
+///
+/// Value from one field is never automatically translated to value of
+/// another field, so <60*60*24 seconds> != <1 days> and so on.
+@immutable
+class Interval {
+  final int months;
+  final int days;
+  final int microseconds;
+
+  Interval({
+    this.months = 0,
+    this.days = 0,
+    this.microseconds = 0,
+  });
+
+  factory Interval.duration(Duration value) =>
+      Interval(microseconds: value.inMicroseconds);
+
+  late final _asStringParts = [
+    if (months != 0) '$months months',
+    if (days != 0) '$days days',
+    if (microseconds != 0) '$microseconds microseconds',
+  ];
+  late final _asString =
+      _asStringParts.isEmpty ? '0 microseconds' : _asStringParts.join(' ');
+
+  @override
+  String toString() => _asString;
+
+  @override
+  int get hashCode => Object.hashAll([months, days, microseconds]);
+
+  @override
+  bool operator ==(Object other) {
+    return other is Interval &&
+        other.months == months &&
+        other.days == days &&
+        other.microseconds == microseconds;
+  }
+}
+
 /// LSN is a PostgreSQL Log Sequence Number.
 ///
 /// For more details, see: https://www.postgresql.org/docs/current/datatype-pg-lsn.html
@@ -118,8 +162,8 @@ enum Type<T extends Object> {
   /// Must be a [DateTime] (microsecond date and time precision)
   timestampWithTimezone<DateTime>(1184, nameForSubstitution: 'timestamptz'),
 
-  /// Must be a [Duration]
-  interval<Duration>(1186, nameForSubstitution: 'interval'),
+  /// Must be a [Interval]
+  interval<Interval>(1186, nameForSubstitution: 'interval'),
 
   /// An arbitrary-precision number.
   ///


Test patch:
diff --git a/test/decode_test.dart b/test/decode_test.dart
index ea266a7f..44345b64 100644
--- a/test/decode_test.dart
+++ b/test/decode_test.dart
@@ -258,4 +258,26 @@ void main() {
       });
     });
   });
+
+  withPostgresServer('interval', (server) {
+    test('decode interval', () async {
+      final connection = await server.newConnection();
+
+      await connection.execute(
+        '''
+      CREATE TABLE IF NOT EXISTS users
+      (id SERIAL PRIMARY KEY, name TEXT NOT NULL, max_age INTERVAL DEFAULT ('1000000 day'::interval) NOT NULL)
+    ''',
+      );
+
+      await connection.execute("INSERT INTO users (name) VALUES ('Alice')");
+
+      final rs = await connection.execute('SELECT * FROM users');
+      expect(rs.single.toColumnMap(), {
+        'id': 1,
+        'name': 'Alice',
+        'max_age': Interval(days: 1000000),
+      });
+    });
+  });
 }
diff --git a/test/encoding_test.dart b/test/encoding_test.dart
index b49f7354..aa099de9 100644
--- a/test/encoding_test.dart
+++ b/test/encoding_test.dart
@@ -184,19 +184,15 @@ void main() {
     });
 
     test('interval', () async {
-      await expectInverse(Duration(minutes: 15), Type.interval);
-      await expectInverse(Duration(days: 1, minutes: 15), Type.interval);
-      await expectInverse(-Duration(days: 1, seconds: 5), Type.interval);
-      await expectInverse(
-          Duration(days: 365 * 100000, microseconds: 1), Type.interval);
-      await expectInverse(
-          -Duration(days: 365 * 100000, microseconds: 1), Type.interval);
+      await expectInverse(Interval(microseconds: 12345678), Type.interval);
+      await expectInverse(Interval(days: 1, microseconds: 15), Type.interval);
+      await expectInverse(Interval(days: -1, months: 5), Type.interval);
       try {
         await conn.query('INSERT INTO t (v) VALUES (@v:interval)',
             substitutionValues: {'v': 'not-interval'});
         fail('unreachable');
       } on FormatException catch (e) {
-        expect(e.toString(), contains('Expected: Duration'));
+        expect(e.toString(), contains('Expected: Interval'));
       }
     });