pack_api 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE.txt +21 -0
- data/README.md +238 -0
- data/lib/pack_api/config/dry_types_initializer.rb +1 -0
- data/lib/pack_api/models/internal_error.rb +25 -0
- data/lib/pack_api/models/mapping/abstract_transformer.rb +46 -0
- data/lib/pack_api/models/mapping/api_to_model_attributes_transformer.rb +27 -0
- data/lib/pack_api/models/mapping/attribute_hash_transformer.rb +46 -0
- data/lib/pack_api/models/mapping/attribute_map.rb +268 -0
- data/lib/pack_api/models/mapping/attribute_map_registry.rb +21 -0
- data/lib/pack_api/models/mapping/error_hash_to_api_attributes_transformer.rb +101 -0
- data/lib/pack_api/models/mapping/filter_map.rb +97 -0
- data/lib/pack_api/models/mapping/model_to_api_attributes_transformer.rb +67 -0
- data/lib/pack_api/models/mapping/normalized_api_attribute.rb +40 -0
- data/lib/pack_api/models/mapping/null_transformer.rb +9 -0
- data/lib/pack_api/models/mapping/value_object_factory.rb +83 -0
- data/lib/pack_api/models/pagination/opaque_token_v2.rb +19 -0
- data/lib/pack_api/models/pagination/paginator.rb +155 -0
- data/lib/pack_api/models/pagination/paginator_builder.rb +112 -0
- data/lib/pack_api/models/pagination/paginator_cursor.rb +86 -0
- data/lib/pack_api/models/pagination/snapshot_paginator.rb +133 -0
- data/lib/pack_api/models/querying/abstract_boolean_filter.rb +38 -0
- data/lib/pack_api/models/querying/abstract_enum_filter.rb +54 -0
- data/lib/pack_api/models/querying/abstract_filter.rb +15 -0
- data/lib/pack_api/models/querying/abstract_numeric_filter.rb +37 -0
- data/lib/pack_api/models/querying/abstract_range_filter.rb +31 -0
- data/lib/pack_api/models/querying/attribute_filter.rb +36 -0
- data/lib/pack_api/models/querying/attribute_filter_factory.rb +62 -0
- data/lib/pack_api/models/querying/collection_query.rb +125 -0
- data/lib/pack_api/models/querying/composable_query.rb +22 -0
- data/lib/pack_api/models/querying/default_filter.rb +20 -0
- data/lib/pack_api/models/querying/discoverable_filter.rb +33 -0
- data/lib/pack_api/models/querying/dynamic_enum_filter.rb +20 -0
- data/lib/pack_api/models/querying/filter_factory.rb +54 -0
- data/lib/pack_api/models/querying/sort_hash.rb +36 -0
- data/lib/pack_api/models/types/aggregate_type.rb +202 -0
- data/lib/pack_api/models/types/base_type.rb +46 -0
- data/lib/pack_api/models/types/boolean_filter_definition.rb +9 -0
- data/lib/pack_api/models/types/collection_result_metadata.rb +48 -0
- data/lib/pack_api/models/types/custom_filter_definition.rb +8 -0
- data/lib/pack_api/models/types/enum_filter_definition.rb +10 -0
- data/lib/pack_api/models/types/filter_option.rb +8 -0
- data/lib/pack_api/models/types/globally_identifiable.rb +19 -0
- data/lib/pack_api/models/types/numeric_filter_definition.rb +9 -0
- data/lib/pack_api/models/types/range_filter_definition.rb +10 -0
- data/lib/pack_api/models/types/result.rb +70 -0
- data/lib/pack_api/models/types/simple_filter_definition.rb +9 -0
- data/lib/pack_api/models/values_in_background_batches.rb +58 -0
- data/lib/pack_api/models/values_in_batches.rb +51 -0
- data/lib/pack_api/version.rb +5 -0
- data/lib/pack_api.rb +72 -0
- data/lib/types.rb +3 -0
- metadata +276 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'brotli'
|
|
4
|
+
|
|
5
|
+
module PackAPI::Pagination
|
|
6
|
+
class OpaqueTokenV2
|
|
7
|
+
def self.create(unencoded)
|
|
8
|
+
Base64.strict_encode64(Brotli.deflate(unencoded.to_json))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.parse(encoded)
|
|
12
|
+
raise JSON::ParserError if encoded.nil?
|
|
13
|
+
|
|
14
|
+
decoded = Base64.strict_decode64(encoded)
|
|
15
|
+
decompressed = Brotli.inflate(decoded)
|
|
16
|
+
JSON.parse(decompressed, symbolize_names: true)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
##
|
|
4
|
+
# Enable paged access to large record sets.
|
|
5
|
+
#
|
|
6
|
+
# For any given query and sort, limit the returned item count, and provide access to adjacent subsets of the records.
|
|
7
|
+
# An opaque token (aka "cursor") is created and passed to the caller for the following "pages" of data:
|
|
8
|
+
# - next page
|
|
9
|
+
# - previous page
|
|
10
|
+
# - first page
|
|
11
|
+
# - last page
|
|
12
|
+
#
|
|
13
|
+
# A cursor acts like a bookmark to a record in a recordset.
|
|
14
|
+
#
|
|
15
|
+
# The pagination strategy used is the most basic one: Limit-Offset.
|
|
16
|
+
# More sophisticated ones exist (https://www.citusdata.com/blog/2016/03/30/five-ways-to-paginate/)
|
|
17
|
+
# and can be implemented as needed.
|
|
18
|
+
#
|
|
19
|
+
# Construct an object of this type using the {PaginatorBuilder}
|
|
20
|
+
# The various *_cursor methods return opaque tokens representing {Paginator} objects
|
|
21
|
+
# They should be parsed using the {PaginatorCursor} class.
|
|
22
|
+
#
|
|
23
|
+
module PackAPI::Pagination
|
|
24
|
+
class Paginator
|
|
25
|
+
###
|
|
26
|
+
# @param [Hash] query The query parameters used to define the recordset.
|
|
27
|
+
# @param [String|Symbol|Hash|Arel] sort The ordering used to define the recordset
|
|
28
|
+
# @param [Integer|:all] per_page The count of items to include on each result page, or :all for single page results
|
|
29
|
+
# @param [Integer] offset The starting index of the next result page
|
|
30
|
+
# @param [Integer] total_items The count of items in the result set
|
|
31
|
+
# @param [Any|nil] metadata optional, extra data structure to be passed along with the cursor
|
|
32
|
+
attr_accessor :query, :sort, :total_items, :per_page, :offset, :metadata
|
|
33
|
+
|
|
34
|
+
DEFAULT_PER_PAGE = 20
|
|
35
|
+
DEFAULT_SORT = 'id asc'
|
|
36
|
+
|
|
37
|
+
###
|
|
38
|
+
# The range of items included in the results.
|
|
39
|
+
def item_range
|
|
40
|
+
return 0..0 if per_page != :all && per_page.zero?
|
|
41
|
+
|
|
42
|
+
lower_bound = offset + 1
|
|
43
|
+
upper_bound = per_page == :all ?
|
|
44
|
+
total_items :
|
|
45
|
+
[(offset + per_page), total_items].min
|
|
46
|
+
lower_bound..upper_bound
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
###
|
|
50
|
+
# Represent the record set as a cursor-- this captures the current search criteria (filters, sort, etc.)
|
|
51
|
+
def recordset_cursor
|
|
52
|
+
make_cursor(recordset_cursor_params)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
###
|
|
56
|
+
# Represents a single page of results in the current record set-- the "current" page
|
|
57
|
+
def current_page_cursor
|
|
58
|
+
make_cursor(current_page_cursor_params)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
###
|
|
62
|
+
# Represents a single page of results in the current record set-- the "next" N results
|
|
63
|
+
def next_page_cursor
|
|
64
|
+
make_cursor(next_page_params)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
###
|
|
68
|
+
# Represents a single page of results in the current record set-- the "previous" N results
|
|
69
|
+
def previous_page_cursor
|
|
70
|
+
make_cursor(previous_page_params)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
###
|
|
74
|
+
# Represents a single page of results in the current record set-- the "first" N results
|
|
75
|
+
def first_page_cursor
|
|
76
|
+
make_cursor(first_page_params)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
###
|
|
80
|
+
# Represents a single page of results in the current record set-- the "last" N results
|
|
81
|
+
def last_page_cursor
|
|
82
|
+
make_cursor(last_page_params)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def limit
|
|
86
|
+
return nil if per_page == :all
|
|
87
|
+
|
|
88
|
+
per_page
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def more_pages?
|
|
94
|
+
return false if per_page == :all || per_page.zero?
|
|
95
|
+
|
|
96
|
+
offset + per_page < total_items
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def recordset_cursor_params
|
|
100
|
+
cursor_params.deep_merge(offset: 0, per_page: :all, metadata: { kind: :recordset })
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def current_page_cursor_params
|
|
104
|
+
return nil if per_page != :all && per_page.zero?
|
|
105
|
+
|
|
106
|
+
cursor_params.deep_merge(offset: offset, metadata: { kind: :current_page })
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def next_page_params
|
|
110
|
+
return nil unless more_pages?
|
|
111
|
+
|
|
112
|
+
cursor_params.deep_merge(offset: offset + per_page, metadata: { kind: :next_page })
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def previous_page_params
|
|
116
|
+
return nil if offset.zero?
|
|
117
|
+
|
|
118
|
+
cursor_params.deep_merge(offset: offset - per_page, metadata: { kind: :previous_page })
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def first_page_params
|
|
122
|
+
return nil if offset.zero?
|
|
123
|
+
|
|
124
|
+
cursor_params.deep_merge(offset: 0, metadata: { kind: :first_page })
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def last_page_params
|
|
128
|
+
return nil unless more_pages?
|
|
129
|
+
|
|
130
|
+
last_page_offset = (last_item_offset / per_page).floor * per_page
|
|
131
|
+
cursor_params.deep_merge(offset: last_page_offset, metadata: { kind: :last_page })
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def last_item_offset
|
|
135
|
+
total_items - 1
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def make_cursor(params)
|
|
139
|
+
return nil if params.nil?
|
|
140
|
+
|
|
141
|
+
PaginatorCursor.create(**params)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def cursor_params
|
|
145
|
+
{
|
|
146
|
+
offset:,
|
|
147
|
+
sort:,
|
|
148
|
+
total_items:,
|
|
149
|
+
per_page:,
|
|
150
|
+
query:,
|
|
151
|
+
metadata:
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Make it easier to correctly instantiate a Paginator object, given that a few different
|
|
4
|
+
# use cases exist each having different data (arguments).
|
|
5
|
+
#
|
|
6
|
+
# This is an application of the Builder pattern (see https://refactoring.guru/design-patterns/builder) for Ruby.
|
|
7
|
+
#
|
|
8
|
+
# Two scenarios exist for constructing a Paginator object:
|
|
9
|
+
#
|
|
10
|
+
# 1. Starting a new query.
|
|
11
|
+
# Uses the following parameters:
|
|
12
|
+
# * query - filters used to define the recordset
|
|
13
|
+
# * sort - ordering used to define the recordset
|
|
14
|
+
# * per_page - count of items to include on each result page
|
|
15
|
+
# * offset - starting index of the next result page
|
|
16
|
+
# * total_items - count of items in the result set, if known
|
|
17
|
+
#
|
|
18
|
+
# OR
|
|
19
|
+
#
|
|
20
|
+
# 2. Continuing an existing query
|
|
21
|
+
# Uses the following parameters:
|
|
22
|
+
# * a pagination cursor
|
|
23
|
+
# * sort (optional) - Used to override the sort order of the cursor; effectively creates a new query
|
|
24
|
+
# * per_page (optional) - Used to override the per_page of the cursor; will limit the count of results in the
|
|
25
|
+
#
|
|
26
|
+
module PackAPI::Pagination
|
|
27
|
+
class PaginatorBuilder
|
|
28
|
+
attr_accessor :paginator
|
|
29
|
+
|
|
30
|
+
def self.build
|
|
31
|
+
builder = new
|
|
32
|
+
yield(builder)
|
|
33
|
+
builder.paginator
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize
|
|
37
|
+
@paginator = Paginator.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def set_cursor(cursor:, per_page: nil, sort: nil)
|
|
41
|
+
cursor_params = PaginatorCursor.parse(cursor)
|
|
42
|
+
effective_per_page = per_page.presence || cursor_params[:per_page]
|
|
43
|
+
effective_per_page = :all if effective_per_page.to_s == 'all'
|
|
44
|
+
|
|
45
|
+
@paginator.query = cursor_params[:query]
|
|
46
|
+
@paginator.total_items = cursor_params[:total_items]
|
|
47
|
+
@paginator.per_page = effective_per_page
|
|
48
|
+
@paginator.sort = sort.presence || cursor_params[:sort]
|
|
49
|
+
@paginator.offset = effective_offset(cursor_params, sort)
|
|
50
|
+
@paginator.metadata = cursor_params[:metadata]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
###
|
|
54
|
+
# @param [Proc<Hash>|Hash] query The query parameters used to define the recordset.
|
|
55
|
+
# @param [String|Symbol|Hash|Arel] sort The ordering used to define the recordset
|
|
56
|
+
# @param [Integer|:all] per_page The count of items to include on each result page, or :all for single page results
|
|
57
|
+
# @param [Integer|nil] offset The starting index of the next result page
|
|
58
|
+
# @param [Integer|nil] total_items The count of items in the result set, if known.
|
|
59
|
+
# @param [Any|nil] metadata Any extra data structure to be passed along with the cursor
|
|
60
|
+
def set_params(query: nil, sort: nil, total_items: nil, per_page: nil, offset: nil, metadata: nil)
|
|
61
|
+
@paginator.query ||= {}
|
|
62
|
+
if query.present?
|
|
63
|
+
original_query = @paginator.query
|
|
64
|
+
@paginator.query = @paginator.query.deep_merge(resolve_query_params(query).deep_symbolize_keys)
|
|
65
|
+
@recordset_changed = original_query.to_json != @paginator.query.to_json
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@paginator.sort ||= Paginator::DEFAULT_SORT
|
|
69
|
+
if sort.present?
|
|
70
|
+
@recordset_changed = sort.to_json != @paginator.sort.to_json
|
|
71
|
+
@paginator.sort = sort.presence
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@paginator.total_items ||= 0
|
|
75
|
+
if total_items.present?
|
|
76
|
+
@paginator.total_items = total_items
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@paginator.offset ||= 0
|
|
80
|
+
if offset.present?
|
|
81
|
+
@paginator.offset = offset
|
|
82
|
+
elsif @recordset_changed
|
|
83
|
+
@paginator.offset = 0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@paginator.per_page ||= Paginator::DEFAULT_PER_PAGE
|
|
87
|
+
if per_page.present?
|
|
88
|
+
@paginator.per_page = per_page
|
|
89
|
+
@paginator.offset = 0 if per_page == :all
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
@paginator.metadata ||= metadata
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def effective_offset(cursor_params, sort)
|
|
98
|
+
sort_changed = sort.to_json != cursor_params[:sort].to_json
|
|
99
|
+
sort_override = sort.present? && sort_changed
|
|
100
|
+
sort_override ?
|
|
101
|
+
0 :
|
|
102
|
+
cursor_params[:offset]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def resolve_query_params(query)
|
|
106
|
+
query.is_a?(Proc) ?
|
|
107
|
+
query.call :
|
|
108
|
+
query
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Pagination
|
|
4
|
+
class PaginatorCursor
|
|
5
|
+
MAX_LENGTH = 2048
|
|
6
|
+
CACHE_KEY_PREFIX = 'paginator_cursor'
|
|
7
|
+
CACHE_EXPIRES_IN = 8.hours
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def create(query:, sort:, total_items:, offset:, per_page:, metadata: nil)
|
|
11
|
+
sort_ready_to_serialize = SqlLiteralSerializer.serialize(sort)
|
|
12
|
+
cursor_params = { query:, sort: sort_ready_to_serialize, total_items:, offset:, per_page:, metadata: }
|
|
13
|
+
token = OpaqueTokenV2.create(cursor_params)
|
|
14
|
+
return token if token.size <= MAX_LENGTH
|
|
15
|
+
|
|
16
|
+
cache_key = generate_cache_key
|
|
17
|
+
Rails.cache.write(cache_key, cursor_params, expires_in: CACHE_EXPIRES_IN)
|
|
18
|
+
OpaqueTokenV2.create(cache_key)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def parse(encoded)
|
|
22
|
+
decoded = parse_opaque_token(encoded)
|
|
23
|
+
decoded = decode_cache_key(decoded) if decoded.is_a?(String)
|
|
24
|
+
decoded[:sort] = deserialize_sort_args(decoded)
|
|
25
|
+
decoded
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def parse_opaque_token(encoded)
|
|
31
|
+
OpaqueTokenV2.parse(encoded)
|
|
32
|
+
rescue ArgumentError, Brotli::Error, JSON::ParserError => e
|
|
33
|
+
raise_error(e.message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def raise_error(message)
|
|
37
|
+
raise(PackAPI::InternalError, "un-parsable paginator cursor: #{message}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def decode_cache_key(cache_key)
|
|
41
|
+
data = Rails.cache.read(cache_key)
|
|
42
|
+
raise(PackAPI::InternalError, "no data found in cache for key #{cache_key}") if data.nil?
|
|
43
|
+
|
|
44
|
+
data
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def generate_cache_key
|
|
48
|
+
"#{CACHE_KEY_PREFIX}:#{SecureRandom.uuid}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def deserialize_sort_args(cursor)
|
|
52
|
+
SqlLiteralSerializer.deserialize(cursor[:sort])
|
|
53
|
+
rescue TypeError => e
|
|
54
|
+
raise_error(e.message)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# in order to recognize Arel::Nodes::SqlLiteral during parsing,
|
|
58
|
+
# we need to serialize it differently than a string
|
|
59
|
+
class SqlLiteralSerializer
|
|
60
|
+
def self.serialize(args)
|
|
61
|
+
return { sql_literal: { raw_sql: args.to_s } } if args.is_a?(Arel::Nodes::SqlLiteral)
|
|
62
|
+
return args unless args.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
args.map.with_index do |entry, index|
|
|
65
|
+
next entry unless entry[0].is_a?(Arel::Nodes::SqlLiteral)
|
|
66
|
+
|
|
67
|
+
["sql_literal_#{index + 1}", { raw_sql: entry[0].to_s, hash_value: entry[1] }]
|
|
68
|
+
end.to_h
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.deserialize(args)
|
|
72
|
+
return args unless args.is_a?(Hash)
|
|
73
|
+
return Arel.sql(args[:sql_literal][:raw_sql]) if args.key?(:sql_literal)
|
|
74
|
+
|
|
75
|
+
args.to_h do |key, value|
|
|
76
|
+
next [key, value] unless key.start_with?('sql_literal_')
|
|
77
|
+
|
|
78
|
+
[Arel.sql(value[:raw_sql]), value[:hash_value]]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Pagination
|
|
4
|
+
|
|
5
|
+
###
|
|
6
|
+
# Current Page Snapshot Query is a query that targets the records in a given page of
|
|
7
|
+
# a record set (regardless of how those records may change state). Whereas the contents of
|
|
8
|
+
# the nth page in a record set may change, the cached results query for that same page will not.
|
|
9
|
+
#
|
|
10
|
+
# This class represents the paginator for such a query.
|
|
11
|
+
class SnapshotPaginator
|
|
12
|
+
METADATA_KEY = :snapshot
|
|
13
|
+
|
|
14
|
+
attr_accessor :paginator, :results, :cursor
|
|
15
|
+
|
|
16
|
+
###
|
|
17
|
+
# Create a snapshot from the current page results of a record set.
|
|
18
|
+
def self.cursor_for_results(results, table_name:, collection_key:)
|
|
19
|
+
offsets = {}
|
|
20
|
+
case_statement = results.map.with_index do |record, index|
|
|
21
|
+
record_id = record.send(collection_key)
|
|
22
|
+
offsets[record_id.to_s] = index.to_s
|
|
23
|
+
# Use SQL standard CAST to convert both sides to strings for comparison
|
|
24
|
+
"WHEN CAST(\"#{table_name}\".\"#{collection_key}\" AS VARCHAR) = '#{record_id}' THEN #{index}"
|
|
25
|
+
end
|
|
26
|
+
sort_sql = "CASE #{case_statement.join("\n")} END"
|
|
27
|
+
filters = { collection_key => results.pluck(collection_key) }
|
|
28
|
+
paginator = PaginatorBuilder.build do |builder|
|
|
29
|
+
builder.set_params(query: { filters: },
|
|
30
|
+
metadata: { METADATA_KEY => true, offsets:, collection_key: },
|
|
31
|
+
sort: Arel.sql(sort_sql),
|
|
32
|
+
total_items: results.size,
|
|
33
|
+
per_page: results.size)
|
|
34
|
+
end
|
|
35
|
+
paginator.current_page_cursor
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(paginator)
|
|
39
|
+
raise PackAPI::InternalError, 'Paginator does not represent CachedResultsQuery' if paginator.metadata.nil?
|
|
40
|
+
|
|
41
|
+
@paginator = paginator
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
###
|
|
45
|
+
# Update the query to focus the result onto the given record within the snapshot.
|
|
46
|
+
# If no record_id is provided, it will attempt to guess the record_id based on the current paginator state.
|
|
47
|
+
# NOTE This method assumes that the paginator.total_items has already been updated to reflect the query's count.
|
|
48
|
+
def apply_to(query, record_id: nil)
|
|
49
|
+
if has_valid_offsets?
|
|
50
|
+
# if no record_id is provided, no customization is needed
|
|
51
|
+
return query unless record_id.present?
|
|
52
|
+
|
|
53
|
+
self.target_record_id = record_id
|
|
54
|
+
updated_query = query.offset(paginator.offset).limit(paginator.limit)
|
|
55
|
+
@results = updated_query.to_a
|
|
56
|
+
@cursor = paginator.current_page_cursor
|
|
57
|
+
updated_query
|
|
58
|
+
else
|
|
59
|
+
# unless we have either a record_id or an offset, there is no customization needed
|
|
60
|
+
return query unless paginator.offset.present? || record_id.present?
|
|
61
|
+
|
|
62
|
+
# Step 1: If we don't have a record_id, try to guess what record_id the user was trying to access
|
|
63
|
+
record_id ||= target_record_id
|
|
64
|
+
|
|
65
|
+
# Step 2: Fetch all the records in the snapshot -- remove the offset and limits on the current query
|
|
66
|
+
snapshot_results = query.unscope(:offset, :limit).to_a
|
|
67
|
+
|
|
68
|
+
# Step 3: Update the offsets based on the current query results
|
|
69
|
+
update_offsets(snapshot_results)
|
|
70
|
+
|
|
71
|
+
# Step 4: get the correct offset for the given record_id
|
|
72
|
+
self.target_record_id = record_id
|
|
73
|
+
|
|
74
|
+
# Step 5: Filter the results to just the record_id
|
|
75
|
+
collection_key = paginator.metadata[:collection_key]
|
|
76
|
+
@results = snapshot_results.select { it.send(collection_key).to_s == record_id.to_s }
|
|
77
|
+
@cursor = paginator.current_page_cursor
|
|
78
|
+
query.offset(paginator.offset).limit(paginator.limit)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
###
|
|
83
|
+
# Is the paginator one produced by this class?
|
|
84
|
+
def self.generated?(paginator)
|
|
85
|
+
paginator.metadata&.fetch(METADATA_KEY, nil).presence
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def has_valid_offsets?
|
|
91
|
+
paginator.metadata[:offsets].size == paginator.total_items
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
###
|
|
95
|
+
# Update the paginator's state based on the current results of the query.
|
|
96
|
+
def update_offsets(results)
|
|
97
|
+
paginator.metadata.merge!(offsets: results_offsets(results))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def target_record_id
|
|
101
|
+
return unless paginator.offset
|
|
102
|
+
|
|
103
|
+
paginator.metadata[:offsets].key(paginator.offset.to_s).to_s
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def target_record_id=(record_id)
|
|
107
|
+
paginator.offset = lookup_offset(record_id)
|
|
108
|
+
paginator.per_page = 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def results_offsets(results)
|
|
112
|
+
collection_key = paginator.metadata[:collection_key]
|
|
113
|
+
results.map.with_index do |record, index|
|
|
114
|
+
record_id = record.send(collection_key)
|
|
115
|
+
[record_id.to_s, index.to_s]
|
|
116
|
+
end.to_h
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
###
|
|
120
|
+
# @return [Integer] The offset to use with the CachedResultsQuery to select the given record
|
|
121
|
+
def lookup_offset(record_id)
|
|
122
|
+
metadata = paginator.metadata[:offsets]
|
|
123
|
+
return 0 unless metadata
|
|
124
|
+
|
|
125
|
+
stringified_record_id = record_id.to_s
|
|
126
|
+
# metadata can have symbol keys or string keys, depending on how the cursor was created.
|
|
127
|
+
offset = metadata.include?(stringified_record_id) ?
|
|
128
|
+
metadata[stringified_record_id] :
|
|
129
|
+
metadata[stringified_record_id.to_sym]
|
|
130
|
+
offset.to_i
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
class AbstractBooleanFilter < DiscoverableFilter
|
|
5
|
+
attr_accessor :value
|
|
6
|
+
|
|
7
|
+
YES_VALUE = 'Yes'
|
|
8
|
+
NO_VALUE = 'No'
|
|
9
|
+
NOT_APPLICABLE_VALUE = ''
|
|
10
|
+
|
|
11
|
+
def self.type
|
|
12
|
+
:tri_state_boolean
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.definition(**)
|
|
16
|
+
options = [
|
|
17
|
+
PackAPI::Types::FilterOption.new(label: 'N/A', value: NOT_APPLICABLE_VALUE),
|
|
18
|
+
PackAPI::Types::FilterOption.new(label: 'Yes', value: YES_VALUE),
|
|
19
|
+
PackAPI::Types::FilterOption.new(label: 'No', value: NO_VALUE)
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
PackAPI::Types::BooleanFilterDefinition.new(name: filter_name, type:, options:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(value:)
|
|
26
|
+
super()
|
|
27
|
+
@value = value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
delegate :present?, to: :value
|
|
31
|
+
|
|
32
|
+
def to_h
|
|
33
|
+
raise NotImplementedError unless self.class.filter_name
|
|
34
|
+
|
|
35
|
+
{ self.class.filter_name => { value: } }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
class AbstractEnumFilter < DiscoverableFilter
|
|
5
|
+
attr_accessor :value, :exclude_param
|
|
6
|
+
validates :exclude_param, inclusion: { in: %w[true false], allow_nil: true, message: 'must be either true or false' }
|
|
7
|
+
|
|
8
|
+
def self.type
|
|
9
|
+
:enum
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.definition(**)
|
|
13
|
+
PackAPI::Types::EnumFilterDefinition.new(name: filter_name,
|
|
14
|
+
type:,
|
|
15
|
+
options: filter_options(**),
|
|
16
|
+
can_exclude: can_exclude?)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.filter_options(**)
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private_class_method :filter_options
|
|
24
|
+
|
|
25
|
+
def initialize(value: nil, exclude: nil)
|
|
26
|
+
super()
|
|
27
|
+
@present = !value.nil?
|
|
28
|
+
@value = Array.wrap(value.presence)
|
|
29
|
+
@exclude_param = exclude
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def present?
|
|
33
|
+
@present
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def exclude?
|
|
37
|
+
exclude_param.to_s.downcase == 'true'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
raise NotImplementedError unless self.class.filter_name
|
|
42
|
+
|
|
43
|
+
payload = present? ?
|
|
44
|
+
{ value: } :
|
|
45
|
+
{ value: nil }
|
|
46
|
+
payload[:exclude] = exclude?.to_s if self.class.can_exclude?
|
|
47
|
+
{ self.class.filter_name => payload }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.can_exclude?
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
class AbstractFilter
|
|
5
|
+
def present?
|
|
6
|
+
false
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
###
|
|
10
|
+
# Applies the filter to the given query.
|
|
11
|
+
# @param [ComposableQuery] query the active record relation to apply the filter to
|
|
12
|
+
def apply_to(query)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
class AbstractNumericFilter < DiscoverableFilter
|
|
5
|
+
attr_accessor :operator, :value
|
|
6
|
+
|
|
7
|
+
def self.type
|
|
8
|
+
:numeric
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.definition(**)
|
|
12
|
+
operators = [
|
|
13
|
+
PackAPI::Types::FilterOption.new(label: 'Greater than', value: '>'),
|
|
14
|
+
PackAPI::Types::FilterOption.new(label: 'Greater than or equal to', value: '>='),
|
|
15
|
+
PackAPI::Types::FilterOption.new(label: 'Equal to', value: '='),
|
|
16
|
+
PackAPI::Types::FilterOption.new(label: 'Less than or equal to', value: '<='),
|
|
17
|
+
PackAPI::Types::FilterOption.new(label: 'Less than', value: '<')
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
PackAPI::Types::NumericFilterDefinition.new(name: filter_name, operators:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(operator:, value:)
|
|
24
|
+
super()
|
|
25
|
+
@operator = operator
|
|
26
|
+
@value = value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
delegate :present?, to: :value
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
raise NotImplementedError unless self.class.filter_name
|
|
33
|
+
|
|
34
|
+
{ self.class.filter_name => { operator:, value: } }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
class AbstractRangeFilter < DiscoverableFilter
|
|
5
|
+
attr_accessor :min_value, :max_value
|
|
6
|
+
|
|
7
|
+
def self.type
|
|
8
|
+
:range
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.definition(**)
|
|
12
|
+
PackAPI::Types::RangeFilterDefinition.new(name: filter_name)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(min_value:, max_value:)
|
|
16
|
+
super()
|
|
17
|
+
@min_value = min_value
|
|
18
|
+
@max_value = max_value
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def present?
|
|
22
|
+
min_value.present? || max_value.present?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
raise NotImplementedError unless self.class.filter_name
|
|
27
|
+
|
|
28
|
+
{ self.class.filter_name => { min_value:, max_value: } }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|