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,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
class AttributeFilter < DiscoverableFilter
|
|
5
|
+
attr_accessor :value
|
|
6
|
+
|
|
7
|
+
def self.type
|
|
8
|
+
:attribute
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.data_type
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.definition(**)
|
|
16
|
+
PackAPI::Types::SimpleFilterDefinition.new(name: filter_name, data_type:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(value)
|
|
20
|
+
super()
|
|
21
|
+
@value = value
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def present?
|
|
25
|
+
!value.nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
{ filter_name => value }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def apply_to(query)
|
|
33
|
+
query.add(query.build.where(to_h))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
class AttributeFilterFactory
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
DATA_TYPE_REGEXP = /\S+$/
|
|
8
|
+
private_constant :DATA_TYPE_REGEXP
|
|
9
|
+
|
|
10
|
+
def self.attribute_filter_cache
|
|
11
|
+
@attribute_filter_cache ||= Concurrent::Map.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :attribute_map_class
|
|
15
|
+
|
|
16
|
+
def initialize(attribute_map_class)
|
|
17
|
+
@attribute_map_class = attribute_map_class
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def from_api_type
|
|
21
|
+
filterable_attributes.each do |attribute_name, attribute_type|
|
|
22
|
+
yield define_filter(attribute_name, attribute_type)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def filterable_attributes
|
|
29
|
+
@filterable_attributes ||= attribute_map_class.api_type.filterable_attributes
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def model_attribute_names
|
|
33
|
+
return @model_attribute_names if defined?(@model_attribute_names)
|
|
34
|
+
|
|
35
|
+
names = {}
|
|
36
|
+
filterable_attributes.each_key { |name| names[name] = name }
|
|
37
|
+
@model_attribute_names = attribute_map_class.model_attribute_keys(names).invert
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def data_type(attribute_type)
|
|
41
|
+
return 'Bool' if attribute_type.name.end_with?('TrueClass | FalseClass')
|
|
42
|
+
|
|
43
|
+
attribute_type.name.include?(' | ') ?
|
|
44
|
+
attribute_type.name[DATA_TYPE_REGEXP] :
|
|
45
|
+
attribute_type.name
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def define_filter(attribute_name, attribute_type)
|
|
49
|
+
filter_name = model_attribute_names[attribute_name]
|
|
50
|
+
data_type = data_type(attribute_type)
|
|
51
|
+
self.class
|
|
52
|
+
.attribute_filter_cache
|
|
53
|
+
.compute_if_absent(filter_name) { Concurrent::Map.new }
|
|
54
|
+
.compute_if_absent(data_type) do
|
|
55
|
+
Class.new(AttributeFilter) do
|
|
56
|
+
define_singleton_method(:filter_name) { filter_name }
|
|
57
|
+
define_singleton_method(:data_type) { data_type }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
###
|
|
5
|
+
# This is a generic query that can be used to query any collection of ActiveRecord models and then
|
|
6
|
+
# expose the results as a collection of value objects.
|
|
7
|
+
#
|
|
8
|
+
# It allows paginated queries, as well as record iteration
|
|
9
|
+
# queries (using snapshot cursors). In order to enable record iteration mode, the query must be called with
|
|
10
|
+
# both a snapshot cursor and a primary key filter.
|
|
11
|
+
class CollectionQuery
|
|
12
|
+
attr_accessor :collection, :collection_key, :filter_factory, :default_sort, :paginator, :sort, :results
|
|
13
|
+
|
|
14
|
+
###
|
|
15
|
+
# Create the query object.
|
|
16
|
+
#
|
|
17
|
+
# @ param [ActiveRecord::Relation] collection The collection to query
|
|
18
|
+
# @ param [Object] value_object_factory The factory to use to convert model objects to value objects
|
|
19
|
+
# @ param [String|Symbol|Hash|Arel] default_sort The default sort to use if none is specified in the query
|
|
20
|
+
# @ param [String|Symbol] collection_key Unique and indexed key to use for the collection.
|
|
21
|
+
# Used for tie-breaker sort criteria and drives the record id lookup when in record iteration mode.
|
|
22
|
+
def initialize(collection:, collection_key: nil, default_sort: nil)
|
|
23
|
+
@collection = collection
|
|
24
|
+
@collection_key = (collection_key || collection.primary_key).to_sym
|
|
25
|
+
@default_sort = default_sort
|
|
26
|
+
@filter_factory = FilterFactory.new
|
|
27
|
+
@filter_factory.use_default_filter = true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
###
|
|
31
|
+
# Perform the query.
|
|
32
|
+
#
|
|
33
|
+
# @param [String] cursor A pagination cursor referencing a page of records in a recordset
|
|
34
|
+
# @param [Integer|Symbol] per_page A count of how many items to include on each page, or :all to skip pagination
|
|
35
|
+
# @param [String|Symbol|Hash|Arel] sort ActiveRecord `order` arguments.
|
|
36
|
+
# @param [Hash] search Attribute/value pairs that define the bounds of the recordset using wildcard matching (attribute LIKE %value%)
|
|
37
|
+
# @param [Hash] filters the keys are names of filters, the values are arguments to the filters
|
|
38
|
+
def call(cursor: nil, per_page: nil, sort: nil, search: nil, filters: {})
|
|
39
|
+
record_id = filters.delete(collection_key) if cursor.present? && filters[collection_key].present?
|
|
40
|
+
build_paginator(cursor, filters, per_page, search, sort)
|
|
41
|
+
build_active_record_query(record_id)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_sql
|
|
45
|
+
@query.to_sql
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reset
|
|
49
|
+
@results = nil
|
|
50
|
+
@query = nil
|
|
51
|
+
@paginator = nil
|
|
52
|
+
@sort = nil
|
|
53
|
+
@current_page_snapshot_cursor = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def current_page_snapshot_cursor
|
|
57
|
+
@current_page_snapshot_cursor ||= PackAPI::Pagination::SnapshotPaginator.cursor_for_results(results,
|
|
58
|
+
table_name: collection.klass.table_name,
|
|
59
|
+
collection_key:)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def build_active_record_query(record_id)
|
|
65
|
+
@query = collection
|
|
66
|
+
if paginator.query&.fetch(:search, nil).present?
|
|
67
|
+
apply_search(search: paginator.query[:search])
|
|
68
|
+
end
|
|
69
|
+
if paginator.query&.fetch(:filters, nil).present?
|
|
70
|
+
apply_filters(filters: paginator.query[:filters])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@sort = paginator.sort
|
|
74
|
+
paginator.total_items = @query.count
|
|
75
|
+
@query = @query.order(paginator.sort)
|
|
76
|
+
.offset(paginator.offset)
|
|
77
|
+
.limit(paginator.limit)
|
|
78
|
+
|
|
79
|
+
if PackAPI::Pagination::SnapshotPaginator.generated?(paginator)
|
|
80
|
+
snapshot_paginator = PackAPI::Pagination::SnapshotPaginator.new(paginator)
|
|
81
|
+
@query = snapshot_paginator.apply_to(@query, record_id:)
|
|
82
|
+
@results = snapshot_paginator.results
|
|
83
|
+
@current_page_snapshot_cursor = snapshot_paginator.cursor
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@results ||= @query.to_a
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_paginator(cursor, filters, per_page, search, sort)
|
|
90
|
+
@paginator = PackAPI::Pagination::PaginatorBuilder.build do |builder|
|
|
91
|
+
cursor.present? ?
|
|
92
|
+
builder.set_cursor(cursor:) :
|
|
93
|
+
builder.set_params(sort: stable_sort(default_sort)) # set a default sort for a new query
|
|
94
|
+
|
|
95
|
+
builder.set_params(sort: stable_sort(sort.presence)) if sort.present?
|
|
96
|
+
builder.set_params(query: { filters: }) if filters.present?
|
|
97
|
+
builder.set_params(query: { search: }) if search.present?
|
|
98
|
+
builder.set_params(per_page:) if per_page.present?
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def apply_search(search:)
|
|
103
|
+
search.keys.each_with_index do |column, index|
|
|
104
|
+
value = search[column]
|
|
105
|
+
@query = index.positive? ?
|
|
106
|
+
@query.or(collection.where("LOWER(#{column}) LIKE LOWER(?)", "%#{value}%")) :
|
|
107
|
+
@query.where("LOWER(#{column}) LIKE LOWER(?)", "%#{value}%")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def apply_filters(filters:)
|
|
112
|
+
filtered_query = ComposableQuery.new(@query)
|
|
113
|
+
filter_factory.create_filters(filters).each { |filter| filter.apply_to(filtered_query) }
|
|
114
|
+
@query = filtered_query.build
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def stable_sort(sort)
|
|
118
|
+
return sort if sort.is_a?(Arel::Nodes::SqlLiteral)
|
|
119
|
+
|
|
120
|
+
SortHash.new(sort).tap do |sort_hash|
|
|
121
|
+
sort_hash[collection_key] = :asc unless sort_hash.key?(collection_key)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
class ComposableQuery
|
|
5
|
+
def initialize(initial_query)
|
|
6
|
+
@query = initial_query
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def add(query_clause)
|
|
10
|
+
@query = @query.merge(query_clause)
|
|
11
|
+
self
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def build
|
|
15
|
+
@query
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_sql
|
|
19
|
+
@query.to_sql
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
class DefaultFilter < AbstractFilter
|
|
5
|
+
attr_accessor :arguments
|
|
6
|
+
|
|
7
|
+
def initialize(arguments)
|
|
8
|
+
super()
|
|
9
|
+
@arguments = arguments
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def present?
|
|
13
|
+
arguments.present?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def apply_to(query)
|
|
17
|
+
query.add(query.build.where(arguments))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
##
|
|
5
|
+
# This class is the base class for all discoverable filters, which can produce a definition that specifies usage.
|
|
6
|
+
class DiscoverableFilter < AbstractFilter
|
|
7
|
+
include ActiveModel::Validations
|
|
8
|
+
|
|
9
|
+
def filter_name
|
|
10
|
+
self.class.filter_name
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.filter_name
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def type
|
|
18
|
+
self.class.type
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.type
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.definition(**)
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
module DynamicEnumFilter
|
|
5
|
+
FROZEN_EMPTY_ARRAY = [].freeze
|
|
6
|
+
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
class_methods do
|
|
9
|
+
def type = :dynamic_enum
|
|
10
|
+
|
|
11
|
+
def can_exclude? = false
|
|
12
|
+
|
|
13
|
+
def search_for_options(relation:, search:) = raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def filter_options(**) = FROZEN_EMPTY_ARRAY
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
class FilterFactory
|
|
5
|
+
attr_accessor :filter_classes, :use_default_filter
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@filter_classes = Hash.new { |_hash, key| raise NotImplementedError, "Unsupported filter #{key}" }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register_filter(name:, klass:)
|
|
12
|
+
filter_classes[name] = klass
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create_filters(filter_hash)
|
|
16
|
+
filter_objects(filter_hash).select(&:present?)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def filter_objects(filter_options)
|
|
22
|
+
if filter_options.is_a?(Hash)
|
|
23
|
+
filter_objects_from_hash(filter_options)
|
|
24
|
+
elsif use_default_filter
|
|
25
|
+
[DefaultFilter.new(filter_options)]
|
|
26
|
+
else
|
|
27
|
+
raise ArgumentError, "Unsupported filter configuration: #{filter_options}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def filter_objects_from_hash(filter_options)
|
|
33
|
+
filter_options.deep_symbolize_keys.map(&method(:filter_object_by_name))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def filter_object_by_name(filter_name, options)
|
|
37
|
+
if filter_classes.key?(filter_name)
|
|
38
|
+
registered_filter_object(filter_name, options)
|
|
39
|
+
elsif use_default_filter
|
|
40
|
+
DefaultFilter.new(filter_name => options)
|
|
41
|
+
else
|
|
42
|
+
raise NotImplementedError, "Unsupported filter: #{filter_name}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def registered_filter_object(filter_name, options)
|
|
47
|
+
options.is_a?(Hash) ?
|
|
48
|
+
filter_classes[filter_name].new(**options) :
|
|
49
|
+
filter_classes[filter_name].new(options)
|
|
50
|
+
rescue ArgumentError => e
|
|
51
|
+
raise ArgumentError, "Invalid filter options for #{filter_name}: #{options} (#{e})"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Querying
|
|
4
|
+
class SortHash < Hash
|
|
5
|
+
|
|
6
|
+
###
|
|
7
|
+
# Normalize a hash object to be used to control the sorting of a collection of objects
|
|
8
|
+
#
|
|
9
|
+
# @param [Hash|Symbol|String] sort_arg When provided with a string or a symbol, the sort hash will treat that
|
|
10
|
+
# as the name of the attribute to sort by, and will sort in ascending order. When provided with a hash, the keys
|
|
11
|
+
# of the hash should be the names of the attributes to sort by, and the values should be either `:asc` or `:desc`
|
|
12
|
+
def initialize(sort_arg)
|
|
13
|
+
super()
|
|
14
|
+
case sort_arg
|
|
15
|
+
when Arel::Nodes::SqlLiteral, nil
|
|
16
|
+
hash_entries = []
|
|
17
|
+
when Hash
|
|
18
|
+
hash_entries = sort_arg.to_a
|
|
19
|
+
when Symbol
|
|
20
|
+
hash_entries = [[sort_arg, :asc]]
|
|
21
|
+
when String
|
|
22
|
+
hash_entries = sort_arg.split(',').map do |sort_term|
|
|
23
|
+
sort_term_parts = sort_term.split
|
|
24
|
+
[sort_term_parts[0].to_sym, sort_term_parts[1]&.to_sym || :asc]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
hash_entries.each do |key, value|
|
|
29
|
+
next unless key.present?
|
|
30
|
+
|
|
31
|
+
self[key] = value
|
|
32
|
+
end
|
|
33
|
+
deep_transform_values! { it.downcase.to_sym }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Types
|
|
4
|
+
###
|
|
5
|
+
# Define a type that blends attributes from multiple resources into a single data structure.
|
|
6
|
+
# For each kind of input resource, pass a block to the `combine_attributes` method, wherein are enumerated the
|
|
7
|
+
# attributes in the output data structure which originate from that input resource.
|
|
8
|
+
#
|
|
9
|
+
# Example:
|
|
10
|
+
#
|
|
11
|
+
# class MyAggregateType < PackAPI::Types::AggregateType
|
|
12
|
+
# combine_attributes from: :resource_one do
|
|
13
|
+
# attribute :id, Types::String
|
|
14
|
+
# attribute :name, Types::String
|
|
15
|
+
# end
|
|
16
|
+
# combine_attributes from: :resource_two do
|
|
17
|
+
# attribute :description, Types::String
|
|
18
|
+
# attribute :created_at, Types::Time
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# This will create a type with attributes `id`, `name`, `description`, and `created_at`, where:
|
|
23
|
+
# - `id` and `name` come from `resource_one`
|
|
24
|
+
# - `description` and `created_at` come from `resource_two`
|
|
25
|
+
#
|
|
26
|
+
# For each input resource, helper methods must be defined to enable the CRUD operations:
|
|
27
|
+
# - query_<resource>s(**params) - returns a hash of resources, indexed by their IDs
|
|
28
|
+
# - get_<resource>(id) - returns a single resource, identified by its ID
|
|
29
|
+
# - create_<resource>(params) - creates a new resource and returns it
|
|
30
|
+
# - update_<resource>(id, params) - updates an existing resource and returns it
|
|
31
|
+
# - delete_<resource>(id)
|
|
32
|
+
#
|
|
33
|
+
# For example, for the above example, you would need to define:
|
|
34
|
+
# - query_resource_ones
|
|
35
|
+
# - get_resource_one
|
|
36
|
+
# - create_resource_one
|
|
37
|
+
# - update_resource_one
|
|
38
|
+
# - delete_resource_one
|
|
39
|
+
#
|
|
40
|
+
# @note: There are 2 kinds of resources:
|
|
41
|
+
# - primary resources
|
|
42
|
+
# - secondary resources
|
|
43
|
+
#
|
|
44
|
+
# The first resource drawn from via `combine_attributes` is considered the primary resource.
|
|
45
|
+
class AggregateType < BaseType
|
|
46
|
+
@attribute_sources = {}
|
|
47
|
+
@attribute_sources = {}
|
|
48
|
+
@next_attribute_list = nil
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
def inherited(subclass)
|
|
52
|
+
subclass.instance_variable_set(:@attribute_sources, {})
|
|
53
|
+
subclass.instance_variable_set(:@next_attribute_list, nil)
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def attribute(name, type = Undefined, &block)
|
|
58
|
+
super
|
|
59
|
+
@next_attribute_list << name if @next_attribute_list
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def combine_attributes(from:, &block)
|
|
63
|
+
@next_attribute_list = []
|
|
64
|
+
block.call
|
|
65
|
+
@attribute_sources[from] = @next_attribute_list
|
|
66
|
+
@next_attribute_list = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def query(**params)
|
|
70
|
+
attribute_blender.query(**params).map { new(it) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def get(id)
|
|
74
|
+
new(attribute_blender.get(id))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def update(id, params)
|
|
78
|
+
new(attribute_blender.update(id, params))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def create(params)
|
|
82
|
+
new(attribute_blender.create(params))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def delete(id)
|
|
86
|
+
attribute_blender.delete(id)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def attribute_blender
|
|
92
|
+
@attribute_blender ||= AttributeBlender.new(@attribute_sources, self)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class AttributeBlender
|
|
97
|
+
attr_reader :attribute_sources, :combined_attributes, :aggregate_type
|
|
98
|
+
|
|
99
|
+
def initialize(attribute_sources, aggregate_type)
|
|
100
|
+
@attribute_sources = attribute_sources
|
|
101
|
+
@aggregate_type = aggregate_type
|
|
102
|
+
@combined_attributes = {}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def query(**params)
|
|
106
|
+
primary_source, primary_resource_attributes = attribute_sources.first
|
|
107
|
+
resource_method = :"query_#{primary_source}s"
|
|
108
|
+
primary_resource_results = aggregate_type.send(resource_method, **params)
|
|
109
|
+
combined_attributes = primary_resource_results.transform_values do |primary_resource|
|
|
110
|
+
primary_resource.to_h.slice(*primary_resource_attributes)
|
|
111
|
+
end
|
|
112
|
+
primary_result_ids = primary_resource_results.keys
|
|
113
|
+
attribute_sources.drop(1).each do |resource_name, resource_attributes|
|
|
114
|
+
resource_method = :"query_#{resource_name}s"
|
|
115
|
+
aggregate_type.send(resource_method, id: primary_result_ids)
|
|
116
|
+
.each do |primary_resource_id, secondary_resource|
|
|
117
|
+
secondary_attributes = secondary_resource.to_h.slice(*resource_attributes)
|
|
118
|
+
combined_attributes[primary_resource_id].merge!(secondary_attributes)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
combined_attributes.values
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def get(id)
|
|
126
|
+
attribute_sources.each do |resource_name, attribute_names|
|
|
127
|
+
resource_method = :"get_#{resource_name}"
|
|
128
|
+
resource = aggregate_type.send(resource_method, id)
|
|
129
|
+
combined_attributes.merge!(resource.to_h.slice(*attribute_names))
|
|
130
|
+
end
|
|
131
|
+
combined_attributes
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def update(id, params)
|
|
135
|
+
initial_state = get(id)
|
|
136
|
+
@combined_attributes = initial_state.to_h
|
|
137
|
+
attribute_sources_updated = []
|
|
138
|
+
attribute_sources.each do |resource_name, resource_attributes|
|
|
139
|
+
resource_params = resource_params(params, resource_attributes)
|
|
140
|
+
next unless resource_params.any?
|
|
141
|
+
|
|
142
|
+
resource_method = :"update_#{resource_name}"
|
|
143
|
+
resource = aggregate_type.send(resource_method, id, resource_params)
|
|
144
|
+
combined_attributes.merge!(resource.to_h.slice(*resource_attributes))
|
|
145
|
+
end
|
|
146
|
+
combined_attributes
|
|
147
|
+
rescue PackAPI::InternalError
|
|
148
|
+
rollback_update(attribute_sources_updated, id, initial_state)
|
|
149
|
+
raise
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def create(params)
|
|
153
|
+
attribute_sources_created = []
|
|
154
|
+
primary_resource_id = nil
|
|
155
|
+
attribute_sources.each do |resource_name, resource_attributes|
|
|
156
|
+
resource_method = :"create_#{resource_name}"
|
|
157
|
+
args = [resource_params(params, resource_attributes)]
|
|
158
|
+
args << primary_resource_id if primary_resource_id
|
|
159
|
+
resource = aggregate_type.send(resource_method, *args)
|
|
160
|
+
primary_resource_id ||= resource.id
|
|
161
|
+
combined_attributes.merge!(resource.to_h.slice(*resource_attributes))
|
|
162
|
+
end
|
|
163
|
+
combined_attributes
|
|
164
|
+
rescue PackAPI::InternalError
|
|
165
|
+
rollback_create(attribute_sources_created, primary_resource_id)
|
|
166
|
+
raise
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def delete(id)
|
|
170
|
+
attribute_sources.each_key do |resource_name|
|
|
171
|
+
resource_method = :"delete_#{resource_name}"
|
|
172
|
+
aggregate_type.send(resource_method, id)
|
|
173
|
+
rescue PackAPI::InternalError => e
|
|
174
|
+
Rails.logger.error("Failed to delete #{resource_name} with id: #{id}")
|
|
175
|
+
Rails.logger.error(e.backtrace.join("\n"))
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def resource_params(params, resource_attributes)
|
|
182
|
+
normalizer = PackAPI::Mapping::NormalizedAPIAttribute.new(resource_attributes)
|
|
183
|
+
params.filter { |key| resource_attributes.include?(normalizer.normalize(key)) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def rollback_update(attribute_sources_updated, id, initial_state)
|
|
187
|
+
attribute_sources_updated.reverse_each do |resource_name|
|
|
188
|
+
resource_method = :"update_#{resource_name}"
|
|
189
|
+
attribute_names = attribute_sources[resource_name]
|
|
190
|
+
aggregate_type.send(resource_method, id, initial_state.to_h.slice(*attribute_names))
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def rollback_create(attribute_sources_created, primary_resource_id)
|
|
195
|
+
attribute_sources_created.reverse_each do |resource_name|
|
|
196
|
+
resource_method = :"delete_#{resource_name}"
|
|
197
|
+
aggregate_type.send(resource_method, primary_resource_id)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PackAPI::Types
|
|
4
|
+
class BaseType < Dry::Struct
|
|
5
|
+
@optional_attributes = []
|
|
6
|
+
@filterable_attributes = {}
|
|
7
|
+
|
|
8
|
+
def self.inherited(subclass)
|
|
9
|
+
subclass.instance_variable_set(:@optional_attributes, [])
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.optional_attribute(name, type = Undefined, &block)
|
|
14
|
+
attribute?(name, type.optional, &block)
|
|
15
|
+
@optional_attributes << name
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.optional_attributes
|
|
19
|
+
@optional_attributes.to_a
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_data(**other_attributes)
|
|
23
|
+
merged_attributes = to_h.merge(other_attributes)
|
|
24
|
+
Data.define(*merged_attributes.keys).new(**merged_attributes)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def merge(**other_attributes)
|
|
28
|
+
self.class.new(to_h.merge(other_attributes))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# This method returns a mapping of attribute names to `Dry::Type::*` objects that describe the attributes that have
|
|
33
|
+
# been designated as filterable, as in the following example:
|
|
34
|
+
# attribute :id, Types::String.meta(filterable: true)
|
|
35
|
+
#
|
|
36
|
+
# NOTE: According to the documentation, `Dry::Types::Schema::Key` is part of a private API, but we haven't found a
|
|
37
|
+
# better way to access an attribute's metadata or type:
|
|
38
|
+
# - https://www.rubydoc.info/github/dry-rb/dry-types/main/Dry/Types/Schema/Key
|
|
39
|
+
# - https://discourse.dry-rb.org/t/how-to-access-meta-fields-in-structs/989
|
|
40
|
+
def self.filterable_attributes
|
|
41
|
+
schema.keys.each_with_object({}) do |schema_key, result|
|
|
42
|
+
result[schema_key.name] = schema_key.type if schema_key.meta[:filterable]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|