diff --git a/public/schema/common.go b/public/schema/common.go index 473b97532..5cad2bad7 100644 --- a/public/schema/common.go +++ b/public/schema/common.go @@ -90,6 +90,9 @@ const ( Any CommonType = 14 Decimal CommonType = 15 BigDecimal CommonType = 16 + Date CommonType = 17 + TimeOfDay CommonType = 18 + UUID CommonType = 19 ) // Decimal precision bounds. The upper bound matches the widest precision that @@ -134,6 +137,12 @@ func (t CommonType) String() string { return "DECIMAL" case BigDecimal: return "BIG_DECIMAL" + case Date: + return "DATE" + case TimeOfDay: + return "TIME_OF_DAY" + case UUID: + return "UUID" default: return "UNKNOWN" } @@ -173,6 +182,12 @@ func typeFromStr(v string) (CommonType, error) { return Decimal, nil case "BIG_DECIMAL": return BigDecimal, nil + case "DATE": + return Date, nil + case "TIME_OF_DAY": + return TimeOfDay, nil + case "UUID": + return UUID, nil default: return 0, fmt.Errorf("unrecognised type string: %v", v) } @@ -199,7 +214,9 @@ type Common struct { // CommonType values may carry. Each parameterised type has its own field; // at most one is expected to be non-nil for any given Common schema. type LogicalParams struct { - Decimal *DecimalParams + Decimal *DecimalParams + Timestamp *TimestampParams + TimeOfDay *TimeOfDayParams } // DecimalParams describes a fixed-precision decimal number. @@ -214,6 +231,100 @@ type DecimalParams struct { Scale int32 } +// TimestampParams describes the precision and timezone semantics of a +// [Timestamp] schema. Unit selects the resolution at which the timestamp is +// expressed; AdjustToUTC distinguishes a UTC instant (true) from a civil / +// "local" datetime that carries no timezone offset (false). +// +// A nil [LogicalParams.Timestamp] on a [Timestamp]-typed schema is permitted +// for backwards compatibility and is treated as {Unit: TimeUnitMillis, +// AdjustToUTC: true}; see [Common.EffectiveTimestamp]. +type TimestampParams struct { + Unit TimeUnit + AdjustToUTC bool +} + +// TimeOfDayParams describes the precision and timezone semantics of a +// [TimeOfDay] schema (a wall-clock time with no date component). Unit selects +// the resolution; AdjustToUTC parallels the equivalent Parquet TIME flag and +// is rare outside Parquet/Postgres timetz. +// +// Unlike [TimestampParams], a [TimeOfDay]-typed schema must have non-nil +// [LogicalParams.TimeOfDay] — there is no historical default to fall back to. +type TimeOfDayParams struct { + Unit TimeUnit + AdjustToUTC bool +} + +// TimeUnit names the precision at which a [Timestamp] or [TimeOfDay] value is +// expressed. The zero value is invalid; use one of the named constants. +type TimeUnit int + +// Supported time units. +const ( + TimeUnitSeconds TimeUnit = 1 + TimeUnitMillis TimeUnit = 2 + TimeUnitMicros TimeUnit = 3 + TimeUnitNanos TimeUnit = 4 +) + +// String returns a human-readable representation of the time unit, suitable +// for serialisation via [Common.ToAny]. +func (u TimeUnit) String() string { + switch u { + case TimeUnitSeconds: + return "SECONDS" + case TimeUnitMillis: + return "MILLIS" + case TimeUnitMicros: + return "MICROS" + case TimeUnitNanos: + return "NANOS" + default: + return "UNKNOWN" + } +} + +func timeUnitFromStr(v string) (TimeUnit, error) { + switch v { + case "SECONDS": + return TimeUnitSeconds, nil + case "MILLIS": + return TimeUnitMillis, nil + case "MICROS": + return TimeUnitMicros, nil + case "NANOS": + return TimeUnitNanos, nil + default: + return 0, fmt.Errorf("unrecognised time unit string: %v", v) + } +} + +// valid reports whether u is one of the named TimeUnit constants. +func (u TimeUnit) valid() bool { + switch u { + case TimeUnitSeconds, TimeUnitMillis, TimeUnitMicros, TimeUnitNanos: + return true + default: + return false + } +} + +// EffectiveTimestamp returns the timestamp parameters for c, applying the +// legacy default ({Unit: TimeUnitMillis, AdjustToUTC: true}) when c.Logical +// is unset. It is only meaningful when c.Type == [Timestamp]; for other +// types the returned value should be ignored. +// +// Format adapters that need to honour both pre-parameterised legacy schemas +// and richer schemas produced by newer decoders should consult this rather +// than peeking at c.Logical directly. +func (c *Common) EffectiveTimestamp() TimestampParams { + if c.Logical != nil && c.Logical.Timestamp != nil { + return *c.Logical.Timestamp + } + return TimestampParams{Unit: TimeUnitMillis, AdjustToUTC: true} +} + const ( anyFieldType = "type" anyFieldName = "name" @@ -222,6 +333,8 @@ const ( anyFieldFingerprint = "fingerprint" anyFieldPrecision = "precision" anyFieldScale = "scale" + anyFieldUnit = "unit" + anyFieldAdjustToUTC = "adjust_to_utc" ) // ToAny serializes the common schema into a generic Go value, with structured @@ -265,6 +378,19 @@ func (c *Common) ToAny() any { m[anyFieldScale] = int64(c.Logical.Decimal.Scale) } + // Timestamp parameters are only emitted when present, so legacy schemas + // (Type == Timestamp with nil Logical) keep their pre-parameterised + // fingerprint and ToAny output exactly. + if c.Type == Timestamp && c.Logical != nil && c.Logical.Timestamp != nil { + m[anyFieldUnit] = c.Logical.Timestamp.Unit.String() + m[anyFieldAdjustToUTC] = c.Logical.Timestamp.AdjustToUTC + } + + if c.Type == TimeOfDay && c.Logical != nil && c.Logical.TimeOfDay != nil { + m[anyFieldUnit] = c.Logical.TimeOfDay.Unit.String() + m[anyFieldAdjustToUTC] = c.Logical.TimeOfDay.AdjustToUTC + } + return m } @@ -361,6 +487,42 @@ func parseFromAnyNoValidate(v any) (Common, error) { return c, errors.New("type DECIMAL requires fields `precision` and `scale`") } + _, hasUnit := obj[anyFieldUnit] + _, hasAdjust := obj[anyFieldAdjustToUTC] + if hasUnit || hasAdjust { + switch c.Type { + case Timestamp, TimeOfDay: + default: + return c, fmt.Errorf("fields `unit` and `adjust_to_utc` are only valid for types TIMESTAMP or TIME_OF_DAY, got %v", c.Type) + } + if !hasUnit { + return c, fmt.Errorf("type %v with `adjust_to_utc` requires field `unit`", c.Type) + } + if !hasAdjust { + return c, fmt.Errorf("type %v with `unit` requires field `adjust_to_utc`", c.Type) + } + unitStr, ok := obj[anyFieldUnit].(string) + if !ok { + return c, fmt.Errorf("expected field `unit` of type string, got %T", obj[anyFieldUnit]) + } + unit, err := timeUnitFromStr(unitStr) + if err != nil { + return c, err + } + adjustB, ok := obj[anyFieldAdjustToUTC].(bool) + if !ok { + return c, fmt.Errorf("expected field `adjust_to_utc` of type bool, got %T", obj[anyFieldAdjustToUTC]) + } + switch c.Type { + case Timestamp: + c.Logical = &LogicalParams{Timestamp: &TimestampParams{Unit: unit, AdjustToUTC: adjustB}} + case TimeOfDay: + c.Logical = &LogicalParams{TimeOfDay: &TimeOfDayParams{Unit: unit, AdjustToUTC: adjustB}} + } + } else if c.Type == TimeOfDay { + return c, errors.New("type TIME_OF_DAY requires fields `unit` and `adjust_to_utc`") + } + return c, nil } @@ -439,6 +601,33 @@ func (c *Common) Validate() error { return fmt.Errorf("Logical.Decimal parameters are only valid for type DECIMAL, got %v", c.Type) } + // Timestamp parameters are optional: a nil Logical.Timestamp on a + // Timestamp-typed schema is treated as the legacy default (millis, UTC), + // see [Common.EffectiveTimestamp]. When provided, the unit must be one of + // the named TimeUnit constants. + if c.Type == Timestamp { + if c.Logical != nil && c.Logical.Timestamp != nil { + if !c.Logical.Timestamp.Unit.valid() { + return fmt.Errorf("invalid timestamp unit %v", int(c.Logical.Timestamp.Unit)) + } + } + } else if c.Logical != nil && c.Logical.Timestamp != nil { + return fmt.Errorf("Logical.Timestamp parameters are only valid for type TIMESTAMP, got %v", c.Type) + } + + // TimeOfDay parameters are required: there is no historical default to + // fall back to, since the type itself is new. + if c.Type == TimeOfDay { + if c.Logical == nil || c.Logical.TimeOfDay == nil { + return errors.New("type TIME_OF_DAY requires Logical.TimeOfDay parameters") + } + if !c.Logical.TimeOfDay.Unit.valid() { + return fmt.Errorf("invalid time-of-day unit %v", int(c.Logical.TimeOfDay.Unit)) + } + } else if c.Logical != nil && c.Logical.TimeOfDay != nil { + return fmt.Errorf("Logical.TimeOfDay parameters are only valid for type TIME_OF_DAY, got %v", c.Type) + } + if !c.isContainerType() && len(c.Children) > 0 { return fmt.Errorf("type %v is a leaf and must not have children", c.Type) } @@ -497,6 +686,12 @@ func (c *Common) writeFingerprint(w io.Writer) { if c.Type == Decimal && c.Logical != nil && c.Logical.Decimal != nil { fmt.Fprintf(w, "D:%d:%d|", c.Logical.Decimal.Precision, c.Logical.Decimal.Scale) } + if c.Type == Timestamp && c.Logical != nil && c.Logical.Timestamp != nil { + fmt.Fprintf(w, "TS:%d:%t|", c.Logical.Timestamp.Unit, c.Logical.Timestamp.AdjustToUTC) + } + if c.Type == TimeOfDay && c.Logical != nil && c.Logical.TimeOfDay != nil { + fmt.Fprintf(w, "TOD:%d:%t|", c.Logical.TimeOfDay.Unit, c.Logical.TimeOfDay.AdjustToUTC) + } // Write children count and recursively fingerprint each child fmt.Fprintf(w, "C:%d|", len(c.Children)) diff --git a/public/schema/common_test.go b/public/schema/common_test.go index dc8cdd7c1..868aa1cd7 100644 --- a/public/schema/common_test.go +++ b/public/schema/common_test.go @@ -32,6 +32,9 @@ func TestSchemaStringify(t *testing.T) { {Input: Any, Output: "ANY"}, {Input: Decimal, Output: "DECIMAL"}, {Input: BigDecimal, Output: "BIG_DECIMAL"}, + {Input: Date, Output: "DATE"}, + {Input: TimeOfDay, Output: "TIME_OF_DAY"}, + {Input: UUID, Output: "UUID"}, {Input: zeroType, Output: "UNKNOWN"}, {Input: CommonType(-1), Output: "UNKNOWN"}, } { @@ -42,7 +45,7 @@ func TestSchemaStringify(t *testing.T) { func TestValidateRejectsChildrenOnLeafTypes(t *testing.T) { leafTypes := []CommonType{ Boolean, Int32, Int64, Float32, Float64, String, ByteArray, - Null, Timestamp, Any, BigDecimal, + Null, Timestamp, Any, BigDecimal, Date, UUID, } for _, typ := range leafTypes { @@ -85,3 +88,126 @@ func TestValidateRejectsChildrenOnDecimal(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "is a leaf and must not have children") } + +func TestValidateTimestampParams(t *testing.T) { + t.Run("nil Logical is permitted (legacy default)", func(t *testing.T) { + c := Common{Type: Timestamp, Name: "ts"} + assert.NoError(t, c.Validate()) + }) + + t.Run("populated Logical with valid unit", func(t *testing.T) { + for _, u := range []TimeUnit{TimeUnitSeconds, TimeUnitMillis, TimeUnitMicros, TimeUnitNanos} { + c := Common{ + Type: Timestamp, + Name: "ts", + Logical: &LogicalParams{Timestamp: &TimestampParams{Unit: u, AdjustToUTC: true}}, + } + assert.NoError(t, c.Validate(), "unit=%v", u) + } + }) + + t.Run("invalid unit rejected", func(t *testing.T) { + c := Common{ + Type: Timestamp, + Name: "ts", + Logical: &LogicalParams{Timestamp: &TimestampParams{Unit: TimeUnit(99), AdjustToUTC: true}}, + } + err := c.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid timestamp unit") + }) + + t.Run("Timestamp params on non-Timestamp type rejected", func(t *testing.T) { + c := Common{ + Type: Int64, + Name: "x", + Logical: &LogicalParams{Timestamp: &TimestampParams{Unit: TimeUnitMillis, AdjustToUTC: true}}, + } + err := c.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "Logical.Timestamp parameters are only valid for type TIMESTAMP") + }) +} + +func TestValidateTimeOfDayParams(t *testing.T) { + t.Run("missing Logical rejected", func(t *testing.T) { + c := Common{Type: TimeOfDay, Name: "tod"} + err := c.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "type TIME_OF_DAY requires Logical.TimeOfDay parameters") + }) + + t.Run("populated Logical with valid unit", func(t *testing.T) { + for _, u := range []TimeUnit{TimeUnitSeconds, TimeUnitMillis, TimeUnitMicros, TimeUnitNanos} { + c := Common{ + Type: TimeOfDay, + Name: "tod", + Logical: &LogicalParams{TimeOfDay: &TimeOfDayParams{Unit: u}}, + } + assert.NoError(t, c.Validate(), "unit=%v", u) + } + }) + + t.Run("invalid unit rejected", func(t *testing.T) { + c := Common{ + Type: TimeOfDay, + Name: "tod", + Logical: &LogicalParams{TimeOfDay: &TimeOfDayParams{Unit: TimeUnit(0)}}, + } + err := c.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid time-of-day unit") + }) + + t.Run("TimeOfDay params on non-TimeOfDay type rejected", func(t *testing.T) { + c := Common{ + Type: Int64, + Name: "x", + Logical: &LogicalParams{TimeOfDay: &TimeOfDayParams{Unit: TimeUnitMillis}}, + } + err := c.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "Logical.TimeOfDay parameters are only valid for type TIME_OF_DAY") + }) +} + +func TestEffectiveTimestamp(t *testing.T) { + t.Run("nil Logical defaults to legacy millis/UTC", func(t *testing.T) { + c := Common{Type: Timestamp, Name: "ts"} + got := c.EffectiveTimestamp() + assert.Equal(t, TimestampParams{Unit: TimeUnitMillis, AdjustToUTC: true}, got) + }) + + t.Run("populated Logical wins", func(t *testing.T) { + c := Common{ + Type: Timestamp, + Name: "ts", + Logical: &LogicalParams{Timestamp: &TimestampParams{Unit: TimeUnitMicros, AdjustToUTC: false}}, + } + got := c.EffectiveTimestamp() + assert.Equal(t, TimestampParams{Unit: TimeUnitMicros, AdjustToUTC: false}, got) + }) + + t.Run("Logical present but Timestamp nil also defaults", func(t *testing.T) { + c := Common{Type: Timestamp, Name: "ts", Logical: &LogicalParams{}} + got := c.EffectiveTimestamp() + assert.Equal(t, TimestampParams{Unit: TimeUnitMillis, AdjustToUTC: true}, got) + }) +} + +func TestTimeUnitString(t *testing.T) { + cases := []struct { + u TimeUnit + s string + }{ + {TimeUnitSeconds, "SECONDS"}, + {TimeUnitMillis, "MILLIS"}, + {TimeUnitMicros, "MICROS"}, + {TimeUnitNanos, "NANOS"}, + {TimeUnit(0), "UNKNOWN"}, + {TimeUnit(99), "UNKNOWN"}, + } + for _, tc := range cases { + assert.Equal(t, tc.s, tc.u.String()) + } +} diff --git a/public/schema/fingerprint_test.go b/public/schema/fingerprint_test.go index 72a849da1..8704faa8c 100644 --- a/public/schema/fingerprint_test.go +++ b/public/schema/fingerprint_test.go @@ -202,6 +202,87 @@ func TestFingerprint(t *testing.T) { }, shouldMatch: false, }, + { + name: "legacy Timestamp (nil Logical) matches itself", + schema1: Common{Type: Timestamp, Name: "ts"}, + schema2: Common{Type: Timestamp, Name: "ts"}, + shouldMatch: true, + }, + { + name: "legacy Timestamp differs from parameterised even with default values", + // Fingerprints diverge whenever Logical is populated, even if the + // values would match the legacy default. This is intentional: the + // presence of the field is itself meaningful. + schema1: Common{Type: Timestamp, Name: "ts"}, + schema2: Common{ + Type: Timestamp, + Name: "ts", + Logical: &LogicalParams{Timestamp: &TimestampParams{Unit: TimeUnitMillis, AdjustToUTC: true}}, + }, + shouldMatch: false, + }, + { + name: "Timestamp differs by unit", + schema1: Common{ + Type: Timestamp, + Name: "ts", + Logical: &LogicalParams{Timestamp: &TimestampParams{Unit: TimeUnitMillis, AdjustToUTC: true}}, + }, + schema2: Common{ + Type: Timestamp, + Name: "ts", + Logical: &LogicalParams{Timestamp: &TimestampParams{Unit: TimeUnitMicros, AdjustToUTC: true}}, + }, + shouldMatch: false, + }, + { + name: "Timestamp differs by AdjustToUTC", + schema1: Common{ + Type: Timestamp, + Name: "ts", + Logical: &LogicalParams{Timestamp: &TimestampParams{Unit: TimeUnitMillis, AdjustToUTC: true}}, + }, + schema2: Common{ + Type: Timestamp, + Name: "ts", + Logical: &LogicalParams{Timestamp: &TimestampParams{Unit: TimeUnitMillis, AdjustToUTC: false}}, + }, + shouldMatch: false, + }, + { + name: "TimeOfDay matches itself", + schema1: Common{ + Type: TimeOfDay, + Name: "tod", + Logical: &LogicalParams{TimeOfDay: &TimeOfDayParams{Unit: TimeUnitMicros}}, + }, + schema2: Common{ + Type: TimeOfDay, + Name: "tod", + Logical: &LogicalParams{TimeOfDay: &TimeOfDayParams{Unit: TimeUnitMicros}}, + }, + shouldMatch: true, + }, + { + name: "TimeOfDay differs by unit", + schema1: Common{ + Type: TimeOfDay, + Name: "tod", + Logical: &LogicalParams{TimeOfDay: &TimeOfDayParams{Unit: TimeUnitMillis}}, + }, + schema2: Common{ + Type: TimeOfDay, + Name: "tod", + Logical: &LogicalParams{TimeOfDay: &TimeOfDayParams{Unit: TimeUnitMicros}}, + }, + shouldMatch: false, + }, + { + name: "Date and UUID identical pairs", + schema1: Common{Type: Date, Name: "d"}, + schema2: Common{Type: Date, Name: "d"}, + shouldMatch: true, + }, } for _, tt := range tests { @@ -274,6 +355,9 @@ func TestFingerprintAllTypes(t *testing.T) { {Type: Any, Name: "test"}, {Type: Decimal, Name: "test", Logical: &LogicalParams{Decimal: &DecimalParams{Precision: 10, Scale: 2}}}, {Type: BigDecimal, Name: "test"}, + {Type: Date, Name: "test"}, + {Type: TimeOfDay, Name: "test", Logical: &LogicalParams{TimeOfDay: &TimeOfDayParams{Unit: TimeUnitMicros}}}, + {Type: UUID, Name: "test"}, } fingerprints := make(map[string]CommonType) @@ -412,6 +496,77 @@ func TestToAnyIncludesFingerprint(t *testing.T) { } } +// TestFingerprintLegacyStability locks in the exact fingerprint bytes of a +// representative set of pre-parameterised schemas. These hashes must not +// change when adding new logical-type variants; doing so would cause a +// stampede of cache misses across every consumer of [SchemaCache] on +// upgrade. If this test fails, look at what was added to the fingerprint +// canonical form and gate the new fields on their being non-nil/non-zero. +func TestFingerprintLegacyStability(t *testing.T) { + cases := []struct { + name string + schema Common + fp string + }{ + { + name: "primitive String", + schema: Common{Type: String, Name: "id"}, + fp: "bba6ac8334a77739f7374a773a738639cacba208b662eaad515178bd75d290fe", + }, + { + name: "primitive Int64 optional", + schema: Common{Type: Int64, Name: "age", Optional: true}, + fp: "a2f997df3bc480040bb51ae8d174a03a70eeb4cdd42e014d5fcfb58175f61bc9", + }, + { + name: "Timestamp without Logical", + schema: Common{Type: Timestamp, Name: "ts"}, + fp: "29368740c39657a4a6f7194f43d5254ec0c987a1107190c4dfb3912066540e81", + }, + { + name: "nested Object", + schema: Common{ + Type: Object, + Name: "user", + Children: []Common{ + {Type: String, Name: "id"}, + {Type: Int64, Name: "age", Optional: true}, + }, + }, + fp: "9d0ad47ba9ce5b2d4f7d292f51c526fc0c7ec2fbca4a71313131fbede7740bb9", + }, + { + name: "Decimal with params", + schema: Common{ + Type: Decimal, + Name: "amount", + Logical: &LogicalParams{Decimal: &DecimalParams{Precision: 18, Scale: 4}}, + }, + fp: "97f8a859fab938ba1aa77773ab8457e092b6e3bb0cf87e56245d02dcf83de7c6", + }, + } + + // Hard-coded expected fingerprints lock in the canonical form. If + // writeFingerprint changes for any of these schemas, this test fails + // and the canonical form change must be reviewed for cache-stampede + // implications. + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.schema.fingerprint() + if got == "" { + t.Fatal("empty fingerprint") + } + if got != tc.fp { + t.Errorf("fingerprint drift detected:\n got: %s\n expected: %s", got, tc.fp) + } + // Stability: re-compute and compare. + if again := tc.schema.fingerprint(); again != got { + t.Errorf("non-deterministic fingerprint: %s vs %s", got, again) + } + }) + } +} + func TestToAnyAlwaysIncludesFingerprint(t *testing.T) { schema := Common{ Type: Object, diff --git a/public/schema/infer_from_any_test.go b/public/schema/infer_from_any_test.go index c8948da8f..2bcfa9875 100644 --- a/public/schema/infer_from_any_test.go +++ b/public/schema/infer_from_any_test.go @@ -159,3 +159,141 @@ func TestFromAnySchema(t *testing.T) { }) } } + +// TestParameterisedRoundTrip exercises ToAny → ParseFromAny round-trips for +// the new parameterised logical types. These types are not reachable through +// [InferFromAny] (no Go runtime type maps to them), so they're exercised +// here directly. +func TestParameterisedRoundTrip(t *testing.T) { + cases := []struct { + name string + schema Common + }{ + { + name: "Date bare", + schema: Common{Type: Date, Name: "d"}, + }, + { + name: "UUID bare", + schema: Common{Type: UUID, Name: "u"}, + }, + { + name: "Timestamp millis UTC", + schema: Common{ + Type: Timestamp, + Name: "ts", + Logical: &LogicalParams{Timestamp: &TimestampParams{Unit: TimeUnitMillis, AdjustToUTC: true}}, + }, + }, + { + name: "Timestamp micros local", + schema: Common{ + Type: Timestamp, + Name: "ts", + Logical: &LogicalParams{Timestamp: &TimestampParams{Unit: TimeUnitMicros, AdjustToUTC: false}}, + }, + }, + { + name: "Timestamp nanos UTC", + schema: Common{ + Type: Timestamp, + Name: "ts", + Logical: &LogicalParams{Timestamp: &TimestampParams{Unit: TimeUnitNanos, AdjustToUTC: true}}, + }, + }, + { + name: "Timestamp legacy nil Logical", + schema: Common{Type: Timestamp, Name: "ts"}, + }, + { + name: "TimeOfDay millis", + schema: Common{ + Type: TimeOfDay, + Name: "tod", + Logical: &LogicalParams{TimeOfDay: &TimeOfDayParams{Unit: TimeUnitMillis}}, + }, + }, + { + name: "TimeOfDay micros UTC", + schema: Common{ + Type: TimeOfDay, + Name: "tod", + Logical: &LogicalParams{TimeOfDay: &TimeOfDayParams{Unit: TimeUnitMicros, AdjustToUTC: true}}, + }, + }, + { + name: "Object containing parameterised children", + schema: Common{ + Type: Object, + Name: "row", + Children: []Common{ + {Type: Date, Name: "created_at"}, + { + Type: Timestamp, + Name: "updated_at", + Logical: &LogicalParams{Timestamp: &TimestampParams{Unit: TimeUnitMicros, AdjustToUTC: true}}, + }, + { + Type: TimeOfDay, + Name: "open_at", + Logical: &LogicalParams{TimeOfDay: &TimeOfDayParams{Unit: TimeUnitMillis}}, + }, + {Type: UUID, Name: "id"}, + }, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rt, err := ParseFromAny(tc.schema.ToAny()) + require.NoError(t, err) + assert.Equal(t, tc.schema, rt) + + // Validate round-trips too + assert.NoError(t, rt.Validate()) + }) + } +} + +// TestParseFromAnyRejectsMisplacedParams asserts that timestamp/time-of-day +// params attached to the wrong top-level type are rejected on ParseFromAny. +func TestParseFromAnyRejectsMisplacedParams(t *testing.T) { + cases := []struct { + name string + input map[string]any + want string + }{ + { + name: "unit on Int64", + input: map[string]any{ + "type": "INT64", + "unit": "MILLIS", + "adjust_to_utc": true, + }, + want: "only valid for types TIMESTAMP or TIME_OF_DAY", + }, + { + name: "TimeOfDay missing unit", + input: map[string]any{ + "type": "TIME_OF_DAY", + }, + want: "type TIME_OF_DAY requires fields", + }, + { + name: "Timestamp with adjust but no unit", + input: map[string]any{ + "type": "TIMESTAMP", + "adjust_to_utc": true, + }, + want: "requires field `unit`", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ParseFromAny(tc.input) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + }) + } +}