From 1e4e124e278084fa289957fca34a032f91261168 Mon Sep 17 00:00:00 2001 From: "Sean T. Allen" Date: Sun, 15 Mar 2026 15:01:59 -0400 Subject: [PATCH] Fix interval text codec only parsing default intervalstyle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _IntervalTextCodec.decode() only handled the default postgres interval style. If a user changed their PostgreSQL session's intervalstyle setting, SimpleQuery results for interval columns would fail to decode. The text codec now detects and parses all four PostgreSQL intervalstyle formats via a dispatch heuristic: leading P → iso_8601, leading @ → postgres_verbose, any ASCII letter → postgres, otherwise sql_standard. Also fixes a pre-existing bug where mixed-sign postgres-style intervals with + prefixed tokens (e.g., +3 days) failed because Pony's String.i64() doesn't handle + prefix. Closes #145 --- CLAUDE.md | 2 +- postgres/_test.pony | 27 +++ postgres/_test_codecs.pony | 390 ++++++++++++++++++++++++++++++++++++- postgres/_text_codecs.pony | 307 +++++++++++++++++++++++++++-- 4 files changed, 708 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 45ef0bc..4d52ce3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,7 +167,7 @@ Codec-based decoding via `CodecRegistry.decode(oid, format_code, data)`. Extende `Codec` interface with `encode`/`decode`/`format` methods. Built-in codecs are primitives: - Binary codecs (`_binary_codecs.pony`): `_BoolBinaryCodec`, `_ByteaBinaryCodec`, `_Int2BinaryCodec`, `_Int4BinaryCodec`, `_Int8BinaryCodec`, `_Float4BinaryCodec`, `_Float8BinaryCodec`, `_DateBinaryCodec`, `_TimeBinaryCodec`, `_TimestampBinaryCodec`, `_IntervalBinaryCodec`, `_OidBinaryCodec`, `_NumericBinaryCodec` (encode implemented: parses decimal string → base-10000 digit format; handles positive/negative integers, fractional values, zero, NaN, Infinity/-Infinity), `_UuidBinaryCodec`, `_JsonbBinaryCodec` — big-endian wire encoding - Text passthrough binary codec (`_text_passthrough_binary_codec.pony`): `_TextPassthroughBinaryCodec` — for text-like OIDs (char, name, text, json, xml, bpchar, varchar) where PostgreSQL binary format is raw UTF-8 -- Text codecs (`_text_codecs.pony`): `_BoolTextCodec`, `_ByteaTextCodec`, `_Int2TextCodec`, `_Int4TextCodec`, `_Int8TextCodec`, `_Float4TextCodec`, `_Float8TextCodec`, `_DateTextCodec`, `_TimeTextCodec`, `_TimestampTextCodec`, `_TimestamptzTextCodec`, `_IntervalTextCodec`, `_TextPassthroughTextCodec`, `_OidTextCodec`, `_NumericTextCodec`, `_UuidTextCodec`, `_JsonbTextCodec` +- Text codecs (`_text_codecs.pony`): `_BoolTextCodec`, `_ByteaTextCodec`, `_Int2TextCodec`, `_Int4TextCodec`, `_Int8TextCodec`, `_Float4TextCodec`, `_Float8TextCodec`, `_DateTextCodec`, `_TimeTextCodec`, `_TimestampTextCodec`, `_TimestamptzTextCodec`, `_IntervalTextCodec` (supports all four `intervalstyle` formats: `postgres`, `postgres_verbose`, `iso_8601`, `sql_standard` — detected via heuristic in `decode()`), `_TextPassthroughTextCodec`, `_OidTextCodec`, `_NumericTextCodec`, `_UuidTextCodec`, `_JsonbTextCodec` - `_ArrayOidMap` (`_array_oid_map.pony`): static bidirectional mapping between element OIDs and array OIDs (23 entries). Methods: `element_oid_for(array_oid)`, `array_oid_for(element_oid)`, `is_array_oid(oid)` - `_ArrayEncoder` (`_array_encoder.pony`): encodes `PgArray` to binary array wire format. Dispatches element encoding on Pony runtime types. String elements routed by `element_oid`: uuid → `_UuidBinaryCodec`, jsonb → `_JsonbBinaryCodec`, oid → `_OidBinaryCodec`, numeric → `_NumericBinaryCodec`; all others → raw UTF-8 bytes. Coupling: element encoding must stay in sync with `_FrontendMessage.bind()` and `_binary_codecs.pony` - `CodecRegistry` (`codec_registry.pony`): maps OIDs to codecs. Adds `_custom_array_element_oids: Map[U32, U32] val` field for custom array type registrations. Default constructor populates all built-ins. `with_codec(oid, codec)` returns a new registry with the codec added/replaced. `with_array_type(array_oid, element_oid)` returns a new registry with the custom array mapping added. `array_oid_for(element_oid): U32` returns the array OID for the given element OID. `decode()` intercepts array OIDs before normal codec dispatch. `has_binary_codec()` checks array OIDs too. `_with_codec` constructor (type-private) used internally by `with_codec` diff --git a/postgres/_test.pony b/postgres/_test.pony index 99c32ea..a8f4645 100644 --- a/postgres/_test.pony +++ b/postgres/_test.pony @@ -307,6 +307,33 @@ actor \nodoc\ Main is TestList test(_TestIntervalTextCodecNegativeDays) test(_TestIntervalTextCodecFractionalSeconds) test(_TestIntervalTextCodecEncode) + test(_TestIntervalTextCodecPostgresMixedSign) + test(_TestIntervalTextCodecISO8601Full) + test(_TestIntervalTextCodecISO8601YearOnly) + test(_TestIntervalTextCodecISO8601TimeOnly) + test(_TestIntervalTextCodecISO8601DaysOnly) + test(_TestIntervalTextCodecISO8601Negative) + test(_TestIntervalTextCodecISO8601Fractional) + test(_TestIntervalTextCodecISO8601Zero) + test(_TestIntervalTextCodecISO8601FullFractional) + test(_TestIntervalTextCodecISO8601NegativeFractional) + test(_TestIntervalTextCodecVerboseFull) + test(_TestIntervalTextCodecVerboseAgo) + test(_TestIntervalTextCodecVerboseMixedAgo) + test(_TestIntervalTextCodecVerboseZero) + test(_TestIntervalTextCodecVerboseFractional) + test(_TestIntervalTextCodecVerboseDaysOnly) + test(_TestIntervalTextCodecVerboseNegNoAgo) + test(_TestIntervalTextCodecVerboseNegFractional) + test(_TestIntervalTextCodecSQLFullMixed) + test(_TestIntervalTextCodecSQLYearMonthOnly) + test(_TestIntervalTextCodecSQLNegYearMonth) + test(_TestIntervalTextCodecSQLDayTime) + test(_TestIntervalTextCodecSQLTimeOnly) + test(_TestIntervalTextCodecSQLZero) + test(_TestIntervalTextCodecSQLNegDayTime) + test(_TestIntervalTextCodecSQLFractional) + test(_TestIntervalTextCodecSQLZeroYM) test(_TestPgTimestampString) test(_TestPgDateString) test(_TestPgTimeString) diff --git a/postgres/_test_codecs.pony b/postgres/_test_codecs.pony index 06f3d48..0345b78 100644 --- a/postgres/_test_codecs.pony +++ b/postgres/_test_codecs.pony @@ -1692,6 +1692,372 @@ class \nodoc\ iso _TestIntervalTextCodecEncode is UnitTest h.assert_eq[String]("1 year 2 mons 5 days 01:00:00", String.from_array(encoded)) +class \nodoc\ iso _TestIntervalTextCodecPostgresMixedSign is UnitTest + fun name(): String => + "Codec/Text/Interval/Postgres/MixedSign" + + fun apply(h: TestHelper) ? => + // Mixed signs: -1 years -2 mons +3 days -04:05:06 + match _IntervalTextCodec.decode( + "-1 years -2 mons +3 days -04:05:06".array())? + | let i: PgInterval => + h.assert_eq[I32](-14, i.months) + h.assert_eq[I32](3, i.days) + h.assert_eq[I64](-14_706_000_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +// --------------------------------------------------------------------------- +// ISO 8601 intervalstyle tests +// --------------------------------------------------------------------------- + +class \nodoc\ iso _TestIntervalTextCodecISO8601Full is UnitTest + fun name(): String => + "Codec/Text/Interval/ISO8601/Full" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("P1Y2M3DT4H5M6S".array())? + | let i: PgInterval => + h.assert_eq[I32](14, i.months) + h.assert_eq[I32](3, i.days) + h.assert_eq[I64](14_706_000_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecISO8601YearOnly is UnitTest + fun name(): String => + "Codec/Text/Interval/ISO8601/YearOnly" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("P1Y".array())? + | let i: PgInterval => + h.assert_eq[I32](12, i.months) + h.assert_eq[I32](0, i.days) + h.assert_eq[I64](0, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecISO8601TimeOnly is UnitTest + fun name(): String => + "Codec/Text/Interval/ISO8601/TimeOnly" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("PT1H".array())? + | let i: PgInterval => + h.assert_eq[I32](0, i.months) + h.assert_eq[I32](0, i.days) + h.assert_eq[I64](3_600_000_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecISO8601DaysOnly is UnitTest + fun name(): String => + "Codec/Text/Interval/ISO8601/DaysOnly" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("P3D".array())? + | let i: PgInterval => + h.assert_eq[I32](0, i.months) + h.assert_eq[I32](3, i.days) + h.assert_eq[I64](0, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecISO8601Negative is UnitTest + fun name(): String => + "Codec/Text/Interval/ISO8601/Negative" + + fun apply(h: TestHelper) ? => + // P-1Y-2M3DT-4H-5M-6S: -1Y=-12, -2M=-2 => -14 months, 3 days, + // -4H-5M-6S => -14706 seconds => -14706000000 us + match _IntervalTextCodec.decode("P-1Y-2M3DT-4H-5M-6S".array())? + | let i: PgInterval => + h.assert_eq[I32](-14, i.months) + h.assert_eq[I32](3, i.days) + h.assert_eq[I64](-14_706_000_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecISO8601Fractional is UnitTest + fun name(): String => + "Codec/Text/Interval/ISO8601/Fractional" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("PT1.5S".array())? + | let i: PgInterval => + h.assert_eq[I64](1_500_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecISO8601Zero is UnitTest + fun name(): String => + "Codec/Text/Interval/ISO8601/Zero" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("PT0S".array())? + | let i: PgInterval => + h.assert_eq[I32](0, i.months) + h.assert_eq[I32](0, i.days) + h.assert_eq[I64](0, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecISO8601FullFractional is UnitTest + fun name(): String => + "Codec/Text/Interval/ISO8601/FullFractional" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("P1Y2M3DT4H5M6.789S".array())? + | let i: PgInterval => + h.assert_eq[I32](14, i.months) + h.assert_eq[I32](3, i.days) + // 4*3600 + 5*60 + 6 = 14706 seconds, + 0.789 = 14706789000 us + h.assert_eq[I64](14_706_789_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecISO8601NegativeFractional is UnitTest + fun name(): String => + "Codec/Text/Interval/ISO8601/NegativeFractional" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("PT-1.5S".array())? + | let i: PgInterval => + h.assert_eq[I64](-1_500_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +// --------------------------------------------------------------------------- +// postgres_verbose intervalstyle tests +// --------------------------------------------------------------------------- + +class \nodoc\ iso _TestIntervalTextCodecVerboseFull is UnitTest + fun name(): String => + "Codec/Text/Interval/Verbose/Full" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode( + "@ 1 year 2 mons 3 days 4 hours 5 mins 6 secs".array())? + | let i: PgInterval => + h.assert_eq[I32](14, i.months) + h.assert_eq[I32](3, i.days) + h.assert_eq[I64](14_706_000_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecVerboseAgo is UnitTest + fun name(): String => + "Codec/Text/Interval/Verbose/Ago" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("@ 3 days ago".array())? + | let i: PgInterval => + h.assert_eq[I32](0, i.months) + h.assert_eq[I32](-3, i.days) + h.assert_eq[I64](0, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecVerboseMixedAgo is UnitTest + fun name(): String => + "Codec/Text/Interval/Verbose/MixedAgo" + + fun apply(h: TestHelper) ? => + // "@ 1 year 2 mons -3 days 4 hours 5 mins 6 secs ago" + // Before ago: months=14, days=-3, us=14706000000 + // After ago negate: months=-14, days=3, us=-14706000000 + match _IntervalTextCodec.decode( + "@ 1 year 2 mons -3 days 4 hours 5 mins 6 secs ago".array())? + | let i: PgInterval => + h.assert_eq[I32](-14, i.months) + h.assert_eq[I32](3, i.days) + h.assert_eq[I64](-14_706_000_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecVerboseZero is UnitTest + fun name(): String => + "Codec/Text/Interval/Verbose/Zero" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("@ 0".array())? + | let i: PgInterval => + h.assert_eq[I32](0, i.months) + h.assert_eq[I32](0, i.days) + h.assert_eq[I64](0, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecVerboseFractional is UnitTest + fun name(): String => + "Codec/Text/Interval/Verbose/Fractional" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("@ 1.5 secs".array())? + | let i: PgInterval => + h.assert_eq[I64](1_500_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecVerboseDaysOnly is UnitTest + fun name(): String => + "Codec/Text/Interval/Verbose/DaysOnly" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("@ 5 days".array())? + | let i: PgInterval => + h.assert_eq[I32](0, i.months) + h.assert_eq[I32](5, i.days) + h.assert_eq[I64](0, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecVerboseNegNoAgo is UnitTest + fun name(): String => + "Codec/Text/Interval/Verbose/NegNoAgo" + + fun apply(h: TestHelper) ? => + // "@ 1 day -1 hours" — mixed signs without ago + match _IntervalTextCodec.decode("@ 1 day -1 hours".array())? + | let i: PgInterval => + h.assert_eq[I32](0, i.months) + h.assert_eq[I32](1, i.days) + h.assert_eq[I64](-3_600_000_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecVerboseNegFractional is UnitTest + fun name(): String => + "Codec/Text/Interval/Verbose/NegFractional" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("@ -1.5 secs".array())? + | let i: PgInterval => + h.assert_eq[I64](-1_500_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +// --------------------------------------------------------------------------- +// sql_standard intervalstyle tests +// --------------------------------------------------------------------------- + +class \nodoc\ iso _TestIntervalTextCodecSQLFullMixed is UnitTest + fun name(): String => + "Codec/Text/Interval/SQL/FullMixed" + + fun apply(h: TestHelper) ? => + // "-1-2 +3 -4:05:06" + match _IntervalTextCodec.decode("-1-2 +3 -4:05:06".array())? + | let i: PgInterval => + h.assert_eq[I32](-14, i.months) + h.assert_eq[I32](3, i.days) + h.assert_eq[I64](-14_706_000_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecSQLYearMonthOnly is UnitTest + fun name(): String => + "Codec/Text/Interval/SQL/YearMonthOnly" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("1-2".array())? + | let i: PgInterval => + h.assert_eq[I32](14, i.months) + h.assert_eq[I32](0, i.days) + h.assert_eq[I64](0, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecSQLNegYearMonth is UnitTest + fun name(): String => + "Codec/Text/Interval/SQL/NegYearMonth" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("-1-2".array())? + | let i: PgInterval => + h.assert_eq[I32](-14, i.months) + h.assert_eq[I32](0, i.days) + h.assert_eq[I64](0, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecSQLDayTime is UnitTest + fun name(): String => + "Codec/Text/Interval/SQL/DayTime" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("3 4:05:06".array())? + | let i: PgInterval => + h.assert_eq[I32](0, i.months) + h.assert_eq[I32](3, i.days) + h.assert_eq[I64](14_706_000_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecSQLTimeOnly is UnitTest + fun name(): String => + "Codec/Text/Interval/SQL/TimeOnly" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("4:05:06".array())? + | let i: PgInterval => + h.assert_eq[I32](0, i.months) + h.assert_eq[I32](0, i.days) + h.assert_eq[I64](14_706_000_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecSQLZero is UnitTest + fun name(): String => + "Codec/Text/Interval/SQL/Zero" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("0".array())? + | let i: PgInterval => + h.assert_eq[I32](0, i.months) + h.assert_eq[I32](0, i.days) + h.assert_eq[I64](0, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecSQLNegDayTime is UnitTest + fun name(): String => + "Codec/Text/Interval/SQL/NegDayTime" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("-3 -4:05:06".array())? + | let i: PgInterval => + h.assert_eq[I32](0, i.months) + h.assert_eq[I32](-3, i.days) + h.assert_eq[I64](-14_706_000_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecSQLFractional is UnitTest + fun name(): String => + "Codec/Text/Interval/SQL/Fractional" + + fun apply(h: TestHelper) ? => + match _IntervalTextCodec.decode("0:00:01.5".array())? + | let i: PgInterval => + h.assert_eq[I64](1_500_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + +class \nodoc\ iso _TestIntervalTextCodecSQLZeroYM is UnitTest + fun name(): String => + "Codec/Text/Interval/SQL/ZeroYM" + + fun apply(h: TestHelper) ? => + // "+0-0 +1 -1:00:00" + match _IntervalTextCodec.decode("+0-0 +1 -1:00:00".array())? + | let i: PgInterval => + h.assert_eq[I32](0, i.months) + h.assert_eq[I32](1, i.days) + h.assert_eq[I64](-3_600_000_000, i.microseconds) + else h.fail("Expected PgInterval from decode") + end + // --------------------------------------------------------------------------- // Temporal type string() tests // --------------------------------------------------------------------------- @@ -2529,9 +2895,31 @@ class \nodoc\ iso _TestIntervalTextCodecBadInput is UnitTest "Codec/Text/Interval/BadInput" fun apply(h: TestHelper) => - // Number without unit pair + // Number without unit pair (routes to sql_standard) h.assert_error({()? => _IntervalTextCodec.decode("5".array())? }) + // ISO 8601: bare "P" with no components + h.assert_error({()? => _IntervalTextCodec.decode("P".array())? }) + // ISO 8601: "PT" with no time components + h.assert_error({()? => _IntervalTextCodec.decode("PT".array())? }) + + // Verbose: "@ " with nothing after prefix + h.assert_error({()? => _IntervalTextCodec.decode("@ ".array())? }) + // Verbose: number without unit pair + h.assert_error({()? => _IntervalTextCodec.decode("@ 5".array())? }) + // Verbose: unrecognized unit + h.assert_error( + {()? => _IntervalTextCodec.decode("@ 1 foobar".array())? }) + + // SQL standard: empty string + h.assert_error({()? => _IntervalTextCodec.decode("".array())? }) + // Too many groups (routes to postgres due to alpha, fails on parse) + h.assert_error( + {()? => _IntervalTextCodec.decode("1-2 3 4:05:06 extra".array())? }) + // SQL standard: too many groups + h.assert_error( + {()? => _IntervalTextCodec.decode("1-2 3 4:05:06 7".array())? }) + // --------------------------------------------------------------------------- // CodecRegistry dispatch tests for remaining temporal types // --------------------------------------------------------------------------- diff --git a/postgres/_text_codecs.pony b/postgres/_text_codecs.pony index 22cdc01..e192ad1 100644 --- a/postgres/_text_codecs.pony +++ b/postgres/_text_codecs.pony @@ -435,9 +435,15 @@ primitive _TimestamptzTextCodec is Codec primitive _IntervalTextCodec is Codec """ Text codec for PostgreSQL `interval` (OID 1186). - Parses PostgreSQL `postgres` output style: - `1 year 2 mons 3 days 04:05:06.789` - Components are optional; time part is always last. + Supports all four `intervalstyle` settings: + + - `postgres` (default): `1 year 2 mons 3 days 04:05:06.789` + - `postgres_verbose`: `@ 1 year 2 mons 3 days 4 hours 5 mins 6.789 secs` + - `iso_8601`: `P1Y2M3DT4H5M6.789S` + - `sql_standard`: `+1-2 +3 +4:05:06.789` + + Detection heuristic: leading `P` → iso_8601, leading `@` → postgres_verbose, + any ASCII letter → postgres, otherwise sql_standard. """ fun format(): U16 => 0 @@ -450,11 +456,22 @@ primitive _IntervalTextCodec is Codec fun decode(data: Array[U8] val): FieldData ? => let s = String.from_array(data) + if s.at("P") then _decode_iso8601(s)? + elseif s.at("@") then _decode_postgres_verbose(s)? + elseif _has_alpha(s) then _decode_postgres(s)? + else _decode_sql_standard(s)? + end + + fun _decode_postgres(s: String): PgInterval ? => + """ + Parse the default `postgres` interval style. + `1 year 2 mons 3 days 04:05:06.789` + Mixed-sign intervals use `+` prefix: `-1 years -2 mons +3 days -04:05:06`. + """ var total_months: I32 = 0 var total_days: I32 = 0 var total_us: I64 = 0 - // Split into tokens let tokens = s.split(" ") var ti: USize = 0 while ti < tokens.size() do @@ -464,15 +481,9 @@ primitive _IntervalTextCodec is Codec continue end - // Check if this token contains ':' — it's the time part if tok.contains(":") then - // Parse [-]HH:MM:SS[.ffffff] - let negative = tok.at("-") - let time_str = if negative then - tok.substring(1) - else - tok - end + // Parse [+|-]HH:MM:SS[.ffffff] + (let negative, let time_str) = _strip_sign(tok) (let h, let m, let sec, let frac) = _TimeTextCodec._parse_time(consume time_str)? let us = (h * 3_600_000_000) + (m * 60_000_000) @@ -481,19 +492,283 @@ primitive _IntervalTextCodec is Codec ti = ti + 1 else // Number + unit pair - let num = tok.i64()? + (let negative, let num_str) = _strip_sign(tok) + let num = num_str.i64()? + let signed_num: I64 = if negative then -num else num end ti = ti + 1 if ti >= tokens.size() then error end let unit = tokens(ti)? if unit.at("year") then - total_months = total_months + (num.i32() * 12) + total_months = total_months + (signed_num.i32() * 12) elseif unit.at("mon") then - total_months = total_months + num.i32() + total_months = total_months + signed_num.i32() elseif unit.at("day") then - total_days = total_days + num.i32() + total_days = total_days + signed_num.i32() end ti = ti + 1 end end PgInterval(total_us, total_days, total_months) + + fun _decode_iso8601(s: String): PgInterval ? => + """ + Parse ISO 8601 interval: `P[nY][nM][nD][T[nH][nM][n[.f]S]]`. + Per-component signs are allowed: `P-1Y-2M3DT-4H-5M-6S`. + """ + if s.size() < 2 then error end // At least "P" + something + var total_months: I32 = 0 + var total_days: I32 = 0 + var total_us: I64 = 0 + var in_time: Bool = false + var buf = String + var has_component: Bool = false + var i: USize = 1 // Skip 'P' + + while i < s.size() do + let c = s(i)? + if c == 'T' then + in_time = true + i = i + 1 + continue + end + + if ((c >= '0') and (c <= '9')) or (c == '-') or (c == '.') then + buf.push(c) + i = i + 1 + continue + end + + // Designator letter — consume accumulated buffer + let current: String val = buf.clone() + buf.clear() + has_component = true + if c == 'Y' then + (let neg, let r) = _strip_sign(current) + let v = r.i64()? + total_months = total_months + + ((if neg then -v else v end).i32() * 12) + elseif (c == 'M') and not in_time then + (let neg, let r) = _strip_sign(current) + let v = r.i64()? + total_months = total_months + (if neg then -v else v end).i32() + elseif c == 'D' then + (let neg, let r) = _strip_sign(current) + let v = r.i64()? + total_days = total_days + (if neg then -v else v end).i32() + elseif c == 'H' then + (let neg, let r) = _strip_sign(current) + let v = r.i64()? + let signed_v: I64 = if neg then -v else v end + total_us = total_us + (signed_v * 3_600_000_000) + elseif (c == 'M') and in_time then + (let neg, let r) = _strip_sign(current) + let v = r.i64()? + let signed_v: I64 = if neg then -v else v end + total_us = total_us + (signed_v * 60_000_000) + elseif c == 'S' then + (let neg, let raw) = _strip_sign(current) + let dot_parts = raw.split(".") + let sec_val = dot_parts(0)?.i64()? + let frac_us: I64 = if dot_parts.size() > 1 then + _TimeTextCodec._parse_fractional(dot_parts(1)?)? + else + 0 + end + let signed_sec: I64 = if neg then -sec_val else sec_val end + let signed_frac: I64 = if neg then -frac_us else frac_us end + total_us = total_us + (signed_sec * 1_000_000) + signed_frac + end + i = i + 1 + end + + if not has_component then error end + PgInterval(total_us, total_days, total_months) + + fun _decode_postgres_verbose(s: String): PgInterval ? => + """ + Parse `postgres_verbose` interval style. + `@ 1 year 2 mons 3 days 4 hours 5 mins 6.789 secs` + Trailing `ago` negates all components. + """ + // Strip "@ " prefix + let body = s.substring(2) + let tokens = body.split(" ") + + // Remove empty tokens + let clean = Array[String] + for tok in (consume tokens).values() do + if tok.size() > 0 then clean.push(tok) end + end + + if clean.size() == 0 then error end + + // Check for "ago" suffix + let negate = try clean(clean.size() - 1)?.eq("ago") else false end + let token_count = if negate then clean.size() - 1 else clean.size() end + + // Special case: "0" + if (token_count == 1) and (try clean(0)?.eq("0") else false end) then + return PgInterval(0, 0, 0) + end + + var total_months: I32 = 0 + var total_days: I32 = 0 + var total_us: I64 = 0 + var ti: USize = 0 + + while ti < token_count do + let num_tok = clean(ti)? + ti = ti + 1 + if ti >= token_count then error end + let unit = clean(ti)? + ti = ti + 1 + + if unit.at("year") then + (let neg, let r) = _strip_sign(num_tok) + let v = r.i64()? + let sv: I64 = if neg then -v else v end + total_months = total_months + (sv.i32() * 12) + elseif unit.at("mon") then + (let neg, let r) = _strip_sign(num_tok) + let v = r.i64()? + let sv: I64 = if neg then -v else v end + total_months = total_months + sv.i32() + elseif unit.at("day") then + (let neg, let r) = _strip_sign(num_tok) + let v = r.i64()? + let sv: I64 = if neg then -v else v end + total_days = total_days + sv.i32() + elseif unit.at("hour") then + (let neg, let r) = _strip_sign(num_tok) + let v = r.i64()? + let sv: I64 = if neg then -v else v end + total_us = total_us + (sv * 3_600_000_000) + elseif unit.at("min") then + (let neg, let r) = _strip_sign(num_tok) + let v = r.i64()? + let sv: I64 = if neg then -v else v end + total_us = total_us + (sv * 60_000_000) + elseif unit.at("sec") then + (let neg, let raw) = _strip_sign(num_tok) + let dot_parts = raw.split(".") + let sec_val = dot_parts(0)?.i64()? + let frac_us: I64 = if dot_parts.size() > 1 then + _TimeTextCodec._parse_fractional(dot_parts(1)?)? + else + 0 + end + let signed_sec: I64 = if neg then -sec_val else sec_val end + let signed_frac: I64 = if neg then -frac_us else frac_us end + total_us = total_us + (signed_sec * 1_000_000) + signed_frac + else + error + end + end + + if negate then + PgInterval(-total_us, -total_days, -total_months) + else + PgInterval(total_us, total_days, total_months) + end + + fun _decode_sql_standard(s: String): PgInterval ? => + """ + Parse `sql_standard` interval style. Group-count dispatch: + - `"0"` → zero + - 1 group: time (`H:MM:SS`) or year-month (`Y-M`) + - 2 groups: day + time + - 3 groups: year-month + day + time + """ + let groups = s.split(" ") + + // Remove empty groups + let clean = Array[String] + for g in (consume groups).values() do + if g.size() > 0 then clean.push(g) end + end + + match clean.size() + | 1 => + let g = clean(0)? + if g.eq("0") then + return PgInterval(0, 0, 0) + end + if g.contains(":") then + // Time only + let us = _parse_sql_time(g)? + return PgInterval(us, 0, 0) + end + // Year-month only (after stripping sign, must contain '-') + (let neg, let r) = _strip_sign(g) + if r.contains("-") then + let months = _parse_sql_year_month(g)? + return PgInterval(0, 0, months) + end + error + | 2 => + // Day + time + let days = _parse_sql_day(clean(0)?)? + let us = _parse_sql_time(clean(1)?)? + return PgInterval(us, days, 0) + | 3 => + // Year-month + day + time + let months = _parse_sql_year_month(clean(0)?)? + let days = _parse_sql_day(clean(1)?)? + let us = _parse_sql_time(clean(2)?)? + return PgInterval(us, days, months) + end + error + + fun _parse_sql_year_month(s: String): I32 ? => + """Parse `[+|-]Y-M` into total months.""" + (let neg, let r) = _strip_sign(s) + let parts = r.split("-") + if parts.size() != 2 then error end + let years = parts(0)?.i64()? + let months = parts(1)?.i64()? + let total = (years * 12) + months + (if neg then -total else total end).i32() + + fun _parse_sql_day(s: String): I32 ? => + """Parse `[+|-]D` into days.""" + (let neg, let r) = _strip_sign(s) + let v = r.i64()? + (if neg then -v else v end).i32() + + fun _parse_sql_time(s: String): I64 ? => + """Parse `[+|-]H:MM:SS[.ffffff]` into microseconds.""" + (let neg, let time_str) = _strip_sign(s) + (let h, let m, let sec, let frac) = + _TimeTextCodec._parse_time(consume time_str)? + let us = (h * 3_600_000_000) + (m * 60_000_000) + + (sec * 1_000_000) + frac + if neg then -us else us end + + fun _has_alpha(s: String box): Bool => + """Scan for any ASCII letter.""" + try + var i: USize = 0 + while i < s.size() do + let c = s(i)? + if ((c >= 'A') and (c <= 'Z')) or ((c >= 'a') and (c <= 'z')) then + return true + end + i = i + 1 + end + end + false + + fun _strip_sign(s: String): (Bool, String) => + """ + Strip leading `+` or `-`, returning `(is_negative, remainder)`. + Pony's `String.i64()` doesn't handle `+` prefix, so all parsers use this + before integer parsing. + """ + if s.at("-") then + (true, s.substring(1)) + elseif s.at("+") then + (false, s.substring(1)) + else + (false, s) + end