From 6392f2d59f3a41b36612fbbd7b42ce00547b0add Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Fri, 5 Jun 2026 20:19:21 +0000 Subject: [PATCH] docs(bsql): document supported date/time formats + cover them with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend TestParseTime with 16 new table-driven cases that exercise every format the connector accepts: RFC3339/Nano with offsets, MySQL/PostgreSQL space-separated timestamps at ms/µs/ns precision, ISO8601 with fractional seconds and timezone offsets, European and US slash notation (datetime and date-only), Oracle NLS_DATE_FORMAT variants (uppercase/mixed-case/short-year abbreviations, long and full month names, date-only), DB2 TIMESTAMP, and numeric Unix epoch seconds and milliseconds. Add docs/datetime-formats.md, an end-user config reference that documents exactly the tested set: grouped by DB family, one concrete example per format, the epoch range guards, the US/EU slash ambiguity note, the best-effort skip-on-failure behaviour, and an escape hatch (cast to ISO 8601 in the query) for unlisted formats. Co-authored-by: c1-squire-dev[bot] --- docs/datetime-formats.md | 105 +++++++++++++++++++++++++++++++++++++++ pkg/bsql/helpers_test.go | 96 +++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 docs/datetime-formats.md diff --git a/docs/datetime-formats.md b/docs/datetime-formats.md new file mode 100644 index 00000000..42707bf5 --- /dev/null +++ b/docs/datetime-formats.md @@ -0,0 +1,105 @@ +# Date & time values + +Two fields in `traits.secret` accept datetime values from your SQL query results: + +| Field | Trait | Description | +|---|---|---| +| `expires_at` | `traits.secret` | When the credential expires | +| `last_used_at` | `traits.secret` | When the credential was last used | + +```yaml +traits: + secret: + expires_at: ".expires_at" # column or CEL expression + last_used_at: ".last_used_at" # column or CEL expression +``` + +## Accepted formats + +The connector accepts both string timestamps and numeric epoch columns. The formats below are exactly those covered by the test suite — every entry has a passing test. + +### RFC 3339 / ISO 8601 + +| Example | Notes | +|---|---| +| `2025-09-01T00:00:00Z` | UTC, no fractional seconds | +| `2025-09-01T00:00:00+05:30` | With timezone offset | +| `2025-04-17T14:30:45.123Z` | Millisecond precision | +| `2025-04-17T14:30:45.123456789Z` | Nanosecond precision | +| `2025-04-17T14:30:45.123-05:00` | Offset with fractional seconds | + +### SQL space-separated (MySQL, PostgreSQL, SQL Server, SQLite) + +| Example | Notes | +|---|---| +| `2025-04-17 14:30:45` | Basic DATETIME / TIMESTAMP | +| `2025-04-17 14:30:45.123` | Millisecond precision (MySQL) | +| `2025-04-17 14:30:45.123456` | Microsecond precision (PostgreSQL) | +| `2025-04-17 14:30:45.123456789` | Nanosecond precision | + +### Date only + +| Example | Notes | +|---|---| +| `2025-04-17` | ISO date — no time component | + +### US / European slash notation + +| Example | Notes | +|---|---| +| `04/17/2025 14:30:45` | US: MM/DD/YYYY with time | +| `17/04/2025 14:30:45` | European: DD/MM/YYYY with time | +| `04/17/2025` | US short date | +| `17/04/2025` | European short date | + +> **Ambiguity note:** when the day and month are both ≤ 12 (e.g. `05/06/2025`), US format is tried first and wins. Use an unambiguous column value or cast it to ISO 8601 in your query if this matters. + +### Oracle NLS_DATE_FORMAT variants + +| Example | Notes | +|---|---| +| `17-APR-2025 14:30:45` | Uppercase month abbreviation | +| `17-Apr-2025 14:30:45` | Mixed-case month abbreviation | +| `17-APR-25 14:30:45` | Uppercase abbreviation, 2-digit year | +| `17-Apr-25 14:30:45` | Mixed-case abbreviation, 2-digit year | +| `Apr 17, 2025 14:30:45` | Short-name, comma-separated | +| `April 17, 2025 14:30:45` | Full month name | +| `17-04-2025` | Day-month-year, date only | +| `17-04-25` | Day-month-year, 2-digit year | + +### DB2 TIMESTAMP + +| Example | Notes | +|---|---| +| `2025-04-17-14.30.45.123456` | DB2 native TIMESTAMP string | + +### Numeric epoch columns + +| Example | Notes | +|---|---| +| `1756684800` | Unix seconds (accepted range: 1970–2100) | +| `1756684800000` | Unix milliseconds (accepted range matching the same window) | + +### Vertica TIMESTAMPTZ + +Vertica's timezone-aware timestamp format (`2025-04-17 14:30:45.123456-04`) is handled by the engine-aware path and does not require any extra configuration. + +## Failure behavior + +Datetime parsing is **best-effort**. If a value cannot be parsed in any of the formats above, the connector logs a warning and skips that field — the resource is still synced, just without `expires_at` or `last_used_at` set. Nothing fails. + +To diagnose a skipped value, enable debug logging and look for: + +``` +failed to parse secret expires_at expires_at= +failed to parse secret last_used_at last_used_at= +``` + +If your column format is not in the list above, cast it to ISO 8601 in your query: + +```sql +-- PostgreSQL +SELECT id, name, + TO_CHAR(expires_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS expires_at +FROM api_tokens +``` diff --git a/pkg/bsql/helpers_test.go b/pkg/bsql/helpers_test.go index 1a098d36..ce3c8ee8 100644 --- a/pkg/bsql/helpers_test.go +++ b/pkg/bsql/helpers_test.go @@ -70,6 +70,102 @@ func TestParseTime(t *testing.T) { expected: time.Date(2025, 4, 17, 14, 30, 45, 0, time.UTC), expectSuccess: true, }, + { + name: "RFC3339 with timezone offset", + input: "2025-09-01T00:00:00+05:30", + expected: time.Date(2025, 9, 1, 0, 0, 0, 0, time.FixedZone("", 5*3600+30*60)), + expectSuccess: true, + }, + { + name: "RFC3339Nano", + input: "2025-04-17T14:30:45.123456789Z", + expected: time.Date(2025, 4, 17, 14, 30, 45, 123456789, time.UTC), + expectSuccess: true, + }, + { + name: "MySQL timestamp with milliseconds", + input: "2025-04-17 14:30:45.123", + expected: time.Date(2025, 4, 17, 14, 30, 45, 123000000, time.UTC), + expectSuccess: true, + }, + { + name: "PostgreSQL timestamp with microseconds", + input: "2025-04-17 14:30:45.123456", + expected: time.Date(2025, 4, 17, 14, 30, 45, 123456000, time.UTC), + expectSuccess: true, + }, + { + name: "Nanosecond precision timestamp", + input: "2025-04-17 14:30:45.123456789", + expected: time.Date(2025, 4, 17, 14, 30, 45, 123456789, time.UTC), + expectSuccess: true, + }, + { + name: "ISO8601 with milliseconds", + input: "2025-04-17T14:30:45.123Z", + expected: time.Date(2025, 4, 17, 14, 30, 45, 123000000, time.UTC), + expectSuccess: true, + }, + { + name: "ISO8601 with timezone offset and milliseconds", + input: "2025-04-17T14:30:45.123-05:00", + expected: time.Date(2025, 4, 17, 14, 30, 45, 123000000, time.FixedZone("", -5*3600)), + expectSuccess: true, + }, + { + name: "European datetime", + input: "17/04/2025 14:30:45", + expected: time.Date(2025, 4, 17, 14, 30, 45, 0, time.UTC), + expectSuccess: true, + }, + { + name: "US short date", + input: "04/17/2025", + expected: time.Date(2025, 4, 17, 0, 0, 0, 0, time.UTC), + expectSuccess: true, + }, + { + name: "European short date", + input: "17/04/2025", + expected: time.Date(2025, 4, 17, 0, 0, 0, 0, time.UTC), + expectSuccess: true, + }, + { + name: "Oracle long format", + input: "Apr 17, 2025 14:30:45", + expected: time.Date(2025, 4, 17, 14, 30, 45, 0, time.UTC), + expectSuccess: true, + }, + { + name: "Oracle mixed case short year", + input: "17-Apr-25 14:30:45", + expected: time.Date(2025, 4, 17, 14, 30, 45, 0, time.UTC), + expectSuccess: true, + }, + { + name: "Oracle date only day-month-year", + input: "17-04-2025", + expected: time.Date(2025, 4, 17, 0, 0, 0, 0, time.UTC), + expectSuccess: true, + }, + { + name: "Oracle date only short year", + input: "17-04-25", + expected: time.Date(2025, 4, 17, 0, 0, 0, 0, time.UTC), + expectSuccess: true, + }, + { + name: "Long month name format", + input: "April 17, 2025 14:30:45", + expected: time.Date(2025, 4, 17, 14, 30, 45, 0, time.UTC), + expectSuccess: true, + }, + { + name: "DB2 timestamp format", + input: "2025-04-17-14.30.45.123456", + expected: time.Date(2025, 4, 17, 14, 30, 45, 123456000, time.UTC), + expectSuccess: true, + }, { name: "Invalid format", input: "not a date",