├── .github ├── CODEOWNERS ├── dependabot.yaml └── workflows │ └── ci.yaml ├── .golangci.yaml ├── LICENSE ├── README.md ├── convert.go ├── convert_test.go ├── date.go ├── date_test.go ├── doc.go ├── generic.go ├── go.mod ├── go.sum ├── must.go ├── must_test.go ├── null.go ├── null_test.go ├── time_vendor.go ├── today.go └── today_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | ### NOTE: The paths in this file match `.gitignore` file patterns. 2 | ### See: 3 | ### - https://git-scm.com/docs/gitignore#_pattern_format 4 | ### - https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-syntax 5 | 6 | # Global / default owners; a later match takes precedence (the last matching 7 | # pattern takes the most precedence). 8 | * @hardfinhq/public-go-codeowners 9 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Hardfin, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | --- 15 | version: 2 16 | updates: 17 | - package-ecosystem: github-actions 18 | directory: / 19 | assignees: 20 | - weisbartb 21 | schedule: 22 | interval: weekly 23 | 24 | - package-ecosystem: gomod 25 | directory: / 26 | assignees: 27 | - weisbartb 28 | schedule: 29 | interval: weekly 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Hardfin, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | --- 15 | name: CI 16 | 17 | on: 18 | pull_request: 19 | branches: 20 | - main 21 | push: 22 | branches: 23 | - main 24 | 25 | concurrency: 26 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 27 | cancel-in-progress: true 28 | 29 | env: 30 | GO_LATEST: "1.22.1" 31 | 32 | jobs: 33 | go-test: 34 | runs-on: 35 | - ubuntu-22.04 36 | strategy: 37 | matrix: 38 | go-version: 39 | - "1.21.8" 40 | - "go-latest" 41 | timeout-minutes: 5 42 | 43 | steps: 44 | - name: Checkout 🛎 45 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 46 | 47 | - name: Install Go 48 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 49 | with: 50 | go-version: ${{ matrix.go-version == 'go-latest' && env.GO_LATEST || matrix.go-version }} 51 | 52 | - name: Go test 53 | run: | 54 | go test -race -covermode=atomic -coverprofile=coverage.out ./... 55 | 56 | - name: golangci-lint 57 | uses: golangci/golangci-lint-action@2226d7cb06a077cd73e56eedd38eecad18e5d837 # v6.5.0 58 | with: 59 | version: v1.56.1 60 | skip-cache: "true" 61 | args: "--verbose --timeout=2m" 62 | 63 | - name: Upload coverage reports to Codecov 64 | if: ${{ matrix.go-version == 'go-latest' }} 65 | uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 66 | with: 67 | token: ${{ secrets.CODECOV_TOKEN }} 68 | slug: hardfinhq/go-date 69 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Hardfin, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | --- 15 | linters: 16 | disable-all: true 17 | enable: 18 | - errcheck 19 | - gofumpt 20 | - goheader 21 | - gosimple 22 | - govet 23 | - ineffassign 24 | - revive 25 | - staticcheck 26 | - unused 27 | issues: 28 | include: 29 | - EXC0012 # EXC0012 revive 30 | - EXC0013 # EXC0013 revive 31 | - EXC0014 # EXC0014 revive 32 | - EXC0015 # EXC0015 revive 33 | linters-settings: 34 | errcheck: 35 | check-type-assertions: true 36 | check-blank: true 37 | exclude-functions: 38 | - (net/http.ResponseWriter).Write 39 | - io.WriteString(net/http.ResponseWriter) 40 | goheader: 41 | values: 42 | regexp: 43 | VALID_YEAR: 2023|2024 44 | template: |- 45 | Copyright {{ VALID_YEAR }} Hardfin, Inc. 46 | 47 | Licensed under the Apache License, Version 2.0 (the "License"); 48 | you may not use this file except in compliance with the License. 49 | You may obtain a copy of the License at 50 | 51 | https://www.apache.org/licenses/LICENSE-2.0 52 | 53 | Unless required by applicable law or agreed to in writing, software 54 | distributed under the License is distributed on an "AS IS" BASIS, 55 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 56 | See the License for the specific language governing permissions and 57 | limitations under the License. 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `go-date` 2 | 3 | [![GoDoc][1]][2] 4 | [![Go ReportCard][3]][4] 5 | [![Build Status][8]][9] 6 | [![codecov][12]][13] 7 | 8 | The `go-date` package provides a dedicated `Date{}` struct to emulate the 9 | standard library `time.Time{}` behavior. 10 | 11 | ## API 12 | 13 | This package provides helpers for: 14 | 15 | - conversion: `ToTime()`, `date.FromTime()`, `date.FromString()` 16 | - serialization: text, JSON, and SQL 17 | - emulating `time.Time{}`: `After()`, `Before()`, `Sub()`, etc. 18 | - explicit null handling: `NullDate{}` and an analog of `sql.NullTime{}` 19 | - emulating `time` helpers: `Today()` as an analog of `time.Now()` 20 | 21 | ## Background 22 | 23 | The Go standard library contains no native type for dates without times. 24 | Instead, common convention is to use a `time.Time{}` with only the year, month, 25 | and day set. For example, this convention is followed when a timestamp of the 26 | form YYYY-MM-DD is parsed via `time.Parse(time.DateOnly, value)`. 27 | 28 | ## Conversion 29 | 30 | For cases where existing code produces a "conventional" 31 | `time.Date(YYYY, MM, DD, 0, 0, 0, 0, time.UTC)` value, it can be validated 32 | and converted to a `Date{}` via: 33 | 34 | ```go 35 | t := time.Date(2024, time.March, 1, 0, 0, 0, 0, time.UTC) 36 | d, err := date.FromTime(t) 37 | fmt.Println(d, err) 38 | // 2024-03-01 39 | ``` 40 | 41 | If there is any deviation from the "conventional" format, this will error. 42 | For example: 43 | 44 | ```text 45 | timestamp contains more than just date information; 2020-05-11T01:00:00Z 46 | timestamp contains more than just date information; 2022-01-31T00:00:00-05:00 47 | ``` 48 | 49 | For cases where we have a discrete timestamp (e.g. "last updated datetime") and 50 | a relevant timezone for a given request, we can extract the date within that 51 | timezone: 52 | 53 | ```go 54 | t := time.Date(2023, time.April, 14, 3, 55, 4, 777000100, time.UTC) 55 | tz, _ := time.LoadLocation("America/Chicago") 56 | d := date.InTimezone(t, tz) 57 | fmt.Println(d) 58 | // 2023-04-13 59 | ``` 60 | 61 | For conversion in the **other** direction, a `Date{}` can be converted back 62 | into a `time.Time{}`: 63 | 64 | ```go 65 | d := date.NewDate(2017, time.July, 3) 66 | t := d.ToTime() 67 | fmt.Println(t) 68 | // 2017-07-03 00:00:00 +0000 UTC 69 | ``` 70 | 71 | By default this will use the "conventional" format, but any of the values 72 | (other than year, month, day) can also be set: 73 | 74 | ```go 75 | d := date.NewDate(2017, time.July, 3) 76 | tz, _ := time.LoadLocation("America/Chicago") 77 | t := d.ToTime(date.OptConvertHour(12), date.OptConvertTimezone(tz)) 78 | fmt.Println(t) 79 | // 2017-07-03 12:00:00 -0500 CDT 80 | ``` 81 | 82 | ## Equivalent methods 83 | 84 | There are a number of methods from `time.Time{}` that directly translate over: 85 | 86 | ```go 87 | d := date.NewDate(2020, time.February, 29) 88 | fmt.Println(d.Year) 89 | // 2020 90 | fmt.Println(d.Month) 91 | // February 92 | fmt.Println(d.Day) 93 | // 29 94 | fmt.Println(d.ISOWeek()) 95 | // 2020 9 96 | fmt.Println(d.Weekday()) 97 | // Saturday 98 | fmt.Println(d.YearDay()) 99 | // 60 100 | fmt.Println(d.Date()) 101 | // 2020 February 29 102 | 103 | fmt.Println(d.IsZero()) 104 | // false 105 | fmt.Println(d.String()) 106 | // 2020-02-29 107 | fmt.Println(d.Format("Jan 2006")) 108 | // Feb 2020 109 | fmt.Println(d.GoString()) 110 | // date.NewDate(2020, time.February, 29) 111 | 112 | d2 := date.NewDate(2021, time.February, 28) 113 | fmt.Println(d2.Equal(d)) 114 | // false 115 | fmt.Println(d2.Before(d)) 116 | // false 117 | fmt.Println(d2.After(d)) 118 | // true 119 | fmt.Println(d2.Compare(d)) 120 | // 1 121 | ``` 122 | 123 | However, some methods translate over only approximately. For example, it's much 124 | more natural for `Sub()` to return the **number of days** between two dates: 125 | 126 | ```go 127 | d := date.NewDate(2020, time.February, 29) 128 | d2 := date.NewDate(2021, time.February, 28) 129 | fmt.Println(d2.Sub(d)) 130 | // 365 131 | ``` 132 | 133 | ## Divergent methods 134 | 135 | We've elected to **translate** the `time.Time{}.AddDate()` method rather 136 | than providing it directly: 137 | 138 | ```go 139 | d := date.NewDate(2020, time.February, 29) 140 | fmt.Println(d.AddDays(1)) 141 | // 2020-03-01 142 | fmt.Println(d.AddDays(100)) 143 | // 2020-06-08 144 | fmt.Println(d.AddMonths(1)) 145 | // 2020-03-29 146 | fmt.Println(d.AddMonths(3)) 147 | // 2020-05-29 148 | fmt.Println(d.AddYears(1)) 149 | // 2021-02-28 150 | ``` 151 | 152 | This is in part because of the behavior of the standard library's 153 | `AddDate()`. In particular, it "overflows" a target month if the number 154 | of days in that month is less than the number of desired days. As a result, 155 | we provide `*Stdlib()` variants of the date addition helpers: 156 | 157 | ```go 158 | d := date.NewDate(2020, time.February, 29) 159 | fmt.Println(d.AddMonths(12)) 160 | // 2021-02-28 161 | fmt.Println(d.AddMonthsStdlib(12)) 162 | // 2021-03-01 163 | fmt.Println(d.AddYears(1)) 164 | // 2021-02-28 165 | fmt.Println(d.AddYearsStdlib(1)) 166 | // 2021-03-01 167 | ``` 168 | 169 | In the same line of thinking as the divergent `AddMonths()` behavior, a 170 | `MonthEnd()` method is provided that can pinpoint the number of days in 171 | the current month: 172 | 173 | ```go 174 | d := date.NewDate(2022, time.January, 14) 175 | fmt.Println(d.MonthEnd()) 176 | // 2022-01-31 177 | fmt.Println(d.MonthStart()) 178 | // 2022-01-01 179 | ``` 180 | 181 | ## Integrating with `sqlc` 182 | 183 | Out of the box, the `sqlc` [library][10] uses a Go `time.Time{}` both for 184 | columns of type `TIMESTAMPTZ` and `DATE`. When reading `DATE` values (which come 185 | over the wire in the form YYYY-MM-DD), the Go standard library produces values 186 | of the form: 187 | 188 | ```go 189 | time.Date(YYYY, MM, DD, 0, 0, 0, 0, time.UTC) 190 | ``` 191 | 192 | Instead, we can instruct `sqlc` to **globally** use `date.Date` and 193 | `date.NullDate` when parsing `DATE` columns: 194 | 195 | ```yaml 196 | --- 197 | version: "2" 198 | overrides: 199 | go: 200 | overrides: 201 | - go_type: 202 | import: github.com/hardfinhq/go-date 203 | package: date 204 | type: NullDate 205 | db_type: date 206 | nullable: true 207 | - go_type: 208 | import: github.com/hardfinhq/go-date 209 | package: date 210 | type: Date 211 | db_type: date 212 | nullable: false 213 | ``` 214 | 215 | ## Alternatives 216 | 217 | This package is intended to be simple to understand and only needs to cover 218 | "modern" dates (i.e. dates between 1900 and 2100). As a result, the core 219 | `Date{}` struct directly exposes the year, month, and day as fields. 220 | 221 | There are several alternative date packages which cover wider date ranges. 222 | (These packages all use the [proleptic Gregorian calendar][6] to cover the 223 | historical date ranges.) Some existing packages: 224 | 225 | - `cloud.google.com/go/civil` [package][14] 226 | - `github.com/fxtlabs/date` [package][7] 227 | - `github.com/rickb777/date` [package][5] 228 | 229 | Additionally, there is a `Date{}` type provided by the `github.com/jackc/pgtype` 230 | [package][11] that is part of the `pgx` ecosystem. However, this type is very 231 | focused on being useful for database serialization and deserialization and 232 | doesn't implement a wider set of methods present on `time.Time{}` (e.g. 233 | `After()`). 234 | 235 | [1]: https://godoc.org/github.com/hardfinhq/go-date?status.svg 236 | [2]: http://godoc.org/github.com/hardfinhq/go-date 237 | [3]: https://goreportcard.com/badge/hardfinhq/go-date 238 | [4]: https://goreportcard.com/report/hardfinhq/go-date 239 | [5]: https://pkg.go.dev/github.com/rickb777/date 240 | [6]: https://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar 241 | [7]: https://pkg.go.dev/github.com/fxtlabs/date 242 | [8]: https://github.com/hardfinhq/go-date/actions/workflows/ci.yaml/badge.svg?branch=main 243 | [9]: https://github.com/hardfinhq/go-date/actions/workflows/ci.yaml 244 | [10]: https://docs.sqlc.dev 245 | [11]: https://pkg.go.dev/github.com/jackc/pgtype 246 | [12]: https://codecov.io/gh/hardfinhq/go-date/graph/badge.svg?token=MBWYQ3W2RM 247 | [13]: https://codecov.io/gh/hardfinhq/go-date 248 | [14]: https://pkg.go.dev/cloud.google.com/go/civil 249 | -------------------------------------------------------------------------------- /convert.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Hardfin, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package date 16 | 17 | import ( 18 | "database/sql" 19 | "fmt" 20 | "time" 21 | ) 22 | 23 | // ConvertConfig helps customize the behavior of conversion functions like 24 | // `NullTimeFromPtr()`. 25 | // 26 | // It allows setting the fields in a `time.Time{}` **other** than year, month, 27 | // and day (i.e. the fields that aren't present in a date). By default, these 28 | // are: 29 | // - hour=0 30 | // - minute=0 31 | // - second=0 32 | // - nanosecond=0 33 | // - timezone/loc=time.UTC 34 | type ConvertConfig struct { 35 | Hour int 36 | Minute int 37 | Second int 38 | Nanosecond int 39 | Timezone *time.Location 40 | } 41 | 42 | // ConvertOption defines a function that will be applied to a convert config. 43 | type ConvertOption func(*ConvertConfig) 44 | 45 | // OptConvertHour returns an option that sets the hour on a convert config. 46 | func OptConvertHour(hour int) ConvertOption { 47 | return func(cc *ConvertConfig) { 48 | cc.Hour = hour 49 | } 50 | } 51 | 52 | // OptConvertMinute returns an option that sets the minute on a convert config. 53 | func OptConvertMinute(minute int) ConvertOption { 54 | return func(cc *ConvertConfig) { 55 | cc.Minute = minute 56 | } 57 | } 58 | 59 | // OptConvertSecond returns an option that sets the second on a convert config. 60 | func OptConvertSecond(second int) ConvertOption { 61 | return func(cc *ConvertConfig) { 62 | cc.Second = second 63 | } 64 | } 65 | 66 | // OptConvertNanosecond returns an option that sets the nanosecond on a convert 67 | // config. 68 | func OptConvertNanosecond(nanosecond int) ConvertOption { 69 | return func(cc *ConvertConfig) { 70 | cc.Nanosecond = nanosecond 71 | } 72 | } 73 | 74 | // OptConvertTimezone returns an option that sets the timezone on a convert 75 | // config. 76 | func OptConvertTimezone(tz *time.Location) ConvertOption { 77 | return func(cc *ConvertConfig) { 78 | cc.Timezone = tz 79 | } 80 | } 81 | 82 | // NullDateFromPtr converts a `Date` pointer into a `NullDate`. 83 | func NullDateFromPtr(d *Date) NullDate { 84 | if d == nil { 85 | return NullDate{Valid: false} 86 | } 87 | 88 | return NullDate{Date: *d, Valid: true} 89 | } 90 | 91 | // NullTimeFromPtr converts a date to a native Go `sql.NullTime`; the 92 | // convention in Go is that a **date-only** is parsed (via `time.DateOnly`) as 93 | // `time.Date(YYYY, MM, DD, 0, 0, 0, 0, time.UTC)`. 94 | func NullTimeFromPtr(d *Date, opts ...ConvertOption) sql.NullTime { 95 | if d == nil { 96 | return sql.NullTime{Valid: false} 97 | } 98 | 99 | t := d.ToTime(opts...) 100 | return sql.NullTime{Time: t, Valid: true} 101 | } 102 | 103 | // FromString parses a string of the form YYYY-MM-DD into a `Date{}`. 104 | func FromString(s string) (Date, error) { 105 | t, err := time.Parse(time.DateOnly, s) 106 | if err != nil { 107 | return Date{}, err 108 | } 109 | 110 | year, month, day := t.Date() 111 | d := Date{Year: year, Month: month, Day: day} 112 | return d, nil 113 | } 114 | 115 | // FromTime validates that a `time.Time{}` contains a date and converts it to a 116 | // `Date{}`. 117 | func FromTime(t time.Time) (Date, error) { 118 | _, offset := t.Zone() 119 | 120 | if t.Hour() != 0 || 121 | t.Minute() != 0 || 122 | t.Second() != 0 || 123 | t.Nanosecond() != 0 || 124 | offset != 0 { 125 | return Date{}, fmt.Errorf("timestamp contains more than just date information; %s", t.Format(time.RFC3339Nano)) 126 | } 127 | 128 | year, month, day := t.Date() 129 | d := Date{Year: year, Month: month, Day: day} 130 | return d, nil 131 | } 132 | 133 | // InTimezone translates a timestamp into a timezone and then captures the date 134 | // in that timezone. 135 | func InTimezone(t time.Time, tz *time.Location) Date { 136 | tLocal := t.In(tz) 137 | year, month, day := tLocal.Date() 138 | return Date{Year: year, Month: month, Day: day} 139 | } 140 | -------------------------------------------------------------------------------- /convert_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Hardfin, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package date_test 16 | 17 | import ( 18 | "database/sql" 19 | "fmt" 20 | "testing" 21 | "time" 22 | 23 | testifyrequire "github.com/stretchr/testify/require" 24 | 25 | date "github.com/hardfinhq/go-date" 26 | ) 27 | 28 | func TestNullDateFromPtr(t *testing.T) { 29 | t.Parallel() 30 | assert := testifyrequire.New(t) 31 | 32 | d1 := &date.Date{Year: 2000, Month: time.January, Day: 1} 33 | nd1 := date.NullDateFromPtr(d1) 34 | expected := date.NullDate{Date: *d1, Valid: true} 35 | assert.Equal(expected, nd1) 36 | 37 | var d2 *date.Date 38 | nd2 := date.NullDateFromPtr(d2) 39 | expected = date.NullDate{Valid: false} 40 | assert.Equal(expected, nd2) 41 | } 42 | 43 | func TestNullTimeFromPtr(t *testing.T) { 44 | t.Parallel() 45 | assert := testifyrequire.New(t) 46 | 47 | var d *date.Date 48 | nt := date.NullTimeFromPtr(d) 49 | expected := sql.NullTime{Valid: false} 50 | assert.Equal(expected, nt) 51 | 52 | d = &date.Date{Year: 2000, Month: time.January, Day: 1} 53 | nt = date.NullTimeFromPtr(d) 54 | expected = sql.NullTime{Time: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), Valid: true} 55 | assert.Equal(expected, nt) 56 | 57 | tz, err := time.LoadLocation("America/Chicago") 58 | assert.Nil(err) 59 | nt = date.NullTimeFromPtr(d, date.OptConvertTimezone(tz)) 60 | expected = sql.NullTime{Time: time.Date(2000, time.January, 1, 0, 0, 0, 0, tz), Valid: true} 61 | assert.Equal(expected, nt) 62 | 63 | nt = date.NullTimeFromPtr( 64 | d, 65 | date.OptConvertHour(12), 66 | date.OptConvertMinute(30), 67 | date.OptConvertSecond(35), 68 | date.OptConvertNanosecond(123456789), 69 | ) 70 | expected = sql.NullTime{Time: time.Date(2000, time.January, 1, 12, 30, 35, 123456789, time.UTC), Valid: true} 71 | assert.Equal(expected, nt) 72 | } 73 | 74 | func TestFromTime(base *testing.T) { 75 | base.Parallel() 76 | 77 | type testCase struct { 78 | Time string 79 | Date date.Date 80 | Timezone timezoneMetadata 81 | Error string 82 | } 83 | 84 | cases := []testCase{ 85 | { 86 | Time: "2022-01-31T00:00:00.000Z", 87 | Date: date.Date{Year: 2022, Month: time.January, Day: 31}, 88 | }, 89 | { 90 | Time: "2020-05-11T07:10:55.209309302Z", 91 | Error: "timestamp contains more than just date information; 2020-05-11T07:10:55.209309302Z", 92 | }, 93 | { 94 | Time: "2022-01-31T00:00:00.000-05:00", 95 | Timezone: timezoneMetadata{Name: valueToPtr(""), Offset: valueToPtr(-18000)}, 96 | Error: "timestamp contains more than just date information; 2022-01-31T00:00:00-05:00", 97 | }, 98 | { 99 | Time: "2022-01-31T05:00:00.000Z", 100 | Timezone: timezoneMetadata{InTimezone: valueToPtr("America/New_York"), Name: valueToPtr("EST"), Offset: valueToPtr(-18000)}, 101 | Error: "timestamp contains more than just date information; 2022-01-31T00:00:00-05:00", 102 | }, 103 | { 104 | Time: "2024-01-11T00:00:00.000-06:00", 105 | Timezone: timezoneMetadata{Name: valueToPtr(""), Offset: valueToPtr(-21600)}, 106 | Error: "timestamp contains more than just date information; 2024-01-11T00:00:00-06:00", 107 | }, 108 | { 109 | Time: "2024-04-11T00:00:00.000-05:00", 110 | Timezone: timezoneMetadata{Name: valueToPtr(""), Offset: valueToPtr(-18000)}, 111 | Error: "timestamp contains more than just date information; 2024-04-11T00:00:00-05:00", 112 | }, 113 | { 114 | Time: "2024-04-11T05:00:00.000Z", 115 | Timezone: timezoneMetadata{InTimezone: valueToPtr("America/Chicago"), Name: valueToPtr("CDT"), Offset: valueToPtr(-18000)}, 116 | Error: "timestamp contains more than just date information; 2024-04-11T00:00:00-05:00", 117 | }, 118 | { 119 | Time: "2020-05-11T00:00:00.000000001Z", 120 | Error: "timestamp contains more than just date information; 2020-05-11T00:00:00.000000001Z", 121 | }, 122 | { 123 | Time: "2020-05-11T00:00:01Z", 124 | Error: "timestamp contains more than just date information; 2020-05-11T00:00:01Z", 125 | }, 126 | { 127 | Time: "2020-05-11T00:01:00Z", 128 | Error: "timestamp contains more than just date information; 2020-05-11T00:01:00Z", 129 | }, 130 | { 131 | Time: "2020-05-11T01:00:00Z", 132 | Error: "timestamp contains more than just date information; 2020-05-11T01:00:00Z", 133 | }, 134 | } 135 | 136 | for i := range cases { 137 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 138 | // `range`) to avoid capturing reference to loop variable. 139 | tc := cases[i] 140 | base.Run(tc.Time, func(t *testing.T) { 141 | t.Parallel() 142 | assert := testifyrequire.New(t) 143 | 144 | timestamp, err := time.Parse(time.RFC3339Nano, tc.Time) 145 | assert.Nil(err) 146 | 147 | timestamp = tc.Timezone.In(assert, timestamp) 148 | 149 | name, offset := timestamp.Zone() 150 | assert.Equal(tc.Timezone.ExpectedName(timestamp), name) 151 | assert.Equal(tc.Timezone.ExpectedOffset(), offset) 152 | 153 | d, err := date.FromTime(timestamp) 154 | if tc.Error == "" { 155 | assert.Nil(err) 156 | assert.Equal(tc.Date, d) 157 | } else { 158 | assert.Equal(tc.Error, fmt.Sprintf("%v", err)) 159 | assert.Equal(date.Date{}, d) 160 | } 161 | }) 162 | } 163 | } 164 | 165 | func TestFromTime_UTCVariants(t *testing.T) { 166 | t.Parallel() 167 | assert := testifyrequire.New(t) 168 | 169 | // 1. Vanilla UTC 170 | tz := time.UTC 171 | dt := time.Date(2022, time.January, 31, 0, 0, 0, 0, tz) 172 | d, err := date.FromTime(dt) 173 | assert.Nil(err) 174 | assert.Equal(date.Date{Year: 2022, Month: time.January, Day: 31}, d) 175 | 176 | // 2. +00:00 177 | dt, err = time.Parse(time.RFC3339Nano, "1970-01-01T00:00:00.000+00:00") 178 | assert.Nil(err) 179 | tz = dt.Location() 180 | dt = time.Date(2022, time.January, 31, 0, 0, 0, 0, tz) 181 | d, err = date.FromTime(dt) 182 | assert.Nil(err) 183 | assert.Equal(date.Date{Year: 2022, Month: time.January, Day: 31}, d) 184 | 185 | // 3. Fixed (no name; offset = 0) 186 | tz = time.FixedZone("", 0) 187 | dt = time.Date(2022, time.January, 31, 0, 0, 0, 0, tz) 188 | d, err = date.FromTime(dt) 189 | assert.Nil(err) 190 | assert.Equal(date.Date{Year: 2022, Month: time.January, Day: 31}, d) 191 | 192 | // 4. Fixed (no name; offset != 0) 193 | tz = time.FixedZone("", -18000) 194 | dt = time.Date(2022, time.January, 31, 0, 0, 0, 0, tz) 195 | d, err = date.FromTime(dt) 196 | assert.Equal(date.Date{}, d) 197 | assert.NotNil(err) 198 | assert.Equal("timestamp contains more than just date information; 2022-01-31T00:00:00-05:00", fmt.Sprintf("%v", err)) 199 | 200 | // 5. Fixed (named; offset = 0) 201 | tz = time.FixedZone("GMT-fixed", 0) 202 | dt = time.Date(2022, time.January, 31, 0, 0, 0, 0, tz) 203 | d, err = date.FromTime(dt) 204 | assert.Nil(err) 205 | assert.Equal(date.Date{Year: 2022, Month: time.January, Day: 31}, d) 206 | 207 | // 6. Fixed (named; offset != 0) 208 | tz = time.FixedZone("GMT-fixed", -18000) 209 | dt = time.Date(2022, time.January, 31, 0, 0, 0, 0, tz) 210 | d, err = date.FromTime(dt) 211 | assert.Equal(date.Date{}, d) 212 | assert.NotNil(err) 213 | assert.Equal("timestamp contains more than just date information; 2022-01-31T00:00:00-05:00", fmt.Sprintf("%v", err)) 214 | 215 | // 7. Load GMT (not UTC) timezone 216 | tz, err = time.LoadLocation("GMT") 217 | assert.Nil(err) 218 | dt = time.Date(2022, time.January, 31, 0, 0, 0, 0, tz) 219 | d, err = date.FromTime(dt) 220 | assert.Nil(err) 221 | assert.Equal(date.Date{Year: 2022, Month: time.January, Day: 31}, d) 222 | } 223 | 224 | func TestInTimezone(base *testing.T) { 225 | base.Parallel() 226 | 227 | type testCase struct { 228 | Time string 229 | Timezone string 230 | Date string 231 | } 232 | 233 | cases := []testCase{ 234 | {Time: "2024-02-01T06:41:35.540349Z", Timezone: "America/Los_Angeles", Date: "2024-01-31"}, 235 | {Time: "2024-02-01T06:41:35.540349Z", Timezone: "America/Denver", Date: "2024-01-31"}, 236 | {Time: "2024-02-01T06:41:35.540349Z", Timezone: "America/Chicago", Date: "2024-02-01"}, 237 | {Time: "2024-02-01T06:41:35.540349Z", Timezone: "America/New_York", Date: "2024-02-01"}, 238 | {Time: "2024-02-01T06:41:35.540349Z", Timezone: "UTC", Date: "2024-02-01"}, 239 | } 240 | 241 | for i := range cases { 242 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 243 | // `range`) to avoid capturing reference to loop variable. 244 | tc := cases[i] 245 | description := fmt.Sprintf("%s::%s", tc.Time, tc.Timezone) 246 | base.Run(description, func(t *testing.T) { 247 | t.Parallel() 248 | assert := testifyrequire.New(t) 249 | 250 | timestamp, err := time.Parse(time.RFC3339Nano, tc.Time) 251 | assert.Nil(err) 252 | 253 | tz, err := time.LoadLocation(tc.Timezone) 254 | assert.Nil(err) 255 | 256 | expected, err := date.FromString(tc.Date) 257 | assert.Nil(err) 258 | 259 | d := date.InTimezone(timestamp, tz) 260 | assert.Equal(expected, d) 261 | }) 262 | } 263 | } 264 | 265 | // timezoneMetadata is a struct that contains timezone metadata for assertions 266 | // and translation across timezones. Intended to be used with `TestFromTime()`. 267 | type timezoneMetadata struct { 268 | InTimezone *string 269 | Name *string 270 | Offset *int 271 | } 272 | 273 | // In translates a timestamp to an "in timezone" if one is set on this 274 | // metadata struct. 275 | func (tm timezoneMetadata) In(assert *testifyrequire.Assertions, t time.Time) time.Time { 276 | if tm.InTimezone == nil { 277 | return t 278 | } 279 | 280 | tz, err := time.LoadLocation(*tm.InTimezone) 281 | assert.Nil(err) 282 | return t.In(tz) 283 | } 284 | 285 | // ExpectedName returns the expected timezone name. 286 | func (tm timezoneMetadata) ExpectedName(t time.Time) string { 287 | if tm.Name == nil { 288 | return "UTC" 289 | } 290 | 291 | name := *tm.Name 292 | tz := t.Location() 293 | if name == "" && tz == time.Local { 294 | name, _ = t.Zone() 295 | } 296 | 297 | return name 298 | } 299 | 300 | // ExpectedOffset returns the expected timezone offset in seconds. 301 | func (tm timezoneMetadata) ExpectedOffset() int { 302 | if tm.Offset != nil { 303 | return *tm.Offset 304 | } 305 | return 0 306 | } 307 | 308 | // valueToPtr is a generic function that returns a pointer to the given value. 309 | func valueToPtr[T any](v T) *T { 310 | return &v 311 | } 312 | -------------------------------------------------------------------------------- /date.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Hardfin, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package date 16 | 17 | import ( 18 | "database/sql" 19 | "database/sql/driver" 20 | "encoding" 21 | "encoding/json" 22 | "fmt" 23 | "time" 24 | ) 25 | 26 | // NOTE: Ensure that 27 | // - `Date` satisfies `fmt.Stringer`. 28 | // - `Date` satisfies `fmt.GoStringer`. 29 | // - `Date` satisfies `encoding.TextMarshaler`. 30 | // - `Date` satisfies `json.Marshaler`. 31 | // - `*Date` satisfies `encoding.TextUnmarshaler`. 32 | // - `*Date` satisfies `json.Unmarshaler`. 33 | // - `*Date` satisfies `sql.Scanner`. 34 | // - `Date` satisfies `driver.Valuer`. 35 | var ( 36 | _ fmt.Stringer = Date{} 37 | _ fmt.GoStringer = Date{} 38 | _ encoding.TextMarshaler = Date{} 39 | _ json.Marshaler = Date{} 40 | _ encoding.TextUnmarshaler = (*Date)(nil) 41 | _ json.Unmarshaler = (*Date)(nil) 42 | _ sql.Scanner = (*Date)(nil) 43 | _ driver.Valuer = Date{} 44 | ) 45 | 46 | // Date is a simple date (i.e. without timestamp). This is intended to be 47 | // JSON serialized / deserialized as YYYY-MM-DD. 48 | type Date struct { 49 | Year int 50 | Month time.Month 51 | Day int 52 | } 53 | 54 | // NewDate returns a new `Date` struct. This is a pure convenience function to 55 | // make it more ergonomic to create a `Date` struct. 56 | func NewDate(year int, month time.Month, day int) Date { 57 | return Date{Year: year, Month: month, Day: day} 58 | } 59 | 60 | // AddDays returns the date corresponding to adding the given number of days. 61 | func (d Date) AddDays(days int) Date { 62 | t := d.ToTime().AddDate(0, 0, days) 63 | return Date{Year: t.Year(), Month: t.Month(), Day: t.Day()} 64 | } 65 | 66 | // AddMonths returns the date corresponding to adding the given number of 67 | // months. This accounts for leap years and variable length months. Typically 68 | // the only change is in the month and year but for changes that would exceed 69 | // the number of days in the target month, the last day of the month is used. 70 | // 71 | // For example: 72 | // - adding 1 month to 2020-05-11 results in 2020-06-11 73 | // - adding 1 month to 2022-01-31 results in 2022-02-28 74 | // - adding 3 months to 2024-01-31 results in 2024-04-30 75 | // - subtracting 2 months from 2022-01-31 results in 2022-11-30 76 | // 77 | // NOTE: This behavior is very similar to but distinct from 78 | // `time.Time{}.AddDate()` specialized to `months` only. 79 | func (d Date) AddMonths(months int) Date { 80 | updatedMonth, yearDelta := monthsChange(d.Month, months) 81 | updatedYear := d.Year + yearDelta 82 | updatedDay := minInt(d.Day, daysIn(updatedMonth, updatedYear)) 83 | return Date{Year: updatedYear, Month: updatedMonth, Day: updatedDay} 84 | } 85 | 86 | // AddMonthsStdlib returns the date corresponding to adding the given number of 87 | // months, using `time.Time{}.AddDate()` from the standard library. This may 88 | // "overshoot" if the target date is not a valid date in that month, e.g. 89 | // 2020-02-31. 90 | // 91 | // For example: 92 | // - adding 1 month to 2020-05-11 results in 2020-06-11 93 | // - adding 1 month to 2022-01-31 results in 2022-03-03 94 | // - adding 3 months to 2024-01-31 results in 2024-05-01 95 | // - subtracting 2 months from 2022-01-31 results in 2022-12-01 96 | func (d Date) AddMonthsStdlib(months int) Date { 97 | t := d.ToTime().AddDate(0, months, 0) 98 | return Date{Year: t.Year(), Month: t.Month(), Day: t.Day()} 99 | } 100 | 101 | func monthsChange(month time.Month, monthDelta int) (time.Month, int) { 102 | monthsTotal := int(month) + monthDelta 103 | monthsInYear := monthsTotal % 12 104 | yearDelta := (monthsTotal - monthsInYear) / 12 105 | if monthsInYear < 1 { 106 | // +12 months <==> -1 year 107 | return time.Month(monthsInYear + 12), yearDelta - 1 108 | } 109 | 110 | return time.Month(monthsInYear), yearDelta 111 | } 112 | 113 | // AddYears returns the date corresponding to adding the given number of 114 | // years, using `time.Time{}.AddDate()` from the standard library. This may 115 | // "overshoot" if the target date is not a valid date in that month, e.g. 116 | // 2020-02-31. 117 | // 118 | // For example: 119 | // - adding 1 year to 2020-02-29 results in 2021-03-01 120 | // - adding 1 year to 2023-02-28 results in 2024-02-28 121 | // - adding 10 years to 2010-05-01 results in 2020-05-01 122 | // - subtracting 10 years from 2010-05-01 results in 2000-05-01 123 | // 124 | // NOTE: This behavior is very similar to but distinct from 125 | // `time.Time{}.AddDate()` specialized to `years` only. 126 | func (d Date) AddYears(years int) Date { 127 | updatedMonth := d.Month 128 | updatedYear := d.Year + years 129 | updatedDay := minInt(d.Day, daysIn(updatedMonth, updatedYear)) 130 | return Date{Year: updatedYear, Month: updatedMonth, Day: updatedDay} 131 | } 132 | 133 | // AddYearsStdlib returns the date corresponding to adding the given number of 134 | // years. This accounts for leap years and variable length months. Typically 135 | // the only change is in the month and year but for changes that would exceed 136 | // the number of days in the target month, the last day of the month is used. 137 | // 138 | // For example: 139 | // - adding 1 year to 2020-02-29 results in 2021-02-28 140 | // - adding 1 year to 2023-02-28 results in 2024-02-28 141 | // - adding 10 years to 2010-05-01 results in 2020-05-01 142 | // - subtracting 10 years from 2010-05-01 results in 2000-05-01 143 | // 144 | // NOTE: This behavior is very similar to but distinct from 145 | // `time.Time{}.AddDate()` specialized to `years` only. 146 | func (d Date) AddYearsStdlib(years int) Date { 147 | t := d.ToTime().AddDate(years, 0, 0) 148 | return Date{Year: t.Year(), Month: t.Month(), Day: t.Day()} 149 | } 150 | 151 | // Sub returns the number of days `d - other`; this converts both dates to 152 | // a `time.Time{}` UTC and then dispatches to `time.Time{}.Sub()`. 153 | func (d Date) Sub(other Date) int64 { 154 | days, err := d.SubErr(other) 155 | mustNil(err) 156 | return int64(days) 157 | } 158 | 159 | // SubErr returns the number of days `d - other`; this converts both dates to 160 | // a `time.Time{}` UTC and then dispatches to `time.Time{}.Sub()`. 161 | // 162 | // If the number of days is not a whole number (due to overflow), an error is 163 | // returned. 164 | func (d Date) SubErr(other Date) (int64, error) { 165 | duration := d.ToTime().Sub(other.ToTime()) 166 | 167 | day := 24 * time.Hour 168 | days := duration / day 169 | remainder := duration % day 170 | if remainder != 0 { 171 | return 0, fmt.Errorf("duration is not a whole number of days; duration=%s", duration) 172 | } 173 | 174 | return int64(days), nil 175 | } 176 | 177 | // MonthStart returns the first date in the month of the current date. 178 | func (d Date) MonthStart() Date { 179 | return Date{Year: d.Year, Month: d.Month, Day: 1} 180 | } 181 | 182 | // MonthEnd returns the last date in the month of the current date. 183 | func (d Date) MonthEnd() Date { 184 | endDay := daysIn(d.Month, d.Year) 185 | return Date{Year: d.Year, Month: d.Month, Day: endDay} 186 | } 187 | 188 | // Before returns true if the date is before the other date. 189 | func (d Date) Before(other Date) bool { 190 | if d.Year != other.Year { 191 | return d.Year < other.Year 192 | } 193 | 194 | if d.Month != other.Month { 195 | return d.Month < other.Month 196 | } 197 | 198 | return d.Day < other.Day 199 | } 200 | 201 | // After returns true if the date is after the other date. 202 | func (d Date) After(other Date) bool { 203 | return other.Before(d) 204 | } 205 | 206 | // Equal returns true if the date is equal to the other date. 207 | func (d Date) Equal(other Date) bool { 208 | return d.Year == other.Year && d.Month == other.Month && d.Day == other.Day 209 | } 210 | 211 | func compareInt(i1, i2 int) int { 212 | if i1 < i2 { 213 | return -1 214 | } 215 | 216 | if i1 > i2 { 217 | return 1 218 | } 219 | 220 | return 0 221 | } 222 | 223 | // Compare compares the date d with other. If d is before other, it returns 224 | // -1; if d is after other, it returns +1; if they're the same, it returns 0. 225 | func (d Date) Compare(other Date) int { 226 | if d.Year != other.Year { 227 | return compareInt(d.Year, other.Year) 228 | } 229 | 230 | if d.Month != other.Month { 231 | return compareInt(int(d.Month), int(other.Month)) 232 | } 233 | 234 | return compareInt(d.Day, other.Day) 235 | } 236 | 237 | // IsZero returns true if the date is the zero value. 238 | func (d Date) IsZero() bool { 239 | return d.Year == 0 && d.Month == 0 && d.Day == 0 240 | } 241 | 242 | // ToTime converts the date to a native Go `time.Time`; the convention in Go is 243 | // that a **date-only** is parsed (via `time.DateOnly`) as 244 | // `time.Date(YYYY, MM, DD, 0, 0, 0, 0, time.UTC)`. 245 | func (d Date) ToTime(opts ...ConvertOption) time.Time { 246 | cc := ConvertConfig{Timezone: time.UTC} 247 | for _, opt := range opts { 248 | opt(&cc) 249 | } 250 | 251 | return time.Date(d.Year, d.Month, d.Day, cc.Hour, cc.Minute, cc.Second, cc.Nanosecond, cc.Timezone) 252 | } 253 | 254 | // Date returns the year, month, and day in which `d` occurs. 255 | // 256 | // This is here for parity with `time.Time{}.Date()` and is likely not 257 | // needed. 258 | func (d Date) Date() (int, time.Month, int) { 259 | return d.Year, d.Month, d.Day 260 | } 261 | 262 | // ISOWeek returns the ISO 8601 year and week number in which `d` occurs. 263 | // Week ranges from 1 to 53. Jan 01 to Jan 03 of year `n` might belong to 264 | // week 52 or 53 of year `n-1`, and Dec 29 to Dec 31 might belong to week 1 265 | // of year `n+1`. 266 | func (d Date) ISOWeek() (year, week int) { 267 | return d.ToTime().ISOWeek() 268 | } 269 | 270 | // Weekday returns the day of the week specified by `d`. 271 | func (d Date) Weekday() time.Weekday { 272 | return d.ToTime().Weekday() 273 | } 274 | 275 | // YearDay returns the day of the year specified by `d`, in the range [1,365] 276 | // for non-leap years, and [1,366] in leap years. 277 | func (d Date) YearDay() int { 278 | return d.ToTime().YearDay() 279 | } 280 | 281 | // MarshalText implements the encoding.TextMarshaler interface. 282 | func (d Date) MarshalText() ([]byte, error) { 283 | return []byte(d.String()), nil 284 | } 285 | 286 | // MarshalJSON implements `json.Marshaler`; formats the date as YYYY-MM-DD. 287 | func (d Date) MarshalJSON() ([]byte, error) { 288 | s := d.String() 289 | return json.Marshal(s) 290 | } 291 | 292 | // UnmarshalText implements the encoding.TextUnmarshaler interface. The time 293 | // must be in the format YYYY-MM-DD. 294 | func (d *Date) UnmarshalText(data []byte) error { 295 | parsed, err := FromString(string(data)) 296 | if err != nil { 297 | return err 298 | } 299 | 300 | *d = parsed 301 | return nil 302 | } 303 | 304 | // UnmarshalJSON implements `json.Unmarshaler`; parses the date as YYYY-MM-DD. 305 | func (d *Date) UnmarshalJSON(data []byte) error { 306 | s := "" 307 | err := json.Unmarshal(data, &s) 308 | if err != nil { 309 | return err 310 | } 311 | 312 | parsed, err := FromString(s) 313 | if err != nil { 314 | return err 315 | } 316 | 317 | *d = parsed 318 | return nil 319 | } 320 | 321 | // Scan implements `sql.Scanner`; it unmarshals values of the type `time.Time` 322 | // onto the current `Date` struct. 323 | func (d *Date) Scan(src any) error { 324 | var t time.Time 325 | 326 | switch srcTyped := src.(type) { 327 | case time.Time: 328 | t = srcTyped 329 | default: 330 | return fmt.Errorf("incompatible type for Date; type=%T", src) 331 | } 332 | 333 | verified, err := FromTime(t) 334 | if err != nil { 335 | return err 336 | } 337 | 338 | *d = verified 339 | return nil 340 | } 341 | 342 | // Value implements `driver.Valuer`; it marshals the value to a `time.Time` 343 | // to be serialized into the database. 344 | func (d Date) Value() (driver.Value, error) { 345 | return d.ToTime(), nil 346 | } 347 | 348 | // String implements `fmt.Stringer`. 349 | func (d Date) String() string { 350 | return d.Format(time.DateOnly) 351 | } 352 | 353 | // Format returns a textual representation of the date value formatted according 354 | // to the provided layout. This uses `time.Time{}.Format()` directly and is 355 | // provided here for convenience. 356 | func (d Date) Format(layout string) string { 357 | return d.ToTime().Format(layout) 358 | } 359 | 360 | // GoString implements `fmt.GoStringer`. 361 | func (d Date) GoString() string { 362 | return fmt.Sprintf("date.NewDate(%d, time.%s, %d)", d.Year, d.Month, d.Day) 363 | } 364 | -------------------------------------------------------------------------------- /date_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Hardfin, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package date_test 16 | 17 | import ( 18 | "encoding/json" 19 | "fmt" 20 | "testing" 21 | "time" 22 | 23 | testifyrequire "github.com/stretchr/testify/require" 24 | 25 | date "github.com/hardfinhq/go-date" 26 | ) 27 | 28 | func TestNewDate(t *testing.T) { 29 | t.Parallel() 30 | assert := testifyrequire.New(t) 31 | 32 | d := date.NewDate(2020, time.May, 11) 33 | expected := date.Date{Year: 2020, Month: time.May, Day: 11} 34 | assert.Equal(expected, d) 35 | } 36 | 37 | func TestDate_AddDays(base *testing.T) { 38 | base.Parallel() 39 | 40 | type testCase struct { 41 | Date string 42 | Delta int 43 | Expected string 44 | } 45 | 46 | cases := []testCase{ 47 | {Date: "2020-05-11", Delta: 0, Expected: "2020-05-11"}, 48 | {Date: "2020-05-11", Delta: 1, Expected: "2020-05-12"}, 49 | {Date: "2020-05-11", Delta: 10, Expected: "2020-05-21"}, 50 | {Date: "2022-01-31", Delta: -5, Expected: "2022-01-26"}, 51 | {Date: "2022-01-31", Delta: 40, Expected: "2022-03-12"}, 52 | {Date: "2022-01-31", Delta: 120, Expected: "2022-05-31"}, 53 | {Date: "1999-12-24", Delta: 300, Expected: "2000-10-19"}, 54 | {Date: "1999-12-24", Delta: 2000, Expected: "2005-06-15"}, 55 | {Date: "1999-12-24", Delta: 571, Expected: "2001-07-17"}, 56 | // Daylight savings time 57 | {Date: "2023-03-10", Delta: 1, Expected: "2023-03-11"}, 58 | {Date: "2023-03-10", Delta: 2, Expected: "2023-03-12"}, 59 | {Date: "2023-03-10", Delta: 3, Expected: "2023-03-13"}, 60 | {Date: "2023-03-10", Delta: 4, Expected: "2023-03-14"}, 61 | {Date: "2023-11-03", Delta: 1, Expected: "2023-11-04"}, 62 | {Date: "2023-11-03", Delta: 2, Expected: "2023-11-05"}, 63 | {Date: "2023-11-03", Delta: 3, Expected: "2023-11-06"}, 64 | {Date: "2023-11-03", Delta: 4, Expected: "2023-11-07"}, 65 | } 66 | 67 | for i := range cases { 68 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 69 | // `range`) to avoid capturing reference to loop variable. 70 | tc := cases[i] 71 | description := fmt.Sprintf("%s + %d -> %s", tc.Date, tc.Delta, tc.Expected) 72 | base.Run(description, func(t *testing.T) { 73 | t.Parallel() 74 | assert := testifyrequire.New(t) 75 | 76 | d, err := date.FromString(tc.Date) 77 | assert.Nil(err) 78 | 79 | computed := d.AddDays(tc.Delta) 80 | assert.Equal(tc.Expected, computed.String()) 81 | }) 82 | } 83 | } 84 | 85 | func TestDate_AddMonths(base *testing.T) { 86 | base.Parallel() 87 | 88 | type testCase struct { 89 | Date string 90 | Delta int 91 | Expected string 92 | Contrast string 93 | } 94 | 95 | cases := []testCase{ 96 | {Date: "2020-05-11", Delta: 0, Expected: "2020-05-11", Contrast: "2020-05-11"}, 97 | {Date: "2020-05-11", Delta: 1, Expected: "2020-06-11", Contrast: "2020-06-11"}, 98 | {Date: "2020-05-11", Delta: 2, Expected: "2020-07-11", Contrast: "2020-07-11"}, 99 | {Date: "2022-01-31", Delta: -2, Expected: "2021-11-30", Contrast: "2021-12-01"}, 100 | {Date: "2022-01-31", Delta: -1, Expected: "2021-12-31", Contrast: "2021-12-31"}, 101 | {Date: "2022-01-31", Delta: 1, Expected: "2022-02-28", Contrast: "2022-03-03"}, 102 | {Date: "2024-01-31", Delta: 2, Expected: "2024-03-31", Contrast: "2024-03-31"}, 103 | {Date: "2024-01-31", Delta: 3, Expected: "2024-04-30", Contrast: "2024-05-01"}, 104 | } 105 | 106 | for i := range cases { 107 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 108 | // `range`) to avoid capturing reference to loop variable. 109 | tc := cases[i] 110 | description := fmt.Sprintf("%s + %d -> %s", tc.Date, tc.Delta, tc.Expected) 111 | base.Run(description, func(t *testing.T) { 112 | t.Parallel() 113 | assert := testifyrequire.New(t) 114 | 115 | d, err := date.FromString(tc.Date) 116 | assert.Nil(err) 117 | 118 | computed := d.AddMonths(tc.Delta) 119 | assert.Equal(tc.Expected, computed.String()) 120 | 121 | // To contrast, consider how `time.Time{}.AddDate()` works. 122 | contrast := d.ToTime().AddDate(0, tc.Delta, 0) 123 | assert.Equal(tc.Contrast, contrast.Format(time.DateOnly)) 124 | }) 125 | } 126 | } 127 | 128 | func TestDate_AddMonthsStdlib(base *testing.T) { 129 | base.Parallel() 130 | 131 | type testCase struct { 132 | Date string 133 | Delta int 134 | Expected string 135 | } 136 | 137 | cases := []testCase{ 138 | {Date: "2020-05-11", Delta: 0, Expected: "2020-05-11"}, 139 | {Date: "2020-05-11", Delta: 1, Expected: "2020-06-11"}, 140 | {Date: "2020-05-11", Delta: 2, Expected: "2020-07-11"}, 141 | {Date: "2022-01-31", Delta: -2, Expected: "2021-12-01"}, 142 | {Date: "2022-01-31", Delta: -1, Expected: "2021-12-31"}, 143 | {Date: "2022-01-31", Delta: 1, Expected: "2022-03-03"}, 144 | {Date: "2024-01-31", Delta: 2, Expected: "2024-03-31"}, 145 | {Date: "2024-01-31", Delta: 3, Expected: "2024-05-01"}, 146 | } 147 | 148 | for i := range cases { 149 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 150 | // `range`) to avoid capturing reference to loop variable. 151 | tc := cases[i] 152 | description := fmt.Sprintf("%s + %d -> %s", tc.Date, tc.Delta, tc.Expected) 153 | base.Run(description, func(t *testing.T) { 154 | t.Parallel() 155 | assert := testifyrequire.New(t) 156 | 157 | d, err := date.FromString(tc.Date) 158 | assert.Nil(err) 159 | 160 | computed := d.AddMonthsStdlib(tc.Delta) 161 | assert.Equal(tc.Expected, computed.String()) 162 | }) 163 | } 164 | } 165 | 166 | func TestDate_AddYears(base *testing.T) { 167 | base.Parallel() 168 | 169 | type testCase struct { 170 | Date string 171 | Delta int 172 | Expected string 173 | Contrast string 174 | } 175 | 176 | cases := []testCase{ 177 | {Date: "2020-05-11", Delta: 0, Expected: "2020-05-11", Contrast: "2020-05-11"}, 178 | {Date: "2020-05-11", Delta: 1, Expected: "2021-05-11", Contrast: "2021-05-11"}, 179 | {Date: "2020-05-11", Delta: 2, Expected: "2022-05-11", Contrast: "2022-05-11"}, 180 | {Date: "2020-02-29", Delta: 1, Expected: "2021-02-28", Contrast: "2021-03-01"}, 181 | {Date: "2020-02-29", Delta: 2, Expected: "2022-02-28", Contrast: "2022-03-01"}, 182 | {Date: "2020-02-29", Delta: 4, Expected: "2024-02-29", Contrast: "2024-02-29"}, 183 | {Date: "2019-02-28", Delta: 1, Expected: "2020-02-28", Contrast: "2020-02-28"}, 184 | {Date: "2019-02-28", Delta: -1, Expected: "2018-02-28", Contrast: "2018-02-28"}, 185 | {Date: "2019-02-28", Delta: -3, Expected: "2016-02-28", Contrast: "2016-02-28"}, 186 | } 187 | 188 | for i := range cases { 189 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 190 | // `range`) to avoid capturing reference to loop variable. 191 | tc := cases[i] 192 | description := fmt.Sprintf("%s + %d -> %s", tc.Date, tc.Delta, tc.Expected) 193 | base.Run(description, func(t *testing.T) { 194 | t.Parallel() 195 | assert := testifyrequire.New(t) 196 | 197 | d, err := date.FromString(tc.Date) 198 | assert.Nil(err) 199 | 200 | computed := d.AddYears(tc.Delta) 201 | assert.Equal(tc.Expected, computed.String()) 202 | 203 | // To contrast, consider how `time.Time{}.AddDate()` works. 204 | contrast := d.ToTime().AddDate(tc.Delta, 0, 0) 205 | assert.Equal(tc.Contrast, contrast.Format(time.DateOnly)) 206 | }) 207 | } 208 | } 209 | 210 | func TestDate_AddYearsStdlib(base *testing.T) { 211 | base.Parallel() 212 | 213 | type testCase struct { 214 | Date string 215 | Delta int 216 | Expected string 217 | } 218 | 219 | cases := []testCase{ 220 | {Date: "2020-05-11", Delta: 0, Expected: "2020-05-11"}, 221 | {Date: "2020-05-11", Delta: 1, Expected: "2021-05-11"}, 222 | {Date: "2020-05-11", Delta: 2, Expected: "2022-05-11"}, 223 | {Date: "2020-02-29", Delta: 1, Expected: "2021-03-01"}, 224 | {Date: "2020-02-29", Delta: 2, Expected: "2022-03-01"}, 225 | {Date: "2020-02-29", Delta: 4, Expected: "2024-02-29"}, 226 | {Date: "2019-02-28", Delta: 1, Expected: "2020-02-28"}, 227 | {Date: "2019-02-28", Delta: -1, Expected: "2018-02-28"}, 228 | {Date: "2019-02-28", Delta: -3, Expected: "2016-02-28"}, 229 | {Date: "2023-02-28", Delta: 1, Expected: "2024-02-28"}, 230 | {Date: "2010-05-01", Delta: 10, Expected: "2020-05-01"}, 231 | {Date: "2010-05-01", Delta: -10, Expected: "2000-05-01"}, 232 | } 233 | 234 | for i := range cases { 235 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 236 | // `range`) to avoid capturing reference to loop variable. 237 | tc := cases[i] 238 | description := fmt.Sprintf("%s + %d -> %s", tc.Date, tc.Delta, tc.Expected) 239 | base.Run(description, func(t *testing.T) { 240 | t.Parallel() 241 | assert := testifyrequire.New(t) 242 | 243 | d, err := date.FromString(tc.Date) 244 | assert.Nil(err) 245 | 246 | computed := d.AddYearsStdlib(tc.Delta) 247 | assert.Equal(tc.Expected, computed.String()) 248 | }) 249 | } 250 | } 251 | 252 | func TestDate_Sub(base *testing.T) { 253 | base.Parallel() 254 | 255 | type testCase struct { 256 | Date string 257 | Other string 258 | Expected int64 259 | } 260 | 261 | cases := []testCase{ 262 | {Date: "2020-05-11", Other: "2020-05-11", Expected: 0}, 263 | {Date: "2020-05-11", Other: "2020-05-12", Expected: -1}, 264 | {Date: "2020-05-11", Other: "2020-05-10", Expected: 1}, 265 | {Date: "2020-05-11", Other: "2002-05-11", Expected: 6575}, 266 | {Date: "2020-05-11", Other: "2022-05-11", Expected: -730}, 267 | {Date: "2020-05-11", Other: "2012-01-31", Expected: 3023}, 268 | {Date: "2016-04-17", Other: "2020-05-12", Expected: -1486}, 269 | {Date: "2020-05-22", Other: "2020-05-10", Expected: 12}, 270 | {Date: "2023-05-03", Other: "2002-05-11", Expected: 7662}, 271 | {Date: "2013-05-19", Other: "2022-05-11", Expected: -3279}, 272 | {Date: "2012-02-28", Other: "2012-01-31", Expected: 28}, 273 | } 274 | 275 | for i := range cases { 276 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 277 | // `range`) to avoid capturing reference to loop variable. 278 | tc := cases[i] 279 | description := fmt.Sprintf("%s - %s", tc.Date, tc.Other) 280 | base.Run(description, func(t *testing.T) { 281 | t.Parallel() 282 | assert := testifyrequire.New(t) 283 | 284 | d, err := date.FromString(tc.Date) 285 | assert.Nil(err) 286 | 287 | other, err := date.FromString(tc.Other) 288 | assert.Nil(err) 289 | 290 | computed := d.Sub(other) 291 | assert.Equal(tc.Expected, computed) 292 | }) 293 | } 294 | } 295 | 296 | func TestDate_Sub_Panic(t *testing.T) { 297 | t.Parallel() 298 | assert := testifyrequire.New(t) 299 | 300 | d1 := date.Date{Year: 1, Month: time.January, Day: 1} 301 | d2 := date.Date{Year: 1_000_000, Month: time.January, Day: 1} 302 | 303 | assert.Panics(func() { d1.Sub(d2) }) 304 | 305 | days, err := d1.SubErr(d2) 306 | assert.Equal(int64(0), days) 307 | assert.NotNil(err) 308 | assert.Equal("duration is not a whole number of days; duration=-2562047h47m16.854775808s", fmt.Sprintf("%v", err)) 309 | } 310 | 311 | func TestDate_MonthStart(base *testing.T) { 312 | base.Parallel() 313 | 314 | type testCase struct { 315 | Date string 316 | Expected string 317 | } 318 | 319 | cases := []testCase{ 320 | {Date: "2020-02-16", Expected: "2020-02-01"}, 321 | {Date: "2021-02-16", Expected: "2021-02-01"}, 322 | {Date: "2023-01-01", Expected: "2023-01-01"}, 323 | } 324 | for i := range cases { 325 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 326 | // `range`) to avoid capturing reference to loop variable. 327 | tc := cases[i] 328 | base.Run(tc.Date, func(t *testing.T) { 329 | t.Parallel() 330 | assert := testifyrequire.New(t) 331 | 332 | d, err := date.FromString(tc.Date) 333 | assert.Nil(err) 334 | expected, err := date.FromString(tc.Expected) 335 | assert.Nil(err) 336 | 337 | shifted := d.MonthStart() 338 | assert.Equal(expected, shifted) 339 | }) 340 | } 341 | } 342 | 343 | func TestDate_MonthEnd(base *testing.T) { 344 | base.Parallel() 345 | 346 | type testCase struct { 347 | Date string 348 | Expected string 349 | } 350 | 351 | cases := []testCase{ 352 | {Date: "2020-02-16", Expected: "2020-02-29"}, 353 | {Date: "2021-02-16", Expected: "2021-02-28"}, 354 | {Date: "2023-01-01", Expected: "2023-01-31"}, 355 | } 356 | for i := range cases { 357 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 358 | // `range`) to avoid capturing reference to loop variable. 359 | tc := cases[i] 360 | base.Run(tc.Date, func(t *testing.T) { 361 | t.Parallel() 362 | assert := testifyrequire.New(t) 363 | 364 | d, err := date.FromString(tc.Date) 365 | assert.Nil(err) 366 | expected, err := date.FromString(tc.Expected) 367 | assert.Nil(err) 368 | 369 | shifted := d.MonthEnd() 370 | assert.Equal(expected, shifted) 371 | }) 372 | } 373 | } 374 | 375 | func TestDate_Before(t *testing.T) { 376 | t.Parallel() 377 | assert := testifyrequire.New(t) 378 | 379 | d1 := date.Date{Year: 2020, Month: time.May, Day: 11} 380 | d2 := date.Date{Year: 2022, Month: time.May, Day: 11} 381 | assert.True(d1.Before(d2)) 382 | assert.False(d2.Before(d1)) 383 | assert.False(d2.Before(d2)) 384 | assert.False(d1.Before(d1)) 385 | 386 | d1 = date.Date{Year: 2022, Month: time.April, Day: 11} 387 | d2 = date.Date{Year: 2022, Month: time.May, Day: 11} 388 | assert.True(d1.Before(d2)) 389 | assert.False(d2.Before(d1)) 390 | 391 | d1 = date.Date{Year: 2022, Month: time.April, Day: 11} 392 | d2 = date.Date{Year: 2022, Month: time.April, Day: 12} 393 | assert.True(d1.Before(d2)) 394 | assert.False(d2.Before(d1)) 395 | } 396 | 397 | func TestDate_After(t *testing.T) { 398 | t.Parallel() 399 | assert := testifyrequire.New(t) 400 | 401 | d1 := date.Date{Year: 2023, Month: time.July, Day: 27} 402 | d2 := date.Date{Year: 2018, Month: time.January, Day: 1} 403 | assert.True(d1.After(d2)) 404 | assert.False(d2.After(d1)) 405 | assert.False(d2.After(d2)) 406 | assert.False(d1.After(d1)) 407 | } 408 | 409 | func TestDate_Equal(t *testing.T) { 410 | t.Parallel() 411 | assert := testifyrequire.New(t) 412 | 413 | d1 := date.Date{Year: 2023, Month: time.July, Day: 27} 414 | d2 := date.Date{Year: 2018, Month: time.January, Day: 1} 415 | d3 := date.Date{Year: 2023, Month: time.July, Day: 27} 416 | assert.True(d1.Equal(d1)) 417 | assert.True(d2.Equal(d2)) 418 | assert.True(d1.Equal(d3)) 419 | assert.False(d2.Equal(d1)) 420 | assert.False(d1.Equal(d2)) 421 | } 422 | 423 | func TestDate_Compare(t *testing.T) { 424 | t.Parallel() 425 | assert := testifyrequire.New(t) 426 | 427 | d1 := date.Date{Year: 2023, Month: time.July, Day: 27} 428 | d2 := date.Date{Year: 2018, Month: time.January, Day: 1} 429 | d3 := date.Date{Year: 2023, Month: time.July, Day: 27} 430 | d4 := date.Date{Year: 2023, Month: time.August, Day: 27} 431 | assert.Equal(0, d1.Compare(d1)) 432 | assert.Equal(0, d2.Compare(d2)) 433 | assert.Equal(0, d1.Compare(d3)) 434 | assert.Equal(-1, d2.Compare(d1)) 435 | assert.Equal(1, d1.Compare(d2)) 436 | assert.Equal(-1, d1.Compare(d4)) 437 | } 438 | 439 | func TestDate_IsZero(t *testing.T) { 440 | t.Parallel() 441 | assert := testifyrequire.New(t) 442 | 443 | d1 := date.Date{} 444 | d2 := date.Date{Year: 2006, Month: time.May, Day: 25} 445 | d3 := date.Date{Year: 2006} 446 | assert.True(d1.IsZero()) 447 | assert.False(d2.IsZero()) 448 | assert.False(d3.IsZero()) 449 | } 450 | 451 | func TestDate_ToTime(t *testing.T) { 452 | t.Parallel() 453 | assert := testifyrequire.New(t) 454 | 455 | d := date.Date{Year: 2006, Month: time.February, Day: 16} 456 | converted := d.ToTime() 457 | expected := time.Time(time.Date(2006, time.February, 16, 0, 0, 0, 0, time.UTC)) 458 | assert.Equal(expected, converted) 459 | 460 | tz, err := time.LoadLocation("America/Chicago") 461 | assert.Nil(err) 462 | converted = d.ToTime(date.OptConvertTimezone(tz)) 463 | expected = time.Time(time.Date(2006, time.February, 16, 0, 0, 0, 0, tz)) 464 | assert.Equal(expected, converted) 465 | } 466 | 467 | func TestDate_Date(t *testing.T) { 468 | t.Parallel() 469 | assert := testifyrequire.New(t) 470 | 471 | d := date.Date{Year: 2006, Month: time.February, Day: 16} 472 | year, month, day := d.Date() 473 | assert.Equal(2006, year) 474 | assert.Equal(time.February, month) 475 | assert.Equal(16, day) 476 | } 477 | 478 | func TestDate_ISOWeek(t *testing.T) { 479 | t.Parallel() 480 | assert := testifyrequire.New(t) 481 | 482 | d := date.Date{Year: 2006, Month: time.February, Day: 16} 483 | year, week := d.ISOWeek() 484 | assert.Equal(2006, year) 485 | assert.Equal(7, week) 486 | } 487 | 488 | func TestDate_Weekday(base *testing.T) { 489 | base.Parallel() 490 | 491 | type testCase struct { 492 | Date date.Date 493 | Expected time.Weekday 494 | } 495 | 496 | cases := []testCase{ 497 | {Date: date.Date{Year: 2023, Month: time.January, Day: 1}, Expected: time.Sunday}, 498 | {Date: date.Date{Year: 2023, Month: time.January, Day: 2}, Expected: time.Monday}, 499 | {Date: date.Date{Year: 2023, Month: time.January, Day: 3}, Expected: time.Tuesday}, 500 | {Date: date.Date{Year: 2023, Month: time.January, Day: 4}, Expected: time.Wednesday}, 501 | {Date: date.Date{Year: 2023, Month: time.January, Day: 5}, Expected: time.Thursday}, 502 | {Date: date.Date{Year: 2023, Month: time.January, Day: 6}, Expected: time.Friday}, 503 | {Date: date.Date{Year: 2023, Month: time.January, Day: 7}, Expected: time.Saturday}, 504 | {Date: date.Date{Year: 2023, Month: time.January, Day: 8}, Expected: time.Sunday}, 505 | } 506 | 507 | for i := range cases { 508 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 509 | // `range`) to avoid capturing reference to loop variable. 510 | tc := cases[i] 511 | base.Run(tc.Date.String(), func(t *testing.T) { 512 | t.Parallel() 513 | assert := testifyrequire.New(t) 514 | 515 | weekday := tc.Date.Weekday() 516 | assert.Equal(tc.Expected, weekday) 517 | }) 518 | } 519 | } 520 | 521 | func TestDate_YearDay(base *testing.T) { 522 | base.Parallel() 523 | 524 | type testCase struct { 525 | Date date.Date 526 | Expected int 527 | } 528 | 529 | cases := []testCase{ 530 | {Date: date.Date{Year: 2022, Month: time.December, Day: 31}, Expected: 365}, 531 | {Date: date.Date{Year: 2023, Month: time.January, Day: 1}, Expected: 1}, 532 | {Date: date.Date{Year: 2023, Month: time.January, Day: 5}, Expected: 5}, 533 | {Date: date.Date{Year: 2023, Month: time.January, Day: 6}, Expected: 6}, 534 | {Date: date.Date{Year: 2023, Month: time.January, Day: 8}, Expected: 8}, 535 | {Date: date.Date{Year: 2024, Month: time.December, Day: 31}, Expected: 366}, 536 | } 537 | 538 | for i := range cases { 539 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 540 | // `range`) to avoid capturing reference to loop variable. 541 | tc := cases[i] 542 | base.Run(tc.Date.String(), func(t *testing.T) { 543 | t.Parallel() 544 | assert := testifyrequire.New(t) 545 | 546 | yearDay := tc.Date.YearDay() 547 | assert.Equal(tc.Expected, yearDay) 548 | }) 549 | } 550 | } 551 | 552 | func TestDate_MarshalText(base *testing.T) { 553 | base.Parallel() 554 | 555 | type testCase struct { 556 | Name string 557 | Date date.Date 558 | Expected string 559 | } 560 | 561 | cases := []testCase{ 562 | {Name: "Remote past", Date: date.Date{Year: 1997, Month: time.July, Day: 15}, Expected: "1997-07-15"}, 563 | {Name: "Recent past", Date: date.Date{Year: 2020, Month: time.February, Day: 20}, Expected: "2020-02-20"}, 564 | } 565 | 566 | for i := range cases { 567 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 568 | // `range`) to avoid capturing reference to loop variable. 569 | tc := cases[i] 570 | base.Run(tc.Name, func(t *testing.T) { 571 | t.Parallel() 572 | assert := testifyrequire.New(t) 573 | 574 | asBytes, err := tc.Date.MarshalText() 575 | assert.Nil(err) 576 | assert.Equal(tc.Expected, string(asBytes)) 577 | }) 578 | } 579 | } 580 | 581 | func TestDate_MarshalJSON(base *testing.T) { 582 | base.Parallel() 583 | 584 | type testCase struct { 585 | Name string 586 | Date *date.Date 587 | Expected string 588 | } 589 | 590 | cases := []testCase{ 591 | {Name: "Remote past", Date: &date.Date{Year: 1997, Month: time.July, Day: 15}, Expected: `"1997-07-15"`}, 592 | {Name: "Recent past", Date: &date.Date{Year: 2020, Month: time.February, Day: 20}, Expected: `"2020-02-20"`}, 593 | {Name: "Unset", Date: nil, Expected: "null"}, 594 | } 595 | 596 | for i := range cases { 597 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 598 | // `range`) to avoid capturing reference to loop variable. 599 | tc := cases[i] 600 | base.Run(tc.Name, func(t *testing.T) { 601 | t.Parallel() 602 | assert := testifyrequire.New(t) 603 | 604 | asBytes, err := json.Marshal(tc.Date) 605 | assert.Nil(err) 606 | assert.Equal(tc.Expected, string(asBytes)) 607 | }) 608 | } 609 | } 610 | 611 | func TestDate_UnmarshalText(base *testing.T) { 612 | base.Parallel() 613 | 614 | type testCase struct { 615 | Input []byte 616 | Date date.Date 617 | Error string 618 | } 619 | 620 | cases := []testCase{ 621 | {Input: []byte(`x`), Error: `parsing time "x" as "2006-01-02": cannot parse "x" as "2006"`}, 622 | {Input: []byte(`10`), Error: `parsing time "10" as "2006-01-02": cannot parse "10" as "2006"`}, 623 | {Input: []byte("01/26/2018"), Error: `parsing time "01/26/2018" as "2006-01-02": cannot parse "01/26/2018" as "2006"`}, 624 | {Input: []byte("1997-07-15"), Date: date.Date{Year: 1997, Month: time.July, Day: 15}}, 625 | {Input: []byte("2020-02-20"), Date: date.Date{Year: 2020, Month: time.February, Day: 20}}, 626 | } 627 | 628 | for i := range cases { 629 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 630 | // `range`) to avoid capturing reference to loop variable. 631 | tc := cases[i] 632 | base.Run(string(tc.Input), func(t *testing.T) { 633 | t.Parallel() 634 | assert := testifyrequire.New(t) 635 | 636 | d := date.Date{} 637 | err := d.UnmarshalText(tc.Input) 638 | if err != nil { 639 | assert.Equal(tc.Error, fmt.Sprintf("%v", err)) 640 | assert.Equal(date.Date{}, d) 641 | } else { 642 | assert.Equal("", tc.Error) 643 | assert.Equal(tc.Date, d) 644 | } 645 | }) 646 | } 647 | } 648 | 649 | func TestDate_UnmarshalJSON(base *testing.T) { 650 | base.Parallel() 651 | 652 | type testCase struct { 653 | Input []byte 654 | Date date.Date 655 | Error string 656 | } 657 | 658 | cases := []testCase{ 659 | {Input: []byte(`x`), Error: "invalid character 'x' looking for beginning of value"}, 660 | {Input: []byte(`10`), Error: "json: cannot unmarshal number into Go value of type string"}, 661 | {Input: []byte(`"abc"`), Error: `parsing time "abc" as "2006-01-02": cannot parse "abc" as "2006"`}, 662 | {Input: []byte(`"01/26/2018"`), Error: `parsing time "01/26/2018" as "2006-01-02": cannot parse "01/26/2018" as "2006"`}, 663 | {Input: []byte(`"1997-07-15"`), Date: date.Date{Year: 1997, Month: time.July, Day: 15}}, 664 | {Input: []byte(`"2020-02-20"`), Date: date.Date{Year: 2020, Month: time.February, Day: 20}}, 665 | } 666 | 667 | for i := range cases { 668 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 669 | // `range`) to avoid capturing reference to loop variable. 670 | tc := cases[i] 671 | base.Run(string(tc.Input), func(t *testing.T) { 672 | t.Parallel() 673 | assert := testifyrequire.New(t) 674 | 675 | d := date.Date{} 676 | err := json.Unmarshal(tc.Input, &d) 677 | if err != nil { 678 | assert.Equal(tc.Error, fmt.Sprintf("%v", err)) 679 | assert.Equal(date.Date{}, d) 680 | } else { 681 | assert.Equal("", tc.Error) 682 | assert.Equal(tc.Date, d) 683 | } 684 | }) 685 | } 686 | } 687 | 688 | func TestDate_Scan(t *testing.T) { 689 | t.Parallel() 690 | assert := testifyrequire.New(t) 691 | 692 | // Wrong type 693 | d := date.Date{} 694 | err := d.Scan(1) 695 | assert.NotNil(err) 696 | assert.Equal("incompatible type for Date; type=int", fmt.Sprintf("%v", err)) 697 | assert.Equal(date.Date{}, d) 698 | 699 | // Time but not date 700 | d = date.Date{} 701 | tz, err := time.LoadLocation("America/Los_Angeles") 702 | assert.Nil(err) 703 | src := time.Date(2001, time.August, 4, 11, 10, 55, 0, tz) 704 | err = d.Scan(src) 705 | assert.NotNil(err) 706 | assert.Equal("timestamp contains more than just date information; 2001-08-04T11:10:55-07:00", fmt.Sprintf("%v", err)) 707 | assert.Equal(date.Date{}, d) 708 | 709 | // Happy path 710 | d = date.Date{} 711 | src = time.Date(1991, time.April, 26, 0, 0, 0, 0, time.UTC) 712 | err = d.Scan(src) 713 | assert.Nil(err) 714 | expected := date.Date{Year: 1991, Month: time.April, Day: 26} 715 | assert.Equal(expected, d) 716 | } 717 | 718 | func TestDate_Value(t *testing.T) { 719 | t.Parallel() 720 | assert := testifyrequire.New(t) 721 | 722 | d := date.Date{Year: 1991, Month: time.April, Day: 26} 723 | v, err := d.Value() 724 | assert.Nil(err) 725 | expected := time.Date(1991, time.April, 26, 0, 0, 0, 0, time.UTC) 726 | assert.Equal(expected, v) 727 | } 728 | 729 | func TestDate_String(base *testing.T) { 730 | base.Parallel() 731 | 732 | type testCase struct { 733 | Date date.Date 734 | Expected string 735 | } 736 | 737 | cases := []testCase{ 738 | {Date: date.Date{Year: 2020, Month: time.May, Day: 11}, Expected: "2020-05-11"}, 739 | {Date: date.Date{Year: 2022, Month: time.January, Day: 31}, Expected: "2022-01-31"}, 740 | {Date: date.Date{Year: 1999, Month: time.December, Day: 24}, Expected: "1999-12-24"}, 741 | } 742 | 743 | for i := range cases { 744 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 745 | // `range`) to avoid capturing reference to loop variable. 746 | tc := cases[i] 747 | base.Run(tc.Expected, func(t *testing.T) { 748 | t.Parallel() 749 | assert := testifyrequire.New(t) 750 | 751 | assert.Equal(tc.Expected, tc.Date.String()) 752 | }) 753 | } 754 | } 755 | 756 | func TestDate_GoString(base *testing.T) { 757 | base.Parallel() 758 | 759 | type testCase struct { 760 | Date date.Date 761 | Expected string 762 | } 763 | 764 | cases := []testCase{ 765 | {Date: date.Date{Year: 2020, Month: time.May, Day: 11}, Expected: "date.NewDate(2020, time.May, 11)"}, 766 | {Date: date.Date{Year: 2022, Month: time.January, Day: 31}, Expected: "date.NewDate(2022, time.January, 31)"}, 767 | {Date: date.Date{Year: 1999, Month: time.December, Day: 24}, Expected: "date.NewDate(1999, time.December, 24)"}, 768 | } 769 | 770 | for i := range cases { 771 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 772 | // `range`) to avoid capturing reference to loop variable. 773 | tc := cases[i] 774 | base.Run(tc.Expected, func(t *testing.T) { 775 | t.Parallel() 776 | assert := testifyrequire.New(t) 777 | 778 | assert.Equal(tc.Expected, tc.Date.GoString()) 779 | }) 780 | } 781 | } 782 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Hardfin, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package date provides tools for working with dates, extending the standard 16 | // library `time` package. 17 | // 18 | // This package provides helpers for converting from a full `time.Time{}` to 19 | // a `Date{}` and back, providing validation along the way. Many methods from 20 | // `time.Time{}` are also provided as equivalents here (`After()`, `Before()`, 21 | // `Sub()`, etc.). Additionally, custom serialization methods are provided both 22 | // for JSON and SQL. 23 | // 24 | // The Go standard library contains no native type for dates without times. 25 | // Instead, common convention is to use a `time.Time{}` with only the year, 26 | // month, and day set. For example, this convention is followed when a 27 | // timestamp of the form YYYY-MM-DD is parsed via 28 | // `time.Parse(time.DateOnly, value)`. 29 | package date 30 | -------------------------------------------------------------------------------- /generic.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Hardfin, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package date 16 | 17 | func minInt(a, b int) int { 18 | if a < b { 19 | return a 20 | } 21 | return b 22 | } 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hardfinhq/go-date 2 | 3 | go 1.22.1 4 | 5 | require github.com/stretchr/testify v1.10.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /must.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Hardfin, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package date 16 | 17 | func mustNil(err error) { 18 | if err != nil { 19 | panic(err) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /must_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Hardfin, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package date 16 | 17 | import ( 18 | "errors" 19 | "testing" 20 | 21 | testifyrequire "github.com/stretchr/testify/require" 22 | ) 23 | 24 | // NOTE: This test file is in `package date` so that it can access 25 | // `mustNil()`, which is intentionally not exported. 26 | 27 | func Test_mustNil(t *testing.T) { 28 | t.Parallel() 29 | assert := testifyrequire.New(t) 30 | 31 | // Happy path 32 | assert.NotPanics(func() { 33 | mustNil(nil) 34 | }) 35 | 36 | // Sad path 37 | err := errors.New("definitely bad") 38 | assert.PanicsWithValue(err, func() { 39 | mustNil(err) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /null.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Hardfin, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package date 16 | 17 | import ( 18 | "database/sql/driver" 19 | ) 20 | 21 | // NullDate is a `Date` that can be null. 22 | type NullDate struct { 23 | Date Date 24 | Valid bool 25 | } 26 | 27 | // Scan implements `sql.Scanner`; it unmarshals nullable values of the type 28 | // `time.Time` onto the current `NullDate` struct. 29 | func (nd *NullDate) Scan(value any) error { 30 | if value == nil { 31 | nd.Date = Date{} 32 | nd.Valid = false 33 | return nil 34 | } 35 | 36 | err := nd.Date.Scan(value) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | nd.Valid = true 42 | return nil 43 | } 44 | 45 | // Value implements `driver.Valuer`; it marshals the value to a `time.Time` 46 | // (or `nil`) to be serialized into the database. 47 | func (nd NullDate) Value() (driver.Value, error) { 48 | if !nd.Valid { 49 | return nil, nil 50 | } 51 | 52 | return nd.Date.Value() 53 | } 54 | -------------------------------------------------------------------------------- /null_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Hardfin, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package date_test 16 | 17 | import ( 18 | "fmt" 19 | "testing" 20 | "time" 21 | 22 | testifyrequire "github.com/stretchr/testify/require" 23 | 24 | date "github.com/hardfinhq/go-date" 25 | ) 26 | 27 | func TestNullDate_Scan(t *testing.T) { 28 | t.Parallel() 29 | assert := testifyrequire.New(t) 30 | 31 | // Wrong type 32 | nd := date.NullDate{} 33 | err := nd.Scan(1) 34 | assert.NotNil(err) 35 | assert.Equal("incompatible type for Date; type=int", fmt.Sprintf("%v", err)) 36 | assert.Equal(date.NullDate{}, nd) 37 | 38 | // Happy path: nil 39 | nd = date.NullDate{} 40 | err = nd.Scan(nil) 41 | assert.Nil(err) 42 | assert.Equal(date.NullDate{}, nd) 43 | 44 | // Happy path: value 45 | nd = date.NullDate{} 46 | src := time.Date(1991, time.April, 26, 0, 0, 0, 0, time.UTC) 47 | err = nd.Scan(src) 48 | assert.Nil(err) 49 | expected := date.NullDate{Date: date.Date{Year: 1991, Month: time.April, Day: 26}, Valid: true} 50 | assert.Equal(expected, nd) 51 | } 52 | 53 | func TestNullDate_Value(t *testing.T) { 54 | t.Parallel() 55 | assert := testifyrequire.New(t) 56 | 57 | // Not valid 58 | nd := date.NullDate{} 59 | v, err := nd.Value() 60 | assert.Nil(err) 61 | assert.Nil(v) 62 | 63 | // Valid 64 | nd = date.NullDate{Date: date.Date{Year: 1991, Month: time.April, Day: 26}, Valid: true} 65 | v, err = nd.Value() 66 | assert.Nil(err) 67 | expected := time.Date(1991, time.April, 26, 0, 0, 0, 0, time.UTC) 68 | assert.Equal(expected, v) 69 | } 70 | -------------------------------------------------------------------------------- /time_vendor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Hardfin, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package date 16 | 17 | import ( 18 | "time" 19 | ) 20 | 21 | // daysBefore is vendored in from the standard library: 22 | // https://github.com/golang/go/blob/go1.22.1/src/time/time.go#L1060-L1077 23 | var daysBefore = [...]int32{ 24 | 0, 25 | 31, 26 | 31 + 28, 27 | 31 + 28 + 31, 28 | 31 + 28 + 31 + 30, 29 | 31 + 28 + 31 + 30 + 31, 30 | 31 + 28 + 31 + 30 + 31 + 30, 31 | 31 + 28 + 31 + 30 + 31 + 30 + 31, 32 | 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31, 33 | 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30, 34 | 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31, 35 | 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31 + 30, 36 | 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31 + 30 + 31, 37 | } 38 | 39 | // daysIn is vendored in from the standard library: 40 | // https://github.com/golang/go/blob/go1.22.1/src/time/time.go#L1079-L1084 41 | func daysIn(m time.Month, year int) int { 42 | if m == time.February && isLeap(year) { 43 | return 29 44 | } 45 | return int(daysBefore[m] - daysBefore[m-1]) 46 | } 47 | 48 | // isLeap is vendored in from the standard library: 49 | // https://github.com/golang/go/blob/go1.22.1/src/time/time.go#L1448-L1450 50 | func isLeap(year int) bool { 51 | return year%4 == 0 && (year%100 != 0 || year%400 == 0) 52 | } 53 | -------------------------------------------------------------------------------- /today.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Hardfin, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package date 16 | 17 | import ( 18 | "time" 19 | ) 20 | 21 | // TodayConfig helps customize the behavior of `Today()`. 22 | type TodayConfig struct { 23 | Timezone *time.Location 24 | NowProvider func() time.Time 25 | } 26 | 27 | // TodayOption defines a function that will be applied to a `Today()` config. 28 | type TodayOption func(*TodayConfig) 29 | 30 | // Today determines the **current** `Date`, shifted to a given timezone 31 | // if need be. 32 | // 33 | // Defaults to using UTC and `time.Now()` to determine the current time. 34 | func Today(opts ...TodayOption) Date { 35 | tc := TodayConfig{ 36 | Timezone: time.UTC, 37 | NowProvider: time.Now, 38 | } 39 | for _, opt := range opts { 40 | opt(&tc) 41 | } 42 | 43 | now := tc.NowProvider().In(tc.Timezone) 44 | year, month, day := now.Date() 45 | return Date{Year: year, Month: month, Day: day} 46 | } 47 | 48 | // OptTodayTimezone returns an option that sets the timezone on a `Today()` 49 | // config. 50 | func OptTodayTimezone(tz *time.Location) TodayOption { 51 | return func(tc *TodayConfig) { 52 | tc.Timezone = tz 53 | } 54 | } 55 | 56 | // OptTodayNow returns an option that sets the now provider on a `Today()` 57 | // config to return a **constant** `now` value. 58 | // 59 | // This is expected to be used in tests. 60 | func OptTodayNow(now time.Time) TodayOption { 61 | return func(tc *TodayConfig) { 62 | tc.NowProvider = func() time.Time { 63 | return now 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /today_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Hardfin, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package date_test 16 | 17 | import ( 18 | "fmt" 19 | "testing" 20 | "time" 21 | 22 | testifyrequire "github.com/stretchr/testify/require" 23 | 24 | date "github.com/hardfinhq/go-date" 25 | ) 26 | 27 | func TestToday(base *testing.T) { 28 | base.Parallel() 29 | 30 | type testCase struct { 31 | Timezone string 32 | Now string 33 | Date date.Date 34 | } 35 | 36 | cases := []testCase{ 37 | { 38 | Timezone: "UTC", 39 | Now: "2020-05-11T07:10:55.209309302Z", 40 | Date: date.Date{Year: 2020, Month: time.May, Day: 11}, 41 | }, 42 | { 43 | Timezone: "UTC", 44 | Now: "2022-01-31T00:00:00.000Z", 45 | Date: date.Date{Year: 2022, Month: time.January, Day: 31}, 46 | }, 47 | { 48 | Timezone: "America/Los_Angeles", 49 | Now: "2022-01-31T00:00:00.000Z", 50 | Date: date.Date{Year: 2022, Month: time.January, Day: 30}, 51 | }, 52 | } 53 | 54 | for i := range cases { 55 | // NOTE: Assign to loop-local (instead of declaring the `tc` variable in 56 | // `range`) to avoid capturing reference to loop variable. 57 | tc := cases[i] 58 | description := fmt.Sprintf("%s:%s", tc.Now, tc.Timezone) 59 | base.Run(description, func(t *testing.T) { 60 | t.Parallel() 61 | assert := testifyrequire.New(t) 62 | 63 | now, err := time.Parse(time.RFC3339Nano, tc.Now) 64 | assert.Nil(err) 65 | tz, err := time.LoadLocation(tc.Timezone) 66 | assert.Nil(err) 67 | 68 | d := date.Today( 69 | date.OptTodayTimezone(tz), 70 | date.OptTodayNow(now), 71 | ) 72 | assert.Equal(tc.Date, d) 73 | }) 74 | } 75 | } 76 | --------------------------------------------------------------------------------