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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +39 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +238 -0
  5. data/lib/pack_api/config/dry_types_initializer.rb +1 -0
  6. data/lib/pack_api/models/internal_error.rb +25 -0
  7. data/lib/pack_api/models/mapping/abstract_transformer.rb +46 -0
  8. data/lib/pack_api/models/mapping/api_to_model_attributes_transformer.rb +27 -0
  9. data/lib/pack_api/models/mapping/attribute_hash_transformer.rb +46 -0
  10. data/lib/pack_api/models/mapping/attribute_map.rb +268 -0
  11. data/lib/pack_api/models/mapping/attribute_map_registry.rb +21 -0
  12. data/lib/pack_api/models/mapping/error_hash_to_api_attributes_transformer.rb +101 -0
  13. data/lib/pack_api/models/mapping/filter_map.rb +97 -0
  14. data/lib/pack_api/models/mapping/model_to_api_attributes_transformer.rb +67 -0
  15. data/lib/pack_api/models/mapping/normalized_api_attribute.rb +40 -0
  16. data/lib/pack_api/models/mapping/null_transformer.rb +9 -0
  17. data/lib/pack_api/models/mapping/value_object_factory.rb +83 -0
  18. data/lib/pack_api/models/pagination/opaque_token_v2.rb +19 -0
  19. data/lib/pack_api/models/pagination/paginator.rb +155 -0
  20. data/lib/pack_api/models/pagination/paginator_builder.rb +112 -0
  21. data/lib/pack_api/models/pagination/paginator_cursor.rb +86 -0
  22. data/lib/pack_api/models/pagination/snapshot_paginator.rb +133 -0
  23. data/lib/pack_api/models/querying/abstract_boolean_filter.rb +38 -0
  24. data/lib/pack_api/models/querying/abstract_enum_filter.rb +54 -0
  25. data/lib/pack_api/models/querying/abstract_filter.rb +15 -0
  26. data/lib/pack_api/models/querying/abstract_numeric_filter.rb +37 -0
  27. data/lib/pack_api/models/querying/abstract_range_filter.rb +31 -0
  28. data/lib/pack_api/models/querying/attribute_filter.rb +36 -0
  29. data/lib/pack_api/models/querying/attribute_filter_factory.rb +62 -0
  30. data/lib/pack_api/models/querying/collection_query.rb +125 -0
  31. data/lib/pack_api/models/querying/composable_query.rb +22 -0
  32. data/lib/pack_api/models/querying/default_filter.rb +20 -0
  33. data/lib/pack_api/models/querying/discoverable_filter.rb +33 -0
  34. data/lib/pack_api/models/querying/dynamic_enum_filter.rb +20 -0
  35. data/lib/pack_api/models/querying/filter_factory.rb +54 -0
  36. data/lib/pack_api/models/querying/sort_hash.rb +36 -0
  37. data/lib/pack_api/models/types/aggregate_type.rb +202 -0
  38. data/lib/pack_api/models/types/base_type.rb +46 -0
  39. data/lib/pack_api/models/types/boolean_filter_definition.rb +9 -0
  40. data/lib/pack_api/models/types/collection_result_metadata.rb +48 -0
  41. data/lib/pack_api/models/types/custom_filter_definition.rb +8 -0
  42. data/lib/pack_api/models/types/enum_filter_definition.rb +10 -0
  43. data/lib/pack_api/models/types/filter_option.rb +8 -0
  44. data/lib/pack_api/models/types/globally_identifiable.rb +19 -0
  45. data/lib/pack_api/models/types/numeric_filter_definition.rb +9 -0
  46. data/lib/pack_api/models/types/range_filter_definition.rb +10 -0
  47. data/lib/pack_api/models/types/result.rb +70 -0
  48. data/lib/pack_api/models/types/simple_filter_definition.rb +9 -0
  49. data/lib/pack_api/models/values_in_background_batches.rb +58 -0
  50. data/lib/pack_api/models/values_in_batches.rb +51 -0
  51. data/lib/pack_api/version.rb +5 -0
  52. data/lib/pack_api.rb +72 -0
  53. data/lib/types.rb +3 -0
  54. 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