From 944aa87f6c6d66336143101145d168612817b901 Mon Sep 17 00:00:00 2001 From: Dylan Simon Date: Wed, 30 Oct 2013 22:03:20 -0400 Subject: [PATCH 1/2] Add support for postgres interval type as Period Internally postgres stores (months, days, seconds), for which it may make sense to use Duration instead. Unfortunately, it externally presents an interface that looks more like Period, so this approach makes parsing easier. --- .../db/postgresql/column/ColumnTypes.scala | 4 +- .../PostgreSQLColumnDecoderRegistry.scala | 4 + .../PostgreSQLColumnEncoderRegistry.scala | 5 +- .../PostgreSQLIntervalEncoderDecoder.scala | 119 ++++++++++++++++++ .../async/db/postgresql/TimeAndDateSpec.scala | 17 ++- .../db/postgresql/column/IntervalSpec.scala | 53 ++++++++ 6 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLIntervalEncoderDecoder.scala create mode 100644 postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/column/IntervalSpec.scala diff --git a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/ColumnTypes.scala b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/ColumnTypes.scala index 675ad32f..945b3019 100644 --- a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/ColumnTypes.scala +++ b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/ColumnTypes.scala @@ -51,6 +51,8 @@ object ColumnTypes { final val TimeArray = 1183 final val TimeWithTimezone = 1266 final val TimeWithTimezoneArray = 1270 + final val Interval = 1186 + final val IntervalArray = 1187 final val Boolean = 16 final val BooleanArray = 1000 @@ -120,4 +122,4 @@ object ColumnTypes { public static final int XML = 142; public static final int XML_ARRAY = 143; -*/ \ No newline at end of file +*/ diff --git a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLColumnDecoderRegistry.scala b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLColumnDecoderRegistry.scala index d5da1513..6b6ea7dd 100644 --- a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLColumnDecoderRegistry.scala +++ b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLColumnDecoderRegistry.scala @@ -44,6 +44,7 @@ class PostgreSQLColumnDecoderRegistry( charset : Charset = CharsetUtil.UTF_8 ) e private final val dateArrayDecoder = new ArrayDecoder(DateEncoderDecoder) private final val timeArrayDecoder = new ArrayDecoder(TimeEncoderDecoder.Instance) private final val timeWithTimestampArrayDecoder = new ArrayDecoder(TimeWithTimezoneEncoderDecoder) + private final val intervalArrayDecoder = new ArrayDecoder(PostgreSQLIntervalEncoderDecoder) override def decode(kind: ColumnData, value: ByteBuf, charset: Charset): Any = { decoderFor(kind.dataType).decode(kind, value, charset) @@ -99,6 +100,9 @@ class PostgreSQLColumnDecoderRegistry( charset : Charset = CharsetUtil.UTF_8 ) e case TimeWithTimezone => TimeWithTimezoneEncoderDecoder case TimeWithTimezoneArray => this.timeWithTimestampArrayDecoder + case Interval => PostgreSQLIntervalEncoderDecoder + case IntervalArray => this.intervalArrayDecoder + case OIDArray => this.stringArrayDecoder case MoneyArray => this.stringArrayDecoder case NameArray => this.stringArrayDecoder diff --git a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLColumnEncoderRegistry.scala b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLColumnEncoderRegistry.scala index b5bd7ec1..f6b3a710 100644 --- a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLColumnEncoderRegistry.scala +++ b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLColumnEncoderRegistry.scala @@ -55,6 +55,9 @@ class PostgreSQLColumnEncoderRegistry extends ColumnEncoderRegistry { classOf[ReadableDateTime] -> (TimestampWithTimezoneEncoderDecoder -> ColumnTypes.TimestampWithTimezone), classOf[ReadableInstant] -> (DateEncoderDecoder -> ColumnTypes.Date), + classOf[ReadablePeriod] -> (PostgreSQLIntervalEncoderDecoder -> ColumnTypes.Interval), + classOf[ReadableDuration] -> (PostgreSQLIntervalEncoderDecoder -> ColumnTypes.Interval), + classOf[java.util.Date] -> (TimestampWithTimezoneEncoderDecoder -> ColumnTypes.TimestampWithTimezone), classOf[java.sql.Date] -> ( DateEncoderDecoder -> ColumnTypes.Date ), classOf[java.sql.Time] -> ( SQLTimeEncoder -> ColumnTypes.Time ), @@ -173,4 +176,4 @@ class PostgreSQLColumnEncoderRegistry extends ColumnEncoderRegistry { } } } -} \ No newline at end of file +} diff --git a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLIntervalEncoderDecoder.scala b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLIntervalEncoderDecoder.scala new file mode 100644 index 00000000..4b98b737 --- /dev/null +++ b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLIntervalEncoderDecoder.scala @@ -0,0 +1,119 @@ +/* + * Copyright 2013 Maurício Linhares + * Copyright 2013 Dylan Simon + * + * Maurício Linhares licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.github.mauricio.async.db.postgresql.column + +import com.github.mauricio.async.db.column.ColumnEncoderDecoder +import com.github.mauricio.async.db.exceptions.DateEncoderNotAvailableException +import com.github.mauricio.async.db.util.Log +import org.joda.time.{Period, ReadablePeriod, ReadableDuration} +import org.joda.time.format.{ISOPeriodFormat, PeriodFormatterBuilder} + +object PostgreSQLIntervalEncoderDecoder extends ColumnEncoderDecoder { + + private val log = Log.getByName(this.getClass.getName) + + /* Postgres accepts all ISO8601 formats. */ + private val formatter = ISOPeriodFormat.standard + + override def encode(value : Any) : String = { + value match { + case t : ReadablePeriod => formatter.print(t) + case t : ReadableDuration => t.toString // defaults to ISO8601 + case _ => throw new DateEncoderNotAvailableException(value) + } + } + + /* these should only be used for parsing: */ + private def postgresYMDBuilder(builder : PeriodFormatterBuilder) = builder + .appendYears .appendSuffix(" year", " years").appendSeparator(" ") + .appendMonths .appendSuffix(" mon", " mons" ).appendSeparator(" ") + .appendDays .appendSuffix(" day", " days" ).appendSeparator(" ") + + private val postgres_verboseParser = + postgresYMDBuilder(new PeriodFormatterBuilder().appendLiteral("@ ")) + .appendHours .appendSuffix(" hour", " hours").appendSeparator(" ") + .appendMinutes.appendSuffix(" min", " mins" ).appendSeparator(" ") + .appendSecondsWithOptionalMillis.appendSuffix(" sec", " secs") + .toFormatter + + private def postgresHMSBuilder(builder : PeriodFormatterBuilder) = builder + // .printZeroAlways // really all-or-nothing + .rejectSignedValues(true) // XXX: sign should apply to all + .appendHours .appendSeparator(":") + .appendMinutes.appendSeparator(":") + .appendSecondsWithOptionalMillis + + private val secsParser = new PeriodFormatterBuilder() + .appendSecondsWithOptionalMillis + .toFormatter + + private val postgresParser = + postgresHMSBuilder(postgresYMDBuilder(new PeriodFormatterBuilder())) + .toFormatter + + /* These sql_standard parsers don't handle negative signs correctly. */ + private def sqlDTBuilder(builder : PeriodFormatterBuilder) = + postgresHMSBuilder(builder + .appendDays.appendSeparator(" ")) + + private val sqlDTParser = + sqlDTBuilder(new PeriodFormatterBuilder()) + .toFormatter + + private val sqlParser = + sqlDTBuilder(new PeriodFormatterBuilder() + .printZeroAlways + .rejectSignedValues(true) // XXX: sign should apply to both + .appendYears.appendSeparator("-").appendMonths + .rejectSignedValues(false) + .printZeroNever + .appendSeparator(" ")) + .toFormatter + + /* This supports all positive intervals, and intervalstyle of postgres_verbose, and iso_8601 perfectly. + * If intervalstyle is set to postgres or sql_standard, some negative intervals may be rejected. + */ + def decode(value : String) : Period = { + if (value.isEmpty) /* huh? */ + Period.ZERO + else { + val format = ( + if (value(0).equals('P')) /* iso_8601 */ + formatter + else if (value.startsWith("@ ")) + postgres_verboseParser + else { + /* try to guess based on what comes after the first number */ + val i = value.indexWhere(!_.isDigit, if ("-+".contains(value(0))) 1 else 0) + if (i <= 0 || value(i).equals('.')) /* just a number */ + secsParser /* postgres treats this as seconds */ + else if (value(i).equals('-')) /* sql_standard: Y-M */ + sqlParser + else if (value(i).equals(' ') && i+1 < value.length && value(i+1).isDigit) /* sql_standard: D H:M:S */ + sqlDTParser + else + postgresParser + } + ) + if (value.endsWith(" ago")) /* only really applies to postgres_verbose, but shouldn't hurt */ + format.parsePeriod(value.stripSuffix(" ago")).negated + else + format.parsePeriod(value) + } + } +} diff --git a/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/TimeAndDateSpec.scala b/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/TimeAndDateSpec.scala index f95b3f1a..6074a67e 100644 --- a/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/TimeAndDateSpec.scala +++ b/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/TimeAndDateSpec.scala @@ -17,7 +17,7 @@ package com.github.mauricio.async.db.postgresql import org.specs2.mutable.Specification -import org.joda.time.{LocalTime, DateTime} +import org.joda.time.{LocalTime, DateTime, Period} class TimeAndDateSpec extends Specification with DatabaseTestHelper { @@ -200,7 +200,22 @@ class TimeAndDateSpec extends Specification with DatabaseTestHelper { date2 === date1 } + } + + "support intervals" in { + withHandler { + handler => + executeDdl(handler, "CREATE TEMP TABLE intervals (duration interval NOT NULL)") + + val p = new Period(1,2,0,4,5,6,7,8) /* postgres normalizes weeks */ + executePreparedStatement(handler, "INSERT INTO intervals (duration) VALUES (?)", Array(p)) + val rows = executeQuery(handler, "SELECT duration FROM intervals").rows.get + + rows.length === 1 + + rows(0)(0) === p + } } } diff --git a/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/column/IntervalSpec.scala b/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/column/IntervalSpec.scala new file mode 100644 index 00000000..011836a2 --- /dev/null +++ b/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/column/IntervalSpec.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2013 Maurício Linhares + * Copyright 2013 Dylan Simon + * + * Maurício Linhares licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.github.mauricio.async.db.postgresql.column + +import org.specs2.mutable.Specification + +class IntervalSpec extends Specification { + + "interval encoder/decoder" should { + + def decode(s : String) : Any = PostgreSQLIntervalEncoderDecoder.decode(s) + def encode(i : Any) : String = PostgreSQLIntervalEncoderDecoder.encode(i) + def both(s : String) : String = encode(decode(s)) + + "parse and encode example intervals" in { + Seq("1-2", "1 year 2 mons", "@ 1 year 2 mons", "@ 1 year 2 mons", "P1Y2M") forall { + both(_) === "P1Y2M" + } + Seq("3 4:05:06", "3 days 04:05:06", "@ 3 days 4 hours 5 mins 6 secs", "P3DT4H5M6S") forall { + both(_) === "P3DT4H5M6S" + } + Seq("1-2 +3 4:05:06", "1 year 2 mons +3 days 04:05:06", "@ 1 year 2 mons 3 days 4 hours 5 mins 6 secs", "P1Y2M3DT4H5M6S") forall { + both(_) === "P1Y2M3DT4H5M6S" + } + Seq("@ 1 year 2 mons -3 days 4 hours 5 mins 6 secs ago", "P-1Y-2M3DT-4H-5M-6S") forall { + both(_) === "P-1Y-2M3DT-4H-5M-6S" + } + } + + "parse and encode example intervals" in { + Seq("-1-2 +3 -4:05:06", "-1 year -2 mons +3 days -04:05:06") forall { + both(_) === "P-1Y-2M3DT-4H-5M-6S" + } + }.pendingUntilFixed("with mixed/grouped negations") + + } + +} From 71514d5b389f934647685c05f9461de6063a9fef Mon Sep 17 00:00:00 2001 From: Dylan Simon Date: Wed, 30 Oct 2013 23:07:24 -0400 Subject: [PATCH 2/2] Add support for simple negative HMS intervals --- .../PostgreSQLIntervalEncoderDecoder.scala | 16 +++++++++------- .../db/postgresql/column/IntervalSpec.scala | 2 ++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLIntervalEncoderDecoder.scala b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLIntervalEncoderDecoder.scala index 4b98b737..3f25ad76 100644 --- a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLIntervalEncoderDecoder.scala +++ b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/column/PostgreSQLIntervalEncoderDecoder.scala @@ -54,12 +54,12 @@ object PostgreSQLIntervalEncoderDecoder extends ColumnEncoderDecoder { private def postgresHMSBuilder(builder : PeriodFormatterBuilder) = builder // .printZeroAlways // really all-or-nothing .rejectSignedValues(true) // XXX: sign should apply to all - .appendHours .appendSeparator(":") - .appendMinutes.appendSeparator(":") + .appendHours .appendSuffix(":") + .appendMinutes.appendSuffix(":") .appendSecondsWithOptionalMillis - private val secsParser = new PeriodFormatterBuilder() - .appendSecondsWithOptionalMillis + private val hmsParser = + postgresHMSBuilder(new PeriodFormatterBuilder()) .toFormatter private val postgresParser = @@ -100,8 +100,8 @@ object PostgreSQLIntervalEncoderDecoder extends ColumnEncoderDecoder { else { /* try to guess based on what comes after the first number */ val i = value.indexWhere(!_.isDigit, if ("-+".contains(value(0))) 1 else 0) - if (i <= 0 || value(i).equals('.')) /* just a number */ - secsParser /* postgres treats this as seconds */ + if (i < 0 || ":.".contains(value(i))) /* simple HMS (to support group negation) */ + hmsParser else if (value(i).equals('-')) /* sql_standard: Y-M */ sqlParser else if (value(i).equals(' ') && i+1 < value.length && value(i+1).isDigit) /* sql_standard: D H:M:S */ @@ -110,7 +110,9 @@ object PostgreSQLIntervalEncoderDecoder extends ColumnEncoderDecoder { postgresParser } ) - if (value.endsWith(" ago")) /* only really applies to postgres_verbose, but shouldn't hurt */ + if ((format eq hmsParser) && value(0).equals('-')) + format.parsePeriod(value.substring(1)).negated + else if (value.endsWith(" ago")) /* only really applies to postgres_verbose, but shouldn't hurt */ format.parsePeriod(value.stripSuffix(" ago")).negated else format.parsePeriod(value) diff --git a/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/column/IntervalSpec.scala b/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/column/IntervalSpec.scala index 011836a2..491e2403 100644 --- a/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/column/IntervalSpec.scala +++ b/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/column/IntervalSpec.scala @@ -40,6 +40,8 @@ class IntervalSpec extends Specification { Seq("@ 1 year 2 mons -3 days 4 hours 5 mins 6 secs ago", "P-1Y-2M3DT-4H-5M-6S") forall { both(_) === "P-1Y-2M3DT-4H-5M-6S" } + both("-1.234") === "PT-1.234S" + both("-4:05:06") === "PT-4H-5M-6S" } "parse and encode example intervals" in {