From 26eff559743dce434f1d11acf9b449cbd068cb3e Mon Sep 17 00:00:00 2001 From: Joakim Olsson Date: Sat, 7 Jun 2025 19:23:44 +0200 Subject: [PATCH] feat: initial version --- .editorconfig | 11 ++++ .gitignore | 2 + .gitlab-ci.yml | 38 ++++++++++++++ .golangci.yml | 22 ++++++++ .pre-commit-config.yaml | 46 +++++++++++++++++ README.md | 6 +++ cliff.toml | 87 ++++++++++++++++++++++++++++++++ go.mod | 11 ++++ go.sum | 10 ++++ pagination.go | 108 ++++++++++++++++++++++++++++++++++++++++ pagination_test.go | 105 ++++++++++++++++++++++++++++++++++++++ renovate.json | 6 +++ 12 files changed, 452 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .golangci.yml create mode 100644 .pre-commit-config.yaml create mode 100644 README.md create mode 100644 cliff.toml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pagination.go create mode 100644 pagination_test.go create mode 100644 renovate.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..04cd3ad --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +[*.go] +indent_style = tab +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ada4d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +/release diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..44301ef --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,38 @@ +include: +- template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml' +- project: unboundsoftware/ci-templates + file: Defaults.gitlab-ci.yml +- project: unboundsoftware/ci-templates + file: Release.gitlab-ci.yml +- project: unboundsoftware/ci-templates + file: Pre-Commit-Go.gitlab-ci.yml + +image: amd64/golang:1.24.4@sha256:40891f7b63de861049787c5262bff91906d30cbe221753840e276b3e785a66ab + +stages: +- deps +- test + +deps: + stage: deps + script: + - go mod download + +test: + stage: test + dependencies: + - deps + script: + - CGO_ENABLED=1 go test -mod=readonly -race -coverprofile=coverage.txt -covermode=atomic -coverpkg=$(go list ./... | tr '\n' , | sed 's/,$//') ./... + - go tool cover -html=coverage.txt -o coverage.html + - go tool cover -func=coverage.txt + - curl -Os https://uploader.codecov.io/latest/linux/codecov + - chmod +x codecov + - ./codecov -t ${CODECOV_TOKEN} -R $CI_PROJECT_DIR -C $CI_COMMIT_SHA -r $CI_PROJECT_PATH + +vulnerabilities: + stage: test + image: amd64/golang:1.24.4@sha256:40891f7b63de861049787c5262bff91906d30cbe221753840e276b3e785a66ab + script: + - go install golang.org/x/vuln/cmd/govulncheck@latest + - govulncheck ./... diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5381cc5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,22 @@ +version: "2" +run: + allow-parallel-runners: true +linters: + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0ad03da --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,46 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + args: + - --allow-multiple-documents + - id: check-added-large-files +- repo: https://gitlab.com/devopshq/gitlab-ci-linter + rev: v1.0.6 + hooks: + - id: gitlab-ci-linter + args: + - --project + - unboundsoftware/pagination +- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook + rev: v9.22.0 + hooks: + - id: commitlint + stages: [ commit-msg ] + additional_dependencies: [ '@commitlint/config-conventional' ] +- repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-mod-tidy + - id: go-imports + args: + - -local + - gitlab.com/unboundsoftware/shiny/presenter +- repo: https://github.com/lietu/go-pre-commit + rev: v0.1.0 + hooks: + - id: go-test + - id: gofumpt +- repo: https://github.com/golangci/golangci-lint + rev: v2.1.6 + hooks: + - id: golangci-lint-full +- repo: https://github.com/gitleaks/gitleaks + rev: v8.27.0 + hooks: + - id: gitleaks diff --git a/README.md b/README.md new file mode 100644 index 0000000..0351f0c --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# Pagination helper + +Pagination helper + +[![Build Status](https://gitlab.com/unboundsoftware/pagination/badges/main/pipeline.svg)](https://gitlab.com/unboundsoftware/pagination/commits/main) +[![codecov](https://codecov.io/gl/unboundsoftware:pagination/branch/main/graph/badge.svg?token=AZ3AHHF0FS)](https://codecov.io/gl/unboundsoftware:pagination) diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..d6ed913 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,87 @@ +# git-cliff ~ default configuration file +# https://git-cliff.org/docs/configuration +# +# Lines starting with "#" are comments. +# Configuration options are organized into tables and keys. +# See documentation for more information on available options. + +[bump] +initial_tag = "v0.0.1" + +[changelog] +# template for the changelog header +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# template for the changelog footer +footer = """ + +""" +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [ + # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL +] +# render body even when there are no releases to process +# render_always = true +# output file path +# output = "test.md" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Replace issue numbers + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit with https://github.com/crate-ci/typos + # If the spelling is incorrect, it will be automatically fixed. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "๐Ÿš€ Features" }, + { message = "^fix", group = "๐Ÿ› Bug Fixes" }, + { message = "^doc", group = "๐Ÿ“š Documentation" }, + { message = "^perf", group = "โšก Performance" }, + { message = "^refactor", group = "๐Ÿšœ Refactor" }, + { message = "^style", group = "๐ŸŽจ Styling" }, + { message = "^test", group = "๐Ÿงช Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore|^ci", group = "โš™๏ธ Miscellaneous Tasks" }, + { body = ".*security", group = "๐Ÿ›ก๏ธ Security" }, + { message = "^revert", group = "โ—€๏ธ Revert" }, +] +# filter out the commits that are not matched by commit parsers +filter_commits = false +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" +tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3af13e6 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module gitlab.com/unboundsoftware/pagination + +go 1.24.4 + +require github.com/stretchr/testify v1.10.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..713a0b4 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pagination.go b/pagination.go new file mode 100644 index 0000000..36fd239 --- /dev/null +++ b/pagination.go @@ -0,0 +1,108 @@ +package pagination + +import ( + "encoding/base64" + "fmt" + "slices" +) + +func Validate(first *int, after *string, last *int, before *string) error { + if first != nil && last != nil { + return fmt.Errorf("only one of first and last can be provided") + } + if first != nil && *first < 0 { + return fmt.Errorf("first must be greater than 0") + } + if last != nil && *last < 0 { + return fmt.Errorf("last must be greater than 0") + } + if after != nil && len(*after) > 0 && before != nil && len(*before) > 0 { + return fmt.Errorf("only one of after and before can be provided") + } + if ValidateCursor(after) != nil { + return fmt.Errorf("after is not a valid cursor") + } + if ValidateCursor(before) != nil { + return fmt.Errorf("before is not a valid cursor") + } + return nil +} + +func ValidateCursor(cursor *string) error { + _, err := DecodeCursor(cursor) + if err != nil { + return err + } + return nil +} + +func DecodeCursor(cursor *string) (string, error) { + if cursor == nil { + return "", nil + } + b64, err := base64.StdEncoding.DecodeString(*cursor) + if err != nil { + return "", err + } + return string(b64), nil +} + +func EncodeCursor(cursor string) string { + return base64.StdEncoding.EncodeToString([]byte(cursor)) +} + +func GetPage[T any](items []T, first *int, after *string, last *int, before *string, max int, fn func(T) string) ([]T, PageInfo) { + if items == nil { + return nil, PageInfo{} + } + tmp := min(max, len(items)) + sIx := 0 + eIx := sIx + tmp + if first != nil { + tmp = *first + eIx = sIx + tmp + } else if last != nil { + tmp = *last + sIx = len(items) - tmp + eIx = len(items) + } + if cursor, err := DecodeCursor(after); err == nil && cursor != "" { + idx := slices.IndexFunc(items, func(item T) bool { + return fn(item) == cursor + }) + idx = idx + 1 + if idx+tmp >= len(items) { + tmp = len(items) - idx + } + sIx = idx + eIx = idx + tmp + } else if cursor, err := DecodeCursor(before); err == nil && cursor != "" { + idx := slices.IndexFunc(items, func(item T) bool { + return fn(item) == cursor + }) + f := idx - tmp + if f < 0 { + f = 0 + } + sIx = f + eIx = idx + } + page := items[sIx:eIx] + return page, PageInfo{ + StartCursor: ptr(EncodeCursor(fn(page[0]))), + HasNextPage: eIx < len(items), + HasPreviousPage: sIx > 0, + EndCursor: ptr(EncodeCursor(fn(page[len(page)-1]))), + } +} + +func ptr[T any](v T) *T { + return &v +} + +type PageInfo struct { + StartCursor *string + HasNextPage bool + HasPreviousPage bool + EndCursor *string +} diff --git a/pagination_test.go b/pagination_test.go new file mode 100644 index 0000000..b7c4b40 --- /dev/null +++ b/pagination_test.go @@ -0,0 +1,105 @@ +package pagination + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetPage(t *testing.T) { + type args[T any] struct { + items []T + first *int + after *string + last *int + before *string + max int + fn func(T) string + } + type testCase[T any] struct { + name string + args args[T] + wantItems []T + wantPageInfo PageInfo + } + tests := []testCase[string]{ + { + name: "empty", + args: args[string]{max: 10}, + wantItems: nil, + wantPageInfo: PageInfo{}, + }, + { + name: "first 2", + args: args[string]{items: []string{"1", "2", "3"}, first: ptr(2), max: 10, fn: func(s string) string { return s }}, + wantItems: []string{"1", "2"}, + wantPageInfo: PageInfo{ + StartCursor: ptr("MQ=="), + HasNextPage: true, + EndCursor: ptr("Mg=="), + }, + }, + { + name: "after 2", + args: args[string]{items: []string{"1", "2", "3", "4"}, first: ptr(2), after: ptr("Mg=="), max: 10, fn: func(s string) string { return s }}, + wantItems: []string{"3", "4"}, + wantPageInfo: PageInfo{ + StartCursor: ptr("Mw=="), + HasNextPage: false, + HasPreviousPage: true, + EndCursor: ptr("NA=="), + }, + }, + { + name: "end of items", + args: args[string]{items: []string{"1", "2", "3"}, first: ptr(2), after: ptr("Mg=="), max: 10, fn: func(s string) string { return s }}, + wantItems: []string{"3"}, + wantPageInfo: PageInfo{ + StartCursor: ptr("Mw=="), + HasNextPage: false, + HasPreviousPage: true, + EndCursor: ptr("Mw=="), + }, + }, + { + name: "last 2", + args: args[string]{items: []string{"1", "2", "3"}, last: ptr(2), max: 10, fn: func(s string) string { return s }}, + wantItems: []string{"2", "3"}, + wantPageInfo: PageInfo{ + StartCursor: ptr("Mg=="), + HasNextPage: false, + HasPreviousPage: true, + EndCursor: ptr("Mw=="), + }, + }, + { + name: "before 3", + args: args[string]{items: []string{"1", "2", "3"}, last: ptr(2), before: ptr("Mw=="), max: 10, fn: func(s string) string { return s }}, + wantItems: []string{"1", "2"}, + wantPageInfo: PageInfo{ + StartCursor: ptr("MQ=="), + HasNextPage: true, + HasPreviousPage: false, + EndCursor: ptr("Mg=="), + }, + }, + { + name: "before 2", + args: args[string]{items: []string{"1", "2", "3"}, last: ptr(2), before: ptr("Mg=="), max: 10, fn: func(s string) string { return s }}, + wantItems: []string{"1"}, + wantPageInfo: PageInfo{ + StartCursor: ptr("MQ=="), + HasNextPage: true, + HasPreviousPage: false, + EndCursor: ptr("MQ=="), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1 := GetPage(tt.args.items, tt.args.first, tt.args.after, tt.args.last, tt.args.before, tt.args.max, tt.args.fn) + assert.Equalf(t, tt.wantItems, got, "GetPage(%v, %v, %v, %v, %v, %v)", tt.args.items, tt.args.first, tt.args.after, tt.args.last, tt.args.before, tt.args.max) + assert.Equalf(t, tt.wantPageInfo, got1, "GetPage(%v, %v, %v, %v, %v, %v)", tt.args.items, tt.args.first, tt.args.after, tt.args.last, tt.args.before, tt.args.max) + }) + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +}