BigW Consortium Gitlab
Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
G
gitlab-ce
Project
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Commits
Issue Boards
Open sidebar
Forest Godfrey
gitlab-ce
Commits
4d3b313a
Commit
4d3b313a
authored
May 22, 2018
by
Yorick Peterse
Browse files
Options
Browse Files
Download
Plain Diff
Merge branch 'ab-42194-keyset-pagination' into 'master'
API: Keyset pagination support Closes #45756 See merge request gitlab-org/gitlab-ce!18584
parents
2c7095c8
f5c42031
Hide whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
417 additions
and
50 deletions
+417
-50
pagination.rb
lib/api/helpers/pagination.rb
+213
-40
pagination.rb
lib/gitlab/serializer/pagination.rb
+0
-2
pagination_spec.rb
spec/lib/api/helpers/pagination_spec.rb
+204
-8
No files found.
lib/api/helpers/pagination.rb
View file @
4d3b313a
...
...
@@ -2,67 +2,240 @@ module API
module
Helpers
module
Pagination
def
paginate
(
relation
)
relation
=
add_default_order
(
relation
)
strategy
=
if
params
[
:pagination
]
==
'keyset'
&&
Feature
.
enabled?
(
'api_keyset_pagination'
)
KeysetPaginationStrategy
else
DefaultPaginationStrategy
end
relation
.
page
(
params
[
:page
]).
per
(
params
[
:per_page
]).
tap
do
|
data
|
add_pagination_headers
(
data
)
end
strategy
.
new
(
self
).
paginate
(
relation
)
end
private
class
KeysetPaginationInfo
attr_reader
:relation
,
:request_context
def
add_pagination_headers
(
paginated_data
)
header
'X-Per-Page'
,
paginated_data
.
limit_value
.
to_s
header
'X-Page'
,
paginated_data
.
current_page
.
to_s
header
'X-Next-Page'
,
paginated_data
.
next_page
.
to_s
header
'X-Prev-Page'
,
paginated_data
.
prev_page
.
to_s
header
'Link'
,
pagination_links
(
paginated_data
)
def
initialize
(
relation
,
request_context
)
# This is because it's rather complex to support multiple values with possibly different sort directions
# (and we don't need this in the API)
if
relation
.
order_values
.
size
>
1
raise
"Pagination only supports ordering by a single column."
\
"The following columns were given:
#{
relation
.
order_values
.
map
{
|
v
|
v
.
expr
.
name
}
}"
end
return
if
data_without_counts?
(
paginated_data
)
@relation
=
relation
@request_context
=
request_context
end
header
'X-Total'
,
paginated_data
.
total_count
.
to_
s
header
'X-Total-Pages'
,
total_pages
(
paginated_data
).
to_s
end
def
field
s
keys
.
zip
(
values
).
reject
{
|
_
,
v
|
v
.
nil?
}.
to_h
end
def
pagination_links
(
paginated_data
)
request_url
=
request
.
url
.
split
(
'?'
).
first
request_params
=
params
.
clone
request_params
[
:per_page
]
=
paginated_data
.
limit_value
def
column_for_order_by
(
relation
)
relation
.
order_values
.
first
&
.
expr
&
.
name
end
links
=
[]
# Sort direction (`:asc` or `:desc`)
def
sort
@sort
||=
if
order_by_primary_key?
# Default order is by id DESC
:desc
else
# API defaults to DESC order if param `sort` not present
request_context
.
params
[
:sort
]
&
.
to_sym
||
:desc
end
end
request_params
[
:page
]
=
paginated_data
.
prev_page
links
<<
%(<#{request_url}?#{request_params.to_query}>; rel="prev")
if
request_params
[
:page
]
# Do we only sort by primary key?
def
order_by_primary_key?
keys
.
size
==
1
&&
keys
.
first
==
primary_key
end
request_params
[
:page
]
=
paginated_data
.
next_page
links
<<
%(<#{request_url}?#{request_params.to_query}>; rel="next")
if
request_params
[
:page
]
def
primary_key
relation
.
model
.
primary_key
.
to_sym
end
request_params
[
:page
]
=
1
links
<<
%(<#{request_url}?#{request_params.to_query}>; rel="first")
def
sort_ascending?
sort
==
:asc
end
unless
data_without_counts?
(
paginated_data
)
request_params
[
:page
]
=
total_pages
(
paginated_data
)
links
<<
%(<#{request_url}?#{request_params.to_query}>; rel="last")
# Build hash of request parameters for a given record (relevant to pagination)
def
params_for
(
record
)
return
{}
unless
record
keys
.
each_with_object
({})
do
|
key
,
h
|
h
[
"ks_prev_
#{
key
}
"
.
to_sym
]
=
record
.
attributes
[
key
.
to_s
]
end
end
links
.
join
(
', '
)
end
private
# All values present in request parameters that correspond to #keys.
def
values
@values
||=
keys
.
map
do
|
key
|
request_context
.
params
[
"ks_prev_
#{
key
}
"
.
to_sym
]
end
end
def
total_pages
(
paginated_data
)
# Ensure there is in total at least 1 page
[
paginated_data
.
total_pages
,
1
].
max
# All keys relevant to pagination.
# This always includes the primary key. Optionally, the `order_by` key is prepended.
def
keys
@keys
||=
[
column_for_order_by
(
relation
),
primary_key
].
compact
.
uniq
end
end
def
add_default_order
(
relation
)
if
relation
.
is_a?
(
ActiveRecord
::
Relation
)
&&
relation
.
order_values
.
empty?
relation
=
relation
.
order
(
:id
)
class
KeysetPaginationStrategy
attr_reader
:request_context
delegate
:params
,
:header
,
:request
,
to: :request_context
def
initialize
(
request_context
)
@request_context
=
request_context
end
def
paginate
(
relation
)
pagination
=
KeysetPaginationInfo
.
new
(
relation
,
request_context
)
paged_relation
=
relation
.
limit
(
per_page
)
if
conds
=
conditions
(
pagination
)
paged_relation
=
paged_relation
.
where
(
*
conds
)
end
# In all cases: sort by primary key (possibly in addition to another sort column)
paged_relation
=
paged_relation
.
order
(
pagination
.
primary_key
=>
pagination
.
sort
)
add_default_pagination_headers
if
last_record
=
paged_relation
.
last
next_page_params
=
pagination
.
params_for
(
last_record
)
add_navigation_links
(
next_page_params
)
end
paged_relation
end
private
def
conditions
(
pagination
)
fields
=
pagination
.
fields
return
nil
if
fields
.
empty?
placeholder
=
fields
.
map
{
'?'
}
comp
=
if
pagination
.
sort_ascending?
'>'
else
'<'
end
[
# Row value comparison:
# (A, B) < (a, b) <=> (A < a) OR (A = a AND B < b)
# <=> A <= a AND ((A < a) OR (A = a AND B < b))
"(
#{
fields
.
keys
.
join
(
','
)
}
)
#{
comp
}
(
#{
placeholder
.
join
(
','
)
}
)"
,
*
fields
.
values
]
end
def
per_page
params
[
:per_page
]
end
def
add_default_pagination_headers
header
'X-Per-Page'
,
per_page
.
to_s
end
def
add_navigation_links
(
next_page_params
)
header
'X-Next-Page'
,
page_href
(
next_page_params
)
header
'Link'
,
link_for
(
'next'
,
next_page_params
)
end
relation
def
page_href
(
next_page_params
)
request_url
=
request
.
url
.
split
(
'?'
).
first
request_params
=
params
.
dup
request_params
[
:per_page
]
=
per_page
request_params
.
merge!
(
next_page_params
)
if
next_page_params
"
#{
request_url
}
?
#{
request_params
.
to_query
}
"
end
def
link_for
(
rel
,
next_page_params
)
%(<#{page_href(next_page_params)}>; rel="#{rel}")
end
end
def
data_without_counts?
(
paginated_data
)
paginated_data
.
is_a?
(
Kaminari
::
PaginatableWithoutCount
)
class
DefaultPaginationStrategy
attr_reader
:request_context
delegate
:params
,
:header
,
:request
,
to: :request_context
def
initialize
(
request_context
)
@request_context
=
request_context
end
def
paginate
(
relation
)
relation
=
add_default_order
(
relation
)
relation
.
page
(
params
[
:page
]).
per
(
params
[
:per_page
]).
tap
do
|
data
|
add_pagination_headers
(
data
)
end
end
private
def
add_default_order
(
relation
)
if
relation
.
is_a?
(
ActiveRecord
::
Relation
)
&&
relation
.
order_values
.
empty?
relation
=
relation
.
order
(
:id
)
end
relation
end
def
add_pagination_headers
(
paginated_data
)
header
'X-Per-Page'
,
paginated_data
.
limit_value
.
to_s
header
'X-Page'
,
paginated_data
.
current_page
.
to_s
header
'X-Next-Page'
,
paginated_data
.
next_page
.
to_s
header
'X-Prev-Page'
,
paginated_data
.
prev_page
.
to_s
header
'Link'
,
pagination_links
(
paginated_data
)
return
if
data_without_counts?
(
paginated_data
)
header
'X-Total'
,
paginated_data
.
total_count
.
to_s
header
'X-Total-Pages'
,
total_pages
(
paginated_data
).
to_s
end
def
pagination_links
(
paginated_data
)
request_url
=
request
.
url
.
split
(
'?'
).
first
request_params
=
params
.
clone
request_params
[
:per_page
]
=
paginated_data
.
limit_value
links
=
[]
request_params
[
:page
]
=
paginated_data
.
prev_page
links
<<
%(<#{request_url}?#{request_params.to_query}>; rel="prev")
if
request_params
[
:page
]
request_params
[
:page
]
=
paginated_data
.
next_page
links
<<
%(<#{request_url}?#{request_params.to_query}>; rel="next")
if
request_params
[
:page
]
request_params
[
:page
]
=
1
links
<<
%(<#{request_url}?#{request_params.to_query}>; rel="first")
unless
data_without_counts?
(
paginated_data
)
request_params
[
:page
]
=
total_pages
(
paginated_data
)
links
<<
%(<#{request_url}?#{request_params.to_query}>; rel="last")
end
links
.
join
(
', '
)
end
def
total_pages
(
paginated_data
)
# Ensure there is in total at least 1 page
[
paginated_data
.
total_pages
,
1
].
max
end
def
data_without_counts?
(
paginated_data
)
paginated_data
.
is_a?
(
Kaminari
::
PaginatableWithoutCount
)
end
end
end
end
...
...
lib/gitlab/serializer/pagination.rb
View file @
4d3b313a
...
...
@@ -17,8 +17,6 @@ module Gitlab
end
end
private
# Methods needed by `API::Helpers::Pagination`
#
...
...
spec/lib/api/helpers/pagination_spec.rb
View file @
4d3b313a
...
...
@@ -7,7 +7,203 @@ describe API::Helpers::Pagination do
Class
.
new
.
include
(
described_class
).
new
end
describe
'#paginate'
do
describe
'#paginate (keyset pagination)'
do
let
(
:value
)
{
spy
(
'return value'
)
}
before
do
allow
(
value
).
to
receive
(
:to_query
).
and_return
(
value
)
allow
(
subject
).
to
receive
(
:header
).
and_return
(
value
)
allow
(
subject
).
to
receive
(
:params
).
and_return
(
value
)
allow
(
subject
).
to
receive
(
:request
).
and_return
(
value
)
end
context
'when resource can be paginated'
do
let!
(
:projects
)
do
[
create
(
:project
,
name:
'One'
),
create
(
:project
,
name:
'Two'
),
create
(
:project
,
name:
'Three'
)
].
sort_by
{
|
e
|
-
e
.
id
}
# sort by id desc (this is the default sort order for the API)
end
describe
'first page'
do
before
do
allow
(
subject
).
to
receive
(
:params
)
.
and_return
({
pagination:
'keyset'
,
per_page:
2
})
end
it
'returns appropriate amount of resources'
do
expect
(
subject
.
paginate
(
resource
).
count
).
to
eq
2
end
it
'returns the first two records (by id desc)'
do
expect
(
subject
.
paginate
(
resource
)).
to
eq
(
projects
[
0
..
1
])
end
it
'adds appropriate headers'
do
expect_header
(
'X-Per-Page'
,
'2'
)
expect_header
(
'X-Next-Page'
,
"
#{
value
}
?ks_prev_id=
#{
projects
[
1
].
id
}
&pagination=keyset&per_page=2"
)
expect_header
(
'Link'
,
anything
)
do
|
_key
,
val
|
expect
(
val
).
to
include
(
'rel="next"'
)
end
subject
.
paginate
(
resource
)
end
end
describe
'second page'
do
before
do
allow
(
subject
).
to
receive
(
:params
)
.
and_return
({
pagination:
'keyset'
,
per_page:
2
,
ks_prev_id:
projects
[
1
].
id
})
end
it
'returns appropriate amount of resources'
do
expect
(
subject
.
paginate
(
resource
).
count
).
to
eq
1
end
it
'returns the third record'
do
expect
(
subject
.
paginate
(
resource
)).
to
eq
(
projects
[
2
..
2
])
end
it
'adds appropriate headers'
do
expect_header
(
'X-Per-Page'
,
'2'
)
expect_header
(
'X-Next-Page'
,
"
#{
value
}
?ks_prev_id=
#{
projects
[
2
].
id
}
&pagination=keyset&per_page=2"
)
expect_header
(
'Link'
,
anything
)
do
|
_key
,
val
|
expect
(
val
).
to
include
(
'rel="next"'
)
end
subject
.
paginate
(
resource
)
end
end
describe
'third page'
do
before
do
allow
(
subject
).
to
receive
(
:params
)
.
and_return
({
pagination:
'keyset'
,
per_page:
2
,
ks_prev_id:
projects
[
2
].
id
})
end
it
'returns appropriate amount of resources'
do
expect
(
subject
.
paginate
(
resource
).
count
).
to
eq
0
end
it
'adds appropriate headers'
do
expect_header
(
'X-Per-Page'
,
'2'
)
expect
(
subject
).
not_to
receive
(
:header
).
with
(
'Link'
)
subject
.
paginate
(
resource
)
end
end
context
'if order'
do
context
'is not present'
do
before
do
allow
(
subject
).
to
receive
(
:params
)
.
and_return
({
pagination:
'keyset'
,
per_page:
2
})
end
it
'is not present it adds default order(:id) desc'
do
resource
.
order_values
=
[]
paginated_relation
=
subject
.
paginate
(
resource
)
expect
(
resource
.
order_values
).
to
be_empty
expect
(
paginated_relation
.
order_values
).
to
be_present
expect
(
paginated_relation
.
order_values
.
size
).
to
eq
(
1
)
expect
(
paginated_relation
.
order_values
.
first
).
to
be_descending
expect
(
paginated_relation
.
order_values
.
first
.
expr
.
name
).
to
eq
:id
end
end
context
'is present'
do
let
(
:resource
)
{
Project
.
all
.
order
(
name: :desc
)
}
let!
(
:projects
)
do
[
create
(
:project
,
name:
'One'
),
create
(
:project
,
name:
'Two'
),
create
(
:project
,
name:
'Three'
),
create
(
:project
,
name:
'Three'
),
# Note the duplicate name
create
(
:project
,
name:
'Four'
),
create
(
:project
,
name:
'Five'
),
create
(
:project
,
name:
'Six'
)
]
# if we sort this by name descending, id descending, this yields:
# {
# 2 => "Two",
# 4 => "Three",
# 3 => "Three",
# 7 => "Six",
# 1 => "One",
# 5 => "Four",
# 6 => "Five"
# }
#
# (key is the id)
end
it
'it also orders by primary key'
do
allow
(
subject
).
to
receive
(
:params
)
.
and_return
({
pagination:
'keyset'
,
per_page:
2
})
paginated_relation
=
subject
.
paginate
(
resource
)
expect
(
paginated_relation
.
order_values
).
to
be_present
expect
(
paginated_relation
.
order_values
.
size
).
to
eq
(
2
)
expect
(
paginated_relation
.
order_values
.
first
).
to
be_descending
expect
(
paginated_relation
.
order_values
.
first
.
expr
.
name
).
to
eq
:name
expect
(
paginated_relation
.
order_values
.
second
).
to
be_descending
expect
(
paginated_relation
.
order_values
.
second
.
expr
.
name
).
to
eq
:id
end
it
'it returns the right records (first page)'
do
allow
(
subject
).
to
receive
(
:params
)
.
and_return
({
pagination:
'keyset'
,
per_page:
2
})
result
=
subject
.
paginate
(
resource
)
expect
(
result
.
first
).
to
eq
(
projects
[
1
])
expect
(
result
.
second
).
to
eq
(
projects
[
3
])
end
it
'it returns the right records (second page)'
do
allow
(
subject
).
to
receive
(
:params
)
.
and_return
({
pagination:
'keyset'
,
ks_prev_id:
projects
[
3
].
id
,
ks_prev_name:
projects
[
3
].
name
,
per_page:
2
})
result
=
subject
.
paginate
(
resource
)
expect
(
result
.
first
).
to
eq
(
projects
[
2
])
expect
(
result
.
second
).
to
eq
(
projects
[
6
])
end
it
'it returns the right records (third page), note increased per_page'
do
allow
(
subject
).
to
receive
(
:params
)
.
and_return
({
pagination:
'keyset'
,
ks_prev_id:
projects
[
6
].
id
,
ks_prev_name:
projects
[
6
].
name
,
per_page:
5
})
result
=
subject
.
paginate
(
resource
)
expect
(
result
.
size
).
to
eq
(
3
)
expect
(
result
.
first
).
to
eq
(
projects
[
0
])
expect
(
result
.
second
).
to
eq
(
projects
[
4
])
expect
(
result
.
last
).
to
eq
(
projects
[
5
])
end
it
'it returns the right link to the next page'
do
allow
(
subject
).
to
receive
(
:params
)
.
and_return
({
pagination:
'keyset'
,
ks_prev_id:
projects
[
3
].
id
,
ks_prev_name:
projects
[
3
].
name
,
per_page:
2
})
expect_header
(
'X-Per-Page'
,
'2'
)
expect_header
(
'X-Next-Page'
,
"
#{
value
}
?ks_prev_id=
#{
projects
[
6
].
id
}
&ks_prev_name=
#{
projects
[
6
].
name
}
&pagination=keyset&per_page=2"
)
expect_header
(
'Link'
,
anything
)
do
|
_key
,
val
|
expect
(
val
).
to
include
(
'rel="next"'
)
end
subject
.
paginate
(
resource
)
end
end
end
end
end
describe
'#paginate (default offset-based pagination)'
do
let
(
:value
)
{
spy
(
'return value'
)
}
before
do
...
...
@@ -146,14 +342,14 @@ describe API::Helpers::Pagination do
end
end
end
end
def
expect_header
(
*
args
,
&
block
)
expect
(
subject
).
to
receive
(
:header
).
with
(
*
args
,
&
block
)
end
def
expect_header
(
*
args
,
&
block
)
expect
(
subject
).
to
receive
(
:header
).
with
(
*
args
,
&
block
)
end
def
expect_message
(
method
)
expect
(
subject
).
to
receive
(
method
)
.
at_least
(
:once
).
and_return
(
value
)
end
def
expect_message
(
method
)
expect
(
subject
).
to
receive
(
method
)
.
at_least
(
:once
).
and_return
(
value
)
end
end
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment