├── .travis.yml ├── .gitignore ├── .circleci └── config.yml ├── go.mod ├── .github └── workflows │ └── go.yml ├── LICENSE ├── go.sum ├── paginate_test.go ├── paginate.go └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | os: linux 3 | dist: xenial 4 | go: 1.x 5 | script: go test -v -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /* 2 | !.gitignore 3 | !LICENSE 4 | !*.go 5 | !*.mod 6 | !*.sum 7 | !*.md 8 | !*.yml 9 | !/.circleci/ 10 | !/.github/ -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: golang:1.16-buster 6 | working_directory: /go/src/github.com/morkid/paginate 7 | steps: 8 | - checkout 9 | - run: go test -v 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/morkid/paginate 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/iancoleman/strcase v0.1.3 7 | github.com/klauspost/compress v1.11.12 // indirect 8 | github.com/morkid/gocache v1.0.0 9 | github.com/valyala/fasthttp v1.22.0 10 | gorm.io/driver/sqlite v1.1.4 11 | gorm.io/gorm v1.21.3 12 | ) 13 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | paths: 7 | - '**.go' 8 | - '**.mod' 9 | - '**.sum' 10 | pull_request: 11 | branches: [ master ] 12 | 13 | jobs: 14 | 15 | build: 16 | name: Build 17 | runs-on: ubuntu-latest 18 | steps: 19 | 20 | - name: Set up Go 1.x 21 | uses: actions/setup-go@v2 22 | with: 23 | go-version: ^1.13 24 | 25 | - name: Check out code into the Go module directory 26 | uses: actions/checkout@v2 27 | 28 | - name: Test 29 | run: go test -v 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 morkid 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.0.1 h1:KqhlKozYbRtJvsPrrEeXcO+N2l6NYT5A2QAFmSULpEc= 2 | github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= 3 | github.com/iancoleman/strcase v0.1.3 h1:dJBk1m2/qjL1twPLf68JND55vvivMupZ4wIzE8CTdBw= 4 | github.com/iancoleman/strcase v0.1.3/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE= 5 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 6 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 7 | github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E= 8 | github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 9 | github.com/klauspost/compress v1.11.8/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= 10 | github.com/klauspost/compress v1.11.12 h1:famVnQVu7QwryBN4jNseQdUKES71ZAOnB6UQQJPZvqk= 11 | github.com/klauspost/compress v1.11.12/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= 12 | github.com/mattn/go-sqlite3 v1.14.5 h1:1IdxlwTNazvbKJQSxoJ5/9ECbEeaTTyeU7sEAZ5KKTQ= 13 | github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= 14 | github.com/morkid/gocache v1.0.0 h1:hTnU78Dqp2vs9al5vJC2TmmMF+Hm3nDH1AgRBjSXE+0= 15 | github.com/morkid/gocache v1.0.0/go.mod h1:xK+hmoEMjYffIBvjn7DE8WfSd/rF5Kz/G9f20OliMJY= 16 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 17 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 18 | github.com/valyala/fasthttp v1.22.0 h1:OpwH5KDOJ9cS2bq8fD+KfT4IrksK0llvkHf4MZx42jQ= 19 | github.com/valyala/fasthttp v1.22.0/go.mod h1:0mw2RjXGOzxf4NL2jni3gUQ7LfjjUSiG5sskOUUSEpU= 20 | github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= 21 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 22 | golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 23 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 24 | golang.org/x/net v0.0.0-20210226101413-39120d07d75e/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 25 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 26 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 27 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 28 | golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 29 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 30 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 31 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 32 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 33 | golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 34 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 35 | gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM= 36 | gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw= 37 | gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 38 | gorm.io/gorm v1.21.3 h1:qDFi55ZOsjZTwk5eN+uhAmHi8GysJ/qCTichM/yO7ME= 39 | gorm.io/gorm v1.21.3/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 40 | -------------------------------------------------------------------------------- /paginate_test.go: -------------------------------------------------------------------------------- 1 | package paginate 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "reflect" 12 | "strings" 13 | "testing" 14 | "time" 15 | 16 | "github.com/morkid/gocache" 17 | "github.com/valyala/fasthttp" 18 | "gorm.io/driver/sqlite" 19 | "gorm.io/gorm" 20 | "gorm.io/gorm/logger" 21 | ) 22 | 23 | var format = "%s doesn't match. Expected: %v, Result: %v" 24 | 25 | func TestGetNetHttp(t *testing.T) { 26 | size := int64(20) 27 | page := int64(1) 28 | sort := "user.name,-id" 29 | avg := "seventy %" 30 | 31 | queryFilter := fmt.Sprintf(`[["user.average_point","like","%s"]]`, avg) 32 | query := fmt.Sprintf(`page=%d&size=%d&sort=%s&filters=%s`, page, size, sort, url.QueryEscape(queryFilter)) 33 | 34 | req := &http.Request{ 35 | Method: "GET", 36 | URL: &url.URL{ 37 | RawQuery: query, 38 | }, 39 | } 40 | 41 | parsed := parseRequest(req, Config{}) 42 | if parsed.Size != size { 43 | t.Errorf(format, "Size", size, parsed.Size) 44 | } 45 | if parsed.Page != page { 46 | t.Errorf(format, "Page", page, parsed.Page) 47 | } 48 | if len(parsed.Sorts) != 2 { 49 | t.Errorf(format, "Sort length", 2, len(parsed.Sorts)) 50 | } else { 51 | if parsed.Sorts[0].Column != "user.name" { 52 | t.Errorf(format, "Sort field 0", "user.name", parsed.Sorts[0].Column) 53 | } 54 | if parsed.Sorts[0].Direction != "ASC" { 55 | t.Errorf(format, "Sort direction 0", "ASC", parsed.Sorts[0].Direction) 56 | } 57 | if parsed.Sorts[1].Column != "id" { 58 | t.Errorf(format, "Sort field 1", "id", parsed.Sorts[1].Column) 59 | } 60 | if parsed.Sorts[1].Direction != "DESC" { 61 | t.Errorf(format, "Sort direction 1", "DESC", parsed.Sorts[1].Direction) 62 | } 63 | } 64 | 65 | filters, ok := parsed.Filters.Value.([]pageFilters) 66 | if ok { 67 | if filters[0].Column != "user.average_point" { 68 | t.Errorf(format, "Filter field for user.average_point", "user.average_point", filters[0].Column) 69 | } 70 | if filters[0].Operator != "LIKE" { 71 | t.Errorf(format, "Filter operator for user.average_point", "LIKE", filters[0].Operator) 72 | } 73 | value, isValid := filters[0].Value.(string) 74 | expected := "%" + avg + "%" 75 | if !isValid || value != expected { 76 | t.Errorf(format, "Filter operator for user.average_point", expected, value) 77 | } 78 | } else { 79 | t.Log(parsed.Filters) 80 | t.Errorf(format, "pageFilters class", "paginate.pageFilters", "null") 81 | } 82 | } 83 | func TestGetFastHttp(t *testing.T) { 84 | size := int64(20) 85 | page := int64(1) 86 | sort := "user.name,-id" 87 | avg := "seventy %" 88 | 89 | queryFilter := fmt.Sprintf(`[["user.average_point","like","%s"]]`, avg) 90 | query := fmt.Sprintf(`page=%d&size=%d&sort=%s&filters=%s`, page, size, sort, url.QueryEscape(queryFilter)) 91 | 92 | req := &fasthttp.Request{} 93 | req.Header.SetMethod("GET") 94 | req.URI().SetQueryString(query) 95 | 96 | parsed := parseRequest(req, Config{}) 97 | if parsed.Size != size { 98 | t.Errorf(format, "Size", size, parsed.Size) 99 | } 100 | if parsed.Page != page { 101 | t.Errorf(format, "Page", page, parsed.Page) 102 | } 103 | if len(parsed.Sorts) != 2 { 104 | t.Errorf(format, "Sort length", 2, len(parsed.Sorts)) 105 | } else { 106 | if parsed.Sorts[0].Column != "user.name" { 107 | t.Errorf(format, "Sort field 0", "user.name", parsed.Sorts[0].Column) 108 | } 109 | if parsed.Sorts[0].Direction != "ASC" { 110 | t.Errorf(format, "Sort direction 0", "ASC", parsed.Sorts[0].Direction) 111 | } 112 | if parsed.Sorts[1].Column != "id" { 113 | t.Errorf(format, "Sort field 1", "id", parsed.Sorts[1].Column) 114 | } 115 | if parsed.Sorts[1].Direction != "DESC" { 116 | t.Errorf(format, "Sort direction 1", "DESC", parsed.Sorts[1].Direction) 117 | } 118 | } 119 | 120 | filters, ok := parsed.Filters.Value.([]pageFilters) 121 | if ok { 122 | if filters[0].Column != "user.average_point" { 123 | t.Errorf(format, "Filter field for user.average_point", "user.average_point", filters[0].Column) 124 | } 125 | if filters[0].Operator != "LIKE" { 126 | t.Errorf(format, "Filter operator for user.average_point", "LIKE", filters[0].Operator) 127 | } 128 | value, isValid := filters[0].Value.(string) 129 | expected := "%" + avg + "%" 130 | if !isValid || value != expected { 131 | t.Errorf(format, "Filter operator for user.average_point", expected, value) 132 | } 133 | } else { 134 | t.Log(parsed.Filters) 135 | t.Errorf(format, "pageFilters class", "paginate.pageFilters", "null") 136 | } 137 | } 138 | 139 | func TestPostNetHttp(t *testing.T) { 140 | size := int64(20) 141 | page := int64(1) 142 | sort := "user.name,-id" 143 | avg := "seventy %" 144 | 145 | data := ` 146 | { 147 | "page": %d, 148 | "size": %d, 149 | "sort": "%s", 150 | "filters": %s 151 | } 152 | ` 153 | 154 | queryFilter := fmt.Sprintf(`[["user.average_point","like","%s"]]`, avg) 155 | query := fmt.Sprintf(data, page, size, sort, queryFilter) 156 | 157 | body := io.NopCloser(bytes.NewReader([]byte(query))) 158 | 159 | req := &http.Request{ 160 | Method: "POST", 161 | Body: body, 162 | } 163 | 164 | parsed := parseRequest(req, Config{}) 165 | if parsed.Size != size { 166 | t.Errorf(format, "Size", size, parsed.Size) 167 | } 168 | if parsed.Page != page { 169 | t.Errorf(format, "Page", page, parsed.Page) 170 | } 171 | if len(parsed.Sorts) != 2 { 172 | t.Errorf(format, "Sort length", 2, len(parsed.Sorts)) 173 | } else { 174 | if parsed.Sorts[0].Column != "user.name" { 175 | t.Errorf(format, "Sort field 0", "user.name", parsed.Sorts[0].Column) 176 | } 177 | if parsed.Sorts[0].Direction != "ASC" { 178 | t.Errorf(format, "Sort direction 0", "ASC", parsed.Sorts[0].Direction) 179 | } 180 | if parsed.Sorts[1].Column != "id" { 181 | t.Errorf(format, "Sort field 1", "id", parsed.Sorts[1].Column) 182 | } 183 | if parsed.Sorts[1].Direction != "DESC" { 184 | t.Errorf(format, "Sort direction 1", "DESC", parsed.Sorts[1].Direction) 185 | } 186 | } 187 | 188 | filters, ok := parsed.Filters.Value.([]pageFilters) 189 | if ok { 190 | if filters[0].Column != "user.average_point" { 191 | t.Errorf(format, "Filter field for user.average_point", "user.average_point", filters[0].Column) 192 | } 193 | if filters[0].Operator != "LIKE" { 194 | t.Errorf(format, "Filter operator for user.average_point", "LIKE", filters[0].Operator) 195 | } 196 | value, isValid := filters[0].Value.(string) 197 | expected := "%" + avg + "%" 198 | if !isValid || value != expected { 199 | t.Errorf(format, "Filter operator for user.average_point", expected, value) 200 | } 201 | } else { 202 | t.Log(parsed.Filters) 203 | t.Errorf(format, "pageFilters class", "paginate.pageFilters", "null") 204 | } 205 | } 206 | func TestPostFastHttp(t *testing.T) { 207 | size := int64(20) 208 | page := int64(1) 209 | sort := "user.name,-id" 210 | avg := "seventy %" 211 | 212 | data := ` 213 | { 214 | "page": %d, 215 | "size": %d, 216 | "sort": "%s", 217 | "filters": %s 218 | } 219 | ` 220 | 221 | queryFilter := fmt.Sprintf(`[["user.average_point","like","%s"]]`, avg) 222 | query := fmt.Sprintf(data, page, size, sort, queryFilter) 223 | 224 | req := &fasthttp.Request{} 225 | req.Header.SetMethod("POST") 226 | req.SetBodyString(query) 227 | 228 | parsed := parseRequest(req, Config{}) 229 | if parsed.Size != size { 230 | t.Errorf(format, "Size", size, parsed.Size) 231 | } 232 | if parsed.Page != page { 233 | t.Errorf(format, "Page", page, parsed.Page) 234 | } 235 | if len(parsed.Sorts) != 2 { 236 | t.Errorf(format, "Sort length", 2, len(parsed.Sorts)) 237 | } else { 238 | if parsed.Sorts[0].Column != "user.name" { 239 | t.Errorf(format, "Sort field 0", "user.name", parsed.Sorts[0].Column) 240 | } 241 | if parsed.Sorts[0].Direction != "ASC" { 242 | t.Errorf(format, "Sort direction 0", "ASC", parsed.Sorts[0].Direction) 243 | } 244 | if parsed.Sorts[1].Column != "id" { 245 | t.Errorf(format, "Sort field 1", "id", parsed.Sorts[1].Column) 246 | } 247 | if parsed.Sorts[1].Direction != "DESC" { 248 | t.Errorf(format, "Sort direction 1", "DESC", parsed.Sorts[1].Direction) 249 | } 250 | } 251 | 252 | filters, ok := parsed.Filters.Value.([]pageFilters) 253 | if ok { 254 | if filters[0].Column != "user.average_point" { 255 | t.Errorf(format, "Filter field for user.average_point", "user.average_point", filters[0].Column) 256 | } 257 | if filters[0].Operator != "LIKE" { 258 | t.Errorf(format, "Filter operator for user.average_point", "LIKE", filters[0].Operator) 259 | } 260 | value, isValid := filters[0].Value.(string) 261 | expected := "%" + avg + "%" 262 | if !isValid || value != expected { 263 | t.Errorf(format, "Filter operator for user.average_point", expected, value) 264 | } 265 | } else { 266 | t.Errorf(format, "pageFilters class", "paginate.pageFilters", "null") 267 | } 268 | } 269 | 270 | func TestProgrammaticallyPaginate(t *testing.T) { 271 | size := int64(20) 272 | page := int64(1) 273 | sort := "user.name,-id" 274 | avg := "seventy %" 275 | 276 | req := &Request{ 277 | Page: page, 278 | Size: size, 279 | Sort: sort, 280 | Filters: []interface{}{ 281 | []interface{}{"user.average_point", "like", avg}, 282 | []interface{}{"and"}, 283 | []interface{}{"user.average_point", "is not", nil}, 284 | }, 285 | } 286 | 287 | parsed := parseRequest(req, Config{}) 288 | if parsed.Size != size { 289 | t.Errorf(format, "Size", size, parsed.Size) 290 | } 291 | if parsed.Page != page { 292 | t.Errorf(format, "Page", page, parsed.Page) 293 | } 294 | if len(parsed.Sorts) != 2 { 295 | t.Errorf(format, "Sort length", 2, len(parsed.Sorts)) 296 | } else { 297 | if parsed.Sorts[0].Column != "user.name" { 298 | t.Errorf(format, "Sort field 0", "user.name", parsed.Sorts[0].Column) 299 | } 300 | if parsed.Sorts[0].Direction != "ASC" { 301 | t.Errorf(format, "Sort direction 0", "ASC", parsed.Sorts[0].Direction) 302 | } 303 | if parsed.Sorts[1].Column != "id" { 304 | t.Errorf(format, "Sort field 1", "id", parsed.Sorts[1].Column) 305 | } 306 | if parsed.Sorts[1].Direction != "DESC" { 307 | t.Errorf(format, "Sort direction 1", "DESC", parsed.Sorts[1].Direction) 308 | } 309 | } 310 | 311 | t.Log(parsed.Filters) 312 | 313 | filters, ok := parsed.Filters.Value.([]pageFilters) 314 | if ok { 315 | if filters[0].Column != "user.average_point" { 316 | t.Errorf(format, "Filter field for user.average_point", "user.average_point", filters[0].Column) 317 | } 318 | if filters[0].Operator != "LIKE" { 319 | t.Errorf(format, "Filter operator for user.average_point", "LIKE", filters[0].Operator) 320 | } 321 | value, isValid := filters[0].Value.(string) 322 | expected := "%" + avg + "%" 323 | if !isValid || value != expected { 324 | t.Errorf(format, "Filter operator for user.average_point", expected, value) 325 | } 326 | } else { 327 | t.Log(parsed.Filters) 328 | t.Errorf(format, "pageFilters class", "paginate.pageFilters", "null") 329 | } 330 | 331 | } 332 | 333 | func TestPaginate(t *testing.T) { 334 | type User struct { 335 | gorm.Model 336 | Name string `json:"name"` 337 | AveragePoint string `json:"average_point"` 338 | } 339 | 340 | type Article struct { 341 | gorm.Model 342 | Title string `json:"title"` 343 | Content string `json:"content"` 344 | UserID uint `json:"-"` 345 | User User `json:"user"` 346 | } 347 | 348 | // dsn := "host=127.0.0.1 port=5433 user=postgres password=postgres dbname=postgres sslmode=disable TimeZone=Asia/Jakarta" 349 | // dsn := "gorm.db" 350 | dsn := "file::memory:?cache=shared" 351 | 352 | db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ 353 | Logger: logger.Discard, 354 | }) 355 | db.Exec("PRAGMA case_sensitive_like=ON;") 356 | db.AutoMigrate(&User{}, &Article{}) 357 | 358 | users := []User{{Name: "John doe", AveragePoint: "Seventy %"}, {Name: "Jane doe", AveragePoint: "one hundred %"}} 359 | articles := []Article{} 360 | 361 | // add massive data 362 | for i := 0; i < 50; i++ { 363 | articles = append(articles, Article{ 364 | Title: fmt.Sprintf("Written by john %d", i), 365 | Content: fmt.Sprintf("Example by john %d", i), 366 | UserID: 1, 367 | }) 368 | articles = append(articles, Article{ 369 | Title: fmt.Sprintf("Written by jane %d", i), 370 | Content: fmt.Sprintf("Example by jane %d", i), 371 | UserID: 2, 372 | }) 373 | } 374 | 375 | if nil != err { 376 | t.Error(err.Error()) 377 | return 378 | } 379 | 380 | tx := db.Begin() 381 | 382 | if err := tx.Create(&users).Error; nil != err { 383 | tx.Rollback() 384 | t.Error(err.Error()) 385 | return 386 | } else if err := tx.Create(&articles).Error; nil != err { 387 | tx.Rollback() 388 | t.Error(err.Error()) 389 | return 390 | } else if err := tx.Commit().Error; nil != err { 391 | tx.Rollback() 392 | t.Error(err.Error()) 393 | return 394 | } 395 | 396 | // wait for transaction to finish 397 | time.Sleep(1 * time.Second) 398 | 399 | size := 1 400 | page := 0 401 | sort := "user.name,-id" 402 | avg := "y %" 403 | data := "page=%v&size=%d&sort=%s&filters=%s" 404 | 405 | queryFilter := fmt.Sprintf(`[["user.average_point","like","%s"],["AND"],["user.name","IS NOT",null]]`, avg) 406 | query := fmt.Sprintf(data, page, size, sort, url.QueryEscape(queryFilter)) 407 | 408 | request := &http.Request{ 409 | Method: "GET", 410 | URL: &url.URL{ 411 | RawQuery: query, 412 | }, 413 | } 414 | response := []Article{} 415 | 416 | stmt := db.Joins("User").Model(&Article{}) 417 | result := New(&Config{LikeAsIlikeDisabled: true}).With(stmt).Request(request).Response(&response) 418 | 419 | _, err = json.MarshalIndent(result, "", " ") 420 | expectNil(t, err) 421 | expect(t, result.Page, int64(0), "Invalid page") 422 | expect(t, result.Total, int64(50), "Invalid total result") 423 | expect(t, result.TotalPages, int64(50), "Invalid total pages") 424 | expect(t, result.MaxPage, int64(49), "Invalid max page") 425 | expectTrue(t, result.First, "Invalid first page") 426 | expectFalse(t, result.Last, "Invalid last page") 427 | 428 | queryFilter = fmt.Sprintf(`[["users.average_point","like","%s"],["AND"],["user.name","IS NOT",null],["id","like","1"]]`, avg) 429 | query = fmt.Sprintf(data, page, size, sort, url.QueryEscape(queryFilter)) 430 | 431 | request = &http.Request{ 432 | Method: "GET", 433 | URL: &url.URL{ 434 | RawQuery: query, 435 | }, 436 | } 437 | response = []Article{} 438 | 439 | stmt = db.Joins("User").Model(&Article{}) 440 | result = New(&Config{ErrorEnabled: true}).With(stmt).Request(request).Response(&response) 441 | expectTrue(t, result.Error, "Failed to get error message") 442 | 443 | page = 1 444 | size = 100 445 | pageStart := int64(1) 446 | query = fmt.Sprintf(data, page, size, sort, "") 447 | 448 | request = &http.Request{ 449 | Method: "GET", 450 | URL: &url.URL{ 451 | RawQuery: query, 452 | }, 453 | } 454 | response = []Article{} 455 | 456 | stmt = db.Joins("User").Model(&Article{}) 457 | result = New(&Config{PageStart: pageStart}).With(stmt).Request(request).Response(&response) 458 | expect(t, result.Page, int64(1), "Invalid page start") 459 | expect(t, result.MaxPage, int64(1), "Invalid max page") 460 | expect(t, len(response), 100, "Invalid total items") 461 | expect(t, result.Total, int64(100), "Invalid total result") 462 | expect(t, result.TotalPages, int64(1), "Invalid total pages") 463 | expectTrue(t, result.First, "Invalid value first") 464 | expectTrue(t, result.Last, "Invalid value last") 465 | 466 | queryFilter = `[["user.average_point","like","y %"],["AND"],["user.name,title","LIKE","john"]]` 467 | query = fmt.Sprintf(data, page, size, sort, url.QueryEscape(queryFilter)) 468 | 469 | request = &http.Request{ 470 | Method: "GET", 471 | URL: &url.URL{ 472 | RawQuery: query, 473 | }, 474 | } 475 | response = []Article{} 476 | 477 | stmt = db.Joins("User").Model(&Article{}) 478 | result = New(&Config{Operator: "AND", PageStart: pageStart, ErrorEnabled: true}). 479 | With(stmt).Request(request).Response(&response) 480 | expectFalse(t, result.Error, "An error occurred") 481 | expect(t, result.Page, int64(1), "Invalid page start") 482 | expect(t, result.MaxPage, int64(1), "Invalid max page") 483 | expect(t, result.Total, int64(50), "Invalid max page") 484 | } 485 | 486 | type noOpAdapter struct { 487 | keyValues map[string]string 488 | T *testing.T 489 | clearCounter int 490 | clearPrefixCounter int 491 | } 492 | 493 | func (n *noOpAdapter) Get(key string) (string, error) { 494 | n.T.Log(key) 495 | if v, ok := n.keyValues[key]; ok { 496 | n.T.Log("OK, Cache found! serving data from cache") 497 | return v, nil 498 | } 499 | 500 | n.T.Log("Cache not found") 501 | 502 | return "", errors.New("Cache not found") 503 | } 504 | func (n *noOpAdapter) Set(key string, value string) error { 505 | if _, ok := n.keyValues[key]; !ok { 506 | n.keyValues = map[string]string{} 507 | } 508 | n.keyValues[key] = value 509 | n.T.Log("Writing cache") 510 | return nil 511 | } 512 | func (n *noOpAdapter) IsValid(key string) bool { 513 | if _, ok := n.keyValues[key]; ok { 514 | n.T.Log("Cache exists and not expired") 515 | return false 516 | } 517 | n.T.Log("Cache doesn't exists or expired") 518 | return true 519 | } 520 | func (n *noOpAdapter) Clear(key string) error { 521 | return nil 522 | } 523 | func (n *noOpAdapter) ClearPrefix(keyPrefix string) error { 524 | if n.clearPrefixCounter > 2 { 525 | return errors.New("maximum clear") 526 | } 527 | n.clearPrefixCounter = n.clearPrefixCounter + 1 528 | return nil 529 | } 530 | func (n *noOpAdapter) ClearAll() error { 531 | if n.clearCounter > 0 { 532 | return errors.New("maximum clear") 533 | } 534 | n.clearCounter = n.clearCounter + 1 535 | return nil 536 | } 537 | 538 | func TestCache(t *testing.T) { 539 | type User struct { 540 | gorm.Model 541 | Name string `json:"name"` 542 | AveragePoint string `json:"average_point"` 543 | } 544 | 545 | type Category struct { 546 | gorm.Model 547 | Name string `json:"name"` 548 | } 549 | 550 | type Article struct { 551 | gorm.Model 552 | Title string `json:"title"` 553 | Content string `json:"content"` 554 | UserID uint `json:"-"` 555 | CategoryID uint `json:"-"` 556 | User User `json:"user"` 557 | Category Category `json:"category"` 558 | } 559 | dsn := "file::memory:" 560 | db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ 561 | Logger: logger.Discard, 562 | }) 563 | if nil != err { 564 | t.Error(err.Error()) 565 | return 566 | } 567 | db.AutoMigrate(&User{}, &Article{}) 568 | categories := []Category{{Name: "Blog"}} 569 | users := []User{{Name: "John doe", AveragePoint: "Seventy %"}, {Name: "Jane doe", AveragePoint: "one hundred %"}} 570 | articles := []Article{} 571 | articles = append(articles, Article{Title: "Written by john", Content: "Example by john", UserID: 1, CategoryID: 1}) 572 | articles = append(articles, Article{Title: "Written by jane", Content: "Example by jane", UserID: 2, CategoryID: 1}) 573 | db.Create(&categories) 574 | db.Create(&users) 575 | db.Create(&articles) 576 | request := &http.Request{ 577 | Method: "GET", 578 | URL: &url.URL{ 579 | RawQuery: "page=0&size=10&fields=id", 580 | }, 581 | } 582 | 583 | var adapter gocache.AdapterInterface = &noOpAdapter{T: t} 584 | config := &Config{ 585 | CacheAdapter: &adapter, 586 | FieldSelectorEnabled: true, 587 | } 588 | pg := New(config) 589 | // set cache 590 | stmt1 := db.Joins("User").Model(&Article{}).Preload(`Category`) 591 | page1 := pg.With(stmt1). 592 | Request(request). 593 | Fields([]string{"id"}). 594 | Cache("cache_prefix"). 595 | Response(&[]Article{}) 596 | 597 | // get cache 598 | var cached []Article 599 | stmt2 := db.Joins("User").Model(&Article{}) 600 | page2 := pg.With(stmt2).Request(request).Cache("cache_prefix").Response(&cached) 601 | 602 | if len(cached) < 1 { 603 | t.Error("Cache pointer not working perfectly") 604 | } 605 | 606 | if page1.Total != page2.Total { 607 | t.Error("Total doesn't match") 608 | } 609 | 610 | pg.ClearCache("cache", "cache_") 611 | pg.ClearCache("cache", "cache_") 612 | pg.ClearAllCache() 613 | pg.ClearAllCache() 614 | } 615 | 616 | func expect(t *testing.T, expected interface{}, actual interface{}, message ...string) { 617 | if expected != actual { 618 | t.Errorf("%s: Expected %s(%v), got %s(%v)", 619 | strings.Join(message, " "), 620 | reflect.TypeOf(expected), expected, 621 | reflect.TypeOf(actual), actual) 622 | t.Fail() 623 | } 624 | } 625 | 626 | func expectFalse(t *testing.T, actual bool, message ...string) { 627 | expect(t, false, actual, message...) 628 | } 629 | 630 | func expectTrue(t *testing.T, actual bool, message ...string) { 631 | expect(t, true, actual, message...) 632 | } 633 | 634 | func expectNil(t *testing.T, actual interface{}, message ...string) { 635 | expect(t, nil, actual, message...) 636 | } 637 | 638 | func expectNotNil(t *testing.T, actual interface{}, message ...string) { 639 | expect(t, false, actual == nil, message...) 640 | } 641 | 642 | func TestArrayFilter(t *testing.T) { 643 | jsonString := `[ 644 | ["name,email,address", "like", "abc"] 645 | ]` 646 | var jsonData []interface{} 647 | json.Unmarshal([]byte(jsonString), &jsonData) 648 | filters := arrayToFilter(jsonData, Config{}) 649 | 650 | expectNotNil(t, filters) 651 | expectNotNil(t, filters.Value) 652 | 653 | subFilters, ok := filters.Value.([]pageFilters) 654 | expectTrue(t, ok) 655 | expect(t, 1, len(subFilters)) 656 | 657 | subFilterValues, ok := subFilters[0].Value.([]pageFilters) 658 | expectTrue(t, ok) 659 | expect(t, 1, len(subFilterValues)) 660 | 661 | contents, ok := subFilterValues[0].Value.([]pageFilters) 662 | expectTrue(t, ok) 663 | expect(t, 5, len(contents)) 664 | 665 | expect(t, "name", contents[0].Column) 666 | expect(t, "LIKE", contents[0].Operator) 667 | expect(t, "%abc%", contents[0].Value) 668 | 669 | expect(t, "OR", contents[1].Operator) 670 | 671 | expect(t, "email", contents[2].Column) 672 | expect(t, "LIKE", contents[2].Operator) 673 | expect(t, "%abc%", contents[2].Value) 674 | 675 | expect(t, "OR", contents[3].Operator) 676 | 677 | expect(t, "address", contents[4].Column) 678 | expect(t, "LIKE", contents[4].Operator) 679 | expect(t, "%abc%", contents[4].Value) 680 | } 681 | 682 | func TestGenerateWhereCauses(t *testing.T) { 683 | jsonString := `[ 684 | ["name,email,address", "like", "abc"], 685 | ["id", ">", 1] 686 | ]` 687 | var jsonData []interface{} 688 | json.Unmarshal([]byte(jsonString), &jsonData) 689 | filters := arrayToFilter(jsonData, Config{}) 690 | wheres, params := generateWhereCauses(filters, Config{}) 691 | 692 | where := strings.Join(wheres, " ") 693 | where = strings.ReplaceAll(where, "( ", "(") 694 | where = strings.ReplaceAll(where, " )", ")") 695 | expect(t, "((((name LIKE ? OR email LIKE ? OR address LIKE ?))) OR (id > ?))", where) 696 | expect(t, 4, len(params)) 697 | } 698 | -------------------------------------------------------------------------------- /paginate.go: -------------------------------------------------------------------------------- 1 | package paginate 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "math" 10 | "net/http" 11 | "reflect" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | 16 | "github.com/iancoleman/strcase" 17 | "github.com/morkid/gocache" 18 | "gorm.io/gorm" 19 | 20 | "github.com/valyala/fasthttp" 21 | ) 22 | 23 | // ResponseContext interface 24 | type ResponseContext interface { 25 | Cache(string) ResponseContext 26 | Fields([]string) ResponseContext 27 | Response(interface{}) Page 28 | } 29 | 30 | // RequestContext interface 31 | type RequestContext interface { 32 | Request(interface{}) ResponseContext 33 | } 34 | 35 | // Pagination gorm paginate struct 36 | type Pagination struct { 37 | Config *Config 38 | } 39 | 40 | // With func 41 | func (p *Pagination) With(stmt *gorm.DB) RequestContext { 42 | return reqContext{ 43 | Statement: stmt, 44 | Pagination: p, 45 | } 46 | } 47 | 48 | // ClearCache clear cache contains prefix 49 | func (p Pagination) ClearCache(keyPrefixes ...string) { 50 | if len(keyPrefixes) > 0 && nil != p.Config && nil != p.Config.CacheAdapter { 51 | adapter := *p.Config.CacheAdapter 52 | for i := range keyPrefixes { 53 | if err := adapter.ClearPrefix(keyPrefixes[i]); nil != err { 54 | log.Println(err) 55 | } 56 | } 57 | } 58 | } 59 | 60 | // ClearAllCache clear all existing cache 61 | func (p Pagination) ClearAllCache() { 62 | if nil != p.Config && nil != p.Config.CacheAdapter { 63 | adapter := *p.Config.CacheAdapter 64 | if err := adapter.ClearAll(); nil != err { 65 | log.Println(err) 66 | } 67 | } 68 | } 69 | 70 | type reqContext struct { 71 | Statement *gorm.DB 72 | Pagination *Pagination 73 | } 74 | 75 | func (r reqContext) Request(req interface{}) ResponseContext { 76 | var response ResponseContext = &resContext{ 77 | Statement: r.Statement, 78 | Request: req, 79 | Pagination: r.Pagination, 80 | } 81 | 82 | return response 83 | } 84 | 85 | type resContext struct { 86 | Pagination *Pagination 87 | Statement *gorm.DB 88 | Request interface{} 89 | cachePrefix string 90 | fieldList []string 91 | } 92 | 93 | func (r *resContext) Cache(prefix string) ResponseContext { 94 | r.cachePrefix = prefix 95 | return r 96 | } 97 | 98 | func (r *resContext) Fields(fields []string) ResponseContext { 99 | r.fieldList = fields 100 | return r 101 | } 102 | 103 | func (r resContext) Response(res interface{}) Page { 104 | p := r.Pagination 105 | query := r.Statement 106 | p.Config = defaultConfig(p.Config) 107 | p.Config.Statement = query.Statement 108 | if p.Config.DefaultSize == 0 { 109 | p.Config.DefaultSize = 10 110 | } 111 | if p.Config.PageStart < 0 { 112 | p.Config.PageStart = 0 113 | } 114 | 115 | defaultWrapper := "LOWER(%s)" 116 | wrappers := map[string]string{ 117 | "sqlite": defaultWrapper, 118 | "mysql": defaultWrapper, 119 | "postgres": "LOWER((%s)::text)", 120 | } 121 | 122 | if p.Config.LikeAsIlikeDisabled { 123 | defaultWrapper := "%s" 124 | wrappers = map[string]string{ 125 | "sqlite": defaultWrapper, 126 | "mysql": defaultWrapper, 127 | "postgres": "(%s)::text", 128 | } 129 | } 130 | 131 | if p.Config.FieldWrapper == "" && p.Config.ValueWrapper == "" { 132 | p.Config.FieldWrapper = defaultWrapper 133 | if wrapper, ok := wrappers[query.Dialector.Name()]; ok { 134 | p.Config.FieldWrapper = wrapper 135 | } 136 | } 137 | 138 | page := Page{} 139 | pr := parseRequest(r.Request, *p.Config) 140 | causes := createCauses(pr) 141 | cKey := "" 142 | var adapter gocache.AdapterInterface 143 | var hasAdapter bool = false 144 | 145 | if nil != p.Config.CacheAdapter { 146 | cKey = createCacheKey(r.cachePrefix, pr) 147 | adapter = *p.Config.CacheAdapter 148 | hasAdapter = true 149 | if cKey != "" && adapter.IsValid(cKey) { 150 | if cache, err := adapter.Get(cKey); nil == err { 151 | page.Items = res 152 | if err := p.Config.JSONUnmarshal([]byte(cache), &page); nil == err { 153 | return page 154 | } 155 | } 156 | } 157 | } 158 | 159 | dbs := query.Statement.DB.Session(&gorm.Session{NewDB: true}) 160 | var selects []string 161 | if len(r.fieldList) > 0 { 162 | if len(pr.Fields) > 0 && p.Config.FieldSelectorEnabled { 163 | for i := range pr.Fields { 164 | for j := range r.fieldList { 165 | if r.fieldList[j] == pr.Fields[i] { 166 | fname := query.Statement.Quote("s." + fieldName(pr.Fields[i])) 167 | if !contains(selects, fname) { 168 | selects = append(selects, fname) 169 | } 170 | break 171 | } 172 | } 173 | } 174 | } else { 175 | for i := range r.fieldList { 176 | fname := query.Statement.Quote("s." + fieldName(r.fieldList[i])) 177 | if !contains(selects, fname) { 178 | selects = append(selects, fname) 179 | } 180 | } 181 | } 182 | } else if len(pr.Fields) > 0 && p.Config.FieldSelectorEnabled { 183 | for i := range pr.Fields { 184 | fname := query.Statement.Quote("s." + fieldName(pr.Fields[i])) 185 | if !contains(selects, fname) { 186 | selects = append(selects, fname) 187 | } 188 | } 189 | } 190 | 191 | result := dbs. 192 | Unscoped(). 193 | Table("(?) AS s", query) 194 | 195 | if len(selects) > 0 { 196 | result = result.Select(selects) 197 | } 198 | 199 | if len(causes.Params) > 0 || len(causes.WhereString) > 0 { 200 | result = result.Where(causes.WhereString, causes.Params...) 201 | } 202 | 203 | result = result.Count(&page.Total). 204 | Limit(int(causes.Limit)). 205 | Offset(int(causes.Offset)) 206 | 207 | page.RawError = result.Error 208 | 209 | if result.Error != nil && p.Config.ErrorEnabled { 210 | page.Error = true 211 | page.ErrorMessage = result.Error.Error() 212 | } 213 | 214 | if nil != query.Statement.Preloads { 215 | for table, args := range query.Statement.Preloads { 216 | result = result.Preload(table, args...) 217 | } 218 | } 219 | if len(causes.Sorts) > 0 { 220 | for _, sort := range causes.Sorts { 221 | result = result.Order(sort.Column + " " + sort.Direction) 222 | } 223 | } 224 | 225 | rs := result.Find(res) 226 | if nil == page.RawError { 227 | page.RawError = rs.Error 228 | } 229 | 230 | if rs.Error != nil && p.Config.ErrorEnabled && !page.Error { 231 | page.Error = true 232 | page.ErrorMessage = rs.Error.Error() 233 | } 234 | 235 | page.Items = res 236 | f := float64(page.Total) / float64(causes.Limit) 237 | if math.Mod(f, 1.0) > 0 { 238 | f = f + 1 239 | } 240 | f = math.Max(f, 1) 241 | 242 | page.TotalPages = int64(f) 243 | page.MaxPage = page.TotalPages - 1 + p.Config.PageStart 244 | page.Page = int64(pr.Page) 245 | page.Size = int64(pr.Size) 246 | page.Visible = rs.RowsAffected 247 | 248 | if page.Total < 1 { 249 | page.MaxPage = p.Config.PageStart 250 | page.TotalPages = 0 251 | } 252 | page.First = causes.Offset < 1 253 | page.Last = page.Page >= page.MaxPage 254 | 255 | if hasAdapter && cKey != "" { 256 | if cache, err := p.Config.JSONMarshal(page); nil == err { 257 | if err := adapter.Set(cKey, string(cache)); err != nil { 258 | log.Println(err) 259 | } 260 | } 261 | } 262 | 263 | return page 264 | } 265 | 266 | // New Pagination instance 267 | func New(params ...interface{}) *Pagination { 268 | if len(params) >= 1 { 269 | var config *Config 270 | for _, param := range params { 271 | c, isConfig := param.(*Config) 272 | if isConfig { 273 | config = c 274 | continue 275 | } 276 | } 277 | 278 | return &Pagination{Config: defaultConfig(config)} 279 | } 280 | 281 | return &Pagination{Config: defaultConfig(nil)} 282 | } 283 | 284 | // parseRequest func 285 | func parseRequest(r interface{}, config Config) pageRequest { 286 | pr := pageRequest{ 287 | Config: *defaultConfig(&config), 288 | } 289 | if netHTTP, isNetHTTP := r.(http.Request); isNetHTTP { 290 | parsingNetHTTPRequest(&netHTTP, &pr) 291 | } else { 292 | if netHTTPp, isNetHTTPp := r.(*http.Request); isNetHTTPp { 293 | parsingNetHTTPRequest(netHTTPp, &pr) 294 | } else { 295 | if fastHTTPp, isFastHTTPp := r.(*fasthttp.Request); isFastHTTPp { 296 | parsingFastHTTPRequest(fastHTTPp, &pr) 297 | } else { 298 | if request, isRequest := r.(*Request); isRequest { 299 | parsingQueryString(request, &pr) 300 | } 301 | } 302 | } 303 | } 304 | 305 | return pr 306 | } 307 | 308 | // createFilters func 309 | func createFilters(filterParams interface{}, p *pageRequest) { 310 | f, ok := filterParams.([]interface{}) 311 | s, ok2 := filterParams.(string) 312 | if ok { 313 | p.Filters = arrayToFilter(f, p.Config) 314 | p.Filters.Fields = p.Fields 315 | } else if ok2 { 316 | iface := []interface{}{} 317 | if e := p.Config.JSONUnmarshal([]byte(s), &iface); nil == e && len(iface) > 0 { 318 | p.Filters = arrayToFilter(iface, p.Config) 319 | } 320 | p.Filters.Fields = p.Fields 321 | } 322 | } 323 | 324 | // createCauses func 325 | func createCauses(p pageRequest) requestQuery { 326 | query := requestQuery{} 327 | wheres, params := generateWhereCauses(p.Filters, p.Config) 328 | sorts := []sortOrder{} 329 | 330 | for _, so := range p.Sorts { 331 | so.Column = fieldName(so.Column) 332 | if nil != p.Config.Statement { 333 | so.Column = p.Config.Statement.Quote(so.Column) 334 | } 335 | sorts = append(sorts, so) 336 | } 337 | 338 | query.Limit = p.Size 339 | query.Offset = (p.Page - p.Config.PageStart) * p.Size 340 | query.Wheres = wheres 341 | query.WhereString = strings.Join(wheres, " ") 342 | query.Sorts = sorts 343 | query.Params = params 344 | 345 | return query 346 | } 347 | 348 | // parsingNetHTTPRequest func 349 | func parsingNetHTTPRequest(r *http.Request, p *pageRequest) { 350 | param := &Request{} 351 | if r.Method == "" { 352 | r.Method = "GET" 353 | } 354 | if strings.ToUpper(r.Method) == "POST" { 355 | body, err := io.ReadAll(r.Body) 356 | if nil != err { 357 | body = []byte("{}") 358 | } 359 | defer r.Body.Close() 360 | if !p.Config.CustomParamEnabled { 361 | var postData Request 362 | if err := p.Config.JSONUnmarshal(body, &postData); nil == err { 363 | param = &postData 364 | } else { 365 | log.Println(err.Error()) 366 | } 367 | } else { 368 | var postData map[string]string 369 | if err := p.Config.JSONUnmarshal(body, &postData); nil == err { 370 | generateParams(param, p.Config, func(key string) string { 371 | value, exists := postData[key] 372 | if !exists { 373 | value = "" 374 | } 375 | return value 376 | }) 377 | } else { 378 | log.Println(err.Error()) 379 | } 380 | } 381 | } else if strings.ToUpper(r.Method) == "GET" { 382 | query := r.URL.Query() 383 | if !p.Config.CustomParamEnabled { 384 | param.Size, _ = strconv.ParseInt(query.Get("size"), 10, 64) 385 | param.Page, _ = strconv.ParseInt(query.Get("page"), 10, 64) 386 | param.Sort = query.Get("sort") 387 | param.Order = query.Get("order") 388 | param.Filters = query.Get("filters") 389 | param.Fields = strings.Split(query.Get("fields"), ",") 390 | } else { 391 | generateParams(param, p.Config, func(key string) string { 392 | return query.Get(key) 393 | }) 394 | } 395 | } 396 | 397 | parsingQueryString(param, p) 398 | } 399 | 400 | // parsingFastHTTPRequest func 401 | func parsingFastHTTPRequest(r *fasthttp.Request, p *pageRequest) { 402 | param := &Request{} 403 | if r.Header.IsPost() { 404 | b := r.Body() 405 | if !p.Config.CustomParamEnabled { 406 | var postData Request 407 | if err := p.Config.JSONUnmarshal(b, &postData); nil == err { 408 | param = &postData 409 | } else { 410 | log.Println(err.Error()) 411 | } 412 | } else { 413 | var postData map[string]string 414 | if err := p.Config.JSONUnmarshal(b, &postData); nil == err { 415 | generateParams(param, p.Config, func(key string) string { 416 | value, exists := postData[key] 417 | if !exists { 418 | value = "" 419 | } 420 | return value 421 | }) 422 | } else { 423 | log.Println(err.Error()) 424 | } 425 | } 426 | } else if r.Header.IsGet() { 427 | query := r.URI().QueryArgs() 428 | if !p.Config.CustomParamEnabled { 429 | param.Size, _ = strconv.ParseInt(string(query.Peek("size")), 10, 64) 430 | param.Page, _ = strconv.ParseInt(string(query.Peek("page")), 10, 64) 431 | param.Sort = string(query.Peek("sort")) 432 | param.Order = string(query.Peek("order")) 433 | param.Filters = string(query.Peek("filters")) 434 | param.Fields = strings.Split(string(query.Peek("fields")), ",") 435 | } else { 436 | generateParams(param, p.Config, func(key string) string { 437 | return string(query.Peek(key)) 438 | }) 439 | } 440 | } 441 | 442 | parsingQueryString(param, p) 443 | } 444 | 445 | func parsingQueryString(param *Request, p *pageRequest) { 446 | p.Size = param.Size 447 | if p.Size == 0 { 448 | if p.Config.DefaultSize > 0 { 449 | p.Size = p.Config.DefaultSize 450 | } else { 451 | p.Size = 10 452 | } 453 | } 454 | 455 | p.Page = param.Page 456 | if p.Page < p.Config.PageStart { 457 | p.Page = p.Config.PageStart 458 | } 459 | 460 | if param.Sort != "" { 461 | sorts := strings.Split(param.Sort, ",") 462 | for _, col := range sorts { 463 | if col == "" { 464 | continue 465 | } 466 | 467 | so := sortOrder{ 468 | Column: col, 469 | Direction: "ASC", 470 | } 471 | if strings.ToUpper(param.Order) == "DESC" { 472 | so.Direction = "DESC" 473 | } 474 | 475 | if string(col[0]) == "-" { 476 | so.Column = string(col[1:]) 477 | so.Direction = "DESC" 478 | } 479 | 480 | p.Sorts = append(p.Sorts, so) 481 | } 482 | } 483 | 484 | if len(param.Fields) > 0 { 485 | re := regexp.MustCompile(`[^A-z0-9_\.,]+`) 486 | for _, field := range param.Fields { 487 | fieldName := re.ReplaceAllString(field, "") 488 | if fieldName != "" { 489 | p.Fields = append(p.Fields, fieldName) 490 | } 491 | } 492 | } 493 | 494 | createFilters(param.Filters, p) 495 | } 496 | 497 | func generateParams(param *Request, config Config, getValue func(string) string) { 498 | findValue := func(keys []string, defaultKey string) string { 499 | found := false 500 | value := "" 501 | for _, key := range keys { 502 | value = getValue(key) 503 | if value != "" { 504 | found = true 505 | break 506 | } 507 | } 508 | if !found { 509 | return getValue(defaultKey) 510 | } 511 | return value 512 | } 513 | 514 | param.Sort = findValue(config.SortParams, "sort") 515 | param.Page, _ = strconv.ParseInt(findValue(config.PageParams, "page"), 10, 64) 516 | param.Size, _ = strconv.ParseInt(findValue(config.SizeParams, "size"), 10, 64) 517 | param.Order = findValue(config.OrderParams, "order") 518 | param.Filters = findValue(config.FilterParams, "filters") 519 | param.Fields = strings.Split(findValue(config.FieldsParams, "fields"), ",") 520 | } 521 | 522 | func arrayToFilter(arr []interface{}, config Config) pageFilters { 523 | filters := pageFilters{ 524 | Single: false, 525 | } 526 | 527 | operatorEscape := regexp.MustCompile(`[^A-z=\<\>\-\+\^/\*%&! ]+`) 528 | arrayLen := len(arr) 529 | defaultOperator := config.Operator 530 | if defaultOperator == "" { 531 | defaultOperator = "OR" 532 | } 533 | 534 | if len(arr) > 0 { 535 | subFilters := []pageFilters{} 536 | for k, i := range arr { 537 | iface, ok := i.([]interface{}) 538 | if ok && !filters.Single { 539 | subFilters = append(subFilters, arrayToFilter(iface, config)) 540 | } else if arrayLen == 1 { 541 | operator, ok := i.(string) 542 | if ok { 543 | operator = operatorEscape.ReplaceAllString(operator, "") 544 | filters.Operator = strings.ToUpper(operator) 545 | filters.IsOperator = true 546 | filters.Single = true 547 | } 548 | } else if arrayLen == 2 { 549 | if k == 0 { 550 | if column, ok := i.(string); ok { 551 | filters.Column = column 552 | filters.Operator = "=" 553 | filters.Single = true 554 | } 555 | } else if k == 1 { 556 | filters.Value = i 557 | if nil == i { 558 | filters.Operator = "IS" 559 | } 560 | if strings.Contains(filters.Column, ",") { 561 | subFilters = filterToSubFilter(&filters, i, config) 562 | continue 563 | } 564 | } 565 | } else if arrayLen == 3 { 566 | if k == 0 { 567 | if column, ok := i.(string); ok { 568 | filters.Column = column 569 | filters.Single = true 570 | } 571 | } else if k == 1 { 572 | if operator, ok := i.(string); ok { 573 | operator = operatorEscape.ReplaceAllString(operator, "") 574 | filters.Operator = strings.ToUpper(operator) 575 | filters.Single = true 576 | } 577 | } else if k == 2 { 578 | if strings.Contains(filters.Column, ",") { 579 | subFilters = filterToSubFilter(&filters, i, config) 580 | continue 581 | } 582 | switch filters.Operator { 583 | case "LIKE", "ILIKE", "NOT LIKE", "NOT ILIKE": 584 | escapeString := "" 585 | escapePattern := `(%|\\)` 586 | if nil != config.Statement { 587 | driverName := config.Statement.Dialector.Name() 588 | switch driverName { 589 | case "sqlite", "sqlserver", "postgres": 590 | escapeString = `\` 591 | filters.ValueSuffix = "ESCAPE '\\'" 592 | case "mysql": 593 | escapeString = `\` 594 | filters.ValueSuffix = `ESCAPE '\\'` 595 | } 596 | } 597 | value := fmt.Sprintf("%v", i) 598 | re := regexp.MustCompile(escapePattern) 599 | value = re.ReplaceAllString(value, escapeString+`$1`) 600 | if config.SmartSearchEnabled { 601 | re := regexp.MustCompile(`[\s]+`) 602 | value = re.ReplaceAllString(value, "%") 603 | } 604 | filters.Value = fmt.Sprintf("%s%s%s", "%", value, "%") 605 | default: 606 | filters.Value = i 607 | } 608 | } 609 | } 610 | } 611 | if len(subFilters) > 0 { 612 | separatedSubFilters := []pageFilters{} 613 | hasOperator := false 614 | for k, s := range subFilters { 615 | if s.IsOperator && len(subFilters) == (k+1) { 616 | break 617 | } 618 | if !hasOperator && !s.IsOperator && k > 0 { 619 | separatedSubFilters = append(separatedSubFilters, pageFilters{ 620 | Operator: defaultOperator, 621 | IsOperator: true, 622 | Single: true, 623 | }) 624 | } 625 | hasOperator = s.IsOperator 626 | separatedSubFilters = append(separatedSubFilters, s) 627 | } 628 | filters.Value = separatedSubFilters 629 | filters.Single = false 630 | filters.IsOperator = false 631 | } 632 | } 633 | 634 | return filters 635 | } 636 | 637 | func filterToSubFilter(filters *pageFilters, value interface{}, config Config) []pageFilters { 638 | subFilters := []pageFilters{} 639 | re := regexp.MustCompile(`[^A-z0-9\._,]+`) 640 | colString := re.ReplaceAllString(filters.Column, "") 641 | columns := strings.Split(colString, ",") 642 | columnRepeat := []interface{}{} 643 | for _, col := range columns { 644 | columnRepeat = append(columnRepeat, []interface{}{col, filters.Operator, value}) 645 | } 646 | 647 | filters.Column = "" 648 | filters.Single = false 649 | filters.Operator = "" 650 | filters.IsOperator = false 651 | subFilters = append(subFilters, arrayToFilter(columnRepeat, config)) 652 | 653 | return subFilters 654 | } 655 | 656 | //gocyclo:ignore 657 | func generateWhereCauses(f pageFilters, config Config) ([]string, []interface{}) { 658 | wheres := []string{} 659 | params := []interface{}{} 660 | 661 | if !f.Single && !f.IsOperator { 662 | ifaces, ok := f.Value.([]pageFilters) 663 | if ok && len(ifaces) > 0 { 664 | wheres = append(wheres, "(") 665 | hasOpen := false 666 | for _, i := range ifaces { 667 | subs, isSub := i.Value.([]pageFilters) 668 | regular, isNotSub := i.Value.(pageFilters) 669 | if isSub && len(subs) > 0 { 670 | wheres = append(wheres, "(") 671 | for _, s := range subs { 672 | subWheres, subParams := generateWhereCauses(s, config) 673 | wheres = append(wheres, subWheres...) 674 | params = append(params, subParams...) 675 | } 676 | wheres = append(wheres, ")") 677 | } else if isNotSub { 678 | subWheres, subParams := generateWhereCauses(regular, config) 679 | wheres = append(wheres, subWheres...) 680 | params = append(params, subParams...) 681 | } else { 682 | if !hasOpen && !i.IsOperator { 683 | wheres = append(wheres, "(") 684 | hasOpen = true 685 | } 686 | subWheres, subParams := generateWhereCauses(i, config) 687 | wheres = append(wheres, subWheres...) 688 | params = append(params, subParams...) 689 | } 690 | } 691 | if hasOpen { 692 | wheres = append(wheres, ")") 693 | } 694 | wheres = append(wheres, ")") 695 | } 696 | } else if f.Single { 697 | if f.IsOperator { 698 | wheres = append(wheres, f.Operator) 699 | } else { 700 | fname := fieldName(f.Column) 701 | if nil != config.Statement { 702 | fname = config.Statement.Quote(fname) 703 | } 704 | switch f.Operator { 705 | case "IS", "IS NOT": 706 | if nil == f.Value { 707 | wheres = append(wheres, fname, f.Operator, "NULL") 708 | } else { 709 | if strValue, isStr := f.Value.(string); isStr && strings.ToLower(strValue) == "null" { 710 | wheres = append(wheres, fname, f.Operator, "NULL") 711 | } else { 712 | wheres = append(wheres, fname, f.Operator, "?") 713 | params = append(params, f.Value) 714 | } 715 | } 716 | case "BETWEEN": 717 | if values, ok := f.Value.([]interface{}); ok && len(values) >= 2 { 718 | wheres = append(wheres, "(", fname, f.Operator, "? AND ?", ")") 719 | params = append(params, valueFixer(values[0]), valueFixer(values[1])) 720 | } 721 | case "IN", "NOT IN": 722 | if values, ok := f.Value.([]interface{}); ok { 723 | wheres = append(wheres, fname, f.Operator, "?") 724 | params = append(params, valueFixer(values)) 725 | } 726 | case "LIKE", "NOT LIKE", "ILIKE", "NOT ILIKE": 727 | if config.FieldWrapper != "" { 728 | fname = fmt.Sprintf(config.FieldWrapper, fname) 729 | } 730 | wheres = append(wheres, fname, f.Operator, "?") 731 | if f.ValueSuffix != "" { 732 | wheres = append(wheres, f.ValueSuffix) 733 | } 734 | value, isStrValue := f.Value.(string) 735 | if isStrValue { 736 | if config.ValueWrapper != "" { 737 | value = fmt.Sprintf(config.ValueWrapper, value) 738 | } else if !config.LikeAsIlikeDisabled { 739 | value = strings.ToLower(value) 740 | } 741 | params = append(params, value) 742 | } else { 743 | params = append(params, f.Value) 744 | } 745 | default: 746 | wheres = append(wheres, fname, f.Operator, "?") 747 | params = append(params, valueFixer(f.Value)) 748 | } 749 | } 750 | } 751 | 752 | return wheres, params 753 | } 754 | 755 | func valueFixer(n interface{}) interface{} { 756 | var values []interface{} 757 | if rawValues, ok := n.([]interface{}); ok { 758 | for i := range rawValues { 759 | values = append(values, valueFixer(rawValues[i])) 760 | } 761 | 762 | return values 763 | } 764 | if nil != n && reflect.TypeOf(n).Name() == "float64" { 765 | strValue := fmt.Sprintf("%v", n) 766 | if match, e := regexp.Match(`^[0-9]+$`, []byte(strValue)); nil == e && match { 767 | v, err := strconv.ParseInt(strValue, 10, 64) 768 | if nil == err { 769 | return v 770 | } 771 | } 772 | } 773 | 774 | return n 775 | } 776 | 777 | func contains(source []string, value string) bool { 778 | found := false 779 | for i := range source { 780 | if source[i] == value { 781 | found = true 782 | break 783 | } 784 | } 785 | 786 | return found 787 | } 788 | 789 | func fieldName(field string) string { 790 | slices := strings.Split(field, ".") 791 | if len(slices) == 1 { 792 | return field 793 | } 794 | newSlices := []string{} 795 | if len(slices) > 0 { 796 | newSlices = append(newSlices, strcase.ToCamel(slices[0])) 797 | for k, s := range slices { 798 | if k > 0 { 799 | newSlices = append(newSlices, s) 800 | } 801 | } 802 | } 803 | if len(newSlices) == 0 { 804 | return field 805 | } 806 | return strings.Join(newSlices, "__") 807 | 808 | } 809 | 810 | // Config for customize pagination result 811 | type Config struct { 812 | Operator string 813 | FieldWrapper string 814 | ValueWrapper string 815 | DefaultSize int64 816 | PageStart int64 817 | LikeAsIlikeDisabled bool 818 | SmartSearchEnabled bool 819 | Statement *gorm.Statement `json:"-"` 820 | CustomParamEnabled bool 821 | SortParams []string 822 | PageParams []string 823 | OrderParams []string 824 | SizeParams []string 825 | FilterParams []string 826 | FieldsParams []string 827 | FieldSelectorEnabled bool 828 | CacheAdapter *gocache.AdapterInterface `json:"-"` 829 | JSONMarshal func(v interface{}) ([]byte, error) `json:"-"` 830 | JSONUnmarshal func(data []byte, v interface{}) error `json:"-"` 831 | ErrorEnabled bool 832 | } 833 | 834 | // pageFilters struct 835 | type pageFilters struct { 836 | Column string 837 | Operator string 838 | Value interface{} 839 | ValuePrefix string 840 | ValueSuffix string 841 | Single bool 842 | IsOperator bool 843 | Fields []string 844 | } 845 | 846 | // Page result wrapper 847 | type Page struct { 848 | Items interface{} `json:"items"` 849 | Page int64 `json:"page"` 850 | Size int64 `json:"size"` 851 | MaxPage int64 `json:"max_page"` 852 | TotalPages int64 `json:"total_pages"` 853 | Total int64 `json:"total"` 854 | Last bool `json:"last"` 855 | First bool `json:"first"` 856 | Visible int64 `json:"visible"` 857 | Error bool `json:"error,omitempty"` 858 | ErrorMessage string `json:"error_message,omitempty"` 859 | RawError error `json:"-"` 860 | } 861 | 862 | // Request struct 863 | type Request struct { 864 | Page int64 `json:"page"` 865 | Size int64 `json:"size"` 866 | Sort string `json:"sort"` 867 | Order string `json:"order"` 868 | Fields []string `json:"fields"` 869 | Filters interface{} `json:"filters"` 870 | } 871 | 872 | // query struct 873 | type requestQuery struct { 874 | WhereString string 875 | Wheres []string 876 | Params []interface{} 877 | Sorts []sortOrder 878 | Limit int64 879 | Offset int64 880 | } 881 | 882 | // pageRequest struct 883 | type pageRequest struct { 884 | Size int64 885 | Page int64 886 | Sorts []sortOrder 887 | Filters pageFilters 888 | Config Config `json:"-"` 889 | Fields []string 890 | } 891 | 892 | // sortOrder struct 893 | type sortOrder struct { 894 | Column string 895 | Direction string 896 | } 897 | 898 | func createCacheKey(cachePrefix string, pr pageRequest) string { 899 | key := "" 900 | if bte, err := pr.Config.JSONMarshal(pr); nil == err && cachePrefix != "" { 901 | key = fmt.Sprintf("%s%x", cachePrefix, md5.Sum(bte)) 902 | } 903 | 904 | return key 905 | } 906 | 907 | func defaultConfig(c *Config) *Config { 908 | if nil == c { 909 | return &Config{ 910 | JSONMarshal: json.Marshal, 911 | JSONUnmarshal: json.Unmarshal, 912 | } 913 | } 914 | 915 | if nil == c.JSONMarshal { 916 | c.JSONMarshal = json.Marshal 917 | } 918 | 919 | if nil == c.JSONUnmarshal { 920 | c.JSONUnmarshal = json.Unmarshal 921 | } 922 | 923 | return c 924 | } 925 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # paginate - Gorm Pagination 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/morkid/paginate.svg)](https://pkg.go.dev/github.com/morkid/paginate) 4 | [![Github Actions](https://github.com/morkid/paginate/workflows/Go/badge.svg)](https://github.com/morkid/paginate/actions) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/morkid/paginate)](https://goreportcard.com/report/github.com/morkid/paginate) 6 | [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/morkid/paginate)](https://github.com/morkid/paginate/releases) 7 | 8 | Simple way to paginate [Gorm](https://github.com/go-gorm/gorm) result. **paginate** is compatible with [net/http](https://golang.org/pkg/net/http/) and [fasthttp](https://github.com/valyala/fasthttp). This library also supports many net/http or fasthttp based frameworks. 9 | 10 | ## Table Of Contents 11 | - [Installation](#installation) 12 | - [Configuration](#configuration) 13 | - [Pagination Result](#pagination-result) 14 | - [Paginate using http request](#paginate-using-http-request) 15 | - [Example usage](#example-usage) 16 | - [net/http](#nethttp-example) 17 | - [Fasthttp](#fasthttp-example) 18 | - [Mux Router](#mux-router-example) 19 | - [Fiber](#fiber-example) 20 | - [Echo](#echo-example) 21 | - [Gin](#gin-example) 22 | - [Martini](#martini-example) 23 | - [Beego](#beego-example) 24 | - [jQuery DataTable Integration](#jquery-datatable-integration) 25 | - [jQuery Select2 Integration](#jquery-select2-integration) 26 | - [Programmatically Pagination](#programmatically-pagination) 27 | - [Filter format](#filter-format) 28 | - [Customize default configuration](#customize-default-configuration) 29 | - [Override results](#override-results) 30 | - [Field Selector](#field-selector) 31 | - [Dynamic Field Selector](#dynamic-field-selector) 32 | - [Speed up response with cache](#speed-up-response-with-cache) 33 | - [In Memory Cache](#in-memory-cache) 34 | - [Disk Cache](#disk-cache) 35 | - [Redis Cache](#redis-cache) 36 | - [Elasticsearch Cache](#elasticsearch-cache) 37 | - [Custom cache](#custom-cache) 38 | - [Clean up cache](#clean-up-cache) 39 | - [Limitations](#limitations) 40 | - [License](#license) 41 | 42 | ## Installation 43 | 44 | ```bash 45 | go get -u github.com/morkid/paginate 46 | ``` 47 | 48 | ## Configuration 49 | 50 | ```go 51 | var db *gorm.DB = ... 52 | var req *http.Request = ... 53 | // or 54 | // var req *fasthttp.Request 55 | 56 | stmt := db.Where("id > ?", 1).Model(&Article{}) 57 | pg := paginate.New() 58 | page := pg.With(stmt).Request(req).Response(&[]Article{}) 59 | 60 | log.Println(page.Total) 61 | log.Println(page.Items) 62 | log.Println(page.First) 63 | log.Println(page.Last) 64 | 65 | ``` 66 | you can customize config with `paginate.Config` struct. 67 | ```go 68 | pg := paginate.New(&paginate.Config{ 69 | DefaultSize: 50, 70 | }) 71 | ``` 72 | see more about [customize default configuration](#customize-default-configuration). 73 | 74 | ## Pagination Result 75 | 76 | ```js 77 | { 78 | // the result items 79 | "items": *[]any, 80 | 81 | // total results 82 | // including next pages 83 | "total": number, 84 | 85 | // Current page 86 | // (provided by request parameter, eg: ?page=1) 87 | // note: page is always start from 0 88 | "page": number, 89 | 90 | // Current size 91 | // (provided by request parameter, eg: ?size=10) 92 | // note: negative value means unlimited 93 | "size": number, 94 | 95 | // Total Pages 96 | "total_pages": number, 97 | 98 | // Max Page 99 | // start from 0 until last index 100 | // example: 101 | // if you have 3 pages (page0, page1, page2) 102 | // max_page is 2 not 3 103 | "max_page": number, 104 | 105 | // Last Page is true if the page 106 | // has reached the end of the page 107 | "last": bool, 108 | 109 | // First Page is true if the page is 0 110 | "first": bool, 111 | 112 | // Visible 113 | // total visible items 114 | "visible": number, 115 | 116 | // Error 117 | // true if an error has occurred and 118 | // paginate.Config.ErrorEnabled is true 119 | "error": bool, 120 | 121 | // Error Message 122 | // current error if available and 123 | // paginate.Config.ErrorEnabled is true 124 | "error_message": string, 125 | } 126 | ``` 127 | ## Paginate using http request 128 | example paging, sorting and filtering: 129 | 1. `http://localhost:3000/?size=10&page=0&sort=-name` 130 | produces: 131 | ```sql 132 | SELECT * FROM user ORDER BY name DESC LIMIT 10 OFFSET 0 133 | ``` 134 | `JSON` response: 135 | ```js 136 | { 137 | // result items 138 | "items": [ 139 | { 140 | "id": 1, 141 | "name": "john", 142 | "age": 20 143 | } 144 | ], 145 | "page": 0, // current selected page 146 | "size": 10, // current limit or size per page 147 | "max_page": 0, // maximum page 148 | "total_pages": 1, // total pages 149 | "total": 1, // total matches including next page 150 | "visible": 1, // total visible on current page 151 | "last": true, // if response is first page 152 | "first": true // if response is last page 153 | } 154 | ``` 155 | 2. `http://localhost:3000/?size=10&page=1&sort=-name,id` 156 | produces: 157 | ```sql 158 | SELECT * FROM user ORDER BY name DESC, id ASC LIMIT 10 OFFSET 10 159 | ``` 160 | 3. `http://localhost:3000/?filters=["name","john"]` 161 | produces: 162 | ```sql 163 | SELECT * FROM user WHERE name = 'john' LIMIT 10 OFFSET 0 164 | ``` 165 | 4. `http://localhost:3000/?filters=["name","like","john"]` 166 | produces: 167 | ```sql 168 | SELECT * FROM user WHERE name LIKE '%john%' LIMIT 10 OFFSET 0 169 | ``` 170 | 5. `http://localhost:3000/?filters=["age","between",[20, 25]]` 171 | produces: 172 | ```sql 173 | SELECT * FROM user WHERE ( age BETWEEN 20 AND 25 ) LIMIT 10 OFFSET 0 174 | ``` 175 | 6. `http://localhost:3000/?filters=[["name","like","john%25"],["OR"],["age","between",[20, 25]]]` 176 | produces: 177 | ```sql 178 | SELECT * FROM user WHERE ( 179 | (name LIKE '%john\%%' ESCAPE '\') OR (age BETWEEN (20 AND 25)) 180 | ) LIMIT 10 OFFSET 0 181 | ``` 182 | 7. `http://localhost:3000/?filters=[[["name","like","john"],["AND"],["name","not like","doe"]],["OR"],["age","between",[20, 25]]]` 183 | produces: 184 | ```sql 185 | SELECT * FROM user WHERE ( 186 | ( 187 | (name LIKE '%john%') 188 | AND 189 | (name NOT LIKE '%doe%') 190 | ) 191 | OR 192 | (age BETWEEN (20 AND 25)) 193 | ) LIMIT 10 OFFSET 0 194 | ``` 195 | 8. `http://localhost:3000/?filters=["name","IS NOT",null]` 196 | produces: 197 | ```sql 198 | SELECT * FROM user WHERE name IS NOT NULL LIMIT 10 OFFSET 0 199 | ``` 200 | 9. Using `POST` method: 201 | ```bash 202 | curl -X POST \ 203 | -H 'Content-type: application/json' \ 204 | -d '{"page":1,"size":20,"sort":"-name","filters":["name","john"]}' \ 205 | http://localhost:3000/ 206 | ``` 207 | 10. You can bypass HTTP Request with [Custom Request](#programmatically-pagination). 208 | 209 | ## Example usage 210 | 211 | ### NetHTTP Example 212 | 213 | ```go 214 | package main 215 | 216 | import ( 217 | "github.com/morkid/paginate" 218 | ... 219 | ) 220 | 221 | func main() { 222 | // var db *gorm.DB 223 | pg := paginate.New() 224 | 225 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 226 | stmt := db.Joins("User").Model(&Article{}) 227 | page := pg.With(stmt).Request(r).Response(&[]Article{}) 228 | j, _ := json.Marshal(page) 229 | w.Header().Set("Content-type", "application/json") 230 | w.Write(j) 231 | }) 232 | 233 | log.Fatal(http.ListenAndServe(":3000", nil)) 234 | } 235 | ``` 236 | 237 | ### Fasthttp Example 238 | 239 | ```go 240 | package main 241 | 242 | import ( 243 | "github.com/morkid/paginate" 244 | ... 245 | ) 246 | 247 | func main() { 248 | // var db *gorm.DB 249 | pg := paginate.New() 250 | 251 | fasthttp.ListenAndServe(":3000", func(ctx *fasthttp.RequestCtx) { 252 | stmt := db.Joins("User").Model(&Article{}) 253 | page := pg.With(stmt).Request(&ctx.Request).Response(&[]Article{}) 254 | j, _ := json.Marshal(page) 255 | ctx.SetContentType("application/json") 256 | ctx.SetBody(j) 257 | }) 258 | } 259 | ``` 260 | 261 | ### Mux Router Example 262 | ```go 263 | package main 264 | 265 | import ( 266 | "github.com/morkid/paginate" 267 | ... 268 | ) 269 | 270 | func main() { 271 | // var db *gorm.DB 272 | pg := paginate.New() 273 | app := mux.NewRouter() 274 | app.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { 275 | stmt := db.Joins("User").Model(&Article{}) 276 | page := pg.With(stmt).Request(req).Response(&[]Article{}) 277 | j, _ := json.Marshal(page) 278 | w.Header().Set("Content-type", "application/json") 279 | w.Write(j) 280 | }).Methods("GET") 281 | http.Handle("/", app) 282 | http.ListenAndServe(":3000", nil) 283 | } 284 | ``` 285 | 286 | ### Fiber example 287 | 288 | ```go 289 | package main 290 | 291 | import ( 292 | "github.com/morkid/paginate" 293 | ... 294 | ) 295 | 296 | func main() { 297 | // var db *gorm.DB 298 | pg := paginate.New() 299 | app := fiber.New() 300 | app.Get("/", func(c *fiber.Ctx) error { 301 | stmt := db.Joins("User").Model(&Article{}) 302 | page := pg.With(stmt).Request(c.Request()).Response(&[]Article{}) 303 | return c.JSON(page) 304 | }) 305 | 306 | app.Listen(":3000") 307 | } 308 | ``` 309 | 310 | ### Echo example 311 | 312 | ```go 313 | package main 314 | 315 | import ( 316 | "github.com/morkid/paginate" 317 | ... 318 | ) 319 | 320 | func main() { 321 | // var db *gorm.DB 322 | pg := paginate.New() 323 | app := echo.New() 324 | app.GET("/", func(c echo.Context) error { 325 | stmt := db.Joins("User").Model(&Article{}) 326 | page := pg.With(stmt).Request(c.Request()).Response(&[]Article{}) 327 | return c.JSON(200, page) 328 | }) 329 | 330 | app.Logger.Fatal(app.Start(":3000")) 331 | } 332 | ``` 333 | 334 | ### Gin Example 335 | 336 | ```go 337 | package main 338 | 339 | import ( 340 | "github.com/morkid/paginate" 341 | ... 342 | ) 343 | 344 | func main() { 345 | // var db *gorm.DB 346 | pg := paginate.New() 347 | app := gin.Default() 348 | app.GET("/", func(c *gin.Context) { 349 | stmt := db.Joins("User").Model(&Article{}) 350 | page := pg.With(stmt).Request(c.Request).Response(&[]Article{}) 351 | c.JSON(200, page) 352 | }) 353 | app.Run(":3000") 354 | } 355 | 356 | ``` 357 | 358 | ### Martini Example 359 | 360 | ```go 361 | package main 362 | 363 | import ( 364 | "github.com/morkid/paginate" 365 | ... 366 | ) 367 | 368 | func main() { 369 | // var db *gorm.DB 370 | pg := paginate.New() 371 | app := martini.Classic() 372 | app.Use(render.Renderer()) 373 | app.Get("/", func(req *http.Request, r render.Render) { 374 | stmt := db.Joins("User").Model(&Article{}) 375 | page := pg.With(stmt).Request(req).Response(&[]Article{}) 376 | r.JSON(200, page) 377 | }) 378 | app.Run() 379 | } 380 | ``` 381 | ### Beego Example 382 | 383 | ```go 384 | package main 385 | 386 | import ( 387 | "github.com/morkid/paginate" 388 | ... 389 | ) 390 | 391 | func main() { 392 | // var db *gorm.DB 393 | pg := paginate.New() 394 | web.Get("/", func(c *context.Context) { 395 | stmt := db.Joins("User").Model(&Article{}) 396 | page := pg.With(stmt).Request(c.Request).Response(&[]Article{}) 397 | c.Output.JSON(page, false, false) 398 | }) 399 | web.Run(":3000") 400 | } 401 | ``` 402 | 403 | ### jQuery DataTable Integration 404 | 405 | ```js 406 | var logicalOperator = "OR" 407 | 408 | $('#myTable').DataTable({ 409 | 410 | columns: [ 411 | { 412 | title: "Author", 413 | data: "user.name" 414 | }, { 415 | title: "Title", 416 | data: "title" 417 | } 418 | ], 419 | 420 | processing: true, 421 | 422 | serverSide: true, 423 | 424 | ajax: { 425 | cache: true, 426 | url: "http://localhost:3000/articles", 427 | dataSrc: function(json) { 428 | json.recordsTotal = json.visible 429 | json.recordsFiltered = json.total 430 | return json.items 431 | }, 432 | data: function(params) { 433 | var custom = { 434 | page: !params.start ? 0 : Math.round(params.start / params.length), 435 | size: params.length 436 | } 437 | 438 | if (params.order.length > 0) { 439 | var sorts = [] 440 | for (var o in params.order) { 441 | var order = params.order[o] 442 | if (params.columns[order.column].orderable != false) { 443 | var sort = order.dir != 'desc' ? '' : '-' 444 | sort += params.columns[order.column].data 445 | sorts.push(sort) 446 | } 447 | } 448 | custom.sort = sorts.join() 449 | } 450 | 451 | if (params.search.value) { 452 | var columns = [] 453 | for (var c in params.columns) { 454 | var col = params.columns[c] 455 | if (col.searchable == false) { 456 | continue 457 | } 458 | columns.push(JSON.stringify([col.data, "like", encodeURIComponent(params.search.value.toLowerCase())])) 459 | } 460 | custom.filters = '[' + columns.join(',["' + logicalOperator + '"],') + ']' 461 | } 462 | 463 | return custom 464 | } 465 | }, 466 | }) 467 | ``` 468 | 469 | ### jQuery Select2 Integration 470 | 471 | ```js 472 | $('#mySelect').select2({ 473 | ajax: { 474 | url: "http://localhost:3000/users", 475 | processResults: function(json) { 476 | json.items.forEach(function(item) { 477 | item.text = item.name 478 | }) 479 | // optional 480 | if (json.first) json.items.unshift({id: 0, text: 'All'}) 481 | 482 | return { 483 | results: json.items, 484 | pagination: { 485 | more: json.last == false 486 | } 487 | } 488 | }, 489 | data: function(params) { 490 | var filters = [ 491 | ["name", "like", params.term] 492 | ] 493 | 494 | return { 495 | filters: params.term ? JSON.stringify(filters) : "", 496 | sort: "name", 497 | page: params.page && params.page - 1 ? params.page - 1 : 0 498 | } 499 | }, 500 | } 501 | }) 502 | ``` 503 | 504 | ### Programmatically Pagination 505 | 506 | ```go 507 | package main 508 | 509 | import ( 510 | "github.com/morkid/paginate" 511 | ... 512 | ) 513 | 514 | func main() { 515 | // var db *gorm.DB 516 | pg := paginate.New() 517 | req := &paginate.Request{ 518 | Page: 2, 519 | Size: 20, 520 | Sort: "-publish_date", 521 | filters: []interface{}{ 522 | []interface{}{"user.name", "like", "john"}, 523 | []interface{}{"and"}, 524 | []interface{}{"publish_date", ">=", "2025-12-31"}, 525 | []interface{}{"and"}, 526 | []interface{}{"user.active", "=", true}, 527 | []interface{}{"and"}, 528 | []interface{}{"user.last_login", "is not", nil}, 529 | } 530 | } 531 | 532 | stmt := db.Joins("User").Model(&Article{}) 533 | page := pg.With(stmt). 534 | Request(req). 535 | Response(&[]Article{}) 536 | 537 | } 538 | ``` 539 | 540 | 541 | ## Filter format 542 | 543 | Paginate Filters was inspired by [Frappe Framework API](https://docs.frappe.io/framework/user/en/api/rest#listing-documents). This feature is very powerful to support deep search and keep it safe. The format of filter param is a json encoded of multidimensional array. 544 | Maximum array members is three, first index is `column_name`, second index is `operator` and third index is `values`, you can also pass array to values. 545 | 546 | ```js 547 | // Format: 548 | ["column_name", "operator", "values"] 549 | 550 | // Example: 551 | ["age", "=", 20] 552 | // Shortcut: 553 | ["age", 20] 554 | 555 | // Produces: 556 | // WHERE age = 20 557 | ``` 558 | 559 | Single array member is known as **Logical Operator**. 560 | ```js 561 | // Example 562 | [["age", "=", 20],["or"],["age", "=", 25]] 563 | 564 | // Produces: 565 | // WHERE age = 20 OR age = 25 566 | ``` 567 | 568 | 569 | You are allowed to send array inside a value. 570 | ```js 571 | ["age", "between", [20, 30] ] 572 | // Produces: 573 | // WHERE age BETWEEN 20 AND 30 574 | 575 | ["age", "not in", [20, 21, 22, 23, 24, 25, 26, 26] ] 576 | // Produces: 577 | // WHERE age NOT IN(20, 21, 22, 23, 24, 25, 26, 26) 578 | ``` 579 | 580 | Define chain columns with same value separated by comma. 581 | ```js 582 | // Example 1 583 | ["price,discount", ">", 10] 584 | // Produces: 585 | // WHERE price > 10 OR discount > 10 586 | 587 | // Example 2 588 | ["deleted_at,expiration_date", null] 589 | // Produces: 590 | // WHERE deleted_at IS NULL OR expiration_date IS NULL 591 | ``` 592 | 593 | You can filter nested condition with deep array. 594 | ```js 595 | [ 596 | [ 597 | ["age", ">", 20], 598 | ["and"] 599 | ["age", "<", 30] 600 | ], 601 | ["and"], 602 | ["name", "like", "john"], 603 | ["and"], 604 | ["name", "like", "doe"] 605 | ] 606 | // Produces: 607 | // WHERE ( (age > 20 AND age < 20) and name like '%john%' and name like '%doe%' ) 608 | ``` 609 | 610 | For `null` value, you can send string `"null"` or `null` value, *(lower)* 611 | ```js 612 | // Wrong request 613 | [ "age", "is", NULL ] 614 | [ "age", "is", Null ] 615 | [ "age", "is not", NULL ] 616 | [ "age", "is not", Null ] 617 | 618 | // Right request 619 | [ "age", "is", "NULL" ] 620 | [ "age", "is", "Null" ] 621 | [ "age", "is", "null" ] 622 | [ "age", "is", null ] 623 | [ "age", null ] 624 | [ "age", "is not", "NULL" ] 625 | [ "age", "is not", "Null" ] 626 | [ "age", "is not", "null" ] 627 | [ "age", "is not", null ] 628 | ``` 629 | 630 | ## Customize default configuration 631 | 632 | You can customize the default configuration with `paginate.Config` struct. 633 | 634 | ```go 635 | pg := paginate.New(&paginate.Config{ 636 | DefaultSize: 50, 637 | }) 638 | ``` 639 | 640 | Config | Type | Default | Description 641 | ------------------ | ---------- | --------------------- | ------------- 642 | Operator | `string` | `OR` | Default conditional operator if no operator specified.
For example
`GET /user?filters=[["name","like","jo"],["age",">",20]]`,
produces
`SELECT * FROM user where name like '%jo' OR age > 20` 643 | FieldWrapper | `string` | `LOWER(%s)` | FieldWrapper for `LIKE` operator *(for postgres default is: `LOWER((%s)::text)`)* 644 | DefaultSize | `int64` | `10` | Default size or limit per page 645 | PageStart | `int64` | `0` | Set start page, default `0` if not set. `total_pages` , `max_page` and `page` variable will be affected if you set `PageStart` greater than `0` 646 | LikeAsIlikeDisabled | `bool` | `false` | By default, paginate using Case Insensitive on `LIKE` operator. Instead of using `ILIKE`, you can use `LIKE` operator to find what you want. You can set `LikeAsIlikeDisabled` to `true` if you need this feature to be disabled. 647 | SmartSearchEnabled | `bool` | `false` | Enable smart search *(Experimental feature)* 648 | CustomParamEnabled | `bool` | `false` | Enable custom request parameter 649 | FieldSelectorEnabled | `bool` | `false` | Enable partial response with specific fields. Comma separated per field. eg: `?fields=title,user.name` 650 | SortParams | `[]string` | `[]string{"sort"}` | if `CustomParamEnabled` is `true`,
you can set the `SortParams` with custom parameter names.
For example: `[]string{"sorting", "ordering", "other_alternative_param"}`.
The following requests will capture same result
`?sorting=-name`
or `?ordering=-name`
or `?other_alternative_param=-name`
or `?sort=-name` 651 | PageParams | `[]string` | `[]string{"page"}` | if `CustomParamEnabled` is `true`,
you can set the `PageParams` with custom parameter names.
For example:
`[]string{"number", "num", "other_alternative_param"}`.
The following requests will capture same result `?number=0`
or `?num=0`
or `?other_alternative_param=0`
or `?page=0` 652 | SizeParams | `[]string` | `[]string{"size"}` | if `CustomParamEnabled` is `true`,
you can set the `SizeParams` with custom parameter names.
For example:
`[]string{"limit", "max", "other_alternative_param"}`.
The following requests will capture same result `?limit=50`
or `?limit=50`
or `?other_alternative_param=50`
or `?max=50` 653 | OrderParams | `[]string` | `[]string{"order"}` | if `CustomParamEnabled` is `true`,
you can set the `OrderParams` with custom parameter names.
For example:
`[]string{"order", "direction", "other_alternative_param"}`.
The following requests will capture same result `?order=desc`
or `?direction=desc`
or `?other_alternative_param=desc` 654 | FilterParams | `[]string` | `[]string{"filters"}` | if `CustomParamEnabled` is `true`,
you can set the `FilterParams` with custom parameter names.
For example:
`[]string{"search", "find", "other_alternative_param"}`.
The following requests will capture same result
`?search=["name","john"]`
or `?find=["name","john"]`
or `?other_alternative_param=["name","john"]`
or `?filters=["name","john"]` 655 | FieldsParams | `[]string` | `[]string{"fields"}` | if `FieldSelectorEnabled` and `CustomParamEnabled` is `true`,
you can set the `FieldsParams` with custom parameter names.
For example:
`[]string{"fields", "columns", "other_alternative_param"}`.
The following requests will capture same result `?fields=title,user.name`
or `?columns=title,user.name`
or `?other_alternative_param=title,user.name` 656 | CacheAdapter | `*gocache.AdapterInterface` | `nil` | the cache adapter, see more about [cache config](#speed-up-response-with-cache). 657 | ErrorEnabled | `bool` | `false` | Show error message in pagination result. 658 | 659 | ## Override results 660 | 661 | You can override result with custom function. 662 | 663 | ```go 664 | // var db = *gorm.DB 665 | // var httpRequest ... net/http or fasthttp instance 666 | // Example override function 667 | override := func(article *Article) { 668 | if article.UserID > 0 { 669 | article.Title = fmt.Sprintf( 670 | "%s written by %s", article.Title, article.User.Name) 671 | } 672 | } 673 | 674 | var articles []Article 675 | stmt := db.Joins("User").Model(&Article{}) 676 | 677 | pg := paginate.New() 678 | page := pg.With(stmt).Request(httpRequest).Response(&articles) 679 | for index := range articles { 680 | override(&articles[index]) 681 | } 682 | 683 | log.Println(page.Items) 684 | 685 | ``` 686 | 687 | ## Field selector 688 | To implement a custom field selector, struct properties must have a json tag with omitempty. 689 | 690 | ```go 691 | // real gorm model 692 | type User { 693 | gorm.Model 694 | Name string `json:"name"` 695 | Age int64 `json:"age"` 696 | } 697 | 698 | // fake gorm model 699 | type UserNullable { 700 | ID *string `json:"id,omitempty"` 701 | CreatedAt *time.Time `json:"created_at,omitempty"` 702 | UpdatedAt *time.Time `json:"updated_at,omitempty"` 703 | Name *string `json:"name,omitempty"` 704 | Age *int64 `json:"age,omitempty"` 705 | } 706 | ``` 707 | 708 | ```go 709 | // usage 710 | nameAndIDOnly := []string{"name","id"} 711 | stmt := db.Model(&User{}) 712 | 713 | page := pg.With(stmt). 714 | Request(req). 715 | Fields(nameAndIDOnly). 716 | Response([]&UserNullable{}) 717 | ``` 718 | 719 | ```javascript 720 | // response 721 | { 722 | "items": [ 723 | { 724 | "id": 1, 725 | "name": "John" 726 | } 727 | ], 728 | ... 729 | } 730 | ``` 731 | ## Dynamic field selector 732 | If the request contains query parameter `fields` (eg: `?fieilds=name,id`), then the response will show only `name` and `id`. To activate this feature, please set `FieldSelectorEnabled` to `true`. 733 | ```go 734 | config := paginate.Config{ 735 | FieldSelectorEnabled: true, 736 | } 737 | 738 | pg := paginate.New(config) 739 | ``` 740 | 741 | ## Speed up response with cache 742 | You can speed up results without looking database directly with cache adapter. See more about [cache adapter](https://github.com/morkid/gocache). 743 | 744 | ### In memory cache 745 | in memory cache is not recommended for production environment: 746 | ```go 747 | import ( 748 | "github.com/morkid/gocache" 749 | ... 750 | ) 751 | 752 | func main() { 753 | ... 754 | adapterConfig := gocache.InMemoryCacheConfig{ 755 | ExpiresIn: 1 * time.Hour, 756 | } 757 | pg := paginate.New(&paginate.Config{ 758 | CacheAdapter: gocache.NewInMemoryCache(adapterConfig), 759 | }) 760 | 761 | page := pg.With(stmt). 762 | Request(req). 763 | Cache("article"). // set cache name 764 | Response(&[]Article{}) 765 | ... 766 | } 767 | ``` 768 | 769 | ### Disk cache 770 | Disk cache will create a file for every single request. You can use disk cache if you don't care about inode. 771 | ```go 772 | import ( 773 | "github.com/morkid/gocache" 774 | ... 775 | ) 776 | 777 | func main() { 778 | adapterConfig := gocache.DiskCacheConfig{ 779 | Directory: "/writable/path/to/my-cache-dir", 780 | ExpiresIn: 1 * time.Hour, 781 | } 782 | pg := paginate.New(&paginate.Config{ 783 | CacheAdapter: gocache.NewDiskCache(adapterConfig), 784 | }) 785 | 786 | page := pg.With(stmt). 787 | Request(req). 788 | Cache("article"). // set cache name 789 | Response(&[]Article{}) 790 | ... 791 | } 792 | ``` 793 | 794 | ### Redis cache 795 | Redis cache require [redis client](https://github.com/go-redis/redis) for golang. 796 | ```go 797 | import ( 798 | cache "github.com/morkid/gocache-redis/v8" 799 | "github.com/go-redis/redis/v8" 800 | ... 801 | ) 802 | 803 | func main() { 804 | client := redis.NewClient(&redis.Options{ 805 | Addr: "localhost:6379", 806 | Password: "", 807 | DB: 0, 808 | }) 809 | 810 | adapterConfig := cache.RedisCacheConfig{ 811 | Client: client, 812 | ExpiresIn: 1 * time.Hour, 813 | } 814 | pg := paginate.New(&paginate.Config{ 815 | CacheAdapter: cache.NewRedisCache(adapterConfig), 816 | }) 817 | 818 | page := pg.With(stmt). 819 | Request(req). 820 | Cache("article"). 821 | Response(&[]Article{}) 822 | ... 823 | } 824 | ``` 825 | > if your code already adopts another redis client, you can implement the [redis adapter](https://github.com/morkid/gocache-redis) according to its version. See more about [redis adapter](https://github.com/morkid/gocache-redis). 826 | 827 | ### Elasticsearch cache 828 | Elasticsearch cache require official [elasticsearch client](https://github.com/elastic/go-elasticsearch) for golang. 829 | ```go 830 | import ( 831 | cache "github.com/morkid/gocache-elasticsearch/v7" 832 | "github.com/elastic/go-elasticsearch/v7" 833 | ... 834 | ) 835 | 836 | func main() { 837 | config := elasticsearch.Config{ 838 | Addresses: []string{ 839 | "http://localhost:9200", 840 | }, 841 | } 842 | es, err := elasticsearch.NewClient(config) 843 | if nil != err { 844 | panic(err) 845 | } 846 | 847 | adapterConfig := cache.ElasticCacheConfig{ 848 | Client: es, 849 | Index: "exampleproject", 850 | ExpiresIn: 1 * time.Hour, 851 | } 852 | pg := paginate.New(&paginate.Config{ 853 | CacheAdapter: cache.NewElasticCache(adapterConfig), 854 | }) 855 | 856 | page := pg.With(stmt). 857 | Request(req). 858 | Cache("article"). 859 | Response(&[]Article{}) 860 | ... 861 | } 862 | ``` 863 | > if your code already adopts another elasticsearch client, you can implement the [elasticsearch adapter](https://github.com/morkid/gocache-elasticsearch) according to its version. See more about [elasticsearch adapter](https://github.com/morkid/gocache-elasticsearch). 864 | 865 | ### Custom cache 866 | Create your own cache adapter by implementing [gocache AdapterInterface](https://github.com/morkid/gocache/blob/master/gocache.go). See more about [cache adapter](https://github.com/morkid/gocache). 867 | ```go 868 | // AdapterInterface interface 869 | type AdapterInterface interface { 870 | // Set cache with key 871 | Set(key string, value string) error 872 | // Get cache by key 873 | Get(key string) (string, error) 874 | // IsValid check if cache is valid 875 | IsValid(key string) bool 876 | // Clear clear cache by key 877 | Clear(key string) error 878 | // ClearPrefix clear cache by key prefix 879 | ClearPrefix(keyPrefix string) error 880 | // Clear all cache 881 | ClearAll() error 882 | } 883 | ``` 884 | 885 | ### Clean up cache 886 | Clear cache by cache name 887 | ```go 888 | pg.ClearCache("article") 889 | ``` 890 | Clear multiple cache 891 | ```go 892 | pg.ClearCache("cache1", "cache2", "cache3") 893 | ``` 894 | 895 | Clear all cache 896 | ```go 897 | pg.ClearAllCache() 898 | ``` 899 | 900 | 901 | ## Limitations 902 | 903 | Paginate doesn't support has many relationship. You can make API with separated endpoints for parent and child: 904 | ```javascript 905 | GET /users 906 | 907 | { 908 | "items": [ 909 | { 910 | "id": 1, 911 | "name": "john", 912 | "age": 20, 913 | "addresses": [...] // doesn't support 914 | } 915 | ], 916 | ... 917 | } 918 | ``` 919 | 920 | Best practice: 921 | 922 | ```javascript 923 | GET /users 924 | { 925 | "items": [ 926 | { 927 | "id": 1, 928 | "name": "john", 929 | "age": 20 930 | } 931 | ], 932 | ... 933 | } 934 | 935 | GET /users/1/addresses 936 | { 937 | "items": [ 938 | { 939 | "id": 1, 940 | "name": "home", 941 | "street": "home street" 942 | "user": { 943 | "id": 1, 944 | "name": "john", 945 | "age": 20 946 | } 947 | } 948 | ], 949 | ... 950 | } 951 | ``` 952 | 953 | Paginate doesn't support for customized json or table field name. 954 | Make sure your struct properties have same name with gorm column and json property before you expose them. 955 | 956 | Example bad configuration: 957 | 958 | ```go 959 | 960 | type User struct { 961 | gorm.Model 962 | UserName string `gorm:"column:nickname" json:"name"` 963 | UserAddress string `gorm:"column:user_address" json:"address"` 964 | } 965 | 966 | // request: GET /path/to/endpoint?sort=-name,address 967 | // response: "items": [] with sql error (column name not found) 968 | ``` 969 | 970 | Best practice: 971 | ```go 972 | type User struct { 973 | gorm.Model 974 | Name string `gorm:"column:name" json:"name"` 975 | Address string `gorm:"column:address" json:"address"` 976 | } 977 | 978 | ``` 979 | 980 | ## License 981 | 982 | Published under the [MIT License](https://github.com/morkid/paginate/blob/master/LICENSE). 983 | --------------------------------------------------------------------------------