├── .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 | [](https://pkg.go.dev/github.com/morkid/paginate)
4 | [](https://github.com/morkid/paginate/actions)
5 | [](https://goreportcard.com/report/github.com/morkid/paginate)
6 | [](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 |
--------------------------------------------------------------------------------