diff --git a/README.md b/README.md index 2603393..bbe11ab 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A Go implementation inspired by Java's `java.time` package (JSR-310), providing - 📅 **LocalDate**: Date without time (e.g., `2024-03-15`) - ⏰ **LocalTime**: Time without date (e.g., `14:30:45.123456789`) - 📆 **LocalDateTime**: Date-time without timezone (e.g., `2024-03-15T14:30:45.123456789`) -- 🌐 **ZoneOffset**: Time-zone offset from Greenwich/UTC (e.g., `+08:00`) +- 🌐 **ZoneOffset**: Time-zone offset from Greenwich/UTC (e.g., `+08:00`, `-05:00`, `Z`) - 🌍 **OffsetDateTime**: Date-time with offset (e.g., `2024-03-15T14:30:45.123456789+01:00`) - 🔢 **Field**: Enumeration of date-time fields (like Java's `ChronoField`) - 🔍 **TemporalAccessor**: Universal interface for querying temporal objects @@ -32,7 +32,7 @@ A Go implementation inspired by Java's `java.time` package (JSR-310), providing - ✅ **Date arithmetic**: Add/subtract days, months, years with overflow handling - ✅ **Type-safe field access**: Query any field with `TemporalValue` return type that validates support and overflow - ✅ **TemporalAccessor interface**: Universal query pattern across all temporal types -- ✅ **Zero-copy text marshaling** with `encoding.TextAppender` +- ✅ **Chain operations**: Fluent API with error handling for complex mutations - ✅ **Immutable**: All operations return new values - ✅ **Type-safe**: Compile-time safety with distinct types - ✅ **Zero-value friendly**: Zero values are properly handled @@ -57,8 +57,8 @@ import ( func main() { // Create dates and times - date := goda.MustNewLocalDate(2024, goda.March, 15) - time := goda.MustNewLocalTime(14, 30, 45, 123456789) + date := goda.MustLocalDateOf(2024, goda.March, 15) + time := goda.MustLocalTimeOf(14, 30, 45, 123456789) datetime := date.AtTime(time) // or time.AtDate(date) fmt.Println(date) // 2024-03-15 @@ -66,7 +66,7 @@ func main() { fmt.Println(datetime) // 2024-03-15T14:30:45.123456789 // Create from components directly - datetime2 := goda.MustNewLocalDateTime(2024, goda.March, 15, 14, 30, 45, 123456789) + datetime2 := goda.MustLocalDateTimeOf(2024, goda.March, 15, 14, 30, 45, 123456789) // With time zone offset offset := goda.MustZoneOffsetOfHours(1) // +01:00 @@ -74,9 +74,9 @@ func main() { fmt.Println(offsetDateTime) // 2024-03-15T14:30:45.123456789+01:00 // Parse from strings - date, _ = goda.ParseLocalDate("2024-03-15") - time = goda.MustParseLocalTime("14:30:45.123456789") - datetime = goda.MustParseLocalDateTime("2024-03-15T14:30:45") + date, _ = goda.LocalDateParse("2024-03-15") + time = goda.MustLocalTimeParse("14:30:45.123456789") + datetime = goda.MustLocalDateTimeParse("2024-03-15T14:30:45") // Get current date/time today := goda.LocalDateNow() @@ -85,9 +85,9 @@ func main() { currentOffsetDateTime := goda.OffsetDateTimeNow() // Date arithmetic - tomorrow := today.PlusDays(1) - nextMonth := today.PlusMonths(1) - nextYear := today.PlusYears(1) + tomorrow := today.Chain().PlusDays(1).MustGet() + nextMonth := today.Chain().PlusMonths(1).MustGet() + nextYear := today.Chain().PlusYears(1).MustGet() // Comparisons if tomorrow.IsAfter(today) { @@ -96,79 +96,42 @@ func main() { } ``` -### Working with Time Zones - -```go -// Create with offset -offset := goda.MustZoneOffsetOfHours(8) // +08:00 (China Standard Time) -odt := goda.MustNewOffsetDateTime(2024, goda.March, 15, 14, 30, 45, 0, offset) - -// Parse with offset -odt, _ = goda.ParseOffsetDateTime("2024-03-15T14:30:45+08:00") -odt = goda.MustParseOffsetDateTime("2024-03-15T14:30:45Z") // UTC - -// Convert from Go's time.Time (preserves offset) -goTime := time.Now() -odt = goda.OffsetDateTimeOfGoTime(goTime) - -// Change offset while keeping local time -est := goda.MustZoneOffsetOfHours(-5) // EST -pst := goda.MustZoneOffsetOfHours(-8) // PST -odtEST := goda.MustNewOffsetDateTime(2024, goda.March, 15, 14, 30, 45, 0, est) -odtPST := odtEST.WithOffsetSameLocal(pst) // Local time unchanged: 14:30:45-08:00 - -// Change offset while keeping the instant -odtPST2 := odtEST.WithOffsetSameInstant(pst) // Instant preserved: 11:30:45-08:00 - -// Time arithmetic with offset -tomorrow := odt.PlusDays(1) -inTwoHours := odt.PlusHours(2) - -// Convert to Unix timestamp -epochSecond := odt.ToEpochSecond() - -// Compare based on instant -if odt1.IsBefore(odt2) { - fmt.Println("odt1 is earlier") -} -``` - ### Field Access with TemporalValue Access individual date-time fields using the `Field` enumeration with type-safe `TemporalValue` returns: ```go -date := goda.MustNewLocalDate(2024, goda.March, 15) +date := goda.MustLocalDateOf(2024, goda.March, 15) // Check field support -fmt.Println(date.IsSupportedField(goda.DayOfMonth)) // true -fmt.Println(date.IsSupportedField(goda.HourOfDay)) // false +fmt.Println(date.IsSupportedField(goda.FieldDayOfMonth)) // true +fmt.Println(date.IsSupportedField(goda.FieldHourOfDay)) // false // Get field values with validation -year := date.GetField(goda.YearField) +year := date.GetField(goda.FieldYear) if year.Valid() { fmt.Println("Year:", year.Int64()) // 2024 } -dayOfWeek := date.GetField(goda.DayOfWeekField) +dayOfWeek := date.GetField(goda.FieldDayOfWeek) if dayOfWeek.Valid() { fmt.Println("Day of week:", dayOfWeek.Int()) // 5 (Friday) } // Unsupported fields return unsupported TemporalValue -hourOfDay := date.GetField(goda.HourOfDay) +hourOfDay := date.GetField(goda.FieldHourOfDay) if hourOfDay.Unsupported() { fmt.Println("Hour field is not supported for LocalDate") } // Time fields -time := goda.MustNewLocalTime(14, 30, 45, 123456789) -hour := time.GetField(goda.HourOfDay) +time := goda.MustLocalTimeOf(14, 30, 45, 123456789) +hour := time.GetField(goda.FieldHourOfDay) if hour.Valid() { fmt.Println("Hour:", hour.Int()) // 14 } -nanoOfDay := time.GetField(goda.NanoOfDay) +nanoOfDay := time.GetField(goda.FieldNanoOfDay) if nanoOfDay.Valid() { fmt.Println("Nanoseconds since midnight:", nanoOfDay.Int64()) } @@ -203,7 +166,7 @@ type TemporalAccessor interface { // Write generic functions that work with any temporal type func printYear(t goda.TemporalAccessor) { - if year := t.GetField(goda.YearField); year.Valid() { + if year := t.GetField(goda.FieldYear); year.Valid() { fmt.Printf("Year: %d\n", year.Int()) } } @@ -213,6 +176,41 @@ printYear(goda.LocalDateNow()) printYear(goda.LocalDateTimeNow()) ``` +### Chain Operations + +All temporal types support chain operations for fluent, error-handled mutations. Chain operations allow you to perform multiple modifications in a single expression with proper error handling: + +```go +// Chain multiple operations fluently +dt := goda.MustLocalDateTimeOf(2024, goda.March, 15, 14, 30, 45, 123456789) + +// Chain date and time modifications +meetingTime := dt.Chain(). + PlusDays(7). // Next week + WithHour(16). // At 4 PM + WithMinute(0). // On the hour + WithSecond(0). // No seconds + WithNano(0). // No nanoseconds + MustGet() // Get result (panics on error) + +fmt.Println("Meeting scheduled for:", meetingTime) + +// Error handling with chains +result, err := dt.Chain(). + PlusMonths(1). + WithDayOfMonth(32). // Invalid day - will cause error + GetResult() // Returns (zero value, error) + +if err != nil { + fmt.Println("Invalid operation:", err) + // Use fallback + validTime := dt.Chain(). + PlusMonths(1). + WithDayOfMonth(31). // Valid day + GetOrElse(dt) // Fallback to original if error +} +``` + ### JSON Serialization ```go @@ -226,10 +224,10 @@ type Event struct { event := Event{ Name: "Meeting", - Date: goda.MustNewLocalDate(2024, goda.March, 15), - Time: goda.MustNewLocalTime(14, 30, 0, 0), - CreatedAt: goda.MustParseLocalDateTime("2024-03-15T14:30:00"), - ScheduledAt: goda.MustParseOffsetDateTime("2024-03-15T14:30:00+08:00"), + Date: goda.MustLocalDateOf(2024, goda.March, 15), + Time: goda.MustLocalTimeOf(14, 30, 0, 0), + CreatedAt: goda.MustLocalDateTimeParse("2024-03-15T14:30:00"), + ScheduledAt: goda.MustOffsetDateTimeParse("2024-03-15T14:30:00+08:00"), } jsonData, _ := json.Marshal(event) @@ -276,6 +274,30 @@ db.Exec("INSERT INTO records (created_at, updated_at) VALUES (?, ?)", | `Field` | Date-time field enumeration | `HourOfDay`, `DayOfMonth` | | `TemporalAccessor` | Interface for querying temporal objects | Implemented by all temporal types | | `TemporalValue` | Type-safe field value with validation | Returned by `GetField()` | +| `Error` | Structured error with context | Provides detailed error information | +| `LocalDateChain` | Chain operations for LocalDate | `date.Chain().PlusDays(1).MustGet()` | +| `LocalTimeChain` | Chain operations for LocalTime | `time.Chain().PlusHours(1).MustGet()` | +| `LocalDateTimeChain`| Chain operations for LocalDateTime | `dt.Chain().PlusDays(1).MustGet()` | +| `OffsetDateTimeChain`| Chain operations for OffsetDateTime | `odt.Chain().PlusHours(1).MustGet()` | + +### Format Specification + +This package uses ISO 8601 basic calendar date and time formats (not the full specification): + +**LocalDate**: `yyyy-MM-dd` (e.g., "2024-03-15") +Only Gregorian calendar dates. No week dates (YYYY-Www-D) or ordinal dates (YYYY-DDD). + +**LocalTime**: `HH:mm:ss[.nnnnnnnnn]` (e.g., "14:30:45.123456789") +24-hour format. Fractional seconds up to nanoseconds. Fractional seconds are aligned to 3-digit boundaries (milliseconds, microseconds, nanoseconds) for Java.time compatibility: 100ms → "14:30:45.100", 123.4ms → "14:30:45.123400". Parsing accepts any length of fractional seconds (e.g., "14:30:45.1" → 100ms). + +**LocalDateTime**: `yyyy-MM-ddTHH:mm:ss[.nnnnnnnnn]` (e.g., "2024-03-15T14:30:45.123456789") +Combined with 'T' separator (lowercase 't' accepted when parsing). + +**ZoneOffset**: `±HH:mm[:ss]` or `Z` for UTC (e.g., "+08:00", "-05:30", "Z") +Hours must be in range [-18, 18], minutes and seconds in [0, 59]. Compact formats (±HH, ±HHMM, ±HHMMSS) are also supported. + +**OffsetDateTime**: `yyyy-MM-ddTHH:mm:ss[.nnnnnnnnn]±HH:mm[:ss]` (e.g., "2024-03-15T14:30:45+08:00") +Combines LocalDateTime and ZoneOffset. 'Z' is accepted as UTC offset. ### Time Formatting @@ -304,7 +326,7 @@ All temporal types (`LocalDate`, `LocalTime`, `LocalDateTime`, `OffsetDateTime`) - `TemporalAccessor`: Universal query interface with `GetField(field Field) TemporalValue` - `fmt.Stringer` - `encoding.TextMarshaler` / `encoding.TextUnmarshaler` -- `encoding.TextAppender` (zero-copy text marshaling) +- `encoding.TextAppender` - `json.Marshaler` / `json.Unmarshaler` - `sql.Scanner` / `driver.Valuer` diff --git a/README.zh.md b/README.zh.md index ce26b1d..c751f23 100644 --- a/README.zh.md +++ b/README.zh.md @@ -18,7 +18,7 @@ - 📅 **LocalDate**:不含时间的日期(例如:`2024-03-15`) - ⏰ **LocalTime**:不含日期的时间(例如:`14:30:45.123456789`) - 📆 **LocalDateTime**:不含时区的日期时间(例如:`2024-03-15T14:30:45.123456789`) -- 🌐 **ZoneOffset**:相对于格林威治/UTC 的时区偏移(例如:`+08:00`) +- 🌐 **ZoneOffset**:相对于格林威治/UTC 的时区偏移(例如:`+08:00`、`-05:00`、`Z`) - 🌍 **OffsetDateTime**:带偏移的日期时间(例如:`2024-03-15T14:30:45.123456789+01:00`) - 🔢 **Field**:日期时间字段枚举(类似 Java 的 `ChronoField`) - 🔍 **TemporalAccessor**:用于查询时间对象的通用接口 @@ -32,7 +32,7 @@ - ✅ **日期运算**:支持溢出处理的天、月、年加减 - ✅ **类型安全的字段访问**:使用 `TemporalValue` 返回类型查询任何字段,验证支持和溢出 - ✅ **TemporalAccessor 接口**:跨所有时间类型的通用查询模式 -- ✅ **零拷贝文本序列化**,使用 `encoding.TextAppender` +- ✅ **链式操作**:流畅 API 配合错误处理进行复杂变更 - ✅ **不可变**:所有操作返回新值 - ✅ **类型安全**:通过不同类型实现编译时安全 - ✅ **零值友好**:正确处理零值 @@ -57,38 +57,38 @@ import ( func main() { // 创建日期和时间 - date := goda.MustNewLocalDate(2024, goda.March, 15) - time := goda.MustNewLocalTime(14, 30, 45, 123456789) + date := goda.MustLocalDateOf(2024, goda.March, 15) + time := goda.MustLocalTimeOf(14, 30, 45, 123456789) datetime := date.AtTime(time) // 或 time.AtDate(date) - + fmt.Println(date) // 2024-03-15 fmt.Println(time) // 14:30:45.123456789 fmt.Println(datetime) // 2024-03-15T14:30:45.123456789 - + // 直接从组件创建 - datetime2 := goda.MustNewLocalDateTime(2024, goda.March, 15, 14, 30, 45, 123456789) - + datetime2 := goda.MustLocalDateTimeOf(2024, goda.March, 15, 14, 30, 45, 123456789) + // 带时区偏移 offset := goda.MustZoneOffsetOfHours(1) // +01:00 offsetDateTime := datetime.AtOffset(offset) fmt.Println(offsetDateTime) // 2024-03-15T14:30:45.123456789+01:00 - + // 从字符串解析 - date, _ = goda.ParseLocalDate("2024-03-15") - time = goda.MustParseLocalTime("14:30:45.123456789") - datetime = goda.MustParseLocalDateTime("2024-03-15T14:30:45") - + date, _ = goda.LocalDateParse("2024-03-15") + time = goda.MustLocalTimeParse("14:30:45.123456789") + datetime = goda.MustLocalDateTimeParse("2024-03-15T14:30:45") + // 获取当前日期/时间 today := goda.LocalDateNow() now := goda.LocalTimeNow() currentDateTime := goda.LocalDateTimeNow() currentOffsetDateTime := goda.OffsetDateTimeNow() - + // 日期运算 - tomorrow := today.PlusDays(1) - nextMonth := today.PlusMonths(1) - nextYear := today.PlusYears(1) - + tomorrow := today.Chain().PlusDays(1).MustGet() + nextMonth := today.Chain().PlusMonths(1).MustGet() + nextYear := today.Chain().PlusYears(1).MustGet() + // 比较 if tomorrow.IsAfter(today) { fmt.Println("明天在今天之后!") @@ -138,37 +138,37 @@ if odt1.IsBefore(odt2) { 使用 `Field` 枚举访问单个日期时间字段,返回类型安全的 `TemporalValue`: ```go -date := goda.MustNewLocalDate(2024, goda.March, 15) +date := goda.MustLocalDateOf(2024, goda.March, 15) // 检查字段支持 -fmt.Println(date.IsSupportedField(goda.DayOfMonth)) // true -fmt.Println(date.IsSupportedField(goda.HourOfDay)) // false +fmt.Println(date.IsSupportedField(goda.FieldDayOfMonth)) // true +fmt.Println(date.IsSupportedField(goda.FieldHourOfDay)) // false // 获取带验证的字段值 -year := date.GetField(goda.YearField) +year := date.GetField(goda.FieldYear) if year.Valid() { fmt.Println("年份:", year.Int64()) // 2024 } -dayOfWeek := date.GetField(goda.DayOfWeekField) +dayOfWeek := date.GetField(goda.FieldDayOfWeek) if dayOfWeek.Valid() { fmt.Println("星期:", dayOfWeek.Int()) // 5(星期五) } // 不支持的字段返回 unsupported 的 TemporalValue -hourOfDay := date.GetField(goda.HourOfDay) +hourOfDay := date.GetField(goda.FieldHourOfDay) if hourOfDay.Unsupported() { fmt.Println("LocalDate 不支持小时字段") } // 时间字段 -time := goda.MustNewLocalTime(14, 30, 45, 123456789) -hour := time.GetField(goda.HourOfDay) +time := goda.MustLocalTimeOf(14, 30, 45, 123456789) +hour := time.GetField(goda.FieldHourOfDay) if hour.Valid() { fmt.Println("小时:", hour.Int()) // 14 } -nanoOfDay := time.GetField(goda.NanoOfDay) +nanoOfDay := time.GetField(goda.FieldNanoOfDay) if nanoOfDay.Valid() { fmt.Println("自午夜以来的纳秒:", nanoOfDay.Int64()) } @@ -213,6 +213,41 @@ printYear(goda.LocalDateNow()) printYear(goda.LocalDateTimeNow()) ``` +### 链式操作 + +所有时间类型都支持链式操作,用于流畅且带错误处理的复杂变更。链式操作允许你在单个表达式中执行多个修改,并进行适当的错误处理: + +```go +// 流畅地链式多个操作 +dt := goda.MustLocalDateTimeOf(2024, goda.March, 15, 14, 30, 45, 123456789) + +// 链式日期和时间修改 +meetingTime := dt.Chain(). + PlusDays(7). // 下周 + WithHour(16). // 下午 4 点 + WithMinute(0). // 整点 + WithSecond(0). // 无秒 + WithNano(0). // 无纳秒 + MustGet() // 获取结果(出错时 panic) + +fmt.Println("会议安排在:", meetingTime) + +// 链式操作中的错误处理 +result, err := dt.Chain(). + PlusMonths(1). + WithDayOfMonth(32). // 无效日期 - 会导致错误 + GetResult() // 返回(零值,错误) + +if err != nil { + fmt.Println("无效操作:", err) + // 使用后备方案 + validTime := dt.Chain(). + PlusMonths(1). + WithDayOfMonth(31). // 有效日期 + GetOrElse(dt) // 出错时返回原始值 +} +``` + ### JSON 序列化 ```go @@ -226,10 +261,10 @@ type Event struct { event := Event{ Name: "会议", - Date: goda.MustNewLocalDate(2024, goda.March, 15), - Time: goda.MustNewLocalTime(14, 30, 0, 0), - CreatedAt: goda.MustParseLocalDateTime("2024-03-15T14:30:00"), - ScheduledAt: goda.MustParseOffsetDateTime("2024-03-15T14:30:00+08:00"), + Date: goda.MustLocalDateOf(2024, goda.March, 15), + Time: goda.MustLocalTimeOf(14, 30, 0, 0), + CreatedAt: goda.MustLocalDateTimeParse("2024-03-15T14:30:00"), + ScheduledAt: goda.MustOffsetDateTimeParse("2024-03-15T14:30:00+08:00"), } jsonData, _ := json.Marshal(event) @@ -276,6 +311,30 @@ db.Exec("INSERT INTO records (created_at, updated_at) VALUES (?, ?)", | `Field` | 日期时间字段枚举 | `HourOfDay`、`DayOfMonth` | | `TemporalAccessor` | 用于查询时间对象的接口 | 所有时间类型都实现了此接口 | | `TemporalValue` | 带验证的类型安全字段值 | 由 `GetField()` 返回 | +| `Error` | 带上下文的结构化错误 | 提供详细的错误信息 | +| `LocalDateChain` | LocalDate 的链式操作 | `date.Chain().PlusDays(1).MustGet()` | +| `LocalTimeChain` | LocalTime 的链式操作 | `time.Chain().PlusHours(1).MustGet()` | +| `LocalDateTimeChain`| LocalDateTime 的链式操作 | `dt.Chain().PlusDays(1).MustGet()` | +| `OffsetDateTimeChain`| OffsetDateTime 的链式操作 | `odt.Chain().PlusHours(1).MustGet()` | + +### 格式规范 + +此包使用 ISO 8601 基本日历日期和时间格式(不是完整规范): + +**LocalDate**:`yyyy-MM-dd`(例如:"2024-03-15") +仅限格里高利历日期。不支持周日期(YYYY-Www-D)或序数日期(YYYY-DDD)。 + +**LocalTime**:`HH:mm:ss[.nnnnnnnnn]`(例如:"14:30:45.123456789") +24 小时格式。小数秒最多到纳秒。小数秒与 3 位数边界对齐(毫秒、微秒、纳秒),以实现 Java.time 兼容性:100ms → "14:30:45.100",123.4ms → "14:30:45.123400"。解析接受任何长度的小数秒(例如:"14:30:45.1" → 100ms)。 + +**LocalDateTime**:`yyyy-MM-ddTHH:mm:ss[.nnnnnnnnn]`(例如:"2024-03-15T14:30:45.123456789") +使用 'T' 分隔符连接(解析时接受小写 't')。 + +**ZoneOffset**:`±HH:mm[:ss]` 或 `Z` 表示 UTC(例如:"+08:00"、" -05:30"、"Z") +小时数范围必须为 [-18, 18],分钟和秒为 [0, 59]。还支持紧凑格式(±HH、±HHMM、±HHMMSS)。 + +**OffsetDateTime**:`yyyy-MM-ddTHH:mm:ss[.nnnnnnnnn]±HH:mm[:ss]`(例如:"2024-03-15T14:30:45+08:00") +结合 LocalDateTime 和 ZoneOffset。接受 'Z' 作为 UTC 偏移。 ### 时间格式化 @@ -304,7 +363,7 @@ db.Exec("INSERT INTO records (created_at, updated_at) VALUES (?, ?)", - `TemporalAccessor`:通用查询接口,使用 `GetField(field Field) TemporalValue` - `fmt.Stringer` - `encoding.TextMarshaler` / `encoding.TextUnmarshaler` -- `encoding.TextAppender`(零拷贝文本序列化) +- `encoding.TextAppender` - `json.Marshaler` / `json.Unmarshaler` - `sql.Scanner` / `driver.Valuer` diff --git a/chain.go b/chain.go new file mode 100644 index 0000000..cb968de --- /dev/null +++ b/chain.go @@ -0,0 +1,77 @@ +package goda + +type Chain[T interface{ IsZero() bool }] struct { + value T + eError error + eType string + eFunc string +} + +func (c Chain[T]) ok() bool { + return c.eError == nil && !c.value.IsZero() +} + +func (c Chain[T]) MustGet() T { + if c.eError != nil { + panic(c.eError) + } + return c.value +} + +func (c Chain[T]) GetError() error { + return c.eError +} + +func (c Chain[T]) GetOrElse(other T) T { + if c.eError != nil { + return other + } + return c.value +} + +func (c Chain[T]) GetOrElseGet(other func() T) T { + if c.eError != nil { + return other() + } + return c.value +} + +func (c Chain[T]) GetResult() (T, error) { + return c.value, c.eError +} + +func (c Chain[T]) IsZero() bool { + return c.eError == nil && c.value.IsZero() +} + +func (c Chain[T]) mergeError(e *error) T { + if *e == nil { + *e = c.eError + } + return c.value +} + +func (c *Chain[T]) enterFunction(typeName string, funcName string) bool { + if !c.ok() || c.eType != "" { + return false + } + c.eType = typeName + c.eFunc = funcName + return true +} + +func (c *Chain[T]) leaveFunction(flag bool) { + if !flag { + return + } + if c.eError == nil { + c.eType = "" + c.eFunc = "" + return + } + //goland:noinspection GoTypeAssertionOnErrors + if ce, ok := c.eError.(*Error); ok { + ce.typeName = c.eType + ce.funcName = c.eFunc + } +} diff --git a/error.go b/error.go index fa6fd11..d83b052 100644 --- a/error.go +++ b/error.go @@ -1,18 +1,14 @@ package goda import ( + "errors" "fmt" ) // newError creates a new Error with the given format and arguments. // All error messages are prefixed with "goda: ". func newError(format string, a ...any) error { - return &Error{"goda: " + fmt.Sprintf(format, a...)} -} - -// unmarshalError creates an error for invalid unmarshaling input. -func unmarshalError(userInput []byte) error { - return newError("unable to unmarshal user input: %q", string(userInput)) + return &Error{message: fmt.Sprintf(format, a...)} } // sqlScannerDefaultBranch creates an error for unsupported SQL scan types. @@ -20,13 +16,90 @@ func sqlScannerDefaultBranch(value any) error { return newError("cannot scan value of type %T", value) } +const ( + errReasonInvalidField int = iota + 1 + errReasonUnsupportedField + errReasonOutOfRange + errReasonArithmeticOverflow + errReasonParseFailed +) + // Error is the error type used by this package. // It wraps error messages with the "goda: " prefix. type Error struct { - message string + reason int + field Field + int64Value int64 + message string + cause error + typeName string + funcName string } // Error implements the error interface. func (e Error) Error() string { - return e.message + var text string + switch e.reason { + case errReasonInvalidField: + text = fmt.Sprintf("goda: invalid field (value=%d)", int64(e.field)) + case errReasonUnsupportedField: + text = fmt.Sprintf("goda: unsupported field %s", e.field) + case errReasonOutOfRange: + fr := e.field.fieldRange() + text = fmt.Sprintf("goda: invalid value of %s (valid range %d - %d): %d", e.field, fr.Min, fr.Max, e.int64Value) + case errReasonArithmeticOverflow: + text = "goda: arithmetic overflow" + case errReasonParseFailed: + text = "goda: parse user input failed" + default: + text = "goda: " + e.message + } + if e.typeName != "" { + text += " at " + e.typeName + "/" + e.funcName + } + if e.cause != nil { + text += ", caused by: " + e.cause.Error() + } + return text +} + +func (e Error) Unwrap() error { + if e.cause == nil && e.reason == errReasonUnsupportedField { + return errors.ErrUnsupported + } + return e.cause +} + +func overflowError() error { + return &Error{reason: errReasonArithmeticOverflow} +} + +func fieldOutOfRangeError(field Field, value int64) error { + return &Error{reason: errReasonOutOfRange, field: field, int64Value: value} +} + +func unsupportedField(field Field) error { + return &Error{reason: errReasonUnsupportedField, field: field} +} + +func invalidFieldError(field Field) error { + return &Error{reason: errReasonInvalidField, field: field} +} + +func parseFailedError(userInput []byte) error { + return &Error{reason: errReasonParseFailed} +} + +func parseFailedErrorWithCause(userInput []byte, cause error) error { + return &Error{reason: errReasonParseFailed, cause: cause} +} + +func deferOpInParse(userInput []byte, e *error) { + if *e == nil { + return + } + //goland:noinspection GoTypeAssertionOnErrors + if _, ok := (*e).(*Error); !ok { + *e = parseFailedErrorWithCause(userInput, *e) + } } diff --git a/example_test.go b/example_test.go index fbaf7a9..62aa285 100644 --- a/example_test.go +++ b/example_test.go @@ -39,9 +39,9 @@ func Example() { fmt.Println("Parsed OffsetDateTime:", parsedOffsetDateTime) // LocalDate arithmetic - tomorrow := date.PlusDays(1) - nextMonth := date.PlusMonths(1) - nextYear := date.PlusYears(1) + tomorrow := date.Chain().PlusDays(1).MustGet() + nextMonth := date.Chain().PlusMonths(1).MustGet() + nextYear := date.Chain().PlusYears(1).MustGet() fmt.Println("Tomorrow:", tomorrow) fmt.Println("Next month:", nextMonth) @@ -83,7 +83,7 @@ func ExampleLocalDateNow() { // Get current date in local timezone today := goda.LocalDateNow() - // Check that we got a valid date + // check that we got a valid date fmt.Printf("Got valid date: %v\n", !today.IsZero()) fmt.Printf("Has year component: %v\n", today.Year() != 0) @@ -157,7 +157,7 @@ func ExampleLocalDateOf() { // Output: // 2024-01-15 - // Error: goda: day 30 of month out of range + // Error: goda: invalid date February 30 } // ExampleMustNewLocalDate demonstrates how to create a date that panics on error. @@ -181,12 +181,12 @@ func ExampleLocalDateOfGoTime() { // 2024-03-15 } -// ExampleLocalDate_PlusDays demonstrates adding days to a date. -func ExampleLocalDate_PlusDays() { +// ExampleLocalDateChain_PlusDays demonstrates adding days to a date. +func ExampleLocalDateChain_PlusDays() { date := goda.MustLocalDateOf(2024, goda.January, 15) fmt.Println("Original:", date) - fmt.Println("Plus 10 days:", date.PlusDays(10)) - fmt.Println("Minus 10 days:", date.PlusDays(-10)) + fmt.Println("Plus 10 days:", date.Chain().PlusDays(10).MustGet()) + fmt.Println("Minus 10 days:", date.Chain().PlusDays(-10).MustGet()) // Output: // Original: 2024-01-15 @@ -194,12 +194,12 @@ func ExampleLocalDate_PlusDays() { // Minus 10 days: 2024-01-05 } -// ExampleLocalDate_PlusMonths demonstrates adding months to a date. -func ExampleLocalDate_PlusMonths() { +// ExampleLocalDateChain_PlusMonths demonstrates adding months to a date. +func ExampleLocalDateChain_PlusMonths() { date := goda.MustLocalDateOf(2024, goda.January, 31) fmt.Println("Original:", date) - fmt.Println("Plus 1 month:", date.PlusMonths(1)) - fmt.Println("Plus 2 months:", date.PlusMonths(2)) + fmt.Println("Plus 1 month:", date.Chain().PlusMonths(1).MustGet()) + fmt.Println("Plus 2 months:", date.Chain().PlusMonths(2).MustGet()) // Output: // Original: 2024-01-31 @@ -313,7 +313,7 @@ func ExampleLocalTimeOf() { // Output: // 14:30:45.123456789 - // Error: goda: hour 25 out of range + // Error: goda: invalid value of HourOfDay (valid range 0 - 23): 25 } // ExampleMustLocalTimeOf demonstrates how to create a time that panics on error. @@ -349,14 +349,14 @@ func ExampleLocalTime_hour() { fmt.Println("Minute:", t.Minute()) fmt.Println("Second:", t.Second()) fmt.Println("Millisecond:", t.Millisecond()) - fmt.Println("Nanosecond:", t.Nanosecond()) + fmt.Println("Nano:", t.Nano()) // Output: // Hour: 14 // Minute: 30 // Second: 45 // Millisecond: 123 - // Nanosecond: 123456789 + // Nano: 123456789 } // ExampleLocalTime_Compare demonstrates comparing times. @@ -579,10 +579,10 @@ func ExampleLocalDateTime_Compare() { // dt3 > dt1: true } -// ExampleLocalDateTime_PlusDays demonstrates adding days. -func ExampleLocalDateTime_PlusDays() { +// ExampleLocalDateTimeChain_PlusDays demonstrates adding days. +func ExampleLocalDateTimeChain_PlusDays() { dt := goda.MustLocalDateTimeParse("2024-03-15T14:30:45") - future := dt.PlusDays(10) + future := dt.Chain().PlusDays(10).MustGet() fmt.Println(future) // Output: @@ -1004,50 +1004,6 @@ func ExampleOffsetDateTimeNow() { // UTC offset: 0 seconds } -// ExampleOffsetDateTime_WithOffsetSameLocal demonstrates changing offset while keeping local time. -func ExampleOffsetDateTime_WithOffsetSameLocal() { - est := goda.MustZoneOffsetOfHours(-5) // EST - pst := goda.MustZoneOffsetOfHours(-8) // PST - - // Create a time in EST - odtEST := goda.MustOffsetDateTimeOf(2024, goda.March, 15, 14, 30, 45, 0, est) - fmt.Println("Original (EST):", odtEST) - - // Change to PST offset, but keep the local time (14:30:45) - odtPST := odtEST.WithOffsetSameLocal(pst) - fmt.Println("Same local (PST):", odtPST) - - // Note: These represent different instants in time! - fmt.Printf("Different instants: %v\n", odtEST.EpochSecond() != odtPST.EpochSecond()) - - // Output: - // Original (EST): 2024-03-15T14:30:45-05:00 - // Same local (PST): 2024-03-15T14:30:45-08:00 - // Different instants: true -} - -// ExampleOffsetDateTime_WithOffsetSameInstant demonstrates changing offset while preserving instant. -func ExampleOffsetDateTime_WithOffsetSameInstant() { - est := goda.MustZoneOffsetOfHours(-5) // EST - pst := goda.MustZoneOffsetOfHours(-8) // PST - - // Create a time in EST - odtEST := goda.MustOffsetDateTimeOf(2024, goda.March, 15, 14, 30, 45, 0, est) - fmt.Println("Original (EST):", odtEST) - - // Change to PST offset, but preserve the instant (same moment in time) - odtPST := odtEST.WithOffsetSameInstant(pst) - fmt.Println("Same instant (PST):", odtPST) - - // The local time is adjusted: 14:30 EST = 11:30 PST - fmt.Printf("Same instant: %v\n", odtEST.EpochSecond() == odtPST.EpochSecond()) - - // Output: - // Original (EST): 2024-03-15T14:30:45-05:00 - // Same instant (PST): 2024-03-15T11:30:45-08:00 - // Same instant: true -} - // ExampleOffsetDateTime_Compare demonstrates comparing offset date-times. func ExampleOffsetDateTime_Compare() { // These represent the same instant in time @@ -1068,16 +1024,16 @@ func ExampleOffsetDateTime_Compare() { // odt3 is later } -// ExampleOffsetDateTime_PlusHours demonstrates time arithmetic with hours. -func ExampleOffsetDateTime_PlusHours() { +// ExampleOffsetDateTimeChain_PlusHours demonstrates time arithmetic with hours. +func ExampleOffsetDateTimeChain_PlusHours() { odt := goda.MustOffsetDateTimeParse("2024-03-15T14:30:45+08:00") // Add hours - later := odt.PlusHours(5) + later := odt.Chain().PlusHours(5).MustGet() fmt.Println("5 hours later:", later) // Subtract hours - earlier := odt.MinusHours(2) + earlier := odt.Chain().MinusHours(2).MustGet() fmt.Println("2 hours earlier:", earlier) // Output: @@ -1329,11 +1285,11 @@ func ExampleZoneId_roundTrip() { } // ExampleLocalDate_PlusWeeks demonstrates adding weeks to a date. -func ExampleLocalDate_PlusWeeks() { +func ExampleLocalDateChain_PlusWeeks() { date := goda.MustLocalDateOf(2024, goda.January, 15) fmt.Println("Original:", date) - fmt.Println("Plus 2 weeks:", date.PlusWeeks(2)) - fmt.Println("Minus 1 week:", date.PlusWeeks(-1)) + fmt.Println("Plus 2 weeks:", date.Chain().PlusWeeks(2).MustGet()) + fmt.Println("Minus 1 week:", date.Chain().PlusWeeks(-1).MustGet()) // Output: // Original: 2024-01-15 @@ -1341,23 +1297,23 @@ func ExampleLocalDate_PlusWeeks() { // Minus 1 week: 2024-01-08 } -// ExampleLocalDate_MinusWeeks demonstrates subtracting weeks from a date. -func ExampleLocalDate_MinusWeeks() { +// ExampleLocalDateChain_MinusWeeks demonstrates subtracting weeks from a date. +func ExampleLocalDateChain_MinusWeeks() { date := goda.MustLocalDateOf(2024, goda.February, 15) fmt.Println("Original:", date) - fmt.Println("Minus 2 weeks:", date.MinusWeeks(2)) + fmt.Println("Minus 2 weeks:", date.Chain().MinusWeeks(2).MustGet()) // Output: // Original: 2024-02-15 // Minus 2 weeks: 2024-02-01 } -// ExampleLocalDate_WithDayOfMonth demonstrates changing the day of month. -func ExampleLocalDate_WithDayOfMonth() { +// ExampleLocalDateChain_WithDayOfMonth demonstrates changing the day of month. +func ExampleLocalDateChain_WithDayOfMonth() { date := goda.MustLocalDateOf(2024, goda.March, 15) // Change to the 1st of the month - date2, err := date.WithDayOfMonth(1) + date2, err := date.Chain().WithDayOfMonth(1).GetResult() if err != nil { fmt.Println("Error:", err) return @@ -1365,7 +1321,7 @@ func ExampleLocalDate_WithDayOfMonth() { fmt.Println("First of month:", date2) // Change to the last day of the month - date3, err := date.WithDayOfMonth(31) + date3, err := date.Chain().WithDayOfMonth(31).GetResult() if err != nil { fmt.Println("Error:", err) return @@ -1377,25 +1333,13 @@ func ExampleLocalDate_WithDayOfMonth() { // Last of month: 2024-03-31 } -// ExampleLocalDate_MustWithDayOfMonth demonstrates changing the day of month (panic version). -func ExampleLocalDate_MustWithDayOfMonth() { - date := goda.MustLocalDateOf(2024, goda.March, 15) - - // Change to the 20th - date2 := date.MustWithDayOfMonth(20) - fmt.Println(date2) - - // Output: - // 2024-03-20 -} - -// ExampleLocalDate_WithDayOfYear demonstrates changing the day of year. -func ExampleLocalDate_WithDayOfYear() { +// ExampleLocalDateChain_WithDayOfYear demonstrates changing the day of year. +func ExampleLocalDateChain_WithDayOfYear() { date := goda.MustLocalDateOf(2024, goda.March, 15) fmt.Println("Original:", date) // Change to the 100th day of the year - date2, err := date.WithDayOfYear(100) + date2, err := date.Chain().WithDayOfYear(100).GetResult() if err != nil { fmt.Println("Error:", err) return @@ -1403,7 +1347,7 @@ func ExampleLocalDate_WithDayOfYear() { fmt.Println("Day 100:", date2) // Change to the 1st day of the year - date3, err := date.WithDayOfYear(1) + date3, err := date.Chain().WithDayOfYear(1).GetResult() if err != nil { fmt.Println("Error:", err) return @@ -1416,25 +1360,13 @@ func ExampleLocalDate_WithDayOfYear() { // Day 1: 2024-01-01 } -// ExampleLocalDate_MustWithDayOfYear demonstrates changing the day of year (panic version). -func ExampleLocalDate_MustWithDayOfYear() { - date := goda.MustLocalDateOf(2024, goda.June, 15) - - // Change to the 200th day of the year - date2 := date.MustWithDayOfYear(200) - fmt.Println(date2) - - // Output: - // 2024-07-18 -} - -// ExampleLocalDate_WithMonth demonstrates changing the month. -func ExampleLocalDate_WithMonth() { +// ExampleLocalDateChain_WithMonth demonstrates changing the month. +func ExampleLocalDateChain_WithMonth() { date := goda.MustLocalDateOf(2024, goda.January, 31) fmt.Println("Original:", date) // Change to February (day will be clamped to 29 in leap year) - date2, err := date.WithMonth(goda.February) + date2, err := date.Chain().WithMonth(goda.February).GetResult() if err != nil { fmt.Println("Error:", err) return @@ -1442,7 +1374,7 @@ func ExampleLocalDate_WithMonth() { fmt.Println("February:", date2) // Change to March (day 31 is valid) - date3, err := date.WithMonth(goda.March) + date3, err := date.Chain().WithMonth(goda.March).GetResult() if err != nil { fmt.Println("Error:", err) return @@ -1455,26 +1387,14 @@ func ExampleLocalDate_WithMonth() { // March: 2024-03-31 } -// ExampleLocalDate_MustWithMonth demonstrates changing the month (panic version). -func ExampleLocalDate_MustWithMonth() { - date := goda.MustLocalDateOf(2024, goda.January, 15) - - // Change to June - date2 := date.MustWithMonth(goda.June) - fmt.Println(date2) - - // Output: - // 2024-06-15 -} - -// ExampleLocalDate_WithYear demonstrates changing the year. -func ExampleLocalDate_WithYear() { +// ExampleLocalDateChain_WithYear demonstrates changing the year. +func ExampleLocalDateChain_WithYear() { // Leap year date (Feb 29) date := goda.MustLocalDateOf(2024, goda.February, 29) fmt.Println("Original (leap year):", date) // Change to non-leap year (day will be clamped to 28) - date2, err := date.WithYear(2023) + date2, err := date.Chain().WithYear(2023).GetResult() if err != nil { fmt.Println("Error:", err) return @@ -1482,7 +1402,7 @@ func ExampleLocalDate_WithYear() { fmt.Println("Non-leap year:", date2) // Change to another leap year (day 29 is valid) - date3, err := date.WithYear(2020) + date3, err := date.Chain().WithYear(2020).GetResult() if err != nil { fmt.Println("Error:", err) return @@ -1495,18 +1415,6 @@ func ExampleLocalDate_WithYear() { // Another leap year: 2020-02-29 } -// ExampleLocalDate_MustWithYear demonstrates changing the year (panic version). -func ExampleLocalDate_MustWithYear() { - date := goda.MustLocalDateOf(2024, goda.March, 15) - - // Change to 2025 - date2 := date.MustWithYear(2025) - fmt.Println(date2) - - // Output: - // 2025-03-15 -} - // ExampleLocalDate_LengthOfMonth demonstrates getting the month length. func ExampleLocalDate_LengthOfMonth() { // February in leap year @@ -1557,13 +1465,13 @@ func ExampleLocalDate_LengthOfYear() { // Year 1900: 365 days } -// ExampleLocalTime_WithField demonstrates mutating LocalTime components using WithField. -func ExampleLocalTime_WithField() { +// ExampleLocalTimeChain_WithField demonstrates mutating LocalTime components using WithField. +func ExampleLocalTimeChain_WithField() { morning := goda.MustLocalTimeOf(7, 30, 45, 123000000) - toEvening, _ := morning.WithField(goda.FieldAmPmOfDay, goda.TemporalValueOf(1)) - withDifferentNanos, _ := morning.WithField(goda.FieldNanoOfSecond, goda.TemporalValueOf(987654321)) - exactTime, _ := morning.WithField(goda.FieldNanoOfDay, goda.TemporalValueOf(int64(22*time.Hour+1*time.Minute))) + toEvening, _ := morning.Chain().WithField(goda.FieldAmPmOfDay, goda.TemporalValueOf(1)).GetResult() + withDifferentNanos, _ := morning.Chain().WithField(goda.FieldNanoOfSecond, goda.TemporalValueOf(987654321)).GetResult() + exactTime, _ := morning.Chain().WithField(goda.FieldNanoOfDay, goda.TemporalValueOf(int64(22*time.Hour+1*time.Minute))).GetResult() fmt.Println(toEvening) fmt.Println(withDifferentNanos) diff --git a/field.go b/field.go index baefe84..c5b514e 100644 --- a/field.go +++ b/field.go @@ -141,46 +141,46 @@ var fieldDescriptors = []fieldDescriptor{ FieldClockHourOfDay: {name: "ClockHourOfDay", javaName: "CLOCK_HOUR_OF_DAY", based: timeBased, fieldRange: makeRange(1, 24)}, FieldAmPmOfDay: {name: "AmPmOfDay", javaName: "AMPM_OF_DAY", based: timeBased, fieldRange: makeRange(0, 1)}, FieldDayOfWeek: {name: "DayOfWeek", javaName: "DAY_OF_WEEK", based: dateBased, fieldRange: makeRange(1, 7)}, - FieldAlignedDayOfWeekInMonth: {name: "AlignedDayOfWeekInMonth", javaName: "ALIGNED_DAY_OF_WEEK_IN_MONTH", based: dateBased}, - FieldAlignedDayOfWeekInYear: {name: "AlignedDayOfWeekInYear", javaName: "ALIGNED_DAY_OF_WEEK_IN_YEAR", based: dateBased}, + FieldAlignedDayOfWeekInMonth: {name: "AlignedDayOfWeekInMonth", javaName: "ALIGNED_DAY_OF_WEEK_IN_MONTH", based: dateBased, fieldRange: makeRange(1, 7)}, + FieldAlignedDayOfWeekInYear: {name: "AlignedDayOfWeekInYear", javaName: "ALIGNED_DAY_OF_WEEK_IN_YEAR", based: dateBased, fieldRange: makeRange(1, 7)}, FieldDayOfMonth: {name: "DayOfMonth", javaName: "DAY_OF_MONTH", based: dateBased, fieldRange: makeRange(1, 31)}, FieldDayOfYear: {name: "DayOfYear", javaName: "DAY_OF_YEAR", based: dateBased, fieldRange: makeRange(1, 366)}, - FieldEpochDay: {name: "EpochDay", javaName: "EPOCH_DAY", based: dateBased}, - FieldAlignedWeekOfMonth: {name: "AlignedWeekOfMonth", javaName: "ALIGNED_WEEK_OF_MONTH", based: dateBased}, - FieldAlignedWeekOfYear: {name: "AlignedWeekOfYear", javaName: "ALIGNED_WEEK_OF_YEAR", based: dateBased}, + FieldEpochDay: {name: "EpochDay", javaName: "EPOCH_DAY", based: dateBased, fieldRange: makeRange(math.MinInt64, math.MaxInt64)}, + FieldAlignedWeekOfMonth: {name: "AlignedWeekOfMonth", javaName: "ALIGNED_WEEK_OF_MONTH", based: dateBased, fieldRange: makeRange(1, 5)}, + FieldAlignedWeekOfYear: {name: "AlignedWeekOfYear", javaName: "ALIGNED_WEEK_OF_YEAR", based: dateBased, fieldRange: makeRange(1, 53)}, FieldMonthOfYear: {name: "MonthOfYear", javaName: "MONTH_OF_YEAR", based: dateBased, fieldRange: makeRange(1, 12)}, FieldProlepticMonth: {name: "ProlepticMonth", javaName: "PROLEPTIC_MONTH", based: dateBased, fieldRange: makeRange(YearMin*12, YearMax*12+11)}, FieldYearOfEra: {name: "YearOfEra", javaName: "YEAR_OF_ERA", based: dateBased, fieldRange: makeRange(1, math.MaxInt64)}, FieldYear: {name: "Year", javaName: "YEAR", based: dateBased, fieldRange: makeRange(YearMin, YearMax)}, - FieldEra: {name: "Era", javaName: "ERA", based: dateBased, fieldRange: makeRange(1, 2)}, - FieldInstantSeconds: {name: "InstantSeconds", javaName: "INSTANT_SECONDS"}, - FieldOffsetSeconds: {name: "OffsetSeconds", javaName: "OFFSET_SECONDS"}, + FieldEra: {name: "Era", javaName: "ERA", based: dateBased, fieldRange: makeRange(0, 1)}, + FieldInstantSeconds: {name: "InstantSeconds", javaName: "INSTANT_SECONDS", fieldRange: makeRange(math.MinInt64, math.MaxInt64)}, + FieldOffsetSeconds: {name: "OffsetSeconds", javaName: "OFFSET_SECONDS", fieldRange: makeRange(-18*3600, 18*3600)}, } func (f Field) check(value int64) error { if !f.Valid() { - return newError("Invalid field: %d", f) + return invalidFieldError(f) } var r = fieldDescriptors[f].fieldRange if r.Valid && (value < r.Min || value > r.Max) { - return &Error{ - outOfRange: &r, - outOfRangeValue: value, - } + return fieldOutOfRangeError(f, value) } return nil } +func (f Field) fieldRange() fieldRange { + return fieldDescriptors[f].fieldRange +} + func (f Field) checkSetE(value int64, e *error) { if *e != nil { return } *e = f.check(value) - return } func (f Field) Valid() bool { - return f > 0 && int(f) < len(fieldDescriptors) + return f > 0 && f <= FieldOffsetSeconds } // String returns the name of the field. diff --git a/local_date_test.go b/local_date_test.go deleted file mode 100644 index e1201b8..0000000 --- a/local_date_test.go +++ /dev/null @@ -1,1309 +0,0 @@ -package goda - -import ( - "encoding/json" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLocalDate_Epoch(t *testing.T) { - var check = func(i int64, tt time.Time) bool { - var st = MustLocalDateOf(Year(tt.Year()), Month(tt.Month()), tt.Day()) - if !assert.Equal(t, i, st.UnixEpochDays(), tt) { - return false - } - if !assert.Equal(t, st, LocalDateOfUnixEpochDays(i), tt) { - return false - } - if !assert.Equal(t, st.DayOfWeek().GoWeekday(), tt.Weekday(), tt) { - return false - } - return assert.Equal(t, tt.Unix()/(24*60*60), st.UnixEpochDays(), tt) - } - var begin = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) - for i := int64(0); i < 100_0000; i++ { - if !check(i, begin) { - break - } - begin = begin.AddDate(0, 0, 1) - } - begin = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) - // negative - for i := int64(0); i > -100_0000; i-- { - if !check(i, begin) { - break - } - begin = begin.AddDate(0, 0, -1) - } -} - -func TestNewLocalDate(t *testing.T) { - t.Run("valid dates", func(t *testing.T) { - d, err := LocalDateOf(2024, January, 1) - require.NoError(t, err) - assert.Equal(t, Year(2024), d.Year()) - assert.Equal(t, January, d.Month()) - assert.Equal(t, 1, d.DayOfMonth()) - - d, err = LocalDateOf(2024, February, 29) // leap year - require.NoError(t, err) - assert.Equal(t, 29, d.DayOfMonth()) - }) - - t.Run("invalid day of month", func(t *testing.T) { - _, err := LocalDateOf(2024, January, 32) - assert.Error(t, err) - - _, err = LocalDateOf(2023, February, 29) // not a leap year - assert.Error(t, err) - - _, err = LocalDateOf(2024, February, 30) - assert.Error(t, err) - - _, err = LocalDateOf(2024, April, 31) - assert.Error(t, err) - }) - - t.Run("invalid month", func(t *testing.T) { - _, err := LocalDateOf(2024, Month(0), 1) - assert.Error(t, err) - - _, err = LocalDateOf(2024, Month(13), 1) - assert.Error(t, err) - }) -} - -func TestMustNewLocalDate(t *testing.T) { - t.Run("valid date", func(t *testing.T) { - assert.NotPanics(t, func() { - d := MustLocalDateOf(2024, March, 15) - assert.Equal(t, Year(2024), d.Year()) - }) - }) - - t.Run("invalid date panics", func(t *testing.T) { - assert.Panics(t, func() { - MustLocalDateOf(2024, January, 32) - }) - }) -} - -func TestLocalDate_IsZero(t *testing.T) { - var zero LocalDate - assert.True(t, zero.IsZero()) - - d := MustLocalDateOf(2024, January, 1) - assert.False(t, d.IsZero()) -} - -func TestLocalDate_IsLeapYear(t *testing.T) { - tests := []struct { - year Year - isLeap bool - }{ - {2000, true}, // divisible by 400 - {2004, true}, // divisible by 4 - {2100, false}, // divisible by 100 but not 400 - {2023, false}, // not divisible by 4 - {2024, true}, // divisible by 4 - {1900, false}, // divisible by 100 but not 400 - } - - for _, tt := range tests { - d := MustLocalDateOf(tt.year, January, 1) - assert.Equal(t, tt.isLeap, d.IsLeapYear(), "year %d", tt.year) - } -} - -func TestLocalDate_DayOfYear(t *testing.T) { - tests := []struct { - date LocalDate - dayOfYear int - }{ - {MustLocalDateOf(2024, January, 1), 1}, - {MustLocalDateOf(2024, January, 31), 31}, - {MustLocalDateOf(2024, February, 1), 32}, - {MustLocalDateOf(2024, February, 29), 60}, // leap year - {MustLocalDateOf(2023, February, 28), 59}, // non-leap year - {MustLocalDateOf(2024, March, 1), 61}, // leap year - {MustLocalDateOf(2023, March, 1), 60}, // non-leap year - {MustLocalDateOf(2024, December, 31), 366}, - {MustLocalDateOf(2023, December, 31), 365}, - } - - for _, tt := range tests { - assert.Equal(t, tt.dayOfYear, tt.date.DayOfYear(), "date: %s", tt.date.String()) - } - - var zero LocalDate - assert.Equal(t, 0, zero.DayOfYear()) -} - -func TestLocalDate_DayOfWeek(t *testing.T) { - tests := []struct { - date LocalDate - dayOfWeek DayOfWeek - }{ - {MustLocalDateOf(2024, 11, 5), Tuesday}, // Known date - {MustLocalDateOf(2024, 1, 1), Monday}, // New Year 2024 - {MustLocalDateOf(2023, 1, 1), Sunday}, // New Year 2023 - {MustLocalDateOf(2000, 1, 1), Saturday}, // Y2K - {MustLocalDateOf(1970, 1, 1), Thursday}, // Unix epoch - {MustLocalDateOf(2024, 2, 29), Thursday}, // Leap day 2024 - } - - for _, tt := range tests { - assert.Equal(t, tt.dayOfWeek, tt.date.DayOfWeek(), "date: %s", tt.date.String()) - } - - var zero LocalDate - assert.Equal(t, DayOfWeek(0), zero.DayOfWeek()) -} - -func TestLocalDate_PlusDays(t *testing.T) { - d := MustLocalDateOf(2024, January, 15) - - tests := []struct { - days int - expected LocalDate - }{ - {0, MustLocalDateOf(2024, January, 15)}, - {1, MustLocalDateOf(2024, January, 16)}, - {16, MustLocalDateOf(2024, January, 31)}, - {17, MustLocalDateOf(2024, February, 1)}, - {365, MustLocalDateOf(2025, January, 14)}, // 2024 is leap year - {-1, MustLocalDateOf(2024, January, 14)}, - {-15, MustLocalDateOf(2023, December, 31)}, - } - - for _, tt := range tests { - result := d.PlusDays(tt.days) - assert.Equal(t, tt.expected, result, "days: %d", tt.days) - } - - var zero LocalDate - assert.Equal(t, zero, zero.PlusDays(10)) -} - -func TestLocalDate_MinusDays(t *testing.T) { - d := MustLocalDateOf(2024, February, 15) - - result := d.MinusDays(10) - assert.Equal(t, MustLocalDateOf(2024, February, 5), result) - - result = d.MinusDays(15) - assert.Equal(t, MustLocalDateOf(2024, January, 31), result) -} - -func TestLocalDate_PlusMonths(t *testing.T) { - tests := []struct { - start LocalDate - months int - expected LocalDate - }{ - {MustLocalDateOf(2024, January, 15), 1, MustLocalDateOf(2024, February, 15)}, - {MustLocalDateOf(2024, January, 15), 12, MustLocalDateOf(2025, January, 15)}, - {MustLocalDateOf(2024, January, 15), 13, MustLocalDateOf(2025, February, 15)}, - {MustLocalDateOf(2024, January, 15), -1, MustLocalDateOf(2023, December, 15)}, - {MustLocalDateOf(2024, January, 15), -13, MustLocalDateOf(2022, December, 15)}, - // Test day clamping - {MustLocalDateOf(2024, January, 31), 1, MustLocalDateOf(2024, February, 29)}, // leap year - {MustLocalDateOf(2023, January, 31), 1, MustLocalDateOf(2023, February, 28)}, // non-leap year - {MustLocalDateOf(2024, March, 31), 1, MustLocalDateOf(2024, April, 30)}, - {MustLocalDateOf(2024, May, 31), 1, MustLocalDateOf(2024, June, 30)}, - // Large month additions - {MustLocalDateOf(2024, January, 1), 24, MustLocalDateOf(2026, January, 1)}, - {MustLocalDateOf(2024, January, 1), -24, MustLocalDateOf(2022, January, 1)}, - } - - for _, tt := range tests { - result := tt.start.PlusMonths(tt.months) - assert.Equal(t, tt.expected, result, "start: %s, months: %d", tt.start.String(), tt.months) - } - - var zero LocalDate - assert.Equal(t, zero, zero.PlusMonths(10)) -} - -func TestLocalDate_MinusMonths(t *testing.T) { - d := MustLocalDateOf(2024, March, 15) - - result := d.MinusMonths(2) - assert.Equal(t, MustLocalDateOf(2024, January, 15), result) - - result = d.MinusMonths(14) - assert.Equal(t, MustLocalDateOf(2023, January, 15), result) -} - -func TestLocalDate_PlusYears(t *testing.T) { - tests := []struct { - start LocalDate - years int - expected LocalDate - }{ - {MustLocalDateOf(2024, March, 15), 1, MustLocalDateOf(2025, March, 15)}, - {MustLocalDateOf(2024, March, 15), -1, MustLocalDateOf(2023, March, 15)}, - {MustLocalDateOf(2024, March, 15), 10, MustLocalDateOf(2034, March, 15)}, - // Leap year edge case - {MustLocalDateOf(2024, February, 29), 1, MustLocalDateOf(2025, February, 28)}, - {MustLocalDateOf(2024, February, 29), 4, MustLocalDateOf(2028, February, 29)}, - {MustLocalDateOf(2024, February, 29), -4, MustLocalDateOf(2020, February, 29)}, - } - - for _, tt := range tests { - result := tt.start.PlusYears(tt.years) - assert.Equal(t, tt.expected, result, "start: %s, years: %d", tt.start.String(), tt.years) - } - - var zero LocalDate - assert.Equal(t, zero, zero.PlusYears(10)) -} - -func TestLocalDate_MinusYears(t *testing.T) { - d := MustLocalDateOf(2024, March, 15) - - result := d.MinusYears(1) - assert.Equal(t, MustLocalDateOf(2023, March, 15), result) - - result = d.MinusYears(10) - assert.Equal(t, MustLocalDateOf(2014, March, 15), result) -} - -func TestLocalDate_Compare(t *testing.T) { - d1 := MustLocalDateOf(2024, March, 15) - d2 := MustLocalDateOf(2024, March, 15) - d3 := MustLocalDateOf(2024, March, 16) - d4 := MustLocalDateOf(2024, February, 15) - d5 := MustLocalDateOf(2023, March, 15) - - assert.Equal(t, 0, d1.Compare(d2)) - assert.Equal(t, -1, d1.Compare(d3)) - assert.Equal(t, 1, d3.Compare(d1)) - assert.Equal(t, 1, d1.Compare(d4)) - assert.Equal(t, 1, d1.Compare(d5)) -} - -func TestLocalDate_IsBefore(t *testing.T) { - d1 := MustLocalDateOf(2024, March, 15) - d2 := MustLocalDateOf(2024, March, 16) - d3 := MustLocalDateOf(2024, March, 15) - - assert.True(t, d1.IsBefore(d2)) - assert.False(t, d2.IsBefore(d1)) - assert.False(t, d1.IsBefore(d3)) -} - -func TestLocalDate_IsAfter(t *testing.T) { - d1 := MustLocalDateOf(2024, March, 15) - d2 := MustLocalDateOf(2024, March, 16) - d3 := MustLocalDateOf(2024, March, 15) - - assert.False(t, d1.IsAfter(d2)) - assert.True(t, d2.IsAfter(d1)) - assert.False(t, d1.IsAfter(d3)) -} - -func TestLocalDate_GoTime(t *testing.T) { - d := MustLocalDateOf(2024, March, 15) - goTime := d.GoTime() - - assert.Equal(t, 2024, goTime.Year()) - assert.Equal(t, time.March, goTime.Month()) - assert.Equal(t, 15, goTime.Day()) - assert.Equal(t, 0, goTime.Hour()) - assert.Equal(t, 0, goTime.Minute()) - assert.Equal(t, 0, goTime.Second()) - assert.Equal(t, time.UTC, goTime.Location()) - - var zero LocalDate - assert.True(t, zero.GoTime().IsZero()) -} - -func TestNewLocalDateByGoTime(t *testing.T) { - goTime := time.Date(2024, time.March, 15, 14, 30, 45, 0, time.UTC) - d := LocalDateOfGoTime(goTime) - - assert.Equal(t, Year(2024), d.Year()) - assert.Equal(t, March, d.Month()) - assert.Equal(t, 15, d.DayOfMonth()) - - // Test with zero time - d = LocalDateOfGoTime(time.Time{}) - assert.True(t, d.IsZero()) -} - -func TestLocalDate_String(t *testing.T) { - tests := []struct { - date LocalDate - expected string - }{ - {MustLocalDateOf(2024, March, 15), "2024-03-15"}, - {MustLocalDateOf(2024, January, 1), "2024-01-01"}, - {MustLocalDateOf(2024, December, 31), "2024-12-31"}, - {MustLocalDateOf(1999, June, 5), "1999-06-05"}, - {LocalDate{}, ""}, - } - - for _, tt := range tests { - assert.Equal(t, tt.expected, tt.date.String()) - } -} - -func TestLocalDate_MarshalText(t *testing.T) { - d := MustLocalDateOf(2024, March, 15) - text, err := d.MarshalText() - require.NoError(t, err) - assert.Equal(t, "2024-03-15", string(text)) - - var zero LocalDate - text, err = zero.MarshalText() - require.NoError(t, err) - assert.Equal(t, "", string(text)) -} - -func TestLocalDate_UnmarshalText(t *testing.T) { - t.Run("valid dates", func(t *testing.T) { - var d LocalDate - err := d.UnmarshalText([]byte("2024-03-15")) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, March, 15), d) - - err = d.UnmarshalText([]byte("1999-12-31")) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(1999, December, 31), d) - }) - - t.Run("empty string", func(t *testing.T) { - var d LocalDate - err := d.UnmarshalText([]byte("")) - require.NoError(t, err) - assert.True(t, d.IsZero()) - }) - - t.Run("invalid format", func(t *testing.T) { - var d LocalDate - err := d.UnmarshalText([]byte("2024/03/15")) - assert.Error(t, err) - - err = d.UnmarshalText([]byte("2024-3-15")) - assert.Error(t, err) - - err = d.UnmarshalText([]byte("not-a-date")) - assert.Error(t, err) - }) - - t.Run("invalid date", func(t *testing.T) { - var d LocalDate - err := d.UnmarshalText([]byte("2024-02-30")) - assert.Error(t, err) - - err = d.UnmarshalText([]byte("2024-13-01")) - assert.Error(t, err) - }) -} - -func TestLocalDate_MarshalJSON(t *testing.T) { - d := MustLocalDateOf(2024, March, 15) - data, err := json.Marshal(d) - require.NoError(t, err) - assert.Equal(t, `"2024-03-15"`, string(data)) - - var zero LocalDate - data, err = json.Marshal(zero) - require.NoError(t, err) - assert.Equal(t, `""`, string(data)) -} - -func TestLocalDate_UnmarshalJSON(t *testing.T) { - t.Run("valid JSON", func(t *testing.T) { - var d LocalDate - err := json.Unmarshal([]byte(`"2024-03-15"`), &d) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, March, 15), d) - }) - - t.Run("empty string", func(t *testing.T) { - var d LocalDate - err := json.Unmarshal([]byte(`""`), &d) - require.NoError(t, err) - assert.True(t, d.IsZero()) - }) - - t.Run("null", func(t *testing.T) { - var d LocalDate - err := json.Unmarshal([]byte(`null`), &d) - require.NoError(t, err) - assert.True(t, d.IsZero()) - }) - - t.Run("invalid JSON", func(t *testing.T) { - var d LocalDate - err := json.Unmarshal([]byte(`"invalid-date"`), &d) - assert.Error(t, err) - }) -} - -func TestLocalDate_Scan(t *testing.T) { - t.Run("nil value", func(t *testing.T) { - var d LocalDate - err := d.Scan(nil) - require.NoError(t, err) - assert.True(t, d.IsZero()) - }) - - t.Run("string value", func(t *testing.T) { - var d LocalDate - err := d.Scan("2024-03-15") - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, March, 15), d) - }) - - t.Run("byte slice value", func(t *testing.T) { - var d LocalDate - err := d.Scan([]byte("2024-03-15")) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, March, 15), d) - }) - - t.Run("time.LocalTime value", func(t *testing.T) { - var d LocalDate - goTime := time.Date(2024, time.March, 15, 14, 30, 0, 0, time.UTC) - err := d.Scan(goTime) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, March, 15), d) - }) -} - -func TestLocalDate_Value(t *testing.T) { - d := MustLocalDateOf(2024, March, 15) - val, err := d.Value() - require.NoError(t, err) - assert.Equal(t, "2024-03-15", val) - - var zero LocalDate - val, err = zero.Value() - require.NoError(t, err) - assert.Nil(t, val) -} - -func TestLocalDate_AppendText(t *testing.T) { - d := MustLocalDateOf(2024, March, 15) - buf := []byte("LocalDate: ") - buf, err := d.AppendText(buf) - require.NoError(t, err) - assert.Equal(t, "LocalDate: 2024-03-15", string(buf)) - - var zero LocalDate - buf = []byte("LocalDate: ") - buf, err = zero.AppendText(buf) - require.NoError(t, err) - assert.Equal(t, "LocalDate: ", string(buf)) -} - -func TestLocalDate_SpecialDates(t *testing.T) { - t.Run("leap year Feb 29", func(t *testing.T) { - d := MustLocalDateOf(2024, February, 29) - assert.Equal(t, 29, d.DayOfMonth()) - assert.Equal(t, 60, d.DayOfYear()) - - // Add years to non-leap year - next := d.PlusYears(1) - assert.Equal(t, MustLocalDateOf(2025, February, 28), next) - }) - - t.Run("year boundaries", func(t *testing.T) { - d := MustLocalDateOf(2024, December, 31) - assert.Equal(t, 366, d.DayOfYear()) // leap year - - next := d.PlusDays(1) - assert.Equal(t, MustLocalDateOf(2025, January, 1), next) - assert.Equal(t, 1, next.DayOfYear()) - }) - - t.Run("negative years", func(t *testing.T) { - // Test that the system can handle negative years - d := MustLocalDateOf(-1, January, 1) - assert.Equal(t, Year(-1), d.Year()) - - d = MustLocalDateOf(-100, December, 31) - assert.Equal(t, Year(-100), d.Year()) - assert.Equal(t, December, d.Month()) - }) -} - -func TestLocalDate_MonthBoundaries(t *testing.T) { - tests := []struct { - name string - date LocalDate - addDays int - expected LocalDate - }{ - {"Jan to Feb", MustLocalDateOf(2024, January, 31), 1, MustLocalDateOf(2024, February, 1)}, - {"Feb to Mar (leap)", MustLocalDateOf(2024, February, 29), 1, MustLocalDateOf(2024, March, 1)}, - {"Feb to Mar (non-leap)", MustLocalDateOf(2023, February, 28), 1, MustLocalDateOf(2023, March, 1)}, - {"Apr to May", MustLocalDateOf(2024, April, 30), 1, MustLocalDateOf(2024, May, 1)}, - {"Dec to Jan", MustLocalDateOf(2024, December, 31), 1, MustLocalDateOf(2025, January, 1)}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.date.PlusDays(tt.addDays) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestLocalDateNow(t *testing.T) { - // Test that LocalDateNow() returns a valid date - today := LocalDateNow() - assert.False(t, today.IsZero(), "LocalDateNow should not be zero") - - // Test that it matches time.Now() - now := time.Now() - expected := LocalDateOfGoTime(now) - - // Allow for the possibility that the date changed between calls - // (very unlikely but possible at midnight) - diff := today.Compare(expected) - assert.True(t, diff >= -1 && diff <= 1, "LocalDateNow should be within 1 day of current time") -} - -func TestLocalDateNowUTC(t *testing.T) { - todayUTC := LocalDateNowUTC() - assert.False(t, todayUTC.IsZero(), "LocalDateNowUTC should not be zero") - - // Test that it matches time.Now().UTC() - now := time.Now().UTC() - expected := LocalDateOfGoTime(now) - - diff := todayUTC.Compare(expected) - assert.True(t, diff >= -1 && diff <= 1, "LocalDateNowUTC should be within 1 day of current UTC time") -} - -func TestParseLocalDate(t *testing.T) { - t.Run("valid dates", func(t *testing.T) { - date, err := LocalDateParse("2024-03-15") - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, March, 15), date) - - date, err = LocalDateParse("1999-12-31") - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(1999, December, 31), date) - - date, err = LocalDateParse("2000-02-29") // leap year - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2000, February, 29), date) - }) - - t.Run("invalid format", func(t *testing.T) { - _, err := LocalDateParse("2024/03/15") - assert.Error(t, err) - - _, err = LocalDateParse("2024-3-15") - assert.Error(t, err) - - _, err = LocalDateParse("not-a-date") - assert.Error(t, err) - }) - - t.Run("invalid date", func(t *testing.T) { - _, err := LocalDateParse("2024-02-30") - assert.Error(t, err) - - _, err = LocalDateParse("2024-13-01") - assert.Error(t, err) - }) - - t.Run("empty string", func(t *testing.T) { - date, err := LocalDateParse("") - require.NoError(t, err) - assert.True(t, date.IsZero()) - }) -} - -func TestMustParseLocalDate(t *testing.T) { - t.Run("valid date", func(t *testing.T) { - assert.NotPanics(t, func() { - date := MustLocalDateParse("2024-03-15") - assert.Equal(t, MustLocalDateOf(2024, March, 15), date) - }) - }) - - t.Run("invalid date panics", func(t *testing.T) { - assert.Panics(t, func() { - MustLocalDateParse("2024-02-30") - }) - - assert.Panics(t, func() { - MustLocalDateParse("invalid") - }) - }) -} - -func TestLocalDateNowIn(t *testing.T) { - // Test with different time zones - locations := []struct { - name string - loc *time.Location - }{ - {"UTC", time.UTC}, - {"Local", time.Local}, - } - - for _, tt := range locations { - t.Run(tt.name, func(t *testing.T) { - todayIn := LocalDateNowIn(tt.loc) - assert.False(t, todayIn.IsZero(), "LocalDateNowIn should not be zero") - - now := time.Now().In(tt.loc) - expected := LocalDateOfGoTime(now) - - diff := todayIn.Compare(expected) - assert.True(t, diff >= -1 && diff <= 1, "LocalDateNowIn should be within 1 day of current time in specified zone") - }) - } -} - -func TestLocalDate_ValuePostgres(t *testing.T) { - var pg = GetPG(t) - t.Run("normal", func(t *testing.T) { - var expected = MustLocalDateParse("2000-12-29") - var actual LocalDate - var expectedTrue bool - var e = pg.QueryRow("SELECT $1::date, $1::date = '2000-12-29'", expected).Scan(&actual, &expectedTrue) - assert.NoError(t, e) - assert.Equal(t, expected, actual) - assert.True(t, expectedTrue) - }) - t.Run("null_value", func(t *testing.T) { - var actual LocalDate - var expectedTrue bool - var e = pg.QueryRow("SELECT NULL::date, $1::date is null", actual).Scan(&actual, &expectedTrue) - assert.NoError(t, e) - assert.True(t, actual.IsZero()) - assert.True(t, expectedTrue) - }) -} - -func TestLocalDate_ValueMySQL(t *testing.T) { - var mysql = GetMySQL(t) - t.Run("normal", func(t *testing.T) { - var expected = MustLocalDateParse("2000-12-29") - var actual LocalDate - var expectedTrue bool - var e = mysql.QueryRow("SELECT CAST(? AS DATE), CAST(? AS DATE) = '2000-12-29'", expected, expected).Scan(&actual, &expectedTrue) - assert.NoError(t, e) - assert.Equal(t, expected, actual) - assert.True(t, expectedTrue) - }) - t.Run("null_value", func(t *testing.T) { - var actual LocalDate - var expectedTrue bool - var e = mysql.QueryRow("SELECT CAST(NULL AS DATE), CAST(? AS DATE) is null", actual).Scan(&actual, &expectedTrue) - assert.NoError(t, e) - assert.True(t, actual.IsZero()) - assert.True(t, expectedTrue) - }) -} - -func TestLocalDate_PlusWeeks(t *testing.T) { - tests := []struct { - name string - date LocalDate - weeks int - expected LocalDate - }{ - {"Add 1 week", MustLocalDateOf(2024, January, 15), 1, MustLocalDateOf(2024, January, 22)}, - {"Add 2 weeks", MustLocalDateOf(2024, January, 15), 2, MustLocalDateOf(2024, January, 29)}, - {"Add 0 weeks", MustLocalDateOf(2024, March, 15), 0, MustLocalDateOf(2024, March, 15)}, - {"Add negative weeks", MustLocalDateOf(2024, March, 15), -2, MustLocalDateOf(2024, March, 1)}, - {"Cross month boundary", MustLocalDateOf(2024, January, 29), 1, MustLocalDateOf(2024, February, 5)}, - {"Cross year boundary", MustLocalDateOf(2024, December, 25), 2, MustLocalDateOf(2025, January, 8)}, - {"Large number of weeks", MustLocalDateOf(2024, January, 1), 52, MustLocalDateOf(2024, December, 30)}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.date.PlusWeeks(tt.weeks) - assert.Equal(t, tt.expected, result, "date: %s, weeks: %d", tt.date.String(), tt.weeks) - }) - } - - t.Run("zero value", func(t *testing.T) { - var zero LocalDate - assert.Equal(t, zero, zero.PlusWeeks(5)) - }) -} - -func TestLocalDate_MinusWeeks(t *testing.T) { - tests := []struct { - name string - date LocalDate - weeks int - expected LocalDate - }{ - {"Minus 1 week", MustLocalDateOf(2024, January, 22), 1, MustLocalDateOf(2024, January, 15)}, - {"Minus 2 weeks", MustLocalDateOf(2024, January, 29), 2, MustLocalDateOf(2024, January, 15)}, - {"Minus 0 weeks", MustLocalDateOf(2024, March, 15), 0, MustLocalDateOf(2024, March, 15)}, - {"Cross month boundary backwards", MustLocalDateOf(2024, February, 5), 1, MustLocalDateOf(2024, January, 29)}, - {"Cross year boundary backwards", MustLocalDateOf(2025, January, 8), 2, MustLocalDateOf(2024, December, 25)}, - {"Large number of weeks", MustLocalDateOf(2024, December, 30), 52, MustLocalDateOf(2024, January, 1)}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.date.MinusWeeks(tt.weeks) - assert.Equal(t, tt.expected, result, "date: %s, weeks: %d", tt.date.String(), tt.weeks) - }) - } - - t.Run("zero value", func(t *testing.T) { - var zero LocalDate - assert.Equal(t, zero, zero.MinusWeeks(5)) - }) -} - -func TestLocalDate_WithDayOfMonth(t *testing.T) { - t.Run("valid day changes", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - - result, err := date.WithDayOfMonth(1) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, March, 1), result) - - result, err = date.WithDayOfMonth(31) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, March, 31), result) - - result, err = date.WithDayOfMonth(15) - require.NoError(t, err) - assert.Equal(t, date, result) - }) - - t.Run("February in leap year", func(t *testing.T) { - date := MustLocalDateOf(2024, February, 15) - - result, err := date.WithDayOfMonth(29) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, February, 29), result) - - _, err = date.WithDayOfMonth(30) - assert.Error(t, err) - }) - - t.Run("February in non-leap year", func(t *testing.T) { - date := MustLocalDateOf(2023, February, 15) - - result, err := date.WithDayOfMonth(28) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2023, February, 28), result) - - _, err = date.WithDayOfMonth(29) - assert.Error(t, err) - }) - - t.Run("invalid day values", func(t *testing.T) { - date := MustLocalDateOf(2024, April, 15) - - _, err := date.WithDayOfMonth(0) - assert.Error(t, err) - - _, err = date.WithDayOfMonth(31) // April has 30 days - assert.Error(t, err) - - _, err = date.WithDayOfMonth(-1) - assert.Error(t, err) - - _, err = date.WithDayOfMonth(100) - assert.Error(t, err) - }) - - t.Run("various months", func(t *testing.T) { - // 31-day month - date := MustLocalDateOf(2024, January, 1) - result, err := date.WithDayOfMonth(31) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, January, 31), result) - - // 30-day month - date = MustLocalDateOf(2024, April, 1) - result, err = date.WithDayOfMonth(30) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, April, 30), result) - - _, err = date.WithDayOfMonth(31) - assert.Error(t, err) - }) -} - -func TestLocalDate_MustWithDayOfMonth(t *testing.T) { - t.Run("valid day", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - assert.NotPanics(t, func() { - result := date.MustWithDayOfMonth(20) - assert.Equal(t, MustLocalDateOf(2024, March, 20), result) - }) - }) - - t.Run("invalid day panics", func(t *testing.T) { - date := MustLocalDateOf(2024, April, 15) - assert.Panics(t, func() { - date.MustWithDayOfMonth(31) // April has only 30 days - }) - - assert.Panics(t, func() { - date.MustWithDayOfMonth(0) - }) - }) -} - -func TestLocalDate_WithDayOfYear(t *testing.T) { - t.Run("valid day of year changes", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - - // Day 1 of year = January 1 - result, err := date.WithDayOfYear(1) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, January, 1), result) - - // Day 32 of year = February 1 - result, err = date.WithDayOfYear(32) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, February, 1), result) - - // Day 60 of leap year = February 29 - result, err = date.WithDayOfYear(60) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, February, 29), result) - - // Day 61 of leap year = March 1 - result, err = date.WithDayOfYear(61) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, March, 1), result) - - // Last day of leap year - result, err = date.WithDayOfYear(366) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, December, 31), result) - }) - - t.Run("non-leap year", func(t *testing.T) { - date := MustLocalDateOf(2023, June, 15) - - // Day 59 of non-leap year = February 28 - result, err := date.WithDayOfYear(59) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2023, February, 28), result) - - // Day 60 of non-leap year = March 1 - result, err = date.WithDayOfYear(60) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2023, March, 1), result) - - // Last day of non-leap year - result, err = date.WithDayOfYear(365) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2023, December, 31), result) - - // Day 366 is invalid for non-leap year - _, err = date.WithDayOfYear(366) - assert.Error(t, err) - }) - - t.Run("zero value", func(t *testing.T) { - var zero LocalDate - result, err := zero.WithDayOfYear(100) - require.NoError(t, err) - assert.True(t, result.IsZero()) - }) - - t.Run("invalid day of year values", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - - _, err := date.WithDayOfYear(0) - assert.Error(t, err) - - _, err = date.WithDayOfYear(-1) - assert.Error(t, err) - - _, err = date.WithDayOfYear(367) // Even leap years only have 366 days - assert.Error(t, err) - - // Non-leap year specific - dateNonLeap := MustLocalDateOf(2023, March, 15) - _, err = dateNonLeap.WithDayOfYear(366) - assert.Error(t, err) - }) - - t.Run("all months in leap year", func(t *testing.T) { - date := MustLocalDateOf(2024, January, 1) - tests := []struct { - dayOfYear int - expected LocalDate - }{ - {1, MustLocalDateOf(2024, January, 1)}, - {31, MustLocalDateOf(2024, January, 31)}, - {32, MustLocalDateOf(2024, February, 1)}, - {60, MustLocalDateOf(2024, February, 29)}, - {91, MustLocalDateOf(2024, March, 31)}, - {121, MustLocalDateOf(2024, April, 30)}, - {152, MustLocalDateOf(2024, May, 31)}, - {182, MustLocalDateOf(2024, June, 30)}, - {213, MustLocalDateOf(2024, July, 31)}, - {244, MustLocalDateOf(2024, August, 31)}, - {274, MustLocalDateOf(2024, September, 30)}, - {305, MustLocalDateOf(2024, October, 31)}, - {335, MustLocalDateOf(2024, November, 30)}, - {366, MustLocalDateOf(2024, December, 31)}, - } - - for _, tt := range tests { - result, err := date.WithDayOfYear(tt.dayOfYear) - require.NoError(t, err, "dayOfYear: %d", tt.dayOfYear) - assert.Equal(t, tt.expected, result, "dayOfYear: %d", tt.dayOfYear) - } - }) -} - -func TestLocalDate_MustWithDayOfYear(t *testing.T) { - t.Run("valid day of year", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - assert.NotPanics(t, func() { - result := date.MustWithDayOfYear(100) - assert.Equal(t, MustLocalDateOf(2024, April, 9), result) - }) - }) - - t.Run("invalid day of year panics", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - assert.Panics(t, func() { - date.MustWithDayOfYear(367) - }) - - assert.Panics(t, func() { - date.MustWithDayOfYear(0) - }) - - assert.Panics(t, func() { - date.MustWithDayOfYear(-1) - }) - }) - - t.Run("non-leap year day 366 panics", func(t *testing.T) { - date := MustLocalDateOf(2023, June, 15) - assert.Panics(t, func() { - date.MustWithDayOfYear(366) - }) - }) -} - -func TestLocalDate_LengthOfMonth(t *testing.T) { - t.Run("31-day months", func(t *testing.T) { - months31 := []Month{January, March, May, July, August, October, December} - for _, month := range months31 { - date := MustLocalDateOf(2024, month, 1) - assert.Equal(t, 31, date.LengthOfMonth(), "month: %d", month) - } - }) - - t.Run("30-day months", func(t *testing.T) { - months30 := []Month{April, June, September, November} - for _, month := range months30 { - date := MustLocalDateOf(2024, month, 1) - assert.Equal(t, 30, date.LengthOfMonth(), "month: %d", month) - } - }) - - t.Run("February in leap year", func(t *testing.T) { - date := MustLocalDateOf(2024, February, 1) - assert.Equal(t, 29, date.LengthOfMonth()) - }) - - t.Run("February in non-leap year", func(t *testing.T) { - date := MustLocalDateOf(2023, February, 1) - assert.Equal(t, 28, date.LengthOfMonth()) - }) - - t.Run("various leap years", func(t *testing.T) { - tests := []struct { - year Year - expected int - }{ - {2000, 29}, // divisible by 400 - {2004, 29}, // divisible by 4 - {2100, 28}, // divisible by 100 but not 400 - {2023, 28}, // not divisible by 4 - } - - for _, tt := range tests { - date := MustLocalDateOf(tt.year, February, 1) - assert.Equal(t, tt.expected, date.LengthOfMonth(), "year: %d", tt.year) - } - }) - - t.Run("zero value", func(t *testing.T) { - var zero LocalDate - assert.Equal(t, 0, zero.LengthOfMonth()) - }) -} - -func TestLocalDate_LengthOfYear(t *testing.T) { - t.Run("leap years", func(t *testing.T) { - leapYears := []Year{2000, 2004, 2024, 2400} - for _, year := range leapYears { - date := MustLocalDateOf(year, January, 1) - assert.Equal(t, 366, date.LengthOfYear(), "year: %d", year) - } - }) - - t.Run("non-leap years", func(t *testing.T) { - nonLeapYears := []Year{1900, 2001, 2023, 2100} - for _, year := range nonLeapYears { - date := MustLocalDateOf(year, January, 1) - assert.Equal(t, 365, date.LengthOfYear(), "year: %d", year) - } - }) - - t.Run("century years", func(t *testing.T) { - tests := []struct { - year Year - expected int - }{ - {1600, 366}, // divisible by 400 - {1700, 365}, // divisible by 100 but not 400 - {1800, 365}, // divisible by 100 but not 400 - {1900, 365}, // divisible by 100 but not 400 - {2000, 366}, // divisible by 400 - {2100, 365}, // divisible by 100 but not 400 - {2400, 366}, // divisible by 400 - } - - for _, tt := range tests { - date := MustLocalDateOf(tt.year, June, 15) - assert.Equal(t, tt.expected, date.LengthOfYear(), "year: %d", tt.year) - } - }) - - t.Run("zero value", func(t *testing.T) { - var zero LocalDate - assert.Equal(t, 0, zero.LengthOfYear()) - }) -} - -func TestLocalDate_WithMonth(t *testing.T) { - t.Run("valid month changes", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - - result, err := date.WithMonth(January) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, January, 15), result) - - result, err = date.WithMonth(December) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, December, 15), result) - - result, err = date.WithMonth(March) - require.NoError(t, err) - assert.Equal(t, date, result) - }) - - t.Run("day clamping - 31 to 30-day month", func(t *testing.T) { - date := MustLocalDateOf(2024, January, 31) - - result, err := date.WithMonth(April) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, April, 30), result) - - result, err = date.WithMonth(June) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, June, 30), result) - - result, err = date.WithMonth(September) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, September, 30), result) - - result, err = date.WithMonth(November) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, November, 30), result) - }) - - t.Run("day clamping - 31 to February", func(t *testing.T) { - // Leap year - date := MustLocalDateOf(2024, January, 31) - result, err := date.WithMonth(February) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, February, 29), result) - - // Non-leap year - date = MustLocalDateOf(2023, January, 31) - result, err = date.WithMonth(February) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2023, February, 28), result) - }) - - t.Run("day clamping - 30 to February", func(t *testing.T) { - date := MustLocalDateOf(2024, April, 30) - result, err := date.WithMonth(February) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, February, 29), result) - }) - - t.Run("invalid month values", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - - _, err := date.WithMonth(Month(0)) - assert.Error(t, err) - - _, err = date.WithMonth(Month(13)) - assert.Error(t, err) - - _, err = date.WithMonth(Month(-1)) - assert.Error(t, err) - }) - - t.Run("zero value", func(t *testing.T) { - var zero LocalDate - result, err := zero.WithMonth(June) - require.NoError(t, err) - assert.True(t, result.IsZero()) - }) -} - -func TestLocalDate_MustWithMonth(t *testing.T) { - t.Run("valid month", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - assert.NotPanics(t, func() { - result := date.MustWithMonth(June) - assert.Equal(t, MustLocalDateOf(2024, June, 15), result) - }) - }) - - t.Run("day clamping works", func(t *testing.T) { - date := MustLocalDateOf(2024, January, 31) - assert.NotPanics(t, func() { - result := date.MustWithMonth(February) - assert.Equal(t, MustLocalDateOf(2024, February, 29), result) - }) - }) - - t.Run("invalid month panics", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - assert.Panics(t, func() { - date.MustWithMonth(Month(0)) - }) - - assert.Panics(t, func() { - date.MustWithMonth(Month(13)) - }) - }) -} - -func TestLocalDate_WithYear(t *testing.T) { - t.Run("valid year changes", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - - result, err := date.WithYear(2025) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2025, March, 15), result) - - result, err = date.WithYear(2000) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2000, March, 15), result) - - result, err = date.WithYear(2024) - require.NoError(t, err) - assert.Equal(t, date, result) - }) - - t.Run("leap year to non-leap year - February 29", func(t *testing.T) { - date := MustLocalDateOf(2024, February, 29) - - result, err := date.WithYear(2023) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2023, February, 28), result) - - result, err = date.WithYear(2025) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2025, February, 28), result) - - result, err = date.WithYear(2100) // divisible by 100 but not 400 - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2100, February, 28), result) - }) - - t.Run("non-leap year to leap year - February 28", func(t *testing.T) { - date := MustLocalDateOf(2023, February, 28) - - result, err := date.WithYear(2024) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2024, February, 28), result) - }) - - t.Run("leap year to leap year - February 29", func(t *testing.T) { - date := MustLocalDateOf(2024, February, 29) - - result, err := date.WithYear(2020) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2020, February, 29), result) - - result, err = date.WithYear(2000) // divisible by 400 - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(2000, February, 29), result) - }) - - t.Run("negative years", func(t *testing.T) { - date := MustLocalDateOf(2024, June, 15) - - result, err := date.WithYear(-100) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(-100, June, 15), result) - - result, err = date.WithYear(-1) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(-1, June, 15), result) - }) - - t.Run("very large year values", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - - result, err := date.WithYear(10000) - require.NoError(t, err) - assert.Equal(t, MustLocalDateOf(10000, March, 15), result) - }) - - t.Run("zero value", func(t *testing.T) { - var zero LocalDate - result, err := zero.WithYear(2024) - require.NoError(t, err) - assert.True(t, result.IsZero()) - }) -} - -func TestLocalDate_MustWithYear(t *testing.T) { - t.Run("valid year", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - assert.NotPanics(t, func() { - result := date.MustWithYear(2025) - assert.Equal(t, MustLocalDateOf(2025, March, 15), result) - }) - }) - - t.Run("day clamping works - February 29 to non-leap year", func(t *testing.T) { - date := MustLocalDateOf(2024, February, 29) - assert.NotPanics(t, func() { - result := date.MustWithYear(2023) - assert.Equal(t, MustLocalDateOf(2023, February, 28), result) - }) - }) - - t.Run("negative years", func(t *testing.T) { - date := MustLocalDateOf(2024, March, 15) - assert.NotPanics(t, func() { - result := date.MustWithYear(-100) - assert.Equal(t, MustLocalDateOf(-100, March, 15), result) - }) - }) -} diff --git a/local_date.go b/localdate.go similarity index 50% rename from local_date.go rename to localdate.go index c634237..0fa6fc4 100644 --- a/local_date.go +++ b/localdate.go @@ -6,7 +6,6 @@ import ( "encoding" "encoding/json" "fmt" - "math" "time" ) @@ -26,115 +25,127 @@ type LocalDate struct { v int64 } -// Scan implements the sql.Scanner interface. -// It supports scanning from nil, string, []byte, and time.Time. -// Nil values are converted to the zero value of LocalDate. -func (d *LocalDate) Scan(src any) error { - switch v := src.(type) { - case nil: - *d = LocalDate{} - return nil - case string: - return d.UnmarshalText([]byte(v)) - case []byte: - return d.UnmarshalText(v) - case time.Time: - *d = LocalDateOfGoTime(v) - return nil - default: - return sqlScannerDefaultBranch(v) - } +// Year returns the year component of this date. +func (d LocalDate) Year() Year { + return Year(d.v >> 16) +} + +// Month returns the month component of this date (1-12). +func (d LocalDate) Month() Month { + return Month(d.v >> 8 & 0xff) } -// Value implements the driver.Valuer interface. -// It returns nil for zero values, otherwise returns the date as a string in yyyy-MM-dd format. -func (d LocalDate) Value() (driver.Value, error) { +// DayOfMonth returns the day-of-month component of this date (1-31). +func (d LocalDate) DayOfMonth() int { + return int(d.v & 0xff) +} + +func (d LocalDate) YearMonth() YearMonth { if d.IsZero() { - return nil, nil + return YearMonth{} } - return d.String(), nil + return MustYearMonthOf(d.Year(), d.Month()) } -// UnmarshalJSON implements the json.Unmarshaler interface. -// It accepts JSON strings in yyyy-MM-dd format or JSON null. -func (d *LocalDate) UnmarshalJSON(bytes []byte) error { - if len(bytes) == 4 && string(bytes) == "null" { - *d = LocalDate{} - return nil +// DayOfWeek returns the day-of-week for this date. +// Returns 0 for zero value, otherwise Monday=1 through Sunday=7. +func (d LocalDate) DayOfWeek() DayOfWeek { + if d.IsZero() { + return 0 } - return unmarshalJsonImpl(d, bytes) + return DayOfWeek(floorMod(d.UnixEpochDays()+3, 7) + 1) } -// UnmarshalText implements the encoding.TextUnmarshaler interface. -// It parses dates in yyyy-MM-dd format. -// Empty input is treated as zero value. -func (d *LocalDate) UnmarshalText(text []byte) (e error) { - if len(text) == 0 { - *d = LocalDate{} - return nil - } - if text[len(text)-3] != '-' || text[len(text)-6] != '-' { - return unmarshalError(text) - } - var y int64 - var m, dom int - dom, e = parseInt(text[len(text)-2:]) - if e != nil { - return - } - m, e = parseInt(text[len(text)-5 : len(text)-3]) - if e != nil { - return - } - y, e = parseInt64(text[:len(text)-6]) - if e != nil { - return - } - dd, e := LocalDateOf(Year(y), Month(m), dom) - if e != nil { - return +// DayOfYear returns the day-of-year for this date (1-366). +// Returns 0 for zero value. +func (d LocalDate) DayOfYear() int { + if d.IsZero() { + return 0 } - *d = dd - return + return d.Month().FirstDayOfYear(d.IsLeapYear()) - 1 + d.DayOfMonth() +} + +// IsLeapYear returns true if the year of this date is a leap year. +// A leap year is divisible by 4, unless it's divisible by 100 (but not 400). +func (d LocalDate) IsLeapYear() bool { + return d.Year().IsLeapYear() } -// MarshalText implements the encoding.TextMarshaler interface. -// It returns the date in yyyy-MM-dd format, or empty for zero value. -func (d LocalDate) MarshalText() (text []byte, err error) { - return marshalTextImpl(d) +// Compare compares this date with another date. +// Returns -1 if this date is before other, 0 if equal, and 1 if after. +// Zero values are considered less than non-zero values. +func (d LocalDate) Compare(other LocalDate) int { + return doCompare(d, other, compareZero, comparing(LocalDate.Year), comparing(LocalDate.Month), comparing(LocalDate.DayOfMonth)) } -// MarshalJSON implements the json.Marshaler interface. -// It returns the date as a JSON string in yyyy-MM-dd format, or empty string for zero value. -func (d LocalDate) MarshalJSON() ([]byte, error) { - return marshalJsonImpl(d) +// IsBefore returns true if this date is before the specified date. +func (d LocalDate) IsBefore(other LocalDate) bool { + return d.Compare(other) < 0 } -// String returns the date in yyyy-MM-dd format, or empty string for zero value. -func (d LocalDate) String() string { - return stringImpl(d) +// IsAfter returns true if this date is after the specified date. +func (d LocalDate) IsAfter(other LocalDate) bool { + return d.Compare(other) > 0 } -// AppendText implements the encoding.TextAppender interface. -// It appends the date in yyyy-MM-dd format to b and returns the extended buffer. -func (d LocalDate) AppendText(b []byte) ([]byte, error) { +// GoTime converts this date to a time.Time at midnight UTC. +// Returns time.Time{} (zero) for zero value. +func (d LocalDate) GoTime() time.Time { if d.IsZero() { - return b, nil + return time.Time{} } - b, _ = d.Year().AppendText(b) - b = append(b, '-', byte('0'+d.Month()/10), byte('0'+d.Month()%10), '-', byte('0'+d.DayOfMonth()/10), byte('0'+d.DayOfMonth()%10)) - return b, nil + return time.Date(int(d.Year()), time.Month(d.Month()), d.DayOfMonth(), 0, 0, 0, 0, time.UTC) } -// Year returns the year component of this date. -func (d LocalDate) Year() Year { - return Year(d.v >> 16) +func (d LocalDate) Chain() (chain LocalDateChain) { + chain.value = d + return } -// IsLeapYear returns true if the year of this date is a leap year. -// A leap year is divisible by 4, unless it's divisible by 100 (but not 400). -func (d LocalDate) IsLeapYear() bool { - return d.Year().IsLeapYear() +func (d LocalDate) chainWithError(e error) (chain LocalDateChain) { + chain = d.Chain() + chain.eError = e + return +} + +// UnixEpochDays returns the number of days since Unix epoch (1970-01-01). +// Positive values represent dates after the epoch, negative before. +// Returns 0 for zero value. +func (d LocalDate) UnixEpochDays() int64 { + if d.IsZero() { + return 0 + } + const DaysPerCycle = 365*400 + 97 + const Days0000To1970 = (DaysPerCycle * 5) - (30*365 + 7) + + y := d.Year().Int64() + m := int64(d.Month()) + day := int64(d.DayOfMonth()) + total := int64(0) + + // Calculate year contribution + total += 365 * y + if y >= 0 { + total += (y+3)/4 - (y+99)/100 + (y+399)/400 + } else { + total -= y/-4 - y/-100 + y/-400 + } + + // Calculate month contribution + total += (367*m - 362) / 12 + + // Calculate day contribution + total += day - 1 + + // Adjust for leap year if month > February + if m > 2 { + total-- + if !d.Year().IsLeapYear() { + total-- + } + } + + return total - Days0000To1970 } // IsSupportedField returns true if the field is supported by LocalDate. @@ -227,369 +238,6 @@ func (d LocalDate) GetField(field Field) TemporalValue { return TemporalValue{v: v} } -// Month returns the month component of this date (1-12). -func (d LocalDate) Month() Month { - return Month(d.v >> 8 & 0xff) -} - -// DayOfMonth returns the day-of-month component of this date (1-31). -func (d LocalDate) DayOfMonth() int { - return int(d.v & 0xff) -} - -// DayOfWeek returns the day-of-week for this date. -// Returns 0 for zero value, otherwise Monday=1 through Sunday=7. -func (d LocalDate) DayOfWeek() DayOfWeek { - if d.IsZero() { - return 0 - } - return DayOfWeek(floorMod(d.UnixEpochDays()+3, 7) + 1) -} - -// DayOfYear returns the day-of-year for this date (1-366). -// Returns 0 for zero value. -func (d LocalDate) DayOfYear() int { - if d.IsZero() { - return 0 - } - return d.Month().FirstDayOfYear(d.IsLeapYear()) - 1 + d.DayOfMonth() -} - -// PlusDays returns a copy of this date with the specified number of days added. -// Negative values subtract days. -// Returns zero value if called on zero value. -func (d LocalDate) PlusDays(days int) LocalDate { - if d.IsZero() { - return d - } - return LocalDateOfUnixEpochDays(d.UnixEpochDays() + int64(days)) -} - -// MinusDays returns a copy of this date with the specified number of days subtracted. -// Equivalent to PlusDays(-days). -func (d LocalDate) MinusDays(days int) LocalDate { - return d.PlusDays(-days) -} - -// PlusMonths returns a copy of this date with the specified number of months added. -// Negative values subtract months. -// If the resulting day-of-month is invalid, -// it is adjusted to the last valid day of the month. -// For example, 2024-01-31 plus 1 month becomes 2024-02-29 (leap year). -// Returns zero value if called on zero value. -func (d LocalDate) PlusMonths(months int) LocalDate { - if d.IsZero() { - return d - } - var m = int(d.Month()) + months - var y = d.Year().Int64() - if m > 12 { - y += int64((m - 1) / 12) - m = (m-1)%12 + 1 - } else if m < 1 { - y += int64((m - 12) / 12) - m = (m-1)%12 + 1 - if m < 1 { - m += 12 - } - } - return MustLocalDateOf(Year(y), Month(m), min(d.DayOfMonth(), Month(m).Length(Year(y).IsLeapYear()))) -} - -// MinusMonths returns a copy of this date with the specified number of months subtracted. -// Equivalent to PlusMonths(-months). -func (d LocalDate) MinusMonths(months int) LocalDate { - return d.PlusMonths(-months) -} - -// PlusYears returns a copy of this date with the specified number of years added. -// Negative values subtract years. -// If the resulting day-of-month is invalid -// (e.g., Feb 29 in a non-leap year), it is adjusted to the last valid day of the month. -// Returns zero value if called on zero value. -func (d LocalDate) PlusYears(years int) LocalDate { - if d.IsZero() { - return d - } - var year = Year(d.Year().Int64() + int64(years)) - return MustLocalDateOf(year, d.Month(), min(d.DayOfMonth(), d.Month().Length(year.IsLeapYear()))) -} - -// MinusYears returns a copy of this date with the specified number of years subtracted. -// Equivalent to PlusYears(-years). -func (d LocalDate) MinusYears(years int) LocalDate { - return d.PlusYears(-years) -} - -// PlusWeeks returns a copy of this date with the specified number of weeks added. -// Negative values subtract weeks. -// This is equivalent to PlusDays(weeks * 7). -// Returns zero value if called on zero value. -func (d LocalDate) PlusWeeks(weeks int) LocalDate { - return d.PlusDays(7 * weeks) -} - -// MinusWeeks returns a copy of this date with the specified number of weeks subtracted. -// Equivalent to PlusWeeks(-weeks). -func (d LocalDate) MinusWeeks(weeks int) LocalDate { - return d.MinusDays(7 * weeks) -} - -// WithField returns a copy of this LocalDate with the specified field replaced. -// Zero values return zero immediately. -// -// Supported fields mirror Java's LocalDate#with(TemporalField, long): -// - FieldDayOfMonth: sets the day-of-month while keeping year and month. -// - FieldDayOfYear: sets the day-of-year while keeping year. -// - FieldMonthOfYear: sets the month-of-year while keeping year and day-of-month (adjusted if necessary). -// - FieldYear: sets the year while keeping month and day-of-month (adjusted if necessary). -// - FieldYearOfEra: sets the year within the current era (same as FieldYear for CE dates). -// - FieldEra: switches between BCE/CE eras while preserving year, month, and day. -// - FieldEpochDay: sets the date based on days since Unix epoch (1970-01-01). -// - FieldProlepticMonth: sets the date based on months since year 0. -// -// Fields outside this list return an error. Range violations propagate the validation error. -func (d LocalDate) WithField(field Field, value TemporalValue) (LocalDate, error) { - if d.IsZero() { - return d, nil - } - - v := value.Int64() - - switch field { - case FieldDayOfMonth: - e := checkTemporalInRange(FieldDayOfMonth, 1, 31, value, nil) - if e != nil { - return d, e - } - return d.WithDayOfMonth(int(v)) - case FieldDayOfYear: - e := checkTemporalInRange(FieldDayOfYear, 1, 366, value, nil) - if e != nil { - return d, e - } - return d.WithDayOfYear(int(v)) - case FieldMonthOfYear: - e := checkTemporalInRange(FieldMonthOfYear, 1, 12, value, nil) - if e != nil { - return d, e - } - return d.WithMonth(Month(v)) - case FieldYear: - // Year can be any int value, no range check needed for the field itself - // LocalDateOf will validate the resulting date - return d.WithYear(Year(v)) - case FieldYearOfEra: - // For CE (positive years), YearOfEra equals Year - // For BCE (negative years), YearOfEra = -Year + 1 - e := checkTemporalInRange(FieldYearOfEra, 1, math.MaxInt, value, nil) - if e != nil { - return d, e - } - var y int64 - if d.Year() >= 1 { - // CE - y = v - } else { - // BCE: convert YearOfEra back to negative year - y = -(v - 1) - } - result, err := LocalDateOf(Year(y), d.Month(), d.DayOfMonth()) - return result, err - case FieldEra: - e := checkTemporalInRange(FieldEra, 0, 1, value, nil) - if e != nil { - return d, e - } - var y = int64(d.Year()) - if v == 0 && d.Year() > 0 { - // Switch from CE to BCE - y = -int64(d.Year()) + 1 - } else if v == 1 && d.Year() < 0 { - // Switch from BCE to CE - y = -int64(d.Year()) + 1 - } - // If already in the correct era, do nothing - result, err := LocalDateOf(Year(y), d.Month(), d.DayOfMonth()) - return result, err - case FieldEpochDay: - // Convert epoch days back to LocalDate - return LocalDateOfUnixEpochDays(v), nil - case FieldProlepticMonth: - // Convert proleptic month back to year and month - // Proleptic month = year * 12 + (month - 1) - //year := v / 12 - year := floorDiv(v, 12) - //month := v%12 + 1 - month := floorMod(v, 12) + 1 - // Ensure year fits in our Year type - if year < math.MinInt || year > math.MaxInt { - return d, newError("proleptic month %d results in year out of range", v) - } - result, err := LocalDateOf(Year(year), Month(month), d.DayOfMonth()) - return result, err - case FieldDayOfWeek: - e := checkTemporalInRange(field, 1, 7, value, nil) - if e != nil { - return d, e - } - return d.PlusDays(int(v - int64(d.DayOfWeek()))), nil - default: - return d, newError("unsupported field: %v", field) - } -} - -// WithDayOfMonth returns a copy of this date with the day-of-month altered. -// If the day-of-month is invalid for the month and year, an error is returned. -// Returns zero value without error if called on zero value. -func (d LocalDate) WithDayOfMonth(dayOfMonth int) (r LocalDate, e error) { - if d.IsZero() { - return - } - r, e = LocalDateOf(d.Year(), d.Month(), dayOfMonth) - if e != nil { - e = newError("dayOfMonth %d out of range", dayOfMonth) - } - return -} - -// MustWithDayOfMonth returns a copy of this date with the day-of-month altered. -// Panics if the day-of-month is invalid. -// Use WithDayOfMonth for error handling. -func (d LocalDate) MustWithDayOfMonth(dayOfMonth int) LocalDate { - return mustValue(d.WithDayOfMonth(dayOfMonth)) -} - -// WithDayOfYear returns a copy of this date with the day-of-year altered. -// The day-of-year must be valid for the year (1-365 for non-leap years, 1-366 for leap years). -// Returns zero value without error if called on zero value. -func (d LocalDate) WithDayOfYear(dayOfYear int) (r LocalDate, e error) { - if d.IsZero() { - return - } - for m := December; m > 0; m-- { - if dayOfYear >= m.FirstDayOfYear(d.Year().IsLeapYear()) { - return LocalDateOf(d.Year(), m, dayOfYear-m.FirstDayOfYear(d.Year().IsLeapYear())+1) - } - } - e = newError("dayOfYear %d out of range", dayOfYear) - return -} - -// MustWithDayOfYear returns a copy of this date with the day-of-year altered. -// Panics if the day-of-year is invalid. -// Use WithDayOfYear for error handling. -func (d LocalDate) MustWithDayOfYear(dayOfYear int) LocalDate { - return mustValue(d.WithDayOfYear(dayOfYear)) -} - -// WithMonth returns a copy of this date with the month altered. -// If the resulting day-of-month is invalid for the new month, -// it is adjusted to the last valid day of the month. -// For example, January 31 with month set to February becomes February 28/29. -// Returns zero value without error if called on zero value. -func (d LocalDate) WithMonth(month Month) (r LocalDate, e error) { - if d.IsZero() { - return - } - if month < January || month > December { - e = newError("month %d out of range", month) - return - } - return LocalDateOf(d.Year(), month, min(d.DayOfMonth(), month.Length(d.Year().IsLeapYear()))) -} - -// MustWithMonth returns a copy of this date with the month altered. -// Panics if the month is invalid. -// Use WithMonth for error handling. -func (d LocalDate) MustWithMonth(month Month) LocalDate { - return mustValue(d.WithMonth(month)) -} - -// WithYear returns a copy of this date with the year altered. -// If the resulting day-of-month is invalid for the new year -// (e.g., February 29 in a non-leap year), it is adjusted to the last valid day of the month. -// Returns zero value without error if called on zero value. -func (d LocalDate) WithYear(year Year) (r LocalDate, e error) { - if d.IsZero() { - return - } - return LocalDateOf(year, d.Month(), min(d.DayOfMonth(), d.Month().Length(year.IsLeapYear()))) -} - -// MustWithYear returns a copy of this date with the year altered. -// Panics if the resulting date is invalid. -// Use WithYear for error handling. -func (d LocalDate) MustWithYear(year Year) LocalDate { - return mustValue(d.WithYear(year)) -} - -// Compare compares this date with another date. -// Returns -1 if this date is before other, 0 if equal, and 1 if after. -// Zero values are considered less than non-zero values. -func (d LocalDate) Compare(other LocalDate) int { - return doCompare(d, other, compareZero, comparing(LocalDate.Year), comparing(LocalDate.Month), comparing(LocalDate.DayOfMonth)) -} - -// IsBefore returns true if this date is before the specified date. -func (d LocalDate) IsBefore(other LocalDate) bool { - return d.Compare(other) < 0 -} - -// IsAfter returns true if this date is after the specified date. -func (d LocalDate) IsAfter(other LocalDate) bool { - return d.Compare(other) > 0 -} - -// GoTime converts this date to a time.Time at midnight UTC. -// Returns time.Time{} (zero) for zero value. -func (d LocalDate) GoTime() time.Time { - if d.IsZero() { - return time.Time{} - } - return time.Date(int(d.Year()), time.Month(d.Month()), d.DayOfMonth(), 0, 0, 0, 0, time.UTC) -} - -// UnixEpochDays returns the number of days since Unix epoch (1970-01-01). -// Positive values represent dates after the epoch, negative before. -// Returns 0 for zero value. -func (d LocalDate) UnixEpochDays() int64 { - if d.IsZero() { - return 0 - } - const DaysPerCycle = 365*400 + 97 - const Days0000To1970 = (DaysPerCycle * 5) - (30*365 + 7) - - y := d.Year().Int64() - m := int64(d.Month()) - day := int64(d.DayOfMonth()) - total := int64(0) - - // Calculate year contribution - total += 365 * y - if y >= 0 { - total += (y+3)/4 - (y+99)/100 + (y+399)/400 - } else { - total -= y/-4 - y/-100 + y/-400 - } - - // Calculate month contribution - total += (367*m - 362) / 12 - - // Calculate day contribution - total += day - 1 - - // Adjust for leap year if month > February - if m > 2 { - total-- - if !d.Year().IsLeapYear() { - total-- - } - } - - return total - Days0000To1970 -} - // AtTime combines this date with a time to create a LocalDateTime. func (d LocalDate) AtTime(time LocalTime) LocalDateTime { return LocalDateTime{ @@ -627,16 +275,17 @@ func (d LocalDate) IsZero() bool { // Returns an error if the date is invalid (e.g., month out of range 1-12, // day out of range for the month, or February 29 in a non-leap year). func LocalDateOf(year Year, month Month, dayOfMonth int) (d LocalDate, e error) { - if year > 1<<47-1 || year < -(1<<47) { - e = newError("year %d out of range", year) - return - } - if month < January || month > December { - e = newError("month %d out of range", month) + _, e = YearMonthOf(year, month) + FieldDayOfMonth.checkSetE(int64(dayOfMonth), &e) + if e != nil { return } - if dayOfMonth < 1 || dayOfMonth > month.Length(year.IsLeapYear()) { - e = newError("day %d of month out of range", dayOfMonth) + if dayOfMonth > month.Length(year.IsLeapYear()) { + if dayOfMonth == 29 { + e = newError("invalid date February 29 in non-leap year") + } else { + e = newError("invalid date %s %d", month, dayOfMonth) + } return } d = LocalDate{ @@ -652,6 +301,25 @@ func MustLocalDateOf(year Year, month Month, dayOfMonth int) LocalDate { return mustValue(LocalDateOf(year, month, dayOfMonth)) } +func LocalDateOfYearDay(year Year, dayOfYear int) (r LocalDate, e error) { + FieldYear.checkSetE(year.Int64(), &e) + FieldDayOfYear.checkSetE(int64(dayOfYear), &e) + if e != nil { + return + } + leap := year.IsLeapYear() + if dayOfYear == 366 && !leap { + e = newError("invalid date DayOfYear 366 in non-leap year") + } + moy := Month((dayOfYear-1)/31 + 1) + var monthEnd = moy.FirstDayOfYear(leap) + moy.Length(leap) - 1 + if dayOfYear > monthEnd { + moy = moy + 1 + } + var dom = dayOfYear - moy.FirstDayOfYear(leap) + 1 + return LocalDateOf(year, moy, dom) +} + // LocalDateOfGoTime creates a LocalDate from a time.Time. // The time zone and time-of-day components are ignored. // Returns zero value if t.IsZero(). @@ -711,9 +379,13 @@ func MustLocalDateParse(s string) LocalDate { return mustValue(LocalDateParse(s)) } -// LocalDateOfUnixEpochDays creates a LocalDate from the number of days since Unix epoch (1970-01-01). +func MustLocalDateOfUnixEpochDays(days int64) LocalDate { + return mustValue(LocalDateOfEpochDays(days)) +} + +// LocalDateOfEpochDays creates a LocalDate from the number of days since Unix epoch (1970-01-01). // Positive values represent dates after the epoch, negative before. -func LocalDateOfUnixEpochDays(days int64) LocalDate { +func LocalDateOfEpochDays(days int64) (LocalDate, error) { const DaysPerCycle = 365*400 + 97 const Days0000To1970 = (DaysPerCycle * 5) - (30*365 + 7) zeroDay := days + Days0000To1970 @@ -754,7 +426,15 @@ func LocalDateOfUnixEpochDays(days int64) LocalDate { if marchDoy0 >= 306 { yearEst++ } - return MustLocalDateOf(Year(yearEst), month, dom) + return LocalDateOf(Year(yearEst), month, dom) +} + +func LocalDateMin() LocalDate { + return MustLocalDateOf(YearMin, January, 1) +} + +func LocalDateMax() LocalDate { + return MustLocalDateOf(YearMax, December, 31) } var ( diff --git a/localdate_chain.go b/localdate_chain.go new file mode 100644 index 0000000..0a92de1 --- /dev/null +++ b/localdate_chain.go @@ -0,0 +1,207 @@ +package goda + +import ( + "math" +) + +type LocalDateChain struct { + Chain[LocalDate] +} + +var localDateMaxEpoch = LocalDateMax().UnixEpochDays() +var localDateMinEpoch = LocalDateMin().UnixEpochDays() + +func (l LocalDateChain) PlusDays(days int64) LocalDateChain { + defer l.leaveFunction(l.enterFunction("LocalDate", "PlusDays")) + if !l.ok() { + return l + } + if days == 0 { + return l + } + r, overflow := addExactly(l.value.UnixEpochDays(), days) + if overflow || r > localDateMaxEpoch || r < localDateMinEpoch { + l.eError = overflowError() + return l + } + l.value, l.eError = LocalDateOfEpochDays(r) + return l +} + +func (l LocalDateChain) MinusDays(days int64) LocalDateChain { + defer l.leaveFunction(l.enterFunction("LocalDate", "MinusDays")) + if days == math.MinInt64 { + return l.PlusDays(math.MaxInt64).PlusDays(1) + } + return l.PlusDays(-days) +} + +func (l LocalDateChain) PlusMonths(months int64) LocalDateChain { + defer l.leaveFunction(l.enterFunction("LocalDate", "PlusMonths")) + if !l.ok() { + return l + } + if months == 0 { + return l + } + newYearMonth := l.value.YearMonth().Chain().PlusMonths(months).mergeError(&l.eError) + if !l.ok() { + return l + } + l.value, l.eError = LocalDateOf(newYearMonth.Year(), newYearMonth.Month(), min(l.value.DayOfMonth(), newYearMonth.LengthOfMonth())) + return l +} + +func (l LocalDateChain) MinusMonths(months int64) LocalDateChain { + defer l.leaveFunction(l.enterFunction("LocalDate", "MinusMonths")) + if months == math.MinInt64 { + return l.PlusMonths(math.MaxInt64).PlusMonths(1) + } + return l.PlusMonths(-months) +} + +func (l LocalDateChain) PlusYears(years int64) LocalDateChain { + defer l.leaveFunction(l.enterFunction("LocalDate", "PlusYears")) + if !l.ok() { + return l + } + if years == 0 { + return l + } + newYearMonth := l.value.YearMonth().Chain().PlusYears(years).mergeError(&l.eError) + if !l.ok() { + return l + } + l.value, l.eError = LocalDateOf(newYearMonth.Year(), newYearMonth.Month(), min(l.value.DayOfMonth(), newYearMonth.LengthOfMonth())) + return l +} + +func (l LocalDateChain) MinusYears(years int64) LocalDateChain { + defer l.leaveFunction(l.enterFunction("LocalDate", "MinusYears")) + if years == math.MinInt64 { + return l.PlusYears(math.MaxInt64).PlusYears(1) + } + return l.PlusYears(-years) +} + +func (l LocalDateChain) PlusWeeks(weeks int64) LocalDateChain { + defer l.leaveFunction(l.enterFunction("LocalDate", "PlusWeeks")) + if !l.ok() { + return l + } + if weeks == 0 { + return l + } + r, overflow := mulExact(7, weeks) + if overflow { + l.eError = overflowError() + return l + } + return l.PlusDays(r) +} + +func (l LocalDateChain) MinusWeeks(weeks int64) LocalDateChain { + defer l.leaveFunction(l.enterFunction("LocalDate", "MinusWeeks")) + if weeks == math.MinInt64 { + return l.PlusWeeks(math.MaxInt64).PlusWeeks(1) + } + return l.PlusWeeks(-weeks) +} + +func (l LocalDateChain) WithDayOfMonth(dayOfMonth int) LocalDateChain { + defer l.leaveFunction(l.enterFunction("LocalDate", "WithDayOfMonth")) + if !l.ok() { + return l + } + l.value, l.eError = LocalDateOf(l.value.Year(), l.value.Month(), dayOfMonth) + return l +} + +func (l LocalDateChain) WithDayOfYear(dayOfYear int) LocalDateChain { + defer l.leaveFunction(l.enterFunction("LocalDate", "WithDayOfYear")) + if !l.ok() { + return l + } + l.value, l.eError = LocalDateOfYearDay(l.value.Year(), dayOfYear) + return l +} + +func (l LocalDateChain) WithMonth(month Month) LocalDateChain { + defer l.leaveFunction(l.enterFunction("LocalDate", "WithMonth")) + l.eError = FieldMonthOfYear.check(int64(month)) + if !l.ok() { + return l + } + l.value, l.eError = LocalDateOf(l.value.Year(), month, min(MustYearMonthOf(l.value.Year(), month).LengthOfMonth(), l.value.DayOfMonth())) + return l +} + +func (l LocalDateChain) WithYear(year Year) LocalDateChain { + defer l.leaveFunction(l.enterFunction("LocalDate", "WithYear")) + l.eError = FieldYear.check(int64(year)) + if !l.ok() { + return l + } + l.value, l.eError = LocalDateOf(year, l.value.Month(), min(MustYearMonthOf(year, l.value.Month()).LengthOfMonth(), l.value.DayOfMonth())) + return l +} + +// WithField returns a copy of this LocalDate with the specified field replaced. +// Zero values return zero immediately. +// +// Supported fields mirror Java's LocalDate#with(TemporalField, long): +// - FieldDayOfMonth: sets the day-of-month while keeping year and month. +// - FieldDayOfYear: sets the day-of-year while keeping year. +// - FieldMonthOfYear: sets the month-of-year while keeping year and day-of-month (adjusted if necessary). +// - FieldYear: sets the year while keeping month and day-of-month (adjusted if necessary). +// - FieldYearOfEra: sets the year within the current era (same as FieldYear for CE dates). +// - FieldEra: switches between BCE/CE eras while preserving year, month, and day. +// - FieldEpochDay: sets the date based on days since Unix epoch (1970-01-01). +// - FieldProlepticMonth: sets the date based on months since year 0. +// +// Fields outside this list return an error. Range violations propagate the validation error. +func (l LocalDateChain) WithField(field Field, value TemporalValue) LocalDateChain { + defer l.leaveFunction(l.enterFunction("LocalDate", "WithField")) + field.checkSetE(value.Int64(), &l.eError) + if !l.ok() { + return l + } + newValue := value.v + switch field { + case FieldDayOfWeek: + return l.PlusDays(newValue - int64(l.value.DayOfWeek())) + case FieldAlignedDayOfWeekInMonth: + return l.PlusDays(newValue - l.value.GetField(FieldAlignedDayOfWeekInMonth).Int64()) + case FieldAlignedDayOfWeekInYear: + return l.PlusDays(newValue - l.value.GetField(FieldAlignedDayOfWeekInYear).Int64()) + case FieldDayOfMonth: + return l.WithDayOfMonth(int(newValue)) + case FieldDayOfYear: + return l.WithDayOfYear(int(newValue)) + case FieldEpochDay: + l.value, l.eError = LocalDateOfEpochDays(newValue) + case FieldAlignedWeekOfMonth: + return l.PlusWeeks(newValue - l.value.GetField(FieldAlignedWeekOfMonth).Int64()) + case FieldAlignedWeekOfYear: + return l.PlusWeeks(newValue - l.value.GetField(FieldAlignedWeekOfYear).Int64()) + case FieldMonthOfYear: + return l.WithMonth(Month(newValue)) + case FieldProlepticMonth: + return l.PlusMonths(newValue - l.value.GetField(FieldProlepticMonth).Int64()) + case FieldYearOfEra: + if l.value.Year() >= 1 { + return l.WithYear(Year(newValue)) + } + return l.WithYear(Year(1 - newValue)) + case FieldYear: + return l.WithYear(Year(newValue)) + case FieldEra: + if l.value.GetField(FieldEra).Int64() == newValue { + return l + } + return l.WithYear(1 - l.value.Year()) + default: + l.eError = unsupportedField(field) + } + return l +} diff --git a/localdate_test.go b/localdate_test.go new file mode 100644 index 0000000..aeb5156 --- /dev/null +++ b/localdate_test.go @@ -0,0 +1,641 @@ +package goda + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLocalDate_Epoch(t *testing.T) { + var check = func(i int64, tt time.Time) bool { + var st = MustLocalDateOf(Year(tt.Year()), Month(tt.Month()), tt.Day()) + if !assert.Equal(t, i, st.UnixEpochDays(), tt) { + return false + } + if !assert.Equal(t, st, MustLocalDateOfUnixEpochDays(i), tt) { + return false + } + if !assert.Equal(t, st.DayOfWeek().GoWeekday(), tt.Weekday(), tt) { + return false + } + return assert.Equal(t, tt.Unix()/(24*60*60), st.UnixEpochDays(), tt) + } + var begin = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + for i := int64(0); i < 100_0000; i++ { + if !check(i, begin) { + break + } + begin = begin.AddDate(0, 0, 1) + } + begin = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + // negative + for i := int64(0); i > -100_0000; i-- { + if !check(i, begin) { + break + } + begin = begin.AddDate(0, 0, -1) + } +} + +func TestNewLocalDate(t *testing.T) { + t.Run("valid dates", func(t *testing.T) { + d, err := LocalDateOf(2024, January, 1) + require.NoError(t, err) + assert.Equal(t, Year(2024), d.Year()) + assert.Equal(t, January, d.Month()) + assert.Equal(t, 1, d.DayOfMonth()) + + d, err = LocalDateOf(2024, February, 29) // leap year + require.NoError(t, err) + assert.Equal(t, 29, d.DayOfMonth()) + }) + + t.Run("invalid day of month", func(t *testing.T) { + _, err := LocalDateOf(2024, January, 32) + assert.Error(t, err) + + _, err = LocalDateOf(2023, February, 29) // not a leap year + assert.Error(t, err) + + _, err = LocalDateOf(2024, February, 30) + assert.Error(t, err) + + _, err = LocalDateOf(2024, April, 31) + assert.Error(t, err) + }) + + t.Run("invalid month", func(t *testing.T) { + _, err := LocalDateOf(2024, Month(0), 1) + assert.Error(t, err) + + _, err = LocalDateOf(2024, Month(13), 1) + assert.Error(t, err) + }) +} + +func TestMustNewLocalDate(t *testing.T) { + t.Run("valid date", func(t *testing.T) { + assert.NotPanics(t, func() { + d := MustLocalDateOf(2024, March, 15) + assert.Equal(t, Year(2024), d.Year()) + }) + }) + + t.Run("invalid date panics", func(t *testing.T) { + assert.Panics(t, func() { + MustLocalDateOf(2024, January, 32) + }) + }) +} + +func TestLocalDate_IsZero(t *testing.T) { + var zero LocalDate + assert.True(t, zero.IsZero()) + + d := MustLocalDateOf(2024, January, 1) + assert.False(t, d.IsZero()) +} + +func TestLocalDate_IsLeapYear(t *testing.T) { + tests := []struct { + year Year + isLeap bool + }{ + {2000, true}, // divisible by 400 + {2004, true}, // divisible by 4 + {2100, false}, // divisible by 100 but not 400 + {2023, false}, // not divisible by 4 + {2024, true}, // divisible by 4 + {1900, false}, // divisible by 100 but not 400 + } + + for _, tt := range tests { + d := MustLocalDateOf(tt.year, January, 1) + assert.Equal(t, tt.isLeap, d.IsLeapYear(), "year %d", tt.year) + } +} + +func TestLocalDate_DayOfYear(t *testing.T) { + tests := []struct { + date LocalDate + dayOfYear int + }{ + {MustLocalDateOf(2024, January, 1), 1}, + {MustLocalDateOf(2024, January, 31), 31}, + {MustLocalDateOf(2024, February, 1), 32}, + {MustLocalDateOf(2024, February, 29), 60}, // leap year + {MustLocalDateOf(2023, February, 28), 59}, // non-leap year + {MustLocalDateOf(2024, March, 1), 61}, // leap year + {MustLocalDateOf(2023, March, 1), 60}, // non-leap year + {MustLocalDateOf(2024, December, 31), 366}, + {MustLocalDateOf(2023, December, 31), 365}, + } + + for _, tt := range tests { + assert.Equal(t, tt.dayOfYear, tt.date.DayOfYear(), "date: %s", tt.date.String()) + } + + var zero LocalDate + assert.Equal(t, 0, zero.DayOfYear()) +} + +func TestLocalDate_DayOfWeek(t *testing.T) { + tests := []struct { + date LocalDate + dayOfWeek DayOfWeek + }{ + {MustLocalDateOf(2024, 11, 5), Tuesday}, // Known date + {MustLocalDateOf(2024, 1, 1), Monday}, // New Year 2024 + {MustLocalDateOf(2023, 1, 1), Sunday}, // New Year 2023 + {MustLocalDateOf(2000, 1, 1), Saturday}, // Y2K + {MustLocalDateOf(1970, 1, 1), Thursday}, // Unix epoch + {MustLocalDateOf(2024, 2, 29), Thursday}, // Leap day 2024 + } + + for _, tt := range tests { + assert.Equal(t, tt.dayOfWeek, tt.date.DayOfWeek(), "date: %s", tt.date.String()) + } + + var zero LocalDate + assert.Equal(t, DayOfWeek(0), zero.DayOfWeek()) +} + +func TestLocalDate_Compare(t *testing.T) { + d1 := MustLocalDateOf(2024, March, 15) + d2 := MustLocalDateOf(2024, March, 15) + d3 := MustLocalDateOf(2024, March, 16) + d4 := MustLocalDateOf(2024, February, 15) + d5 := MustLocalDateOf(2023, March, 15) + + assert.Equal(t, 0, d1.Compare(d2)) + assert.Equal(t, -1, d1.Compare(d3)) + assert.Equal(t, 1, d3.Compare(d1)) + assert.Equal(t, 1, d1.Compare(d4)) + assert.Equal(t, 1, d1.Compare(d5)) +} + +func TestLocalDate_IsBefore(t *testing.T) { + d1 := MustLocalDateOf(2024, March, 15) + d2 := MustLocalDateOf(2024, March, 16) + d3 := MustLocalDateOf(2024, March, 15) + + assert.True(t, d1.IsBefore(d2)) + assert.False(t, d2.IsBefore(d1)) + assert.False(t, d1.IsBefore(d3)) +} + +func TestLocalDate_IsAfter(t *testing.T) { + d1 := MustLocalDateOf(2024, March, 15) + d2 := MustLocalDateOf(2024, March, 16) + d3 := MustLocalDateOf(2024, March, 15) + + assert.False(t, d1.IsAfter(d2)) + assert.True(t, d2.IsAfter(d1)) + assert.False(t, d1.IsAfter(d3)) +} + +func TestLocalDate_GoTime(t *testing.T) { + d := MustLocalDateOf(2024, March, 15) + goTime := d.GoTime() + + assert.Equal(t, 2024, goTime.Year()) + assert.Equal(t, time.March, goTime.Month()) + assert.Equal(t, 15, goTime.Day()) + assert.Equal(t, 0, goTime.Hour()) + assert.Equal(t, 0, goTime.Minute()) + assert.Equal(t, 0, goTime.Second()) + assert.Equal(t, time.UTC, goTime.Location()) + + var zero LocalDate + assert.True(t, zero.GoTime().IsZero()) +} + +func TestNewLocalDateByGoTime(t *testing.T) { + goTime := time.Date(2024, time.March, 15, 14, 30, 45, 0, time.UTC) + d := LocalDateOfGoTime(goTime) + + assert.Equal(t, Year(2024), d.Year()) + assert.Equal(t, March, d.Month()) + assert.Equal(t, 15, d.DayOfMonth()) + + // Test with zero time + d = LocalDateOfGoTime(time.Time{}) + assert.True(t, d.IsZero()) +} + +func TestLocalDate_String(t *testing.T) { + tests := []struct { + date LocalDate + expected string + }{ + {MustLocalDateOf(2024, March, 15), "2024-03-15"}, + {MustLocalDateOf(2024, January, 1), "2024-01-01"}, + {MustLocalDateOf(2024, December, 31), "2024-12-31"}, + {MustLocalDateOf(1999, June, 5), "1999-06-05"}, + {LocalDate{}, ""}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, tt.date.String()) + } +} + +func TestLocalDate_MarshalText(t *testing.T) { + d := MustLocalDateOf(2024, March, 15) + text, err := d.MarshalText() + require.NoError(t, err) + assert.Equal(t, "2024-03-15", string(text)) + + var zero LocalDate + text, err = zero.MarshalText() + require.NoError(t, err) + assert.Equal(t, "", string(text)) +} + +func TestLocalDate_UnmarshalText(t *testing.T) { + t.Run("valid dates", func(t *testing.T) { + var d LocalDate + err := d.UnmarshalText([]byte("2024-03-15")) + require.NoError(t, err) + assert.Equal(t, MustLocalDateOf(2024, March, 15), d) + + err = d.UnmarshalText([]byte("1999-12-31")) + require.NoError(t, err) + assert.Equal(t, MustLocalDateOf(1999, December, 31), d) + }) + + t.Run("empty string", func(t *testing.T) { + var d LocalDate + err := d.UnmarshalText([]byte("")) + require.NoError(t, err) + assert.True(t, d.IsZero()) + }) + + t.Run("invalid format", func(t *testing.T) { + var d LocalDate + err := d.UnmarshalText([]byte("2024/03/15")) + assert.Error(t, err) + + err = d.UnmarshalText([]byte("2024-3-15")) + assert.Error(t, err) + + err = d.UnmarshalText([]byte("not-a-date")) + assert.Error(t, err) + }) + + t.Run("invalid date", func(t *testing.T) { + var d LocalDate + err := d.UnmarshalText([]byte("2024-02-30")) + assert.Error(t, err) + + err = d.UnmarshalText([]byte("2024-13-01")) + assert.Error(t, err) + }) +} + +func TestLocalDate_MarshalJSON(t *testing.T) { + d := MustLocalDateOf(2024, March, 15) + data, err := json.Marshal(d) + require.NoError(t, err) + assert.Equal(t, `"2024-03-15"`, string(data)) + + var zero LocalDate + data, err = json.Marshal(zero) + require.NoError(t, err) + assert.Equal(t, `""`, string(data)) +} + +func TestLocalDate_UnmarshalJSON(t *testing.T) { + t.Run("valid JSON", func(t *testing.T) { + var d LocalDate + err := json.Unmarshal([]byte(`"2024-03-15"`), &d) + require.NoError(t, err) + assert.Equal(t, MustLocalDateOf(2024, March, 15), d) + }) + + t.Run("empty string", func(t *testing.T) { + var d LocalDate + err := json.Unmarshal([]byte(`""`), &d) + require.NoError(t, err) + assert.True(t, d.IsZero()) + }) + + t.Run("null", func(t *testing.T) { + var d LocalDate + err := json.Unmarshal([]byte(`null`), &d) + require.NoError(t, err) + assert.True(t, d.IsZero()) + }) + + t.Run("invalid JSON", func(t *testing.T) { + var d LocalDate + err := json.Unmarshal([]byte(`"invalid-date"`), &d) + assert.Error(t, err) + }) +} + +func TestLocalDate_Scan(t *testing.T) { + t.Run("nil value", func(t *testing.T) { + var d LocalDate + err := d.Scan(nil) + require.NoError(t, err) + assert.True(t, d.IsZero()) + }) + + t.Run("string value", func(t *testing.T) { + var d LocalDate + err := d.Scan("2024-03-15") + require.NoError(t, err) + assert.Equal(t, MustLocalDateOf(2024, March, 15), d) + }) + + t.Run("byte slice value", func(t *testing.T) { + var d LocalDate + err := d.Scan([]byte("2024-03-15")) + require.NoError(t, err) + assert.Equal(t, MustLocalDateOf(2024, March, 15), d) + }) + + t.Run("time.LocalTime value", func(t *testing.T) { + var d LocalDate + goTime := time.Date(2024, time.March, 15, 14, 30, 0, 0, time.UTC) + err := d.Scan(goTime) + require.NoError(t, err) + assert.Equal(t, MustLocalDateOf(2024, March, 15), d) + }) +} + +func TestLocalDate_Value(t *testing.T) { + d := MustLocalDateOf(2024, March, 15) + val, err := d.Value() + require.NoError(t, err) + assert.Equal(t, "2024-03-15", val) + + var zero LocalDate + val, err = zero.Value() + require.NoError(t, err) + assert.Nil(t, val) +} + +func TestLocalDate_AppendText(t *testing.T) { + d := MustLocalDateOf(2024, March, 15) + buf := []byte("LocalDate: ") + buf, err := d.AppendText(buf) + require.NoError(t, err) + assert.Equal(t, "LocalDate: 2024-03-15", string(buf)) + + var zero LocalDate + buf = []byte("LocalDate: ") + buf, err = zero.AppendText(buf) + require.NoError(t, err) + assert.Equal(t, "LocalDate: ", string(buf)) +} + +func TestLocalDateNow(t *testing.T) { + // Test that LocalDateNow() returns a valid date + today := LocalDateNow() + assert.False(t, today.IsZero(), "LocalDateNow should not be zero") + + // Test that it matches time.Now() + now := time.Now() + expected := LocalDateOfGoTime(now) + + // Allow for the possibility that the date changed between calls + // (very unlikely but possible at midnight) + diff := today.Compare(expected) + assert.True(t, diff >= -1 && diff <= 1, "LocalDateNow should be within 1 day of current time") +} + +func TestLocalDateNowUTC(t *testing.T) { + todayUTC := LocalDateNowUTC() + assert.False(t, todayUTC.IsZero(), "LocalDateNowUTC should not be zero") + + // Test that it matches time.Now().UTC() + now := time.Now().UTC() + expected := LocalDateOfGoTime(now) + + diff := todayUTC.Compare(expected) + assert.True(t, diff >= -1 && diff <= 1, "LocalDateNowUTC should be within 1 day of current UTC time") +} + +func TestParseLocalDate(t *testing.T) { + t.Run("valid dates", func(t *testing.T) { + date, err := LocalDateParse("2024-03-15") + require.NoError(t, err) + assert.Equal(t, MustLocalDateOf(2024, March, 15), date) + + date, err = LocalDateParse("1999-12-31") + require.NoError(t, err) + assert.Equal(t, MustLocalDateOf(1999, December, 31), date) + + date, err = LocalDateParse("2000-02-29") // leap year + require.NoError(t, err) + assert.Equal(t, MustLocalDateOf(2000, February, 29), date) + }) + + t.Run("invalid format", func(t *testing.T) { + _, err := LocalDateParse("2024/03/15") + assert.Error(t, err) + + _, err = LocalDateParse("2024-3-15") + assert.Error(t, err) + + _, err = LocalDateParse("not-a-date") + assert.Error(t, err) + }) + + t.Run("invalid date", func(t *testing.T) { + _, err := LocalDateParse("2024-02-30") + assert.Error(t, err) + + _, err = LocalDateParse("2024-13-01") + assert.Error(t, err) + }) + + t.Run("empty string", func(t *testing.T) { + date, err := LocalDateParse("") + require.NoError(t, err) + assert.True(t, date.IsZero()) + }) +} + +func TestMustParseLocalDate(t *testing.T) { + t.Run("valid date", func(t *testing.T) { + assert.NotPanics(t, func() { + date := MustLocalDateParse("2024-03-15") + assert.Equal(t, MustLocalDateOf(2024, March, 15), date) + }) + }) + + t.Run("invalid date panics", func(t *testing.T) { + assert.Panics(t, func() { + MustLocalDateParse("2024-02-30") + }) + + assert.Panics(t, func() { + MustLocalDateParse("invalid") + }) + }) +} + +func TestLocalDateNowIn(t *testing.T) { + // Test with different time zones + locations := []struct { + name string + loc *time.Location + }{ + {"UTC", time.UTC}, + {"Local", time.Local}, + } + + for _, tt := range locations { + t.Run(tt.name, func(t *testing.T) { + todayIn := LocalDateNowIn(tt.loc) + assert.False(t, todayIn.IsZero(), "LocalDateNowIn should not be zero") + + now := time.Now().In(tt.loc) + expected := LocalDateOfGoTime(now) + + diff := todayIn.Compare(expected) + assert.True(t, diff >= -1 && diff <= 1, "LocalDateNowIn should be within 1 day of current time in specified zone") + }) + } +} + +func TestLocalDate_ValuePostgres(t *testing.T) { + var pg = GetPG(t) + t.Run("normal", func(t *testing.T) { + var expected = MustLocalDateParse("2000-12-29") + var actual LocalDate + var expectedTrue bool + var e = pg.QueryRow("SELECT $1::date, $1::date = '2000-12-29'", expected).Scan(&actual, &expectedTrue) + assert.NoError(t, e) + assert.Equal(t, expected, actual) + assert.True(t, expectedTrue) + }) + t.Run("null_value", func(t *testing.T) { + var actual LocalDate + var expectedTrue bool + var e = pg.QueryRow("SELECT NULL::date, $1::date is null", actual).Scan(&actual, &expectedTrue) + assert.NoError(t, e) + assert.True(t, actual.IsZero()) + assert.True(t, expectedTrue) + }) +} + +func TestLocalDate_ValueMySQL(t *testing.T) { + var mysql = GetMySQL(t) + t.Run("normal", func(t *testing.T) { + var expected = MustLocalDateParse("2000-12-29") + var actual LocalDate + var expectedTrue bool + var e = mysql.QueryRow("SELECT CAST(? AS DATE), CAST(? AS DATE) = '2000-12-29'", expected, expected).Scan(&actual, &expectedTrue) + assert.NoError(t, e) + assert.Equal(t, expected, actual) + assert.True(t, expectedTrue) + }) + t.Run("null_value", func(t *testing.T) { + var actual LocalDate + var expectedTrue bool + var e = mysql.QueryRow("SELECT CAST(NULL AS DATE), CAST(? AS DATE) is null", actual).Scan(&actual, &expectedTrue) + assert.NoError(t, e) + assert.True(t, actual.IsZero()) + assert.True(t, expectedTrue) + }) +} + +func TestLocalDate_LengthOfMonth(t *testing.T) { + t.Run("31-day months", func(t *testing.T) { + months31 := []Month{January, March, May, July, August, October, December} + for _, month := range months31 { + date := MustLocalDateOf(2024, month, 1) + assert.Equal(t, 31, date.LengthOfMonth(), "month: %d", month) + } + }) + + t.Run("30-day months", func(t *testing.T) { + months30 := []Month{April, June, September, November} + for _, month := range months30 { + date := MustLocalDateOf(2024, month, 1) + assert.Equal(t, 30, date.LengthOfMonth(), "month: %d", month) + } + }) + + t.Run("February in leap year", func(t *testing.T) { + date := MustLocalDateOf(2024, February, 1) + assert.Equal(t, 29, date.LengthOfMonth()) + }) + + t.Run("February in non-leap year", func(t *testing.T) { + date := MustLocalDateOf(2023, February, 1) + assert.Equal(t, 28, date.LengthOfMonth()) + }) + + t.Run("various leap years", func(t *testing.T) { + tests := []struct { + year Year + expected int + }{ + {2000, 29}, // divisible by 400 + {2004, 29}, // divisible by 4 + {2100, 28}, // divisible by 100 but not 400 + {2023, 28}, // not divisible by 4 + } + + for _, tt := range tests { + date := MustLocalDateOf(tt.year, February, 1) + assert.Equal(t, tt.expected, date.LengthOfMonth(), "year: %d", tt.year) + } + }) + + t.Run("zero value", func(t *testing.T) { + var zero LocalDate + assert.Equal(t, 0, zero.LengthOfMonth()) + }) +} + +func TestLocalDate_LengthOfYear(t *testing.T) { + t.Run("leap years", func(t *testing.T) { + leapYears := []Year{2000, 2004, 2024, 2400} + for _, year := range leapYears { + date := MustLocalDateOf(year, January, 1) + assert.Equal(t, 366, date.LengthOfYear(), "year: %d", year) + } + }) + + t.Run("non-leap years", func(t *testing.T) { + nonLeapYears := []Year{1900, 2001, 2023, 2100} + for _, year := range nonLeapYears { + date := MustLocalDateOf(year, January, 1) + assert.Equal(t, 365, date.LengthOfYear(), "year: %d", year) + } + }) + + t.Run("century years", func(t *testing.T) { + tests := []struct { + year Year + expected int + }{ + {1600, 366}, // divisible by 400 + {1700, 365}, // divisible by 100 but not 400 + {1800, 365}, // divisible by 100 but not 400 + {1900, 365}, // divisible by 100 but not 400 + {2000, 366}, // divisible by 400 + {2100, 365}, // divisible by 100 but not 400 + {2400, 366}, // divisible by 400 + } + + for _, tt := range tests { + date := MustLocalDateOf(tt.year, June, 15) + assert.Equal(t, tt.expected, date.LengthOfYear(), "year: %d", tt.year) + } + }) + + t.Run("zero value", func(t *testing.T) { + var zero LocalDate + assert.Equal(t, 0, zero.LengthOfYear()) + }) +} diff --git a/localdate_text.go b/localdate_text.go new file mode 100644 index 0000000..a97658a --- /dev/null +++ b/localdate_text.go @@ -0,0 +1,108 @@ +package goda + +import ( + "database/sql/driver" + "errors" + "time" +) + +// UnmarshalJSON implements the json.Unmarshaler interface. +// It accepts JSON strings in yyyy-MM-dd format or JSON null. +func (d *LocalDate) UnmarshalJSON(bytes []byte) error { + if len(bytes) == 4 && string(bytes) == "null" { + *d = LocalDate{} + return nil + } + return unmarshalJsonImpl(d, bytes) +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface. +// It parses dates in yyyy-MM-dd format. +// Empty input is treated as zero value. +func (d *LocalDate) UnmarshalText(text []byte) (e error) { + defer deferOpInParse(text, &e) + if len(text) == 0 { + *d = LocalDate{} + return nil + } + if text[len(text)-3] != '-' || text[len(text)-6] != '-' { + return errors.New("'-' required") + } + var y int64 + var m, dom int + dom, e = parseInt(text[len(text)-2:]) + if e != nil { + return + } + m, e = parseInt(text[len(text)-5 : len(text)-3]) + if e != nil { + return + } + y, e = parseInt64(text[:len(text)-6]) + if e != nil { + return + } + dd, e := LocalDateOf(Year(y), Month(m), dom) + if e != nil { + return + } + *d = dd + return +} + +// MarshalText implements the encoding.TextMarshaler interface. +// It returns the date in yyyy-MM-dd format, or empty for zero value. +func (d LocalDate) MarshalText() (text []byte, err error) { + return marshalTextImpl(d) +} + +// MarshalJSON implements the json.Marshaler interface. +// It returns the date as a JSON string in yyyy-MM-dd format, or empty string for zero value. +func (d LocalDate) MarshalJSON() ([]byte, error) { + return marshalJsonImpl(d) +} + +// String returns the date in yyyy-MM-dd format, or empty string for zero value. +func (d LocalDate) String() string { + return stringImpl(d) +} + +// AppendText implements the encoding.TextAppender interface. +// It appends the date in yyyy-MM-dd format to b and returns the extended buffer. +func (d LocalDate) AppendText(b []byte) ([]byte, error) { + if d.IsZero() { + return b, nil + } + b, _ = d.Year().AppendText(b) + b = append(b, '-', byte('0'+d.Month()/10), byte('0'+d.Month()%10), '-', byte('0'+d.DayOfMonth()/10), byte('0'+d.DayOfMonth()%10)) + return b, nil +} + +// Value implements the driver.Valuer interface. +// It returns nil for zero values, otherwise returns the date as a string in yyyy-MM-dd format. +func (d LocalDate) Value() (driver.Value, error) { + if d.IsZero() { + return nil, nil + } + return d.String(), nil +} + +// Scan implements the sql.Scanner interface. +// It supports scanning from nil, string, []byte, and time.Time. +// Nil values are converted to the zero value of LocalDate. +func (d *LocalDate) Scan(src any) error { + switch v := src.(type) { + case nil: + *d = LocalDate{} + return nil + case string: + return d.UnmarshalText([]byte(v)) + case []byte: + return d.UnmarshalText(v) + case time.Time: + *d = LocalDateOfGoTime(v) + return nil + default: + return sqlScannerDefaultBranch(v) + } +} diff --git a/local_date_time.go b/localdatetime.go similarity index 72% rename from local_date_time.go rename to localdatetime.go index d4bbf26..031dd75 100644 --- a/local_date_time.go +++ b/localdatetime.go @@ -6,6 +6,7 @@ import ( "encoding" "encoding/json" "fmt" + "math" "time" ) @@ -94,7 +95,7 @@ func (dt LocalDateTime) Millisecond() int { // Nanosecond returns the nanosecond component (0-999999999). func (dt LocalDateTime) Nanosecond() int { - return dt.time.Nanosecond() + return dt.time.Nano() } // IsZero returns true if this is the zero value. @@ -183,151 +184,14 @@ func (dt LocalDateTime) IsAfter(other LocalDateTime) bool { return dt.Compare(other) > 0 } -// PlusDays returns a copy with the specified number of days added. -func (dt LocalDateTime) PlusDays(days int) LocalDateTime { - return dt.date.PlusDays(days).AtTime(dt.time) -} - -// MinusDays returns a copy with the specified number of days subtracted. -func (dt LocalDateTime) MinusDays(days int) LocalDateTime { - return dt.PlusDays(-days) -} - -// PlusMonths returns a copy with the specified number of months added. -func (dt LocalDateTime) PlusMonths(months int) LocalDateTime { - return dt.date.PlusMonths(months).AtTime(dt.time) -} - -// MinusMonths returns a copy with the specified number of months subtracted. -func (dt LocalDateTime) MinusMonths(months int) LocalDateTime { - return dt.PlusMonths(-months) -} - -// PlusYears returns a copy with the specified number of years added. -func (dt LocalDateTime) PlusYears(years int) LocalDateTime { - return dt.date.PlusYears(years).AtTime(dt.time) -} - -// MinusYears returns a copy with the specified number of years subtracted. -func (dt LocalDateTime) MinusYears(years int) LocalDateTime { - return dt.PlusYears(-years) -} - -// String returns the ISO 8601 string representation (yyyy-MM-ddTHH:mm:ss[.nnnnnnnnn]). -func (dt LocalDateTime) String() string { - return stringImpl(dt) -} - -// AppendText implements encoding.TextAppender. -func (dt LocalDateTime) AppendText(b []byte) ([]byte, error) { - if dt.IsZero() { - return b, nil - } - b, _ = dt.date.AppendText(b) - b = append(b, 'T') - b, _ = dt.time.AppendText(b) - return b, nil -} - -// MarshalText implements encoding.TextMarshaler. -func (dt LocalDateTime) MarshalText() ([]byte, error) { - return marshalTextImpl(dt) -} - -// UnmarshalText implements encoding.TextUnmarshaler. -// Accepts ISO 8601 format: yyyy-MM-ddTHH:mm:ss[.nnnnnnnnn] -func (dt *LocalDateTime) UnmarshalText(text []byte) error { - if len(text) == 0 { - *dt = LocalDateTime{} - return nil - } - - // Find the 'T' separator - sepIdx := -1 - for i, ch := range text { - if ch == 'T' || ch == 't' || ch == ' ' { - sepIdx = i - break - } - } - - if sepIdx < 0 { - return newError("invalid date-time format: missing 'T' separator") - } - - // Parse date part - var date LocalDate - if err := date.UnmarshalText(text[:sepIdx]); err != nil { - return err - } - - // Parse time part - var timePart LocalTime - if err := timePart.UnmarshalText(text[sepIdx+1:]); err != nil { - return err - } - - *dt = date.AtTime(timePart) - return nil -} - -// MarshalJSON implements json.Marshaler. -func (dt LocalDateTime) MarshalJSON() ([]byte, error) { - return marshalJsonImpl(dt) -} - -// UnmarshalJSON implements json.Unmarshaler. -func (dt *LocalDateTime) UnmarshalJSON(data []byte) error { - if len(data) == 4 && string(data) == "null" { - *dt = LocalDateTime{} - return nil - } - return unmarshalJsonImpl(dt, data) -} - -// Scan implements sql.Scanner. -func (dt *LocalDateTime) Scan(src any) error { - switch v := src.(type) { - case nil: - *dt = LocalDateTime{} - return nil - case string: - return dt.UnmarshalText([]byte(v)) - case []byte: - return dt.UnmarshalText(v) - case time.Time: - *dt = LocalDateTimeOfGoTime(v) - return nil - default: - return sqlScannerDefaultBranch(v) - } -} - -// Value implements driver.Valuer. -func (dt LocalDateTime) Value() (driver.Value, error) { - if dt.IsZero() { - return nil, nil - } - return dt.String(), nil +func (dt LocalDateTime) Chain() (chain LocalDateTimeChain) { + chain.value = dt + return } -func (dt LocalDateTime) WithField(field Field, value TemporalValue) (r LocalDateTime, e error) { - if dt.IsZero() { - return - } - if dt.date.IsSupportedField(field) { - r.date, e = dt.date.WithField(field, value) - if e != nil { - return - } - r.time = dt.time - } else { - r.time, e = dt.time.WithField(field, value) - if e != nil { - return - } - r.date = dt.date - } +func (dt LocalDateTime) chainWithError(e error) (chain LocalDateTimeChain) { + chain = dt.Chain() + chain.eError = e return } @@ -354,6 +218,22 @@ func MustLocalDateTimeOf(year Year, month Month, day, hour, minute, second, nano return mustValue(LocalDateTimeOf(year, month, day, hour, minute, second, nanosecond)) } +func LocalDateTimeOfEpochSecond(epochSecond int64, nanoOfSecond int64, offset ZoneOffset) (r LocalDateTime, e error) { + FieldNanoOfSecond.checkSetE(nanoOfSecond, &e) + if e != nil { + return + } + var localSecond = epochSecond + int64(offset.TotalSeconds()) + var localEpochDay = floorDiv(localSecond, 86400) + var secsOfDay = floorMod(localSecond, 86400) + r.date, e = LocalDateOfEpochDays(localEpochDay) + if e != nil { + return + } + r.time, e = LocalTimeOfNanoOfDay(secsOfDay*time.Second.Nanoseconds() + nanoOfSecond) + return +} + // LocalDateTimeNow returns the current date-time in the system's local time zone. func LocalDateTimeNow() LocalDateTime { return LocalDateTimeOfGoTime(time.Now()) @@ -417,3 +297,6 @@ var ( func _assertLocalDateTimeIsComparable[T comparable](t T) {} var _ = _assertLocalDateTimeIsComparable[LocalDateTime] + +var localDateTimeMinEpochSecond = mustValue(LocalDateTimeOfEpochSecond(math.MinInt64, 0, ZoneOffsetUTC())) +var localDateTimeMaxEpochSecond = mustValue(LocalDateTimeOfEpochSecond(math.MaxInt64, 0, ZoneOffsetUTC())) diff --git a/localdatetime_chain.go b/localdatetime_chain.go new file mode 100644 index 0000000..445a208 --- /dev/null +++ b/localdatetime_chain.go @@ -0,0 +1,192 @@ +package goda + +import "time" + +type LocalDateTimeChain struct { + Chain[LocalDateTime] +} + +func (l LocalDateTimeChain) PlusYears(years int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "PlusYears")) + l.value.date = l.value.date.chainWithError(l.eError).PlusYears(years).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) MinusYears(years int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "MinusYears")) + l.value.date = l.value.date.chainWithError(l.eError).MinusYears(years).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) PlusMonths(months int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "PlusMonths")) + l.value.date = l.value.date.chainWithError(l.eError).PlusMonths(months).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) MinusMonths(months int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "MinusMonths")) + l.value.date = l.value.date.chainWithError(l.eError).MinusMonths(months).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) PlusWeeks(weeks int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "PlusWeeks")) + l.value.date = l.value.date.chainWithError(l.eError).PlusWeeks(weeks).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) MinusWeeks(weeks int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "MinusWeeks")) + l.value.date = l.value.date.chainWithError(l.eError).MinusWeeks(weeks).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) PlusDays(days int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "PlusDays")) + l.value.date = l.value.date.chainWithError(l.eError).PlusDays(days).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) MinusDays(days int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "MinusDays")) + l.value.date = l.value.date.chainWithError(l.eError).MinusDays(days).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) PlusHours(hours int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "PlusHours")) + return l.plusWithOverflow(l.value.date, hours, 0, 0, 0, 1) +} + +func (l LocalDateTimeChain) MinusHours(hours int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "MinusHours")) + return l.plusWithOverflow(l.value.date, hours, 0, 0, 0, -1) +} + +func (l LocalDateTimeChain) PlusMinutes(minutes int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "PlusMinutes")) + return l.plusWithOverflow(l.value.date, 0, minutes, 0, 0, 1) +} + +func (l LocalDateTimeChain) MinusMinutes(minutes int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "MinusMinutes")) + return l.plusWithOverflow(l.value.date, 0, minutes, 0, 0, -1) +} + +func (l LocalDateTimeChain) PlusSeconds(seconds int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "PlusSeconds")) + return l.plusWithOverflow(l.value.date, 0, 0, seconds, 0, 1) +} + +func (l LocalDateTimeChain) MinusSeconds(seconds int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "MinusSeconds")) + return l.plusWithOverflow(l.value.date, 0, 0, seconds, 0, -1) +} + +func (l LocalDateTimeChain) PlusNanos(nanos int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "PlusNanos")) + return l.plusWithOverflow(l.value.date, 0, 0, 0, nanos, 1) +} + +func (l LocalDateTimeChain) MinusNanos(nanos int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "MinusNanos")) + return l.plusWithOverflow(l.value.date, 0, 0, 0, nanos, -1) +} + +func (l LocalDateTimeChain) plusWithOverflow(newDate LocalDate, hours, minutes, seconds, nanos, sign int64) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "plusWithOverflow")) + if !l.ok() { + return l + } + if hours|minutes|seconds|nanos == 0 { + l.value.date = newDate + return l + } + const ( + NanosPerSecond = int64(time.Second) + NanosPerMinute = int64(time.Minute) + NanosPerHour = int64(time.Hour) + NanosPerDay = int64(time.Hour * 24) + + SecondsPerDay = int64(24 * 60 * 60) + MinutesPerDay = int64(24 * 60) + HoursPerDay = int64(24) + ) + var totDays = nanos/NanosPerDay + + seconds/SecondsPerDay + + minutes/MinutesPerDay + + hours/HoursPerDay + totDays *= sign + var totNanos = nanos%NanosPerDay + + (seconds%SecondsPerDay)*NanosPerSecond + + (minutes%MinutesPerDay)*NanosPerMinute + + (hours%HoursPerDay)*NanosPerHour + var curNoD = l.value.time.NanoOfDay() + totNanos = totNanos*sign + curNoD + totDays += floorDiv(totNanos, NanosPerDay) + var newNoD = floorMod(totNanos, NanosPerDay) + if newNoD != curNoD { + l.value.time, l.eError = LocalTimeOfNanoOfDay(newNoD) + } + l.value.date = newDate.Chain().PlusDays(totDays).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) WithYear(year Year) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "WithYear")) + l.value.date = l.value.date.chainWithError(l.eError).WithYear(year).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) WithMonth(month Month) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "WithMonth")) + l.value.date = l.value.date.chainWithError(l.eError).WithMonth(month).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) WithDayOfMonth(dayOfMonth int) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "WithDayOfMonth")) + l.value.date = l.value.date.chainWithError(l.eError).WithDayOfMonth(dayOfMonth).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) WithDayOfYear(dayOfYear int) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "WithDayOfYear")) + l.value.date = l.value.date.chainWithError(l.eError).WithDayOfYear(dayOfYear).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) WithHour(hour int) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "WithHour")) + l.value.time = l.value.time.chainWithError(l.eError).WithHour(hour).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) WithMinute(minute int) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "WithMinute")) + l.value.time = l.value.time.chainWithError(l.eError).WithMinute(minute).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) WithSecond(second int) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "WithSecond")) + l.value.time = l.value.time.chainWithError(l.eError).WithSecond(second).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) WithNano(nanoOfSecond int) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "WithNano")) + l.value.time = l.value.time.chainWithError(l.eError).WithNano(nanoOfSecond).mergeError(&l.eError) + return l +} + +func (l LocalDateTimeChain) WithField(field Field, value TemporalValue) LocalDateTimeChain { + defer l.leaveFunction(l.enterFunction("LocalDateTime", "WithField")) + if field.IsTimeBased() { + l.value.time = l.value.time.chainWithError(l.eError).WithField(field, value).mergeError(&l.eError) + } else { + l.value.date = l.value.date.chainWithError(l.eError).WithField(field, value).mergeError(&l.eError) + } + return l +} diff --git a/local_date_time_test.go b/localdatetime_test.go similarity index 90% rename from local_date_time_test.go rename to localdatetime_test.go index bb9486f..4e4bdac 100644 --- a/local_date_time_test.go +++ b/localdatetime_test.go @@ -182,61 +182,6 @@ func TestLocalDateTime_IsBefore_IsAfter(t *testing.T) { assert.True(t, dt3.IsAfter(dt1)) } -func TestLocalDateTime_PlusDays(t *testing.T) { - dt := MustLocalDateTimeOf(2024, March, 15, 14, 30, 45, 0) - - dt2 := dt.PlusDays(10) - assert.Equal(t, Year(2024), dt2.Year()) - assert.Equal(t, March, dt2.Month()) - assert.Equal(t, 25, dt2.DayOfMonth()) - assert.Equal(t, 14, dt2.Hour()) - assert.Equal(t, 30, dt2.Minute()) - - dt3 := dt.PlusDays(-10) - assert.Equal(t, 5, dt3.DayOfMonth()) -} - -func TestLocalDateTime_MinusDays(t *testing.T) { - dt := MustLocalDateTimeOf(2024, March, 15, 14, 30, 45, 0) - - dt2 := dt.MinusDays(10) - assert.Equal(t, 5, dt2.DayOfMonth()) - assert.Equal(t, 14, dt2.Hour()) -} - -func TestLocalDateTime_PlusMonths(t *testing.T) { - dt := MustLocalDateTimeOf(2024, January, 31, 14, 30, 45, 0) - - dt2 := dt.PlusMonths(1) - assert.Equal(t, February, dt2.Month()) - assert.Equal(t, 29, dt2.DayOfMonth()) // 2024 is leap year - assert.Equal(t, 14, dt2.Hour()) -} - -func TestLocalDateTime_MinusMonths(t *testing.T) { - dt := MustLocalDateTimeOf(2024, March, 15, 14, 30, 45, 0) - - dt2 := dt.MinusMonths(1) - assert.Equal(t, February, dt2.Month()) - assert.Equal(t, 15, dt2.DayOfMonth()) -} - -func TestLocalDateTime_PlusYears(t *testing.T) { - dt := MustLocalDateTimeOf(2024, February, 29, 14, 30, 45, 0) - - dt2 := dt.PlusYears(1) - assert.Equal(t, Year(2025), dt2.Year()) - assert.Equal(t, February, dt2.Month()) - assert.Equal(t, 28, dt2.DayOfMonth()) // 2025 is not leap year -} - -func TestLocalDateTime_MinusYears(t *testing.T) { - dt := MustLocalDateTimeOf(2024, March, 15, 14, 30, 45, 0) - - dt2 := dt.MinusYears(1) - assert.Equal(t, Year(2023), dt2.Year()) -} - func TestLocalDateTime_String(t *testing.T) { tests := []struct { name string @@ -520,7 +465,7 @@ func TestLocalDateTime_WithField_JavaMatch(t *testing.T) { } t.Run(field.String(), func(t *testing.T) { var ndt LocalDateTime - ndt, e = dt.WithField(field, TemporalValueOf(1)) + ndt, e = dt.Chain().WithField(field, TemporalValueOf(1)).GetResult() if e != nil { t.Fatal(e) } @@ -535,7 +480,7 @@ func TestLocalDateTime_WithField_JavaMatch(t *testing.T) { if fmt.Sprint(nv) != v { panic("invalid value: " + v) } - ndt, e = ndt.WithField(field, TemporalValueOf(nv)) + ndt, e = ndt.Chain().WithField(field, TemporalValueOf(nv)).GetResult() if e != nil { t.Fatal(e) } diff --git a/localdatetime_text.go b/localdatetime_text.go new file mode 100644 index 0000000..caa9522 --- /dev/null +++ b/localdatetime_text.go @@ -0,0 +1,106 @@ +package goda + +import ( + "database/sql/driver" + "errors" + "time" +) + +// String returns the ISO 8601 string representation (yyyy-MM-ddTHH:mm:ss[.nnnnnnnnn]). +func (dt LocalDateTime) String() string { + return stringImpl(dt) +} + +// AppendText implements encoding.TextAppender. +func (dt LocalDateTime) AppendText(b []byte) ([]byte, error) { + if dt.IsZero() { + return b, nil + } + b, _ = dt.date.AppendText(b) + b = append(b, 'T') + b, _ = dt.time.AppendText(b) + return b, nil +} + +// MarshalText implements encoding.TextMarshaler. +func (dt LocalDateTime) MarshalText() ([]byte, error) { + return marshalTextImpl(dt) +} + +// UnmarshalText implements encoding.TextUnmarshaler. +// Accepts ISO 8601 format: yyyy-MM-ddTHH:mm:ss[.nnnnnnnnn] +func (dt *LocalDateTime) UnmarshalText(text []byte) (e error) { + defer deferOpInParse(text, &e) + if len(text) == 0 { + *dt = LocalDateTime{} + return nil + } + + // Find the 'T' separator + sepIdx := -1 + for i, ch := range text { + if ch == 'T' || ch == 't' || ch == ' ' { + sepIdx = i + break + } + } + + if sepIdx < 0 { + return errors.New("invalid date-time format: missing 'T' separator") + } + + // Parse date part + var date LocalDate + if err := date.UnmarshalText(text[:sepIdx]); err != nil { + return err + } + + // Parse time part + var timePart LocalTime + if err := timePart.UnmarshalText(text[sepIdx+1:]); err != nil { + return err + } + + *dt = date.AtTime(timePart) + return nil +} + +// MarshalJSON implements json.Marshaler. +func (dt LocalDateTime) MarshalJSON() ([]byte, error) { + return marshalJsonImpl(dt) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (dt *LocalDateTime) UnmarshalJSON(data []byte) error { + if len(data) == 4 && string(data) == "null" { + *dt = LocalDateTime{} + return nil + } + return unmarshalJsonImpl(dt, data) +} + +// Scan implements sql.Scanner. +func (dt *LocalDateTime) Scan(src any) error { + switch v := src.(type) { + case nil: + *dt = LocalDateTime{} + return nil + case string: + return dt.UnmarshalText([]byte(v)) + case []byte: + return dt.UnmarshalText(v) + case time.Time: + *dt = LocalDateTimeOfGoTime(v) + return nil + default: + return sqlScannerDefaultBranch(v) + } +} + +// Value implements driver.Valuer. +func (dt LocalDateTime) Value() (driver.Value, error) { + if dt.IsZero() { + return nil, nil + } + return dt.String(), nil +} diff --git a/local_time.go b/localtime.go similarity index 50% rename from local_time.go rename to localtime.go index 5a00177..acb7dd4 100644 --- a/local_time.go +++ b/localtime.go @@ -38,161 +38,6 @@ const ( localTimeValueMask int64 = 0x7FFFFFFFFFFFFFFF // bits 0-62 ) -// Value implements driver.Valuer for database serialization. -func (t LocalTime) Value() (driver.Value, error) { - if t.IsZero() { - return nil, nil - } - return t.String(), nil -} - -// Scan implements sql.Scanner for database deserialization. -func (t *LocalTime) Scan(src any) error { - switch v := src.(type) { - case nil: - *t = LocalTime{} - return nil - case []byte: - return t.UnmarshalText(v) - case string: - return t.UnmarshalText([]byte(v)) - case time.Time: - *t = LocalTimeOfGoTime(v) - return nil - default: - return sqlScannerDefaultBranch(src) - } -} - -// UnmarshalJSON implements json.Unmarshaler. -func (t *LocalTime) UnmarshalJSON(bytes []byte) error { - if len(bytes) == 4 && string(bytes) == "null" { - *t = LocalTime{} - return nil - } - return unmarshalJsonImpl(t, bytes) -} - -// UnmarshalText implements encoding.TextUnmarshaler. -func (t *LocalTime) UnmarshalText(text []byte) error { - if len(text) == 0 { - *t = LocalTime{} - return nil - } - - if len(text) < 8 { - return newError("invalid format") - } - - hour, err := parseInt(text[0:2]) - if err != nil { - return err - } - if text[2] != ':' { - return newError("expect ':'") - } - minute, err := parseInt(text[3:5]) - if err != nil { - return err - } - if text[5] != ':' { - return newError("expect ':'") - } - second, err := parseInt(text[6:8]) - if err != nil { - return err - } - - var nano int64 = 0 - var e error - if len(text) > 8 { - if text[8] != '.' { - return newError("expect '.'") - } - } - if len(text) > 9 { - var nanoBuf [9]byte - copy(nanoBuf[:], text[9:min(len(text), 18)]) - for i := 8; i >= 0 && nanoBuf[i] == 0; i-- { - nanoBuf[i] = '0' - } - nano, e = parseInt64(nanoBuf[:]) - if e != nil { - return e - } - } - - if hour >= 24 || minute >= 60 || second >= 60 { - return newError("invalid time value") - } - - nanos := int64(hour)*int64(time.Hour) + int64(minute)*int64(time.Minute) + int64(second)*int64(time.Second) + nano - t.v = nanos | localTimeValidBit - return nil -} - -// AppendText in ISO-8601 format -func (t LocalTime) AppendText(b []byte) ([]byte, error) { - if t.IsZero() { - return b, nil - } - - var buf [18]byte - hour := t.Hour() - minute := t.Minute() - second := t.Second() - nano := t.Nanosecond() - - // Write hours, minutes, seconds - buf[0] = byte('0' + hour/10) - buf[1] = byte('0' + hour%10) - buf[2] = ':' - buf[3] = byte('0' + minute/10) - buf[4] = byte('0' + minute%10) - buf[5] = ':' - buf[6] = byte('0' + second/10) - buf[7] = byte('0' + second%10) - - if nano == 0 { - return append(b, buf[:8]...), nil - } - - buf[8] = '.' - // Format nanoseconds - ns := nano - for i := 17; i >= 9; i-- { - buf[i] = byte('0' + ns%10) - ns /= 10 - } - - // Trim trailing zeros in groups of 3 (align to milliseconds, microseconds, nanoseconds) - // This follows Java LocalTime behavior: 100000000ns -> ".100" not ".1" - l := 18 - for l > 9 && buf[l-1] == '0' { - l-- - } - // Align to 3-digit boundaries (9, 12, 15, 18 total length) - remainder := (l - 9) % 3 - if remainder != 0 { - l += 3 - remainder - } - return append(b, buf[:l]...), nil -} - -// MarshalText implements encoding.TextMarshaler. -func (t LocalTime) MarshalText() (text []byte, err error) { - return marshalTextImpl(t) -} - -// MarshalJSON implements json.Marshaler. -func (t LocalTime) MarshalJSON() ([]byte, error) { - return marshalJsonImpl(t) -} - -func (t LocalTime) String() string { - return stringImpl(t) -} - // Hour returns the hour component (0-23). func (t LocalTime) Hour() int { ns := t.v & localTimeValueMask @@ -217,8 +62,8 @@ func (t LocalTime) Millisecond() int { return int(ns / int64(time.Millisecond) % 1000) } -// Nanosecond returns the nanosecond component (0-999999999). -func (t LocalTime) Nanosecond() int { +// Nano returns the nanosecond component (0-999999999). +func (t LocalTime) Nano() int { ns := t.v & localTimeValueMask return int(ns % int64(time.Second)) } @@ -365,7 +210,7 @@ func (t LocalTime) GoTime() time.Time { // Returns -1 if this time is before other, 0 if equal, and 1 if after. // Zero values are considered less than non-zero values. func (t LocalTime) Compare(other LocalTime) int { - return doCompare(t, other, compareZero, comparing(LocalTime.Hour), comparing(LocalTime.Minute), comparing(LocalTime.Second), comparing(LocalTime.Nanosecond)) + return doCompare(t, other, compareZero, comparing(LocalTime.NanoOfDay)) } // IsBefore returns true if this time is before the specified time. @@ -386,239 +231,23 @@ func (t LocalTime) AtDate(date LocalDate) LocalDateTime { } } -// PlusHours returns a copy of this LocalTime with the specified number of hours added. -// The calculation wraps around midnight. For example, 23:00 + 2 hours = 01:00. -// Negative values subtract hours. Returns zero value if called on zero value. -func (t LocalTime) PlusHours(hours int) LocalTime { - if t.IsZero() { - return t - } - return t.plusNanos(int64(hours) * int64(time.Hour)) -} - -// MinusHours returns a copy of this LocalTime with the specified number of hours subtracted. -// Equivalent to PlusHours(-hours). -func (t LocalTime) MinusHours(hours int) LocalTime { - return t.PlusHours(-hours) -} - -// PlusMinutes returns a copy of this LocalTime with the specified number of minutes added. -// The calculation wraps around midnight. For example, 23:50 + 20 minutes = 00:10. -// Negative values subtract minutes. Returns zero value if called on zero value. -func (t LocalTime) PlusMinutes(minutes int) LocalTime { - if t.IsZero() { - return t - } - return t.plusNanos(int64(minutes) * int64(time.Minute)) -} - -// MinusMinutes returns a copy of this LocalTime with the specified number of minutes subtracted. -// Equivalent to PlusMinutes(-minutes). -func (t LocalTime) MinusMinutes(minutes int) LocalTime { - return t.PlusMinutes(-minutes) -} - -// PlusSeconds returns a copy of this LocalTime with the specified number of seconds added. -// The calculation wraps around midnight. For example, 23:59:50 + 20 seconds = 00:00:10. -// Negative values subtract seconds. Returns zero value if called on zero value. -func (t LocalTime) PlusSeconds(seconds int) LocalTime { - if t.IsZero() { - return t - } - return t.plusNanos(int64(seconds) * int64(time.Second)) -} - -// MinusSeconds returns a copy of this LocalTime with the specified number of seconds subtracted. -// Equivalent to PlusSeconds(-seconds). -func (t LocalTime) MinusSeconds(seconds int) LocalTime { - return t.PlusSeconds(-seconds) -} - -// PlusNanos returns a copy of this LocalTime with the specified number of nanoseconds added. -// The calculation wraps around midnight. -// Negative values subtract nanoseconds. Returns zero value if called on zero value. -func (t LocalTime) PlusNanos(nanos int64) LocalTime { - if t.IsZero() { - return t - } - return t.plusNanos(nanos) -} - -// MinusNanos returns a copy of this LocalTime with the specified number of nanoseconds subtracted. -// Equivalent to PlusNanos(-nanos). -func (t LocalTime) MinusNanos(nanos int64) LocalTime { - return t.PlusNanos(-nanos) -} - -// plusNanos is the internal implementation for adding nanoseconds. -// It handles wrapping around midnight (modulo operation with nanoseconds per day). -func (t LocalTime) plusNanos(nanosToAdd int64) LocalTime { - const nanosPerDay = 24 * int64(time.Hour) - currentNanos := t.v & localTimeValueMask - - // Add the nanoseconds - newNanos := currentNanos + nanosToAdd - - // Wrap around midnight (handle both positive and negative overflow) - newNanos = newNanos % nanosPerDay - if newNanos < 0 { - newNanos += nanosPerDay - } - - return LocalTime{v: newNanos | localTimeValidBit} -} - -func (t LocalTime) WithHour(hour int) (LocalTime, error) { - return t.WithField(FieldHourOfDay, TemporalValue{v: int64(hour)}) -} - -func (t LocalTime) WithMinute(minute int) (LocalTime, error) { - return t.WithField(FieldMinuteOfHour, TemporalValue{v: int64(minute)}) -} - -func (t LocalTime) WithSecond(second int) (LocalTime, error) { - return t.WithField(FieldSecondOfMinute, TemporalValue{v: int64(second)}) -} - -func (t LocalTime) WithNanosecond(nano int) (LocalTime, error) { - return t.WithField(FieldNanoOfSecond, TemporalValue{v: int64(nano)}) -} - -func (t LocalTime) MustWithHour(hour int) LocalTime { - return mustValue(t.WithHour(hour)) -} - -func (t LocalTime) MustWithMinute(minute int) LocalTime { - return mustValue(t.WithMinute(minute)) +func (t LocalTime) SecondOfDay() int { + return int(t.NanoOfDay() / 1000_000_000) } -func (t LocalTime) MustWithSecond(second int) LocalTime { - return mustValue(t.WithSecond(second)) +func (t LocalTime) NanoOfDay() int64 { + return t.v & localTimeValueMask } -func (t LocalTime) MustWithNanosecond(nano int) LocalTime { - return mustValue(t.WithNanosecond(nano)) +func (t LocalTime) Chain() (chain LocalTimeChain) { + chain.value = t + return } -// WithField returns a copy of this LocalTime with the specified field replaced. -// Zero values return zero immediately. -// -// Supported fields mirror Java's LocalTime#with(TemporalField, long): -// - FieldNanoOfSecond: sets the nano-of-second while keeping hour, minute, and second. -// - FieldNanoOfDay: replaces the entire time using the provided nano-of-day (equivalent to LocalTimeOfNanoOfDay). -// - FieldMicroOfSecond: replaces the nano-of-second with micro-of-second × 1,000; hour, minute, and second stay the same. -// - FieldMicroOfDay: replaces the entire time using micro-of-day × 1,000 (equivalent to LocalTimeOfNanoOfDay). -// - FieldMilliOfSecond: replaces the nano-of-second with milli-of-second × 1,000,000; hour, minute, and second stay the same. -// - FieldMilliOfDay: replaces the entire time using milli-of-day × 1,000,000 (equivalent to LocalTimeOfNanoOfDay). -// - FieldSecondOfMinute: sets the second-of-minute while leaving hour, minute, and nano-of-second untouched. -// - FieldSecondOfDay: replaces hour, minute, and second based on the provided second-of-day while keeping nano-of-second. -// - FieldMinuteOfHour: sets the minute-of-hour; hour, second, and nano-of-second stay unchanged. -// - FieldMinuteOfDay: replaces hour and minute based on the minute-of-day; second and nano-of-second stay unchanged. -// - FieldHourOfAmPm: sets the hour within AM/PM while keeping the current half of day, minute, second, and nano-of-second. -// - FieldClockHourOfAmPm: sets the 1-12 clock hour within AM/PM while keeping the current half of day, minute, second, and nano-of-second. -// - FieldHourOfDay: sets the hour-of-day while leaving minute, second, and nano-of-second untouched. -// - FieldClockHourOfDay: sets the 1-24 clock hour-of-day (24 → 0) while leaving minute, second, and nano-of-second untouched. -// - FieldAmPmOfDay: toggles AM/PM while preserving the hour-of-am-pm, minute, second, and nano-of-second. -// -// Fields outside this list return an error. Range violations propagate the validation error. -func (t LocalTime) WithField(field Field, value TemporalValue) (r LocalTime, e error) { - if t.IsZero() { - return t, nil - } - - h := t.GetField(FieldHourOfDay).Int64() - m := t.GetField(FieldMinuteOfHour).Int64() - s := t.GetField(FieldSecondOfMinute).Int64() - n := t.GetField(FieldNanoOfSecond).Int64() - v := value.Int64() - - switch field { - case FieldNanoOfSecond: - e = checkTemporalInRange(FieldNanoOfSecond, 0, 999_999_999, value, e) - n = v - case FieldNanoOfDay: - e = checkTemporalInRange(FieldNanoOfDay, 0, 86_399_999_999_999, value, e) - if e != nil { - return - } - return LocalTimeOfNanoOfDay(v) - case FieldMicroOfSecond: - e = checkTemporalInRange(FieldMicroOfSecond, 0, 999_999, value, e) - if e != nil { - return - } - n = v * 1_000 - case FieldMicroOfDay: - e = checkTemporalInRange(FieldMicroOfDay, 0, 86_399_999_999, value, e) - if e != nil { - return - } - return LocalTimeOfNanoOfDay(v * 1_000) - case FieldMilliOfSecond: - e = checkTemporalInRange(FieldMilliOfSecond, 0, 999, value, e) - n = v * 1_000_000 - case FieldMilliOfDay: - e = checkTemporalInRange(FieldMilliOfDay, 0, 86_399_999, value, e) - if e != nil { - return - } - return LocalTimeOfNanoOfDay(v * 1_000_000) - case FieldSecondOfMinute: - e = checkTemporalInRange(FieldSecondOfMinute, 0, 59, value, e) - s = v - case FieldSecondOfDay: - e = checkTemporalInRange(FieldSecondOfDay, 0, 86_399, value, e) - h = v / 3600 - m = (v / 60) % 60 - s = v % 60 - // nano unchanged - case FieldMinuteOfHour: - e = checkTemporalInRange(FieldMinuteOfHour, 0, 59, value, e) - m = v - case FieldMinuteOfDay: - e = checkTemporalInRange(FieldMinuteOfDay, 0, 1_439, value, e) - h = v / 60 - m = v % 60 - case FieldHourOfAmPm: - e = checkTemporalInRange(FieldHourOfAmPm, 0, 11, value, e) - if h >= 12 { // PM - h = v + 12 - } else { - h = v - } - case FieldClockHourOfAmPm: - e = checkTemporalInRange(FieldClockHourOfAmPm, 1, 12, value, e) - tmp := v % 12 // 12 → 0 - if h >= 12 { // PM - h = tmp + 12 - } else { // AM - h = tmp - } - case FieldHourOfDay: - e = checkTemporalInRange(FieldHourOfDay, 0, 23, value, e) - h = v - case FieldClockHourOfDay: - e = checkTemporalInRange(FieldClockHourOfDay, 1, 24, value, e) - if v == 24 { - h = 0 - } else { - h = v - } - case FieldAmPmOfDay: - e = checkTemporalInRange(FieldAmPmOfDay, 0, 1, value, e) - if v == 0 && h >= 12 { // to AM - h -= 12 - } - if v == 1 && h < 12 { // to PM - h += 12 - } - default: - return t, newError("unsupported field: %v", field) - } - if e != nil { - return - } - return localTimeOf(h, m, s, n) +func (t LocalTime) chainWithError(e error) (chain LocalTimeChain) { + chain = t.Chain() + chain.eError = e + return } // LocalTimeOf creates a new LocalTime from the specified hour, minute, second, and nanosecond. @@ -627,30 +256,18 @@ func (t LocalTime) WithField(field Field, value TemporalValue) (r LocalTime, e e // - minute must be 0-59 // - second must be 0-59 // - nanosecond must be 0-999999999 -func LocalTimeOf(hour, minute, second, nanosecond int) (LocalTime, error) { - return localTimeOf(int64(hour), int64(minute), int64(second), int64(nanosecond)) -} - -func localTimeOf(hour, minute, second, nanosecond int64) (r LocalTime, e error) { - if hour < 0 || hour >= 24 { - e = newError("hour %d out of range", hour) - } - if minute < 0 || minute >= 60 { - e = newError("minute %d out of range", minute) - } - if second < 0 || second >= 60 { - e = newError("second %d out of range", second) - } - if nanosecond < 0 || nanosecond >= 1000000000 { - e = newError("nanosecond %d out of range", nanosecond) - } +func LocalTimeOf(hour, minute, second, nanosecond int) (r LocalTime, e error) { + FieldHourOfDay.checkSetE(int64(hour), &e) + FieldMinuteOfHour.checkSetE(int64(minute), &e) + FieldSecondOfMinute.checkSetE(int64(second), &e) + FieldNanoOfSecond.checkSetE(int64(nanosecond), &e) if e != nil { return } - nanos := hour*int64(time.Hour) + - minute*int64(time.Minute) + - second*int64(time.Second) + - nanosecond + nanos := int64(hour)*int64(time.Hour) + + int64(minute)*int64(time.Minute) + + int64(second)*int64(time.Second) + + int64(nanosecond) return LocalTime{ v: nanos | localTimeValidBit, }, nil @@ -683,10 +300,10 @@ func LocalTimeOfGoTime(t time.Time) LocalTime { // // // Create time for 12:00:00 // lt, err := LocalTimeOfNanoOfDay(12 * 60 * 60 * 1000000000) -func LocalTimeOfNanoOfDay(nanoOfDay int64) (LocalTime, error) { - const nanosPerDay = 24 * int64(time.Hour) - if nanoOfDay < 0 || nanoOfDay >= nanosPerDay { - return LocalTime{}, newError("nanoOfDay %d out of range [0, %d)", nanoOfDay, nanosPerDay) +func LocalTimeOfNanoOfDay(nanoOfDay int64) (r LocalTime, e error) { + FieldNanoOfDay.checkSetE(nanoOfDay, &e) + if e != nil { + return } return LocalTime{ v: nanoOfDay | localTimeValidBit, @@ -713,10 +330,10 @@ func MustLocalTimeOfNanoOfDay(nanoOfDay int64) LocalTime { // // // Create time for 12:00:00 // lt, err := LocalTimeOfSecondOfDay(12 * 60 * 60) -func LocalTimeOfSecondOfDay(secondOfDay int) (LocalTime, error) { - const secondsPerDay = 24 * 60 * 60 - if secondOfDay < 0 || secondOfDay >= secondsPerDay { - return LocalTime{}, newError("secondOfDay %d out of range [0, %d)", secondOfDay, secondsPerDay) +func LocalTimeOfSecondOfDay(secondOfDay int) (r LocalTime, e error) { + FieldSecondOfDay.checkSetE(int64(secondOfDay), &e) + if e != nil { + return } nanos := int64(secondOfDay) * int64(time.Second) return LocalTime{ @@ -801,6 +418,6 @@ var ( ) // Compile-time check that LocalTime is comparable -func _assertLocalTimeIsComparable[T comparable](t T) {} +func _assertLocalTimeIsComparable[T comparable](T) {} var _ = _assertLocalTimeIsComparable[LocalTime] diff --git a/localtime_chain.go b/localtime_chain.go new file mode 100644 index 0000000..cabea2f --- /dev/null +++ b/localtime_chain.go @@ -0,0 +1,204 @@ +package goda + +type LocalTimeChain struct { + Chain[LocalTime] +} + +func (l LocalTimeChain) PlusHours(hours int64) LocalTimeChain { + defer l.leaveFunction(l.enterFunction("LocalTime", "PlusHours")) + if hours == 0 { + return l + } + if !l.ok() { + return l + } + newHour := (hours%24 + int64(l.value.Hour()) + 24) % 24 + l.value, l.eError = LocalTimeOf(int(newHour), l.value.Minute(), l.value.Second(), l.value.Nano()) + return l +} + +func (l LocalTimeChain) MinusHours(hoursToSubtract int64) LocalTimeChain { + defer l.leaveFunction(l.enterFunction("LocalTime", "MinusHours")) + return l.PlusHours(-(hoursToSubtract % 24)) +} + +func (l LocalTimeChain) PlusMinutes(minutesToAdd int64) LocalTimeChain { + defer l.leaveFunction(l.enterFunction("LocalTime", "PlusMinutes")) + if minutesToAdd == 0 { + return l + } + var mofd = int64(l.value.Hour()*60 + l.value.Minute()) + var newMofd = (minutesToAdd%(60*24) + mofd + (60 * 24)) % (60 * 24) + if mofd == newMofd { + return l + } + l.value, l.eError = LocalTimeOf(int(newMofd/60), int(newMofd%60), l.value.Second(), l.value.Nano()) + return l +} + +func (l LocalTimeChain) MinusMinutes(minutesToSubtract int64) LocalTimeChain { + defer l.leaveFunction(l.enterFunction("LocalTime", "MinusMinutes")) + return l.PlusMinutes(-(minutesToSubtract % 1440)) +} + +func (l LocalTimeChain) PlusSeconds(secondsToAdd int64) LocalTimeChain { + defer l.leaveFunction(l.enterFunction("LocalTime", "PlusSeconds")) + if secondsToAdd == 0 { + return l + } + var sofd = int64(l.value.SecondOfDay()) + var newSofd = (secondsToAdd%86400 + sofd + 86400) % 86400 + if sofd == newSofd { + return l + } + l.value, l.eError = LocalTimeOf(int(newSofd/3600), int(newSofd/60%60), int(newSofd%60), l.value.Nano()) + return l +} + +func (l LocalTimeChain) MinusSeconds(secondsToSubtract int64) LocalTimeChain { + defer l.leaveFunction(l.enterFunction("LocalTime", "MinusSeconds")) + return l.PlusSeconds(-(secondsToSubtract % 86400)) +} + +func (l LocalTimeChain) PlusNano(nanosToAdd int64) LocalTimeChain { + defer l.leaveFunction(l.enterFunction("LocalTime", "PlusNano")) + if nanosToAdd == 0 { + return l + } + const NanosPerDay = 86400_000_000_000 + var nofd = l.value.NanoOfDay() + var newNofd = ((nanosToAdd % NanosPerDay) + nofd + NanosPerDay) % NanosPerDay + if nofd == newNofd { + return l + } + const NanosPerHour = 3600_000_000_000 + const NanosPerMinute = 60_000_000_000 + var newHour = int(newNofd / NanosPerHour) + var newMinute = int((newNofd / NanosPerMinute) % 60) + var newSecond = int((newNofd / 1000_000_000) % 60) + var newNano = int(newNofd % 1000_000_000) + l.value, l.eError = LocalTimeOf(newHour, newMinute, newSecond, newNano) + return l +} + +func (l LocalTimeChain) MinusNano(nanosToSubtract int64) LocalTimeChain { + defer l.leaveFunction(l.enterFunction("LocalTime", "MinusNano")) + return l.PlusNano(-(nanosToSubtract % 86400_000_000_000)) +} + +func (l LocalTimeChain) WithNano(nanoOfSecond int) LocalTimeChain { + defer l.leaveFunction(l.enterFunction("LocalTime", "WithNano")) + FieldNanoOfSecond.checkSetE(int64(nanoOfSecond), &l.eError) + if !l.ok() { + return l + } + l.value, l.eError = LocalTimeOf(l.value.Hour(), l.value.Minute(), l.value.Second(), nanoOfSecond) + return l +} + +func (l LocalTimeChain) WithSecond(second int) LocalTimeChain { + defer l.leaveFunction(l.enterFunction("LocalTime", "WithSecond")) + FieldSecondOfMinute.checkSetE(int64(second), &l.eError) + if !l.ok() { + return l + } + l.value, l.eError = LocalTimeOf(l.value.Hour(), l.value.Minute(), second, l.value.Nano()) + return l +} + +func (l LocalTimeChain) WithMinute(minute int) LocalTimeChain { + defer l.leaveFunction(l.enterFunction("LocalTime", "WithMinute")) + FieldMinuteOfHour.checkSetE(int64(minute), &l.eError) + if !l.ok() { + return l + } + l.value, l.eError = LocalTimeOf(l.value.Hour(), minute, l.value.Second(), l.value.Nano()) + return l +} + +func (l LocalTimeChain) WithHour(hour int) LocalTimeChain { + defer l.leaveFunction(l.enterFunction("LocalTime", "WithHour")) + FieldHourOfDay.checkSetE(int64(hour), &l.eError) + if !l.ok() { + return l + } + l.value, l.eError = LocalTimeOf(hour, l.value.Minute(), l.value.Second(), l.value.Nano()) + return l +} + +// WithField returns a copy of this LocalTime with the specified field replaced. +// Zero values return zero immediately. +// +// Supported fields mirror Java's LocalTime#with(TemporalField, long): +// - FieldNanoOfSecond: sets the nano-of-second while keeping hour, minute, and second. +// - FieldNanoOfDay: replaces the entire time using the provided nano-of-day (equivalent to LocalTimeOfNanoOfDay). +// - FieldMicroOfSecond: replaces the nano-of-second with micro-of-second × 1,000; hour, minute, and second stay the same. +// - FieldMicroOfDay: replaces the entire time using micro-of-day × 1,000 (equivalent to LocalTimeOfNanoOfDay). +// - FieldMilliOfSecond: replaces the nano-of-second with milli-of-second × 1,000,000; hour, minute, and second stay the same. +// - FieldMilliOfDay: replaces the entire time using milli-of-day × 1,000,000 (equivalent to LocalTimeOfNanoOfDay). +// - FieldSecondOfMinute: sets the second-of-minute while leaving hour, minute, and nano-of-second untouched. +// - FieldSecondOfDay: replaces hour, minute, and second based on the provided second-of-day while keeping nano-of-second. +// - FieldMinuteOfHour: sets the minute-of-hour; hour, second, and nano-of-second stay unchanged. +// - FieldMinuteOfDay: replaces hour and minute based on the minute-of-day; second and nano-of-second stay unchanged. +// - FieldHourOfAmPm: sets the hour within AM/PM while keeping the current half of day, minute, second, and nano-of-second. +// - FieldClockHourOfAmPm: sets the 1-12 clock hour within AM/PM while keeping the current half of day, minute, second, and nano-of-second. +// - FieldHourOfDay: sets the hour-of-day while leaving minute, second, and nano-of-second untouched. +// - FieldClockHourOfDay: sets the 1-24 clock hour-of-day (24 → 0) while leaving minute, second, and nano-of-second untouched. +// - FieldAmPmOfDay: toggles AM/PM while preserving the hour-of-am-pm, minute, second, and nano-of-second. +// +// Fields outside this list return an error. Range violations propagate the validation error. +func (l LocalTimeChain) WithField(field Field, value TemporalValue) LocalTimeChain { + defer l.leaveFunction(l.enterFunction("LocalTime", "WithField")) + field.checkSetE(value.Int64(), &l.eError) + if !l.ok() { + return l + } + newValue := value.v + var hour = l.value.Hour() + var minute = l.value.Minute() + switch field { + case FieldNanoOfDay: + l.value, l.eError = LocalTimeOfNanoOfDay(newValue) + case FieldMicroOfDay: + l.value, l.eError = LocalTimeOfNanoOfDay(newValue * 1000) + case FieldMilliOfDay: + l.value, l.eError = LocalTimeOfNanoOfDay(newValue * 1000_000) + case FieldNanoOfSecond: + return l.WithNano(int(newValue)) + case FieldMicroOfSecond: + return l.WithNano(int(newValue) * 1000) + case FieldMilliOfSecond: + return l.WithNano(int(newValue) * 1000_000) + case FieldSecondOfMinute: + return l.WithSecond(int(newValue)) + case FieldSecondOfDay: + return l.PlusSeconds(newValue - int64(l.value.SecondOfDay())) + case FieldMinuteOfHour: + return l.WithMinute(int(newValue)) + case FieldMinuteOfDay: + return l.PlusMinutes(newValue - int64(hour*60+minute)) + case FieldHourOfAmPm: + if newValue == 12 { + return l.PlusHours(int64(0 - hour%12)) + } + return l.PlusHours(newValue - int64(hour%12)) + case FieldHourOfDay: + return l.WithHour(int(newValue)) + case FieldClockHourOfDay: + if newValue == 24 { + return l.WithHour(0) + } + return l.WithHour(int(newValue)) + case FieldAmPmOfDay: + return l.PlusHours((newValue - int64(hour)/12) * 12) + case FieldClockHourOfAmPm: + var a = newValue + if newValue == 12 { + a = 0 + } + return l.PlusHours(a - int64(l.value.Hour())%12) + default: + l.eError = unsupportedField(field) + } + return l +} diff --git a/local_time_test.go b/localtime_test.go similarity index 68% rename from local_time_test.go rename to localtime_test.go index 8c0d932..ed3ff1a 100644 --- a/local_time_test.go +++ b/localtime_test.go @@ -16,21 +16,21 @@ func TestNewLocalTime(t *testing.T) { assert.Equal(t, 0, lt.Hour()) assert.Equal(t, 0, lt.Minute()) assert.Equal(t, 0, lt.Second()) - assert.Equal(t, 0, lt.Nanosecond()) + assert.Equal(t, 0, lt.Nano()) lt, err = LocalTimeOf(23, 59, 59, 999999999) require.NoError(t, err) assert.Equal(t, 23, lt.Hour()) assert.Equal(t, 59, lt.Minute()) assert.Equal(t, 59, lt.Second()) - assert.Equal(t, 999999999, lt.Nanosecond()) + assert.Equal(t, 999999999, lt.Nano()) lt, err = LocalTimeOf(12, 30, 45, 123456789) require.NoError(t, err) assert.Equal(t, 12, lt.Hour()) assert.Equal(t, 30, lt.Minute()) assert.Equal(t, 45, lt.Second()) - assert.Equal(t, 123456789, lt.Nanosecond()) + assert.Equal(t, 123456789, lt.Nano()) }) t.Run("invalid hour", func(t *testing.T) { @@ -82,7 +82,7 @@ func TestMustNewLocalTime(t *testing.T) { assert.Equal(t, 14, lt.Hour()) assert.Equal(t, 30, lt.Minute()) assert.Equal(t, 45, lt.Second()) - assert.Equal(t, 123456789, lt.Nanosecond()) + assert.Equal(t, 123456789, lt.Nano()) }) }) @@ -139,7 +139,7 @@ func TestLocalTime_Components(t *testing.T) { assert.Equal(t, tt.hour, lt.Hour(), "Hour") assert.Equal(t, tt.minute, lt.Minute(), "Minute") assert.Equal(t, tt.second, lt.Second(), "Second") - assert.Equal(t, tt.nanosecond, lt.Nanosecond(), "Nanosecond") + assert.Equal(t, tt.nanosecond, lt.Nano(), "Nano") assert.Equal(t, tt.millisecond, lt.Millisecond(), "Millisecond") }) } @@ -148,7 +148,7 @@ func TestLocalTime_Components(t *testing.T) { assert.Equal(t, 0, zero.Hour()) assert.Equal(t, 0, zero.Minute()) assert.Equal(t, 0, zero.Second()) - assert.Equal(t, 0, zero.Nanosecond()) + assert.Equal(t, 0, zero.Nano()) assert.Equal(t, 0, zero.Millisecond()) } @@ -203,7 +203,7 @@ func TestLocalTime_GoTime(t *testing.T) { assert.Equal(t, 123456789, goTime.Nanosecond()) assert.Equal(t, time.UTC, goTime.Location()) - // Check that date is set to epoch + // check that date is set to epoch assert.Equal(t, 1970, goTime.Year()) assert.Equal(t, time.January, goTime.Month()) assert.Equal(t, 1, goTime.Day()) @@ -219,7 +219,7 @@ func TestNewLocalTimeByGoTime(t *testing.T) { assert.Equal(t, 14, lt.Hour()) assert.Equal(t, 30, lt.Minute()) assert.Equal(t, 45, lt.Second()) - assert.Equal(t, 123456789, lt.Nanosecond()) + assert.Equal(t, 123456789, lt.Nano()) // Test with different time zone (should ignore timezone) loc, _ := time.LoadLocation("America/New_York") @@ -229,7 +229,7 @@ func TestNewLocalTimeByGoTime(t *testing.T) { assert.Equal(t, 14, lt.Hour()) assert.Equal(t, 30, lt.Minute()) assert.Equal(t, 45, lt.Second()) - assert.Equal(t, 123456789, lt.Nanosecond()) + assert.Equal(t, 123456789, lt.Nano()) // Test with zero time lt = LocalTimeOfGoTime(time.Time{}) @@ -496,7 +496,7 @@ func TestLocalTime_SpecialCases(t *testing.T) { assert.Equal(t, 0, lt.Hour()) assert.Equal(t, 0, lt.Minute()) assert.Equal(t, 0, lt.Second()) - assert.Equal(t, 0, lt.Nanosecond()) + assert.Equal(t, 0, lt.Nano()) }) t.Run("one nanosecond before midnight", func(t *testing.T) { @@ -504,7 +504,7 @@ func TestLocalTime_SpecialCases(t *testing.T) { assert.Equal(t, 23, lt.Hour()) assert.Equal(t, 59, lt.Minute()) assert.Equal(t, 59, lt.Second()) - assert.Equal(t, 999999999, lt.Nanosecond()) + assert.Equal(t, 999999999, lt.Nano()) assert.Equal(t, 999, lt.Millisecond()) }) @@ -524,7 +524,7 @@ func TestLocalTime_SpecialCases(t *testing.T) { lt = MustLocalTimeOf(12, 0, 0, 123456000) assert.Equal(t, "12:00:00.123456", lt.String()) - // Nanosecond precision + // Nano precision lt = MustLocalTimeOf(12, 0, 0, 123456789) assert.Equal(t, "12:00:00.123456789", lt.String()) @@ -564,7 +564,7 @@ func TestLocalTime_BoundaryValues(t *testing.T) { assert.Equal(t, tt.hour, lt.Hour()) assert.Equal(t, tt.minute, lt.Minute()) assert.Equal(t, tt.second, lt.Second()) - assert.Equal(t, tt.nanosecond, lt.Nanosecond()) + assert.Equal(t, tt.nanosecond, lt.Nano()) // Round-trip through string str := lt.String() @@ -646,7 +646,7 @@ func TestLocalTime_CompareConsistency(t *testing.T) { assert.False(t, lt.IsAfter(lt), "time should not be after itself") // Create copy and test - copy := MustLocalTimeOf(lt.Hour(), lt.Minute(), lt.Second(), lt.Nanosecond()) + copy := MustLocalTimeOf(lt.Hour(), lt.Minute(), lt.Second(), lt.Nano()) assert.Equal(t, 0, lt.Compare(copy), "times[%d] should equal its copy", i) } } @@ -821,443 +821,6 @@ func TestLocalTime_ValueMySQL(t *testing.T) { }) } -func TestLocalTime_PlusHours(t *testing.T) { - t.Run("add positive hours", func(t *testing.T) { - // Normal addition - lt := MustLocalTimeOf(10, 30, 45, 123456789) - result := lt.PlusHours(2) - assert.Equal(t, MustLocalTimeOf(12, 30, 45, 123456789), result) - - // Add 1 hour - lt = MustLocalTimeOf(14, 0, 0, 0) - result = lt.PlusHours(1) - assert.Equal(t, MustLocalTimeOf(15, 0, 0, 0), result) - }) - - t.Run("wrap around midnight", func(t *testing.T) { - // 23:00 + 2 hours = 01:00 - lt := MustLocalTimeOf(23, 0, 0, 0) - result := lt.PlusHours(2) - assert.Equal(t, MustLocalTimeOf(1, 0, 0, 0), result) - - // 22:30 + 3 hours = 01:30 - lt = MustLocalTimeOf(22, 30, 45, 123) - result = lt.PlusHours(3) - assert.Equal(t, MustLocalTimeOf(1, 30, 45, 123), result) - - // Add exactly 24 hours (full day wrap) - lt = MustLocalTimeOf(10, 30, 0, 0) - result = lt.PlusHours(24) - assert.Equal(t, lt, result) - }) - - t.Run("add negative hours", func(t *testing.T) { - // Subtract hours - lt := MustLocalTimeOf(10, 30, 45, 123456789) - result := lt.PlusHours(-2) - assert.Equal(t, MustLocalTimeOf(8, 30, 45, 123456789), result) - - // Wrap backwards past midnight - lt = MustLocalTimeOf(1, 0, 0, 0) - result = lt.PlusHours(-2) - assert.Equal(t, MustLocalTimeOf(23, 0, 0, 0), result) - }) - - t.Run("large values", func(t *testing.T) { - // Add many hours (multiple days) - lt := MustLocalTimeOf(10, 0, 0, 0) - result := lt.PlusHours(50) // 2 days + 2 hours - assert.Equal(t, MustLocalTimeOf(12, 0, 0, 0), result) - - // Subtract many hours - lt = MustLocalTimeOf(10, 0, 0, 0) - result = lt.PlusHours(-50) // Go back 2 days + 2 hours - assert.Equal(t, MustLocalTimeOf(8, 0, 0, 0), result) - }) - - t.Run("zero value", func(t *testing.T) { - var zero LocalTime - result := zero.PlusHours(5) - assert.True(t, result.IsZero()) - }) -} - -func TestLocalTime_PlusMinutes(t *testing.T) { - t.Run("add positive minutes", func(t *testing.T) { - // Normal addition - lt := MustLocalTimeOf(10, 30, 45, 123456789) - result := lt.PlusMinutes(15) - assert.Equal(t, MustLocalTimeOf(10, 45, 45, 123456789), result) - - // Add minutes that roll over to next hour - lt = MustLocalTimeOf(14, 50, 0, 0) - result = lt.PlusMinutes(20) - assert.Equal(t, MustLocalTimeOf(15, 10, 0, 0), result) - }) - - t.Run("wrap around midnight", func(t *testing.T) { - // 23:50 + 20 minutes = 00:10 - lt := MustLocalTimeOf(23, 50, 0, 0) - result := lt.PlusMinutes(20) - assert.Equal(t, MustLocalTimeOf(0, 10, 0, 0), result) - - // 23:59 + 1 minute = 00:00 - lt = MustLocalTimeOf(23, 59, 30, 500) - result = lt.PlusMinutes(1) - assert.Equal(t, MustLocalTimeOf(0, 0, 30, 500), result) - }) - - t.Run("add negative minutes", func(t *testing.T) { - // Subtract minutes - lt := MustLocalTimeOf(10, 30, 45, 123456789) - result := lt.PlusMinutes(-15) - assert.Equal(t, MustLocalTimeOf(10, 15, 45, 123456789), result) - - // Wrap backwards past midnight - lt = MustLocalTimeOf(0, 10, 0, 0) - result = lt.PlusMinutes(-20) - assert.Equal(t, MustLocalTimeOf(23, 50, 0, 0), result) - }) - - t.Run("large values", func(t *testing.T) { - // Add many minutes (multiple days) - lt := MustLocalTimeOf(10, 0, 0, 0) - result := lt.PlusMinutes(1500) // 25 hours = 1 day + 1 hour - assert.Equal(t, MustLocalTimeOf(11, 0, 0, 0), result) - }) - - t.Run("zero value", func(t *testing.T) { - var zero LocalTime - result := zero.PlusMinutes(30) - assert.True(t, result.IsZero()) - }) -} - -func TestLocalTime_PlusSeconds(t *testing.T) { - t.Run("add positive seconds", func(t *testing.T) { - // Normal addition - lt := MustLocalTimeOf(10, 30, 45, 123456789) - result := lt.PlusSeconds(10) - assert.Equal(t, MustLocalTimeOf(10, 30, 55, 123456789), result) - - // Add seconds that roll over to next minute - lt = MustLocalTimeOf(14, 30, 50, 0) - result = lt.PlusSeconds(20) - assert.Equal(t, MustLocalTimeOf(14, 31, 10, 0), result) - }) - - t.Run("wrap around midnight", func(t *testing.T) { - // 23:59:50 + 20 seconds = 00:00:10 - lt := MustLocalTimeOf(23, 59, 50, 0) - result := lt.PlusSeconds(20) - assert.Equal(t, MustLocalTimeOf(0, 0, 10, 0), result) - - // 23:59:59 + 1 second = 00:00:00 - lt = MustLocalTimeOf(23, 59, 59, 999) - result = lt.PlusSeconds(1) - assert.Equal(t, MustLocalTimeOf(0, 0, 0, 999), result) - }) - - t.Run("add negative seconds", func(t *testing.T) { - // Subtract seconds - lt := MustLocalTimeOf(10, 30, 45, 123456789) - result := lt.PlusSeconds(-10) - assert.Equal(t, MustLocalTimeOf(10, 30, 35, 123456789), result) - - // Wrap backwards past midnight - lt = MustLocalTimeOf(0, 0, 10, 0) - result = lt.PlusSeconds(-20) - assert.Equal(t, MustLocalTimeOf(23, 59, 50, 0), result) - }) - - t.Run("large values", func(t *testing.T) { - // Add many seconds (multiple days) - lt := MustLocalTimeOf(10, 0, 0, 0) - result := lt.PlusSeconds(90000) // 25 hours in seconds - assert.Equal(t, MustLocalTimeOf(11, 0, 0, 0), result) - }) - - t.Run("zero value", func(t *testing.T) { - var zero LocalTime - result := zero.PlusSeconds(30) - assert.True(t, result.IsZero()) - }) -} - -func TestLocalTime_PlusNanos(t *testing.T) { - t.Run("add positive nanoseconds", func(t *testing.T) { - // Normal addition - lt := MustLocalTimeOf(10, 30, 45, 123456789) - result := lt.PlusNanos(100) - assert.Equal(t, MustLocalTimeOf(10, 30, 45, 123456889), result) - - // Add nanoseconds that roll over to next second - lt = MustLocalTimeOf(14, 30, 50, 999999999) - result = lt.PlusNanos(1) - assert.Equal(t, MustLocalTimeOf(14, 30, 51, 0), result) - }) - - t.Run("wrap around midnight", func(t *testing.T) { - // Add nanoseconds that wrap to next day - lt := MustLocalTimeOf(23, 59, 59, 999999999) - result := lt.PlusNanos(1) - assert.Equal(t, MustLocalTimeOf(0, 0, 0, 0), result) - }) - - t.Run("add negative nanoseconds", func(t *testing.T) { - // Subtract nanoseconds - lt := MustLocalTimeOf(10, 30, 45, 123456789) - result := lt.PlusNanos(-100) - assert.Equal(t, MustLocalTimeOf(10, 30, 45, 123456689), result) - - // Wrap backwards past midnight - lt = MustLocalTimeOf(0, 0, 0, 0) - result = lt.PlusNanos(-1) - assert.Equal(t, MustLocalTimeOf(23, 59, 59, 999999999), result) - }) - - t.Run("add milliseconds as nanoseconds", func(t *testing.T) { - lt := MustLocalTimeOf(10, 0, 0, 0) - result := lt.PlusNanos(int64(500 * time.Millisecond)) - assert.Equal(t, MustLocalTimeOf(10, 0, 0, 500000000), result) - }) - - t.Run("large values", func(t *testing.T) { - // Add nanoseconds equivalent to multiple days - lt := MustLocalTimeOf(10, 0, 0, 0) - result := lt.PlusNanos(int64(25 * time.Hour)) - assert.Equal(t, MustLocalTimeOf(11, 0, 0, 0), result) - }) - - t.Run("zero value", func(t *testing.T) { - var zero LocalTime - result := zero.PlusNanos(1000000) - assert.True(t, result.IsZero()) - }) -} - -func TestLocalTime_PlusMethodsConsistency(t *testing.T) { - t.Run("hours and minutes equivalence", func(t *testing.T) { - lt := MustLocalTimeOf(10, 0, 0, 0) - - // 2 hours should equal 120 minutes - resultHours := lt.PlusHours(2) - resultMinutes := lt.PlusMinutes(120) - assert.Equal(t, resultHours, resultMinutes) - }) - - t.Run("minutes and seconds equivalence", func(t *testing.T) { - lt := MustLocalTimeOf(10, 0, 0, 0) - - // 5 minutes should equal 300 seconds - resultMinutes := lt.PlusMinutes(5) - resultSeconds := lt.PlusSeconds(300) - assert.Equal(t, resultMinutes, resultSeconds) - }) - - t.Run("seconds and nanoseconds equivalence", func(t *testing.T) { - lt := MustLocalTimeOf(10, 0, 0, 0) - - // 3 seconds should equal 3,000,000,000 nanoseconds - resultSeconds := lt.PlusSeconds(3) - resultNanos := lt.PlusNanos(3000000000) - assert.Equal(t, resultSeconds, resultNanos) - }) - - t.Run("chaining operations", func(t *testing.T) { - lt := MustLocalTimeOf(10, 30, 45, 123456789) - - // Chain multiple operations - result := lt.PlusHours(2).PlusMinutes(30).PlusSeconds(15).PlusNanos(100) - expected := MustLocalTimeOf(13, 1, 0, 123456889) - assert.Equal(t, expected, result) - }) -} - -func TestLocalTime_MinusHours(t *testing.T) { - t.Run("subtract positive hours", func(t *testing.T) { - // Normal subtraction - lt := MustLocalTimeOf(12, 30, 45, 123456789) - result := lt.MinusHours(2) - assert.Equal(t, MustLocalTimeOf(10, 30, 45, 123456789), result) - - // Subtract 1 hour - lt = MustLocalTimeOf(14, 0, 0, 0) - result = lt.MinusHours(1) - assert.Equal(t, MustLocalTimeOf(13, 0, 0, 0), result) - }) - - t.Run("wrap backwards past midnight", func(t *testing.T) { - // 01:00 - 2 hours = 23:00 - lt := MustLocalTimeOf(1, 0, 0, 0) - result := lt.MinusHours(2) - assert.Equal(t, MustLocalTimeOf(23, 0, 0, 0), result) - - // 01:30 - 3 hours = 22:30 - lt = MustLocalTimeOf(1, 30, 45, 123) - result = lt.MinusHours(3) - assert.Equal(t, MustLocalTimeOf(22, 30, 45, 123), result) - }) - - t.Run("subtract negative hours (adds)", func(t *testing.T) { - lt := MustLocalTimeOf(10, 30, 45, 123456789) - result := lt.MinusHours(-2) - assert.Equal(t, MustLocalTimeOf(12, 30, 45, 123456789), result) - }) - - t.Run("equivalence with PlusHours", func(t *testing.T) { - lt := MustLocalTimeOf(15, 30, 0, 0) - - // MinusHours(5) should equal PlusHours(-5) - result1 := lt.MinusHours(5) - result2 := lt.PlusHours(-5) - assert.Equal(t, result1, result2) - }) - - t.Run("zero value", func(t *testing.T) { - var zero LocalTime - result := zero.MinusHours(5) - assert.True(t, result.IsZero()) - }) -} - -func TestLocalTime_MinusMinutes(t *testing.T) { - t.Run("subtract positive minutes", func(t *testing.T) { - // Normal subtraction - lt := MustLocalTimeOf(10, 45, 45, 123456789) - result := lt.MinusMinutes(15) - assert.Equal(t, MustLocalTimeOf(10, 30, 45, 123456789), result) - - // Subtract minutes that roll back to previous hour - lt = MustLocalTimeOf(15, 10, 0, 0) - result = lt.MinusMinutes(20) - assert.Equal(t, MustLocalTimeOf(14, 50, 0, 0), result) - }) - - t.Run("wrap backwards past midnight", func(t *testing.T) { - // 00:10 - 20 minutes = 23:50 - lt := MustLocalTimeOf(0, 10, 0, 0) - result := lt.MinusMinutes(20) - assert.Equal(t, MustLocalTimeOf(23, 50, 0, 0), result) - }) - - t.Run("equivalence with PlusMinutes", func(t *testing.T) { - lt := MustLocalTimeOf(10, 30, 45, 123456789) - - // MinusMinutes(30) should equal PlusMinutes(-30) - result1 := lt.MinusMinutes(30) - result2 := lt.PlusMinutes(-30) - assert.Equal(t, result1, result2) - }) - - t.Run("zero value", func(t *testing.T) { - var zero LocalTime - result := zero.MinusMinutes(30) - assert.True(t, result.IsZero()) - }) -} - -func TestLocalTime_MinusSeconds(t *testing.T) { - t.Run("subtract positive seconds", func(t *testing.T) { - // Normal subtraction - lt := MustLocalTimeOf(10, 30, 55, 123456789) - result := lt.MinusSeconds(10) - assert.Equal(t, MustLocalTimeOf(10, 30, 45, 123456789), result) - - // Subtract seconds that roll back to previous minute - lt = MustLocalTimeOf(14, 31, 10, 0) - result = lt.MinusSeconds(20) - assert.Equal(t, MustLocalTimeOf(14, 30, 50, 0), result) - }) - - t.Run("wrap backwards past midnight", func(t *testing.T) { - // 00:00:10 - 20 seconds = 23:59:50 - lt := MustLocalTimeOf(0, 0, 10, 0) - result := lt.MinusSeconds(20) - assert.Equal(t, MustLocalTimeOf(23, 59, 50, 0), result) - }) - - t.Run("equivalence with PlusSeconds", func(t *testing.T) { - lt := MustLocalTimeOf(10, 30, 45, 123456789) - - // MinusSeconds(45) should equal PlusSeconds(-45) - result1 := lt.MinusSeconds(45) - result2 := lt.PlusSeconds(-45) - assert.Equal(t, result1, result2) - }) - - t.Run("zero value", func(t *testing.T) { - var zero LocalTime - result := zero.MinusSeconds(30) - assert.True(t, result.IsZero()) - }) -} - -func TestLocalTime_MinusNanos(t *testing.T) { - t.Run("subtract positive nanoseconds", func(t *testing.T) { - // Normal subtraction - lt := MustLocalTimeOf(10, 30, 45, 123456889) - result := lt.MinusNanos(100) - assert.Equal(t, MustLocalTimeOf(10, 30, 45, 123456789), result) - - // Subtract nanoseconds that roll back to previous second - lt = MustLocalTimeOf(14, 30, 51, 0) - result = lt.MinusNanos(1) - assert.Equal(t, MustLocalTimeOf(14, 30, 50, 999999999), result) - }) - - t.Run("wrap backwards past midnight", func(t *testing.T) { - // 00:00:00.0 - 1 nanosecond = 23:59:59.999999999 - lt := MustLocalTimeOf(0, 0, 0, 0) - result := lt.MinusNanos(1) - assert.Equal(t, MustLocalTimeOf(23, 59, 59, 999999999), result) - }) - - t.Run("equivalence with PlusNanos", func(t *testing.T) { - lt := MustLocalTimeOf(10, 30, 45, 123456789) - - // MinusNanos(1000) should equal PlusNanos(-1000) - result1 := lt.MinusNanos(1000) - result2 := lt.PlusNanos(-1000) - assert.Equal(t, result1, result2) - }) - - t.Run("zero value", func(t *testing.T) { - var zero LocalTime - result := zero.MinusNanos(1000000) - assert.True(t, result.IsZero()) - }) -} - -func TestLocalTime_PlusMinusCombinations(t *testing.T) { - t.Run("plus and minus cancel out", func(t *testing.T) { - lt := MustLocalTimeOf(10, 30, 45, 123456789) - - // Add then subtract same amount - should return to original - result := lt.PlusHours(5).MinusHours(5) - assert.Equal(t, lt, result) - - result = lt.PlusMinutes(100).MinusMinutes(100) - assert.Equal(t, lt, result) - - result = lt.PlusSeconds(500).MinusSeconds(500) - assert.Equal(t, lt, result) - - result = lt.PlusNanos(1000000000).MinusNanos(1000000000) - assert.Equal(t, lt, result) - }) - - t.Run("complex chaining", func(t *testing.T) { - lt := MustLocalTimeOf(12, 0, 0, 0) - - // Complex chain: +3h -1h +30m -15m +45s -30s - result := lt.PlusHours(3).MinusHours(1).PlusMinutes(30).MinusMinutes(15).PlusSeconds(45).MinusSeconds(30) - // Net: +2h +15m +15s = 14:15:15 - expected := MustLocalTimeOf(14, 15, 15, 0) - assert.Equal(t, expected, result) - }) -} - func TestLocalTimeOfNanoOfDay(t *testing.T) { t.Run("valid nano of day", func(t *testing.T) { // Midnight (0 nanoseconds) @@ -1291,7 +854,7 @@ func TestLocalTimeOfNanoOfDay(t *testing.T) { _, err := LocalTimeOfNanoOfDay(-1) assert.Error(t, err) - // Value >= 24 hours in nanoseconds + // value >= 24 hours in nanoseconds _, err = LocalTimeOfNanoOfDay(24 * int64(time.Hour)) assert.Error(t, err) @@ -1364,7 +927,7 @@ func TestLocalTimeOfSecondOfDay(t *testing.T) { _, err := LocalTimeOfSecondOfDay(-1) assert.Error(t, err) - // Value >= 24 hours in seconds (86,400) + // value >= 24 hours in seconds (86,400) _, err = LocalTimeOfSecondOfDay(86400) assert.Error(t, err) @@ -1394,7 +957,7 @@ func TestLocalTimeOfSecondOfDay(t *testing.T) { require.NoError(t, err) // Nanoseconds should be 0 - assert.Equal(t, 0, lt.Nanosecond()) + assert.Equal(t, 0, lt.Nano()) }) } @@ -1584,30 +1147,9 @@ func TestLocalTime_WithTemporal(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := tt.base.WithField(tt.field, TemporalValue{v: tt.value}) - require.NoError(t, err) - assert.Equal(t, tt.want, got, "want %s got %s", tt.want, got) + r := tt.base.Chain().WithField(tt.field, TemporalValue{v: tt.value}) + require.NoError(t, r.eError) + assert.Equal(t, tt.want, r.MustGet(), "want %s got %s", tt.want, r.MustGet()) }) } } - -func TestLocalTime_WithTemporal_errors(t *testing.T) { - t.Run("zero value returns zero", func(t *testing.T) { - var zero LocalTime - got, err := zero.WithField(FieldHourOfDay, TemporalValue{v: 5}) - require.NoError(t, err) - assert.True(t, got.IsZero()) - }) - - t.Run("invalid range", func(t *testing.T) { - base := MustLocalTimeOf(10, 0, 0, 0) - _, err := base.WithField(FieldSecondOfMinute, TemporalValue{v: 99}) - assert.Error(t, err) - }) - - t.Run("unsupported field", func(t *testing.T) { - base := MustLocalTimeOf(10, 0, 0, 0) - _, err := base.WithField(FieldDayOfMonth, TemporalValue{v: 10}) - assert.Error(t, err) - }) -} diff --git a/localtime_text.go b/localtime_text.go new file mode 100644 index 0000000..96e2ee7 --- /dev/null +++ b/localtime_text.go @@ -0,0 +1,156 @@ +package goda + +import ( + "database/sql/driver" + "errors" + "time" +) + +// Value implements driver.Valuer for database serialization. +func (t LocalTime) Value() (driver.Value, error) { + if t.IsZero() { + return nil, nil + } + return t.String(), nil +} + +// Scan implements sql.Scanner for database deserialization. +func (t *LocalTime) Scan(src any) error { + switch v := src.(type) { + case nil: + *t = LocalTime{} + return nil + case []byte: + return t.UnmarshalText(v) + case string: + return t.UnmarshalText([]byte(v)) + case time.Time: + *t = LocalTimeOfGoTime(v) + return nil + default: + return sqlScannerDefaultBranch(src) + } +} + +// UnmarshalJSON implements json.Unmarshaler. +func (t *LocalTime) UnmarshalJSON(bytes []byte) error { + if len(bytes) == 4 && string(bytes) == "null" { + *t = LocalTime{} + return nil + } + return unmarshalJsonImpl(t, bytes) +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (t *LocalTime) UnmarshalText(text []byte) (e error) { + defer deferOpInParse(text, &e) + if len(text) == 0 { + *t = LocalTime{} + return nil + } + + if len(text) < 8 { + return errors.New("invalid format") + } + + hour, err := parseInt(text[0:2]) + if err != nil { + return err + } + if text[2] != ':' { + return errors.New("expect ':'") + } + minute, err := parseInt(text[3:5]) + if err != nil { + return err + } + if text[5] != ':' { + return errors.New("expect ':'") + } + second, err := parseInt(text[6:8]) + if err != nil { + return err + } + + var nano int64 = 0 + if len(text) > 8 { + if text[8] != '.' { + return errors.New("expect '.'") + } + } + if len(text) > 9 { + var nanoBuf [9]byte + copy(nanoBuf[:], text[9:min(len(text), 18)]) + for i := 8; i >= 0 && nanoBuf[i] == 0; i-- { + nanoBuf[i] = '0' + } + nano, e = parseInt64(nanoBuf[:]) + if e != nil { + return e + } + } + *t, e = LocalTimeOf(hour, minute, second, int(nano)) + return e +} + +// AppendText in ISO-8601 format +func (t LocalTime) AppendText(b []byte) ([]byte, error) { + if t.IsZero() { + return b, nil + } + + var buf [18]byte + hour := t.Hour() + minute := t.Minute() + second := t.Second() + nano := t.Nano() + + // Write hours, minutes, seconds + buf[0] = byte('0' + hour/10) + buf[1] = byte('0' + hour%10) + buf[2] = ':' + buf[3] = byte('0' + minute/10) + buf[4] = byte('0' + minute%10) + buf[5] = ':' + buf[6] = byte('0' + second/10) + buf[7] = byte('0' + second%10) + + if nano == 0 { + return append(b, buf[:8]...), nil + } + + buf[8] = '.' + // Format nanoseconds + ns := nano + for i := 17; i >= 9; i-- { + buf[i] = byte('0' + ns%10) + ns /= 10 + } + + // Trim trailing zeros in groups of 3 (align to milliseconds, microseconds, nanoseconds) + // This follows Java LocalTime behavior: 100000000ns -> ".100" not ".1" + l := 18 + for l > 9 && buf[l-1] == '0' { + l-- + } + // Align to 3-digit boundaries (9, 12, 15, 18 total length) + remainder := (l - 9) % 3 + if remainder != 0 { + l += 3 - remainder + } + return append(b, buf[:l]...), nil +} + +// MarshalText implements encoding.TextMarshaler. +func (t LocalTime) MarshalText() (text []byte, err error) { + return marshalTextImpl(t) +} + +// MarshalJSON implements json.Marshaler. +func (t LocalTime) MarshalJSON() ([]byte, error) { + return marshalJsonImpl(t) +} + +func (t LocalTime) String() string { + return stringImpl(t) +} diff --git a/month.go b/month.go index 81059f9..cd790f9 100644 --- a/month.go +++ b/month.go @@ -102,12 +102,3 @@ func (m Month) Length(isLeap bool) int { } return m.MaxDays() } - -// String returns the English name of the month (e.g., "January", "February"). -// Returns empty string for zero value. -func (m Month) String() string { - if m.IsZero() { - return "" - } - return time.Month(m).String() -} diff --git a/month_text.go b/month_text.go new file mode 100644 index 0000000..d7a6396 --- /dev/null +++ b/month_text.go @@ -0,0 +1,12 @@ +package goda + +import "time" + +// String returns the English name of the month (e.g., "January", "February"). +// Returns empty string for zero value. +func (m Month) String() string { + if m.IsZero() { + return "" + } + return time.Month(m).String() +} diff --git a/offset_date_time.go b/offsetdatetime.go similarity index 56% rename from offset_date_time.go rename to offsetdatetime.go index 68d4c62..dfa4113 100644 --- a/offset_date_time.go +++ b/offsetdatetime.go @@ -48,32 +48,6 @@ func (odt OffsetDateTime) Offset() ZoneOffset { return odt.offset } -// WithOffsetSameLocal returns a copy with the specified offset. -// The local date-time is not changed, only the offset is changed. -func (odt OffsetDateTime) WithOffsetSameLocal(offset ZoneOffset) OffsetDateTime { - if odt.IsZero() { - return OffsetDateTime{} - } - return OffsetDateTime{ - datetime: odt.datetime, - offset: offset, - } -} - -// WithOffsetSameInstant returns a copy with the specified offset. -// The instant represented by this date-time is preserved. -// The local date-time is adjusted to maintain the same instant. -func (odt OffsetDateTime) WithOffsetSameInstant(offset ZoneOffset) OffsetDateTime { - if odt.IsZero() { - return OffsetDateTime{} - } - diff := offset.TotalSeconds() - odt.offset.TotalSeconds() - newOdt := odt.PlusSeconds(int64(diff)) - // Update the offset to the new one - newOdt.offset = offset - return newOdt -} - // Year returns the year component. func (odt OffsetDateTime) Year() Year { return odt.datetime.Year() @@ -160,7 +134,10 @@ func (odt OffsetDateTime) GetField(field Field) TemporalValue { // Handle offset-specific fields switch field { case FieldInstantSeconds: - return TemporalValue{v: odt.EpochSecond() - int64(odt.offset.TotalSeconds())} + v, o := odt.epochSecondOverflow() + return TemporalValue{v: v, overflow: o} + case FieldOffsetSeconds: + return TemporalValue{v: int64(odt.Offset().totalSeconds)} } // Delegate to LocalDateTime for date and time fields @@ -180,12 +157,19 @@ func (odt OffsetDateTime) GoTime() time.Time { // EpochSecond returns the number of seconds since Unix epoch (1970-01-01T00:00:00Z). func (odt OffsetDateTime) EpochSecond() int64 { + i, _ := odt.epochSecondOverflow() + return i +} + +func (odt OffsetDateTime) epochSecondOverflow() (i int64, overflow bool) { if odt.IsZero() { - return 0 + return 0, false } epochDay := odt.datetime.LocalDate().UnixEpochDays() secondsOfDay := odt.datetime.LocalTime().GetField(FieldSecondOfDay).Int64() - return epochDay*86400 + secondsOfDay - int64(odt.offset.TotalSeconds()) + i, overflow = addExactly(epochDay*86400+secondsOfDay, -int64(odt.offset.TotalSeconds())) + overflow = overflow || odt.LocalDateTime().Compare(localDateTimeMinEpochSecond) < 0 || odt.LocalDateTime().Compare(localDateTimeMaxEpochSecond) > 0 + return } // Compare compares this offset date-time with another. @@ -205,228 +189,9 @@ func (odt OffsetDateTime) IsAfter(other OffsetDateTime) bool { return doCompare(odt, other, compareZero, comparing(OffsetDateTime.EpochSecond), comparing(OffsetDateTime.Nanosecond)) > 0 } -// PlusYears returns a copy with the specified number of years added. -func (odt OffsetDateTime) PlusYears(years int) OffsetDateTime { - if odt.IsZero() { - return OffsetDateTime{} - } - return OffsetDateTime{ - datetime: odt.datetime.PlusYears(years), - offset: odt.offset, - } -} - -// MinusYears returns a copy with the specified number of years subtracted. -func (odt OffsetDateTime) MinusYears(years int) OffsetDateTime { - return odt.PlusYears(-years) -} - -// PlusMonths returns a copy with the specified number of months added. -func (odt OffsetDateTime) PlusMonths(months int) OffsetDateTime { - if odt.IsZero() { - return OffsetDateTime{} - } - return OffsetDateTime{ - datetime: odt.datetime.PlusMonths(months), - offset: odt.offset, - } -} - -// MinusMonths returns a copy with the specified number of months subtracted. -func (odt OffsetDateTime) MinusMonths(months int) OffsetDateTime { - return odt.PlusMonths(-months) -} - -// PlusDays returns a copy with the specified number of days added. -func (odt OffsetDateTime) PlusDays(days int) OffsetDateTime { - if odt.IsZero() { - return OffsetDateTime{} - } - return OffsetDateTime{ - datetime: odt.datetime.PlusDays(days), - offset: odt.offset, - } -} - -// MinusDays returns a copy with the specified number of days subtracted. -func (odt OffsetDateTime) MinusDays(days int) OffsetDateTime { - return odt.PlusDays(-days) -} - -// PlusHours returns a copy with the specified number of hours added. -func (odt OffsetDateTime) PlusHours(hours int64) OffsetDateTime { - return odt.PlusSeconds(hours * 3600) -} - -// MinusHours returns a copy with the specified number of hours subtracted. -func (odt OffsetDateTime) MinusHours(hours int64) OffsetDateTime { - return odt.PlusHours(-hours) -} - -// PlusMinutes returns a copy with the specified number of minutes added. -func (odt OffsetDateTime) PlusMinutes(minutes int64) OffsetDateTime { - return odt.PlusSeconds(minutes * 60) -} - -// MinusMinutes returns a copy with the specified number of minutes subtracted. -func (odt OffsetDateTime) MinusMinutes(minutes int64) OffsetDateTime { - return odt.PlusMinutes(-minutes) -} - -// PlusSeconds returns a copy with the specified number of seconds added. -func (odt OffsetDateTime) PlusSeconds(seconds int64) OffsetDateTime { - if odt.IsZero() || seconds == 0 { - return odt - } - return odt.PlusNanos(seconds * 1_000_000_000) -} - -// MinusSeconds returns a copy with the specified number of seconds subtracted. -func (odt OffsetDateTime) MinusSeconds(seconds int64) OffsetDateTime { - return odt.PlusSeconds(-seconds) -} - -// PlusNanos returns a copy with the specified number of nanoseconds added. -func (odt OffsetDateTime) PlusNanos(nanos int64) OffsetDateTime { - if odt.IsZero() || nanos == 0 { - return odt - } - - // Convert current date-time to total nanoseconds since epoch day - secondsOfDay := int64(odt.datetime.LocalTime().GetField(FieldSecondOfDay).Int64()) - nanosOfDay := secondsOfDay*1_000_000_000 + int64(odt.Nanosecond()) - - // Add the nanos - totalNanos := nanosOfDay + nanos - - // Calculate days overflow - daysToAdd := totalNanos / 86400_000_000_000 - newNanosOfDay := totalNanos % 86400_000_000_000 - - // Handle negative overflow - if newNanosOfDay < 0 { - newNanosOfDay += 86400_000_000_000 - daysToAdd-- - } - - // Create new date and time - newDate := odt.datetime.LocalDate().PlusDays(int(daysToAdd)) - newTime := MustLocalTimeOfNanoOfDay(newNanosOfDay) - - return OffsetDateTime{ - datetime: newDate.AtTime(newTime), - offset: odt.offset, - } -} - -// MinusNanos returns a copy with the specified number of nanoseconds subtracted. -func (odt OffsetDateTime) MinusNanos(nanos int64) OffsetDateTime { - return odt.PlusNanos(-nanos) -} - -// String returns the ISO 8601 string representation (yyyy-MM-ddTHH:mm:ss[.nnnnnnnnn]±HH:mm). -func (odt OffsetDateTime) String() string { - return stringImpl(odt) -} - -// AppendText implements encoding.TextAppender. -func (odt OffsetDateTime) AppendText(b []byte) ([]byte, error) { - if odt.IsZero() { - return b, nil - } - b, _ = odt.datetime.AppendText(b) - b, _ = odt.offset.AppendText(b) - return b, nil -} - -// MarshalText implements encoding.TextMarshaler. -func (odt OffsetDateTime) MarshalText() ([]byte, error) { - return marshalTextImpl(odt) -} - -// UnmarshalText implements encoding.TextUnmarshaler. -// Accepts ISO 8601 format: yyyy-MM-ddTHH:mm:ss[.nnnnnnnnn]±HH:mm[:ss] or Z for UTC. -func (odt *OffsetDateTime) UnmarshalText(text []byte) error { - if len(text) == 0 { - *odt = OffsetDateTime{} - return nil - } - - // Find the offset part (starts with +, -, or Z) - offsetIdx := -1 - for i := len(text) - 1; i >= 0; i-- { - ch := text[i] - if ch == '+' || ch == '-' { - offsetIdx = i - break - } - if ch == 'Z' || ch == 'z' { - offsetIdx = i - break - } - } - - if offsetIdx < 0 { - return newError("invalid offset date-time format: missing offset") - } - - // Parse date-time part - var dt LocalDateTime - if err := dt.UnmarshalText(text[:offsetIdx]); err != nil { - return err - } - - // Parse offset part - var offset ZoneOffset - if err := offset.UnmarshalText(text[offsetIdx:]); err != nil { - return err - } - - *odt = OffsetDateTime{ - datetime: dt, - offset: offset, - } - return nil -} - -// MarshalJSON implements json.Marshaler. -func (odt OffsetDateTime) MarshalJSON() ([]byte, error) { - return marshalJsonImpl(odt) -} - -// UnmarshalJSON implements json.Unmarshaler. -func (odt *OffsetDateTime) UnmarshalJSON(data []byte) error { - if len(data) == 4 && string(data) == "null" { - *odt = OffsetDateTime{} - return nil - } - return unmarshalJsonImpl(odt, data) -} - -// Scan implements sql.Scanner. -func (odt *OffsetDateTime) Scan(src any) error { - switch v := src.(type) { - case nil: - *odt = OffsetDateTime{} - return nil - case string: - return odt.UnmarshalText([]byte(v)) - case []byte: - return odt.UnmarshalText(v) - case time.Time: - *odt = OffsetDateTimeOfGoTime(v) - return nil - default: - return sqlScannerDefaultBranch(v) - } -} - -// Value implements driver.Valuer. -func (odt OffsetDateTime) Value() (driver.Value, error) { - if odt.IsZero() { - return nil, nil - } - return odt.String(), nil +func (odt OffsetDateTime) Chain() (chain OffsetDateTimeChain) { + chain.value = odt + return } // OffsetDateTimeOf creates a new OffsetDateTime from individual components. diff --git a/offsetdatetime_chain.go b/offsetdatetime_chain.go new file mode 100644 index 0000000..3e9a852 --- /dev/null +++ b/offsetdatetime_chain.go @@ -0,0 +1,144 @@ +package goda + +type OffsetDateTimeChain struct { + Chain[OffsetDateTime] +} + +func (o OffsetDateTimeChain) WithField(field Field, value TemporalValue) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "WithField")) + newValue := value.Int64() + field.checkSetE(newValue, &o.eError) + if !o.ok() { + return o + } + switch field { + case FieldInstantSeconds: + o.value.datetime, o.eError = LocalDateTimeOfEpochSecond(newValue+int64(o.value.offset.totalSeconds), int64(o.value.Nanosecond()), o.value.offset) + case FieldOffsetSeconds: + o.value.offset.totalSeconds = int32(newValue) + default: + o.value.datetime = o.value.datetime.chainWithError(o.eError).WithField(field, value).mergeError(&o.eError) + } + return o +} + +func (o OffsetDateTimeChain) PlusYears(years int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "PlusYears")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).PlusYears(years).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) MinusYears(years int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "MinusYears")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).MinusYears(years).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) PlusMonths(months int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "PlusMonths")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).PlusMonths(months).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) MinusMonths(months int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "MinusMonths")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).MinusMonths(months).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) PlusWeeks(weeks int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "PlusWeeks")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).PlusWeeks(weeks).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) MinusWeeks(weeks int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "MinusWeeks")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).MinusWeeks(weeks).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) PlusDays(days int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "PlusDays")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).PlusDays(days).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) MinusDays(days int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "MinusDays")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).MinusDays(days).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) PlusHours(hours int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "PlusHours")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).PlusHours(hours).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) MinusHours(hours int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "MinusHours")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).MinusHours(hours).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) PlusMinutes(minutes int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "PlusMinutes")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).PlusMinutes(minutes).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) MinusMinutes(minutes int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "MinusMinutes")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).MinusMinutes(minutes).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) PlusSeconds(seconds int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "PlusSeconds")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).PlusSeconds(seconds).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) MinusSeconds(seconds int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "MinusSeconds")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).MinusSeconds(seconds).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) PlusNanos(nanos int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "PlusNanos")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).PlusNanos(nanos).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) MinusNanos(nanos int64) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "MinusNanos")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).MinusNanos(nanos).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) WithYear(year Year) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "WithYear")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).WithYear(year).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) WithMonth(month Month) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "WithMonth")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).WithMonth(month).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) WithDayOfMonth(dayOfMonth int) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "WithDayOfMonth")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).WithDayOfMonth(dayOfMonth).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) WithDayOfYear(dayOfYear int) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "WithDayOfYear")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).WithDayOfYear(dayOfYear).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) WithHour(hour int) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "WithHour")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).WithHour(hour).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) WithMinute(minute int) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "WithMinute")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).WithMinute(minute).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) WithSecond(second int) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "WithSecond")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).WithSecond(second).mergeError(&o.eError) + return o +} +func (o OffsetDateTimeChain) WithNano(nanoOfSecond int) OffsetDateTimeChain { + defer o.leaveFunction(o.enterFunction("OffsetDateTime", "WithNano")) + o.value.datetime = o.value.datetime.chainWithError(o.eError).WithNano(nanoOfSecond).mergeError(&o.eError) + return o +} diff --git a/offset_date_time_test.go b/offsetdatetime_test.go similarity index 80% rename from offset_date_time_test.go rename to offsetdatetime_test.go index 9f889c0..b1c607a 100644 --- a/offset_date_time_test.go +++ b/offsetdatetime_test.go @@ -193,40 +193,6 @@ func TestOffsetDateTime_IsLeapYear(t *testing.T) { assert.False(t, odt2023.IsLeapYear()) } -func TestOffsetDateTime_WithOffsetSameLocal(t *testing.T) { - offset1 := MustZoneOffsetOfHours(1) - offset2 := MustZoneOffsetOfHours(5) - odt := MustOffsetDateTimeOf(2024, March, 15, 14, 30, 45, 0, offset1) - - odt2 := odt.WithOffsetSameLocal(offset2) - assert.Equal(t, 14, odt2.Hour()) // Local time unchanged - assert.Equal(t, 5*3600, odt2.Offset().TotalSeconds()) - - // Check that the instant changed (because local time is the same but offset changed) - assert.NotEqual(t, odt.EpochSecond(), odt2.EpochSecond()) -} - -func TestOffsetDateTime_WithOffsetSameInstant(t *testing.T) { - offset1 := MustZoneOffsetOfHours(1) - offset2 := MustZoneOffsetOfHours(5) - odt := MustOffsetDateTimeOf(2024, March, 15, 14, 30, 45, 0, offset1) - - odt2 := odt.WithOffsetSameInstant(offset2) - - // The implementation adds the offset difference as seconds - // offset2 (5*3600) - offset1 (1*3600) = 4*3600 seconds - // So we're adding 4 hours: 14:30:45 + 4 hours = 18:30:45 - assert.Equal(t, 18, odt2.Hour()) // Local time changed by 4 hours - assert.Equal(t, 30, odt2.Minute()) - assert.Equal(t, 45, odt2.Second()) - - // But the offset should be updated to offset2 - assert.Equal(t, 5*3600, odt2.Offset().TotalSeconds()) - - // Check that the instant is the same - assert.Equal(t, odt.EpochSecond(), odt2.EpochSecond()) -} - func TestOffsetDateTime_ToEpochSecond(t *testing.T) { t.Run("UTC", func(t *testing.T) { offset := ZoneOffsetUTC() @@ -313,109 +279,6 @@ func TestOffsetDateTime_IsAfter(t *testing.T) { assert.False(t, odt1.IsAfter(odt1)) } -func TestOffsetDateTime_PlusYears(t *testing.T) { - offset := MustZoneOffsetOfHours(1) - odt := MustOffsetDateTimeOf(2024, March, 15, 14, 30, 45, 0, offset) - - odt2 := odt.PlusYears(1) - assert.Equal(t, Year(2025), odt2.Year()) - assert.Equal(t, March, odt2.Month()) - assert.Equal(t, 1*3600, odt2.Offset().TotalSeconds()) - - odt3 := odt.PlusYears(-1) - assert.Equal(t, Year(2023), odt3.Year()) -} - -func TestOffsetDateTime_PlusMonths(t *testing.T) { - offset := ZoneOffsetUTC() - odt := MustOffsetDateTimeOf(2024, March, 15, 14, 30, 45, 0, offset) - - odt2 := odt.PlusMonths(2) - assert.Equal(t, May, odt2.Month()) - - odt3 := odt.PlusMonths(-1) - assert.Equal(t, February, odt3.Month()) -} - -func TestOffsetDateTime_PlusDays(t *testing.T) { - offset := ZoneOffsetUTC() - odt := MustOffsetDateTimeOf(2024, March, 15, 14, 30, 45, 0, offset) - - odt2 := odt.PlusDays(10) - assert.Equal(t, 25, odt2.DayOfMonth()) - - odt3 := odt.PlusDays(-10) - assert.Equal(t, 5, odt3.DayOfMonth()) -} - -func TestOffsetDateTime_PlusHours(t *testing.T) { - offset := ZoneOffsetUTC() - odt := MustOffsetDateTimeOf(2024, March, 15, 14, 30, 45, 0, offset) - - odt2 := odt.PlusHours(5) - assert.Equal(t, 19, odt2.Hour()) - - // Test overflow to next day - odt3 := odt.PlusHours(10) - assert.Equal(t, 0, odt3.Hour()) - assert.Equal(t, 16, odt3.DayOfMonth()) -} - -func TestOffsetDateTime_PlusMinutes(t *testing.T) { - offset := ZoneOffsetUTC() - odt := MustOffsetDateTimeOf(2024, March, 15, 14, 30, 45, 0, offset) - - odt2 := odt.PlusMinutes(45) - assert.Equal(t, 15, odt2.Hour()) - assert.Equal(t, 15, odt2.Minute()) - - odt3 := odt.PlusMinutes(-30) - assert.Equal(t, 14, odt3.Hour()) - assert.Equal(t, 0, odt3.Minute()) -} - -func TestOffsetDateTime_PlusSeconds(t *testing.T) { - offset := ZoneOffsetUTC() - odt := MustOffsetDateTimeOf(2024, March, 15, 14, 30, 45, 0, offset) - - odt2 := odt.PlusSeconds(30) - assert.Equal(t, 15, odt2.Second()) - - odt3 := odt.PlusSeconds(-45) - assert.Equal(t, 0, odt3.Second()) -} - -func TestOffsetDateTime_PlusNanos(t *testing.T) { - offset := ZoneOffsetUTC() - odt := MustOffsetDateTimeOf(2024, March, 15, 14, 30, 45, 0, offset) - - odt2 := odt.PlusNanos(123456789) - assert.Equal(t, 123456789, odt2.Nanosecond()) - - // Test overflow - odt3 := odt.PlusNanos(2_000_000_000) - assert.Equal(t, 47, odt3.Second()) - assert.Equal(t, 0, odt3.Nanosecond()) - - // Test day overflow - odt4 := odt.PlusNanos(86400_000_000_000) - assert.Equal(t, 16, odt4.DayOfMonth()) - assert.Equal(t, 14, odt4.Hour()) -} - -func TestOffsetDateTime_MinusMethods(t *testing.T) { - offset := ZoneOffsetUTC() - odt := MustOffsetDateTimeOf(2024, March, 15, 14, 30, 45, 123456789, offset) - - assert.Equal(t, Year(2023), odt.MinusYears(1).Year()) - assert.Equal(t, February, odt.MinusMonths(1).Month()) - assert.Equal(t, 14, odt.MinusDays(1).DayOfMonth()) - assert.Equal(t, 13, odt.MinusHours(1).Hour()) - assert.Equal(t, 29, odt.MinusMinutes(1).Minute()) - assert.Equal(t, 44, odt.MinusSeconds(1).Second()) - assert.Equal(t, 123456788, odt.MinusNanos(1).Nanosecond()) -} - func TestOffsetDateTime_IsSupportedField(t *testing.T) { offset := ZoneOffsetUTC() odt := MustOffsetDateTimeOf(2024, March, 15, 14, 30, 45, 0, offset) diff --git a/offsetdatetime_text.go b/offsetdatetime_text.go new file mode 100644 index 0000000..2ae0170 --- /dev/null +++ b/offsetdatetime_text.go @@ -0,0 +1,113 @@ +package goda + +import ( + "database/sql/driver" + "errors" + "time" +) + +// String returns the ISO 8601 string representation (yyyy-MM-ddTHH:mm:ss[.nnnnnnnnn]±HH:mm). +func (odt OffsetDateTime) String() string { + return stringImpl(odt) +} + +// AppendText implements encoding.TextAppender. +func (odt OffsetDateTime) AppendText(b []byte) ([]byte, error) { + if odt.IsZero() { + return b, nil + } + b, _ = odt.datetime.AppendText(b) + b, _ = odt.offset.AppendText(b) + return b, nil +} + +// MarshalText implements encoding.TextMarshaler. +func (odt OffsetDateTime) MarshalText() ([]byte, error) { + return marshalTextImpl(odt) +} + +// UnmarshalText implements encoding.TextUnmarshaler. +// Accepts ISO 8601 format: yyyy-MM-ddTHH:mm:ss[.nnnnnnnnn]±HH:mm[:ss] or Z for UTC. +func (odt *OffsetDateTime) UnmarshalText(text []byte) (e error) { + defer deferOpInParse(text, &e) + if len(text) == 0 { + *odt = OffsetDateTime{} + return nil + } + + // Find the offset part (starts with +, -, or Z) + offsetIdx := -1 + for i := len(text) - 1; i >= 0; i-- { + ch := text[i] + if ch == '+' || ch == '-' { + offsetIdx = i + break + } + if ch == 'Z' || ch == 'z' { + offsetIdx = i + break + } + } + + if offsetIdx < 0 { + return errors.New("invalid offset date-time format: missing offset") + } + + // Parse date-time part + var dt LocalDateTime + if err := dt.UnmarshalText(text[:offsetIdx]); err != nil { + return err + } + + // Parse offset part + var offset ZoneOffset + if err := offset.UnmarshalText(text[offsetIdx:]); err != nil { + return err + } + + *odt = OffsetDateTime{ + datetime: dt, + offset: offset, + } + return nil +} + +// MarshalJSON implements json.Marshaler. +func (odt OffsetDateTime) MarshalJSON() ([]byte, error) { + return marshalJsonImpl(odt) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (odt *OffsetDateTime) UnmarshalJSON(data []byte) error { + if len(data) == 4 && string(data) == "null" { + *odt = OffsetDateTime{} + return nil + } + return unmarshalJsonImpl(odt, data) +} + +// Scan implements sql.Scanner. +func (odt *OffsetDateTime) Scan(src any) error { + switch v := src.(type) { + case nil: + *odt = OffsetDateTime{} + return nil + case string: + return odt.UnmarshalText([]byte(v)) + case []byte: + return odt.UnmarshalText(v) + case time.Time: + *odt = OffsetDateTimeOfGoTime(v) + return nil + default: + return sqlScannerDefaultBranch(v) + } +} + +// Value implements driver.Valuer. +func (odt OffsetDateTime) Value() (driver.Value, error) { + if odt.IsZero() { + return nil, nil + } + return odt.String(), nil +} diff --git a/utils.go b/utils.go index 578dc5b..df1c8da 100644 --- a/utils.go +++ b/utils.go @@ -3,6 +3,7 @@ package goda import ( "cmp" "encoding" + "math" "strconv" ) @@ -10,13 +11,14 @@ func parseInt64(input []byte) (int64, error) { return strconv.ParseInt(string(input), 10, 64) } -func parseInt(input []byte) (int, error) { - return strconv.Atoi(string(input)) +func parseInt(input []byte) (i int, e error) { + i, e = strconv.Atoi(string(input)) + return } func unmarshalJsonImpl[T encoding.TextUnmarshaler](ref T, data []byte) error { if len(data) < 2 || data[0] != '"' || data[len(data)-1] != '"' { - return newError("expect a JSON string") + return parseFailedError(data) } return ref.UnmarshalText(data[1 : len(data)-1]) } @@ -46,15 +48,20 @@ func bytes2string(b []byte) string { return string(b) } -func floorDiv(a, b int64) int64 { - if a >= 0 { - return a / b +func floorDiv(x, y int64) (q int64) { + q = x / y + if (x^y) < 0 && (q*y != x) { + q = q - 1 } - return -((-a + b - 1) / b) + return } -func floorMod(a, b int64) int64 { - return a - floorDiv(a, b)*b +func floorMod(x, y int64) (r int64) { + r = x % y + if (x^y) < 0 && r != 0 { + r = r + y + } + return } type comparable0[T any] interface { @@ -104,16 +111,14 @@ func mustValue[T any](v T, err error) T { return v } -func checkTemporalInRange(field Field, from, to int64, value TemporalValue, oldError error) (e error) { - if oldError != nil { - return oldError - } - return checkInRange(field.String(), from, to, value.Int64()) +func addExactly(x, y int64) (r int64, overflow bool) { + r = x + y + overflow = ((x ^ r) & (y ^ r)) < 0 + return } -func checkInRange(field string, from, to, value int64) (e error) { - if value < from || value > to { - return newError("%s out of range: %d", field, value) - } - return nil +func mulExact(x, y int64) (r int64, overflow bool) { + r = x * y + overflow = ((y != 0) && (r/y != x)) || (x == math.MinInt64 && y == -1) + return } diff --git a/year.go b/year.go index 10e8766..6ed7819 100644 --- a/year.go +++ b/year.go @@ -3,31 +3,14 @@ package goda import ( "encoding" "fmt" - "strconv" ) // Year represents a year in the ISO-8601 calendar system. // It can represent any year from math.MinInt64 to math.MaxInt64. type Year int64 -// String returns the string representation of this year. -// Years 0-9999 are formatted as 4 digits with leading zeros (e.g., "0001", "2024"). -// Years outside this range are formatted without padding. -func (y Year) String() string { - return stringImpl(y) -} - -// AppendText implements the encoding.TextAppender interface. -// It appends the year representation to b and returns the extended buffer. -func (y Year) AppendText(b []byte) ([]byte, error) { - if y >= 0 && y <= 9999 { - return append(b, '0'+byte(y/1000), '0'+byte((y/100)%10), '0'+byte((y/10)%10), '0'+byte(y%10)), nil - } else if y < 0 && y >= -9999 { - return append(b, '-', '0'+byte((-y)/1000), '0'+byte(((-y)/100)%10), '0'+byte(((-y)/10)%10), '0'+byte((-y)%10)), nil - } - b = append(b, strconv.FormatInt(y.Int64(), 10)...) - return b, nil -} +const YearMax = 1<<47 - 1 +const YearMin = -YearMax - 1 // Int returns this year as an int. func (y Year) Int() int { diff --git a/year_text.go b/year_text.go new file mode 100644 index 0000000..8d101a8 --- /dev/null +++ b/year_text.go @@ -0,0 +1,22 @@ +package goda + +import "strconv" + +// String returns the string representation of this year. +// Years 0-9999 are formatted as 4 digits with leading zeros (e.g., "0001", "2024"). +// Years outside this range are formatted without padding. +func (y Year) String() string { + return stringImpl(y) +} + +// AppendText implements the encoding.TextAppender interface. +// It appends the year representation to b and returns the extended buffer. +func (y Year) AppendText(b []byte) ([]byte, error) { + if y >= 0 && y <= 9999 { + return append(b, '0'+byte(y/1000), '0'+byte((y/100)%10), '0'+byte((y/10)%10), '0'+byte(y%10)), nil + } else if y < 0 && y >= -9999 { + return append(b, '-', '0'+byte((-y)/1000), '0'+byte(((-y)/100)%10), '0'+byte(((-y)/10)%10), '0'+byte((-y)%10)), nil + } + b = append(b, strconv.FormatInt(y.Int64(), 10)...) + return b, nil +} diff --git a/yearmonth.go b/yearmonth.go new file mode 100644 index 0000000..d282fbc --- /dev/null +++ b/yearmonth.go @@ -0,0 +1,80 @@ +package goda + +import ( + "database/sql" + "database/sql/driver" + "encoding" + "encoding/json" + "fmt" +) + +type YearMonth struct { + v int64 +} + +func (y YearMonth) IsZero() bool { + return y.v == 0 +} + +func (y YearMonth) Compare(other YearMonth) int { + return doCompare(y, other, compareZero, comparing(YearMonth.Year), comparing(YearMonth.Month)) +} + +func (y YearMonth) IsLeapYear() bool { + return y.Year().IsLeapYear() +} + +func (y YearMonth) LengthOfMonth() int { + return y.Month().Length(y.IsLeapYear()) +} + +func (y YearMonth) LengthOfYear() int { + return y.Year().Length() +} + +func (y YearMonth) Year() Year { + return Year(y.v >> 16) +} + +func (y YearMonth) ProlepticMonth() int64 { + return y.Year().Int64()*12 + int64(y.Month()) - 1 +} + +func (y YearMonth) Month() Month { + return Month(y.v & 0xffff) +} + +func (y YearMonth) Chain() (chain YearMonthChain) { + chain.value = y + return +} + +func YearMonthOf(year Year, month Month) (y YearMonth, e error) { + FieldYear.checkSetE(year.Int64(), &e) + FieldMonthOfYear.checkSetE(int64(month), &e) + if e != nil { + return + } + return YearMonth{int64(year)<<16 | int64(month)}, nil +} + +func MustYearMonthOf(year Year, month Month) YearMonth { + return mustValue(YearMonthOf(year, month)) +} + +// Compile-time interface checks +var ( + _ encoding.TextAppender = (*YearMonth)(nil) + _ fmt.Stringer = (*YearMonth)(nil) + _ encoding.TextMarshaler = (*YearMonth)(nil) + _ encoding.TextUnmarshaler = (*YearMonth)(nil) + _ json.Marshaler = (*YearMonth)(nil) + _ json.Unmarshaler = (*YearMonth)(nil) + _ driver.Valuer = (*YearMonth)(nil) + _ sql.Scanner = (*YearMonth)(nil) +) + +// Compile-time check that YearMonth is comparable +func _assertYearMonthIsComparable[T comparable](t T) {} + +var _ = _assertYearMonthIsComparable[YearMonth] diff --git a/yearmonth_chain.go b/yearmonth_chain.go new file mode 100644 index 0000000..4dc555d --- /dev/null +++ b/yearmonth_chain.go @@ -0,0 +1,99 @@ +package goda + +import ( + "math" +) + +type YearMonthChain struct { + Chain[YearMonth] +} + +func (y YearMonthChain) PlusMonths(months int64) YearMonthChain { + defer y.leaveFunction(y.enterFunction("YearMonth", "PlusMonths")) + if !y.ok() { + return y + } + if months == 0 { + return y + } + if _, overflow := mulExact(y.value.Year().Int64(), 12); overflow { + y.eError = overflowError() + return y + } + var monthCount = y.value.Year().Int64()*12 + (int64(y.value.Month()) - 1) + var calcMonth = monthCount + months + var newYear = floorDiv(calcMonth, 12) + var newMonth = floorMod(calcMonth, 12) + 1 + y.value, y.eError = YearMonthOf(Year(newYear), Month(newMonth)) + return y +} + +func (y YearMonthChain) MinusMonths(months int64) YearMonthChain { + defer y.leaveFunction(y.enterFunction("YearMonth", "MinusMonths")) + if months == math.MinInt64 { + return y.PlusMonths(math.MaxInt64).PlusMonths(1) + } + return y.PlusMonths(-months) +} + +func (y YearMonthChain) PlusYears(years int64) YearMonthChain { + defer y.leaveFunction(y.enterFunction("YearMonth", "PlusYears")) + if !y.ok() { + return y + } + newYear, overflow := addExactly(y.value.Year().Int64(), years) + if overflow { + y.eError = overflowError() + } + return y.WithField(FieldYear, TemporalValueOf(newYear)) +} + +func (y YearMonthChain) MinusYears(years int64) YearMonthChain { + defer y.leaveFunction(y.enterFunction("YearMonth", "MinusYears")) + if years == math.MinInt64 { + return y.PlusYears(math.MaxInt64).PlusYears(1) + } + return y.PlusYears(-years) +} + +func (y YearMonthChain) WithMonth(month Month) YearMonthChain { + defer y.leaveFunction(y.enterFunction("YearMonth", "WithMonth")) + return y.WithField(FieldMonthOfYear, TemporalValueOf(int64(month))) +} + +func (y YearMonthChain) WithYear(year Year) YearMonthChain { + defer y.leaveFunction(y.enterFunction("YearMonth", "WithYear")) + return y.WithField(FieldYear, TemporalValueOf(int64(year))) +} + +func (y YearMonthChain) WithField(field Field, value TemporalValue) YearMonthChain { + defer y.leaveFunction(y.enterFunction("YearMonth", "WithField")) + field.checkSetE(value.Int64(), &y.eError) + if !y.ok() { + return y + } + if field == FieldProlepticMonth { + return y.PlusMonths(value.Int64() - y.value.ProlepticMonth()) + } + var year = y.value.Year() + var month = y.value.Month() + switch field { + case FieldYear: + year = Year(value.v) + case FieldYearOfEra: + if year >= 1 { + // CE + year = Year(value.v) + } else { + // BCE: convert YearOfEra back to negative year + year = Year(-(value.v - 1)) + } + case FieldMonthOfYear: + month = Month(value.v) + default: + y.eError = unsupportedField(field) + return y + } + y.value, y.eError = YearMonthOf(year, month) + return y +} diff --git a/yearmonth_text.go b/yearmonth_text.go new file mode 100644 index 0000000..bcafad8 --- /dev/null +++ b/yearmonth_text.go @@ -0,0 +1,79 @@ +package goda + +import ( + "bytes" + "database/sql/driver" + "strconv" +) + +func (y *YearMonth) UnmarshalJSON(i []byte) error { + return unmarshalJsonImpl(y, i) +} + +func (y YearMonth) MarshalJSON() ([]byte, error) { + return marshalJsonImpl(y) +} + +func (y YearMonth) MarshalText() (text []byte, err error) { + return marshalTextImpl(y) +} + +func (y *YearMonth) UnmarshalText(text []byte) (e error) { + if len(text) == 0 { + return nil + } + var i = bytes.IndexByte(text, '-') + if i == -1 { + return parseFailedError(text) + } + var year int64 + var month int64 + year, e = strconv.ParseInt(string(text[:i]), 10, 64) + if e != nil { + return + } + month, e = strconv.ParseInt(string(text[i+1:]), 10, 64) + if e != nil { + return + } + *y, e = YearMonthOf(Year(year), Month(month)) + return +} + +func (y YearMonth) AppendText(b []byte) ([]byte, error) { + if y.IsZero() { + return b, nil + } + b, _ = y.Year().AppendText(b) + b = append(b, '-') + if y.Month() < December { + b = append(b, '0') + } + b = strconv.AppendInt(b, int64(y.Month()), 10) + return b, nil +} + +func (y YearMonth) String() string { + return stringImpl(y) +} + +func (y *YearMonth) Scan(src any) error { + switch v := src.(type) { + case nil: + *y = YearMonth{} + return nil + case string: + return y.UnmarshalText([]byte(v)) + case []byte: + return y.UnmarshalText(v) + default: + return sqlScannerDefaultBranch(v) + } +} + +func (y YearMonth) Value() (driver.Value, error) { + if y.IsZero() { + return nil, nil + } + return y.String(), nil +} diff --git a/zone_id.go b/zoneid.go similarity index 100% rename from zone_id.go rename to zoneid.go diff --git a/zone_id_test.go b/zoneid_test.go similarity index 100% rename from zone_id_test.go rename to zoneid_test.go diff --git a/zone_offset.go b/zoneoffset.go similarity index 57% rename from zone_offset.go rename to zoneoffset.go index 595310b..9b0bf2c 100644 --- a/zone_offset.go +++ b/zoneoffset.go @@ -4,7 +4,6 @@ import ( "encoding" "encoding/json" "fmt" - "strconv" ) // ZoneOffset represents a time-zone offset from UTC, such as +02:00. @@ -86,182 +85,6 @@ func (z ZoneOffset) Compare(other ZoneOffset) int { return 0 } -// String returns the string representation of the zone offset. -// Returns "Z" for UTC, otherwise returns the format ±HH:MM or ±HH:MM:SS. -func (z ZoneOffset) String() string { - return stringImpl(z) -} - -// AppendText implements encoding.TextAppender. -func (z ZoneOffset) AppendText(b []byte) ([]byte, error) { - if z.totalSeconds == 0 { - return append(b, 'Z'), nil - } - - absSeconds := z.totalSeconds - if absSeconds < 0 { - b = append(b, '-') - absSeconds = -absSeconds - } else { - b = append(b, '+') - } - - hours := absSeconds / 3600 - minutes := (absSeconds % 3600) / 60 - seconds := absSeconds % 60 - - // Format hours (always 2 digits) - b = append(b, byte('0'+hours/10), byte('0'+hours%10)) - b = append(b, ':') - // Format minutes (always 2 digits) - b = append(b, byte('0'+minutes/10), byte('0'+minutes%10)) - - // Only append seconds if non-zero - if seconds != 0 { - b = append(b, ':') - b = append(b, byte('0'+seconds/10), byte('0'+seconds%10)) - } - - return b, nil -} - -// MarshalText implements encoding.TextMarshaler. -func (z ZoneOffset) MarshalText() ([]byte, error) { - return marshalTextImpl(z) -} - -// UnmarshalText implements encoding.TextUnmarshaler. -func (z *ZoneOffset) UnmarshalText(text []byte) error { - s := string(text) - if len(s) == 0 { - return newError("zone offset cannot be empty") - } - - // Handle UTC - if s == "Z" || s == "z" { - *z = ZoneOffsetUTC() - return nil - } - - // Must start with + or - - if s[0] != '+' && s[0] != '-' { - return newError("zone offset must start with + or -, got %q", s) - } - - negative := s[0] == '-' - s = s[1:] // Remove sign - - var hours, minutes, seconds int - var err error - - // Determine format based on length and colons - if len(s) == 0 { - return newError("zone offset has no digits after sign") - } - - // Check for colon-separated format - hasColon := false - for i := 0; i < len(s); i++ { - if s[i] == ':' { - hasColon = true - break - } - } - - if hasColon { - // Colon-separated format: HH:MM or HH:MM:SS or H:MM - parts := []string{} - start := 0 - for i := 0; i <= len(s); i++ { - if i == len(s) || s[i] == ':' { - if i > start { - parts = append(parts, s[start:i]) - } - start = i + 1 - } - } - - if len(parts) < 2 || len(parts) > 3 { - return newError("invalid zone offset format %q", string(text)) - } - - hours, err = strconv.Atoi(parts[0]) - if err != nil { - return newError("invalid zone offset hours: %v", err) - } - - minutes, err = strconv.Atoi(parts[1]) - if err != nil { - return newError("invalid zone offset minutes: %v", err) - } - - if len(parts) == 3 { - seconds, err = strconv.Atoi(parts[2]) - if err != nil { - return newError("invalid zone offset seconds: %v", err) - } - } - } else { - // Compact format: H, HH, HHMM, or HHMMSS - switch len(s) { - case 1, 2: // H or HH - hours, err = strconv.Atoi(s) - if err != nil { - return newError("invalid zone offset hours: %v", err) - } - case 4: // HHMM - hours, err = strconv.Atoi(s[0:2]) - if err != nil { - return newError("invalid zone offset hours: %v", err) - } - minutes, err = strconv.Atoi(s[2:4]) - if err != nil { - return newError("invalid zone offset minutes: %v", err) - } - case 6: // HHMMSS - hours, err = strconv.Atoi(s[0:2]) - if err != nil { - return newError("invalid zone offset hours: %v", err) - } - minutes, err = strconv.Atoi(s[2:4]) - if err != nil { - return newError("invalid zone offset minutes: %v", err) - } - seconds, err = strconv.Atoi(s[4:6]) - if err != nil { - return newError("invalid zone offset seconds: %v", err) - } - default: - return newError("invalid zone offset format %q", string(text)) - } - } - - // Apply sign - if negative { - hours = -hours - minutes = -minutes - seconds = -seconds - } - - offset, err := ZoneOffsetOf(hours, minutes, seconds) - if err != nil { - return err - } - - *z = offset - return nil -} - -// MarshalJSON implements json.Marshaler. -func (z ZoneOffset) MarshalJSON() ([]byte, error) { - return marshalJsonImpl(z) -} - -// UnmarshalJSON implements json.Unmarshaler. -func (z *ZoneOffset) UnmarshalJSON(data []byte) error { - return unmarshalJsonImpl(z, data) -} - // ZoneOffsetUTC returns UTC offset (+00:00). func ZoneOffsetUTC() ZoneOffset { return ZoneOffset{totalSeconds: 0} @@ -281,11 +104,13 @@ func ZoneOffsetMax() ZoneOffset { // The offset must be in the range -18:00 to +18:00, which corresponds to -64800 to +64800 seconds. // // Returns an error if the offset is outside the valid range. -func ZoneOffsetOfSeconds(seconds int) (ZoneOffset, error) { - if seconds > 18*3600 || seconds < -18*3600 { - return ZoneOffset{}, newError("zone offset seconds must be in range -64800 to +64800, got %d", seconds) +func ZoneOffsetOfSeconds(seconds int) (r ZoneOffset, e error) { + FieldOffsetSeconds.checkSetE(int64(seconds), &e) + if e != nil { + return } - return ZoneOffset{totalSeconds: int32(seconds)}, nil + r.totalSeconds = int32(seconds) + return } // MustZoneOffsetOfSeconds creates a ZoneOffset from the total offset in seconds. @@ -301,37 +126,39 @@ func MustZoneOffsetOfSeconds(seconds int) ZoneOffset { // all non-zero components must be negative or zero. // // Returns an error if the offset is invalid. -func ZoneOffsetOf(hours, minutes, seconds int) (ZoneOffset, error) { +func ZoneOffsetOf(hours, minutes, seconds int) (r ZoneOffset, e error) { // Validate that signs are consistent if hours < 0 { if minutes > 0 || seconds > 0 { - return ZoneOffset{}, newError("zone offset minutes and seconds must not be positive when hours is negative") + e = newError("zone offset minutes and seconds must not be positive when hours is negative") } } else if hours > 0 { if minutes < 0 || seconds < 0 { - return ZoneOffset{}, newError("zone offset minutes and seconds must not be negative when hours is positive") + e = newError("zone offset minutes and seconds must not be negative when hours is positive") } } else if minutes < 0 { if seconds > 0 { - return ZoneOffset{}, newError("zone offset seconds must not be positive when minutes is negative") + e = newError("zone offset seconds must not be positive when minutes is negative") } } else if minutes > 0 { if seconds < 0 { - return ZoneOffset{}, newError("zone offset seconds must not be negative when minutes is positive") + e = newError("zone offset seconds must not be negative when minutes is positive") } } // Validate ranges if hours < -18 || hours > 18 { - return ZoneOffset{}, newError("zone offset hours must be in range -18 to +18, got %d", hours) + e = newError("zone offset hours must be in range -18 to +18, got %d", hours) } if minutes < -59 || minutes > 59 { - return ZoneOffset{}, newError("zone offset minutes must be in range -59 to +59, got %d", minutes) + e = newError("zone offset minutes must be in range -59 to +59, got %d", minutes) } if seconds < -59 || seconds > 59 { - return ZoneOffset{}, newError("zone offset seconds must be in range -59 to +59, got %d", seconds) + e = newError("zone offset seconds must be in range -59 to +59, got %d", seconds) + } + if e != nil { + return } - totalSeconds := hours*3600 + minutes*60 + seconds return ZoneOffsetOfSeconds(totalSeconds) } diff --git a/zone_offset_test.go b/zoneoffset_test.go similarity index 100% rename from zone_offset_test.go rename to zoneoffset_test.go diff --git a/zoneoffset_text.go b/zoneoffset_text.go new file mode 100644 index 0000000..f5dfc8a --- /dev/null +++ b/zoneoffset_text.go @@ -0,0 +1,183 @@ +package goda + +import ( + "fmt" + "strconv" +) + +// String returns the string representation of the zone offset. +// Returns "Z" for UTC, otherwise returns the format ±HH:MM or ±HH:MM:SS. +func (z ZoneOffset) String() string { + return stringImpl(z) +} + +// AppendText implements encoding.TextAppender. +func (z ZoneOffset) AppendText(b []byte) ([]byte, error) { + if z.totalSeconds == 0 { + return append(b, 'Z'), nil + } + + absSeconds := z.totalSeconds + if absSeconds < 0 { + b = append(b, '-') + absSeconds = -absSeconds + } else { + b = append(b, '+') + } + + hours := absSeconds / 3600 + minutes := (absSeconds % 3600) / 60 + seconds := absSeconds % 60 + + // Format hours (always 2 digits) + b = append(b, byte('0'+hours/10), byte('0'+hours%10)) + b = append(b, ':') + // Format minutes (always 2 digits) + b = append(b, byte('0'+minutes/10), byte('0'+minutes%10)) + + // Only append seconds if non-zero + if seconds != 0 { + b = append(b, ':') + b = append(b, byte('0'+seconds/10), byte('0'+seconds%10)) + } + + return b, nil +} + +// MarshalText implements encoding.TextMarshaler. +func (z ZoneOffset) MarshalText() ([]byte, error) { + return marshalTextImpl(z) +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (z *ZoneOffset) UnmarshalText(text []byte) (e error) { + defer deferOpInParse(text, &e) + s := string(text) + if len(s) == 0 { + return fmt.Errorf("zone offset cannot be empty") + } + + // Handle UTC + if s == "Z" || s == "z" { + *z = ZoneOffsetUTC() + return nil + } + + // Must start with + or - + if s[0] != '+' && s[0] != '-' { + return fmt.Errorf("zone offset must start with + or -, got %q", s) + } + + negative := s[0] == '-' + s = s[1:] // Remove sign + + var hours, minutes, seconds int + var err error + + // Determine format based on length and colons + if len(s) == 0 { + return fmt.Errorf("zone offset has no digits after sign") + } + + // check for colon-separated format + hasColon := false + for i := 0; i < len(s); i++ { + if s[i] == ':' { + hasColon = true + break + } + } + + if hasColon { + // Colon-separated format: HH:MM or HH:MM:SS or H:MM + var parts []string + start := 0 + for i := 0; i <= len(s); i++ { + if i == len(s) || s[i] == ':' { + if i > start { + parts = append(parts, s[start:i]) + } + start = i + 1 + } + } + + if len(parts) < 2 || len(parts) > 3 { + return fmt.Errorf("invalid zone offset format %q", string(text)) + } + + hours, err = strconv.Atoi(parts[0]) + if err != nil { + return fmt.Errorf("invalid zone offset hours: %v", err) + } + + minutes, err = strconv.Atoi(parts[1]) + if err != nil { + return fmt.Errorf("invalid zone offset minutes: %v", err) + } + + if len(parts) == 3 { + seconds, err = strconv.Atoi(parts[2]) + if err != nil { + return fmt.Errorf("invalid zone offset seconds: %v", err) + } + } + } else { + // Compact format: H, HH, HHMM, or HHMMSS + switch len(s) { + case 1, 2: // H or HH + hours, err = strconv.Atoi(s) + if err != nil { + return fmt.Errorf("invalid zone offset hours: %v", err) + } + case 4: // HHMM + hours, err = strconv.Atoi(s[0:2]) + if err != nil { + return fmt.Errorf("invalid zone offset hours: %v", err) + } + minutes, err = strconv.Atoi(s[2:4]) + if err != nil { + return fmt.Errorf("invalid zone offset minutes: %v", err) + } + case 6: // HHMMSS + hours, err = strconv.Atoi(s[0:2]) + if err != nil { + return fmt.Errorf("invalid zone offset hours: %v", err) + } + minutes, err = strconv.Atoi(s[2:4]) + if err != nil { + return fmt.Errorf("invalid zone offset minutes: %v", err) + } + seconds, err = strconv.Atoi(s[4:6]) + if err != nil { + return fmt.Errorf("invalid zone offset seconds: %v", err) + } + default: + return fmt.Errorf("invalid zone offset format %q", string(text)) + } + } + + // Apply sign + if negative { + hours = -hours + minutes = -minutes + seconds = -seconds + } + + offset, err := ZoneOffsetOf(hours, minutes, seconds) + if err != nil { + return err + } + + *z = offset + return nil +} + +// MarshalJSON implements json.Marshaler. +func (z ZoneOffset) MarshalJSON() ([]byte, error) { + return marshalJsonImpl(z) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (z *ZoneOffset) UnmarshalJSON(data []byte) error { + return unmarshalJsonImpl(z, data) +}