dtb 1.0.0.rc1
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/.github/workflows/gem-push.yml +31 -0
- data/.github/workflows/main.yml +20 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +13 -0
- data/.standard.yml +1 -0
- data/.yardopts +1 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +169 -0
- data/README.md +46 -0
- data/Rakefile +17 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/dtb.gemspec +37 -0
- data/lib/dtb/builds_data_table.rb +35 -0
- data/lib/dtb/column.rb +92 -0
- data/lib/dtb/data_table.rb +143 -0
- data/lib/dtb/empty_state.rb +76 -0
- data/lib/dtb/errors.rb +77 -0
- data/lib/dtb/filter.rb +229 -0
- data/lib/dtb/filter_set.rb +86 -0
- data/lib/dtb/has_columns.rb +81 -0
- data/lib/dtb/has_default_implementation.rb +77 -0
- data/lib/dtb/has_empty_state.rb +56 -0
- data/lib/dtb/has_filters.rb +149 -0
- data/lib/dtb/has_i18n.rb +73 -0
- data/lib/dtb/has_options.rb +89 -0
- data/lib/dtb/has_url.rb +64 -0
- data/lib/dtb/options_map.rb +149 -0
- data/lib/dtb/query.rb +150 -0
- data/lib/dtb/query_builder.rb +197 -0
- data/lib/dtb/query_builder_set.rb +110 -0
- data/lib/dtb/renderable.rb +171 -0
- data/lib/dtb/version.rb +6 -0
- data/lib/dtb.rb +8 -0
- metadata +170 -0
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
require_relative "errors"
|
5
|
+
|
6
|
+
module DTB
|
7
|
+
# Provides a base implementation for running a Query, which can be expanded on
|
8
|
+
# by extending the #run method.
|
9
|
+
#
|
10
|
+
# In their simplest form, queries provide a default scope, and then all
|
11
|
+
# filters, and columns are applied automatically on top.
|
12
|
+
#
|
13
|
+
# If a query does not provide a default scope, then it should override the run
|
14
|
+
# method to craft the query, which might be required for more complex queries.
|
15
|
+
#
|
16
|
+
# @example Defining a default_scope on a query
|
17
|
+
# class OrdersQuery < DTB::Query
|
18
|
+
# default_scope { Current.user.orders }
|
19
|
+
#
|
20
|
+
# column :number, ->(scope) { scope.select(:number, :id) }
|
21
|
+
# column :buyer, ->(scope) { scope.select(:buyer_id).includes(:buyer) }
|
22
|
+
# # ...
|
23
|
+
# end
|
24
|
+
#
|
25
|
+
# @example Overwriting the #run method for more control
|
26
|
+
# class OrdersQuery < DTB::Query
|
27
|
+
# column :number, ->(scope) { scope.select(:number, :id) }
|
28
|
+
# column :buyer, ->(scope) { scope.select(:buyer_id).includes(:buyer) }
|
29
|
+
# # ...
|
30
|
+
#
|
31
|
+
# def run
|
32
|
+
# scope = Current.user.orders
|
33
|
+
# scope = columns.call(scope)
|
34
|
+
# scope = filters.call(scope)
|
35
|
+
# scope
|
36
|
+
# end
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
module HasDefaultImplementation
|
40
|
+
extend ActiveSupport::Concern
|
41
|
+
|
42
|
+
class_methods do
|
43
|
+
# Define the default scope for this query.
|
44
|
+
#
|
45
|
+
# @yield a block that should return an initial scope for the query.
|
46
|
+
# @yieldreturn [Object] an object compatible with your
|
47
|
+
# {QueryBuilder} proc's input.
|
48
|
+
# @return [void]
|
49
|
+
def default_scope(&block)
|
50
|
+
@default_scope = block if block
|
51
|
+
@default_scope
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# @return [Object, nil] the default scope defined for this query, if any.
|
56
|
+
def default_scope
|
57
|
+
self.class.default_scope
|
58
|
+
end
|
59
|
+
|
60
|
+
# Runs the query, returning the result of applying all the query builders on
|
61
|
+
# top of the default scope.
|
62
|
+
#
|
63
|
+
# @return [Object] the result of running the query.
|
64
|
+
# @raise {NotImplementedError} if no default scope is defined.
|
65
|
+
def run
|
66
|
+
if default_scope
|
67
|
+
instance_exec(&default_scope)
|
68
|
+
else
|
69
|
+
fail DTB::NotImplementedError, <<~ERROR
|
70
|
+
Either add a `default_scope` to your Query to apply all columns and
|
71
|
+
filters by default, or override the `#run` method to manually build
|
72
|
+
the query from the respective atoms.
|
73
|
+
ERROR
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
require_relative "empty_state"
|
5
|
+
require_relative "has_options"
|
6
|
+
|
7
|
+
module DTB
|
8
|
+
# This mixin provides access to {EmptyState empty state configuration} to both
|
9
|
+
# queries and data tables.
|
10
|
+
#
|
11
|
+
# @example Configuring a default partial to render empty states
|
12
|
+
# class ApplicationQuery < DTB::Query
|
13
|
+
# options[:empty_state][:render_with] = "data_tables/empty_state"
|
14
|
+
# end
|
15
|
+
#
|
16
|
+
# @example Rendering the empty state of a data table
|
17
|
+
# <% if data_table.empty? %>
|
18
|
+
# <%= render data_table.empty_state.renderer(data_table: data_table) %>
|
19
|
+
# <% end %>
|
20
|
+
#
|
21
|
+
# @example A sample default empty state partial
|
22
|
+
# <div class="empty_state">
|
23
|
+
# <h2><%= empty_state.title %><h2>
|
24
|
+
# <p><%= empty_state.explanation %></p>
|
25
|
+
#
|
26
|
+
# <% if data_table.filtered? %>
|
27
|
+
# <p><%= empty_state.update_filters %></p>
|
28
|
+
# <% end %>
|
29
|
+
# <div>
|
30
|
+
module HasEmptyState
|
31
|
+
extend ActiveSupport::Concern
|
32
|
+
include HasOptions
|
33
|
+
|
34
|
+
included do
|
35
|
+
# @!group Options
|
36
|
+
|
37
|
+
# @!attribute [rw] empty_state
|
38
|
+
# @return [OptionsMap] a set of options for handling the empty state.
|
39
|
+
# @see EmptyState
|
40
|
+
nested_options :empty_state, EmptyState.options
|
41
|
+
|
42
|
+
# @!endgroup
|
43
|
+
end
|
44
|
+
|
45
|
+
# @return [EmptyState] access information about the empty state to render
|
46
|
+
# for this query, if there are no results.
|
47
|
+
def empty_state
|
48
|
+
@empty_state ||= EmptyState.new(options[:empty_state].merge(context: self))
|
49
|
+
end
|
50
|
+
|
51
|
+
# (see BuildsDataTable#to_data_table)
|
52
|
+
def to_data_table
|
53
|
+
super.merge(empty_state: empty_state)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
require_relative "builds_data_table"
|
5
|
+
require_relative "filter"
|
6
|
+
require_relative "filter_set"
|
7
|
+
require_relative "has_default_implementation"
|
8
|
+
require_relative "has_options"
|
9
|
+
require_relative "has_url"
|
10
|
+
|
11
|
+
module DTB
|
12
|
+
# This mixin provides {Query Queries} with a set of filter objects that can be
|
13
|
+
# used to modify the query and to render the filters form in the view.
|
14
|
+
# Including this module gives you access to the {.filter} class method, which
|
15
|
+
# you can use to define filters in your query.
|
16
|
+
#
|
17
|
+
# @example (see .filter)
|
18
|
+
module HasFilters
|
19
|
+
extend ActiveSupport::Concern
|
20
|
+
include HasDefaultImplementation
|
21
|
+
include BuildsDataTable
|
22
|
+
include HasOptions
|
23
|
+
include HasUrl
|
24
|
+
|
25
|
+
included do
|
26
|
+
# @!group Options
|
27
|
+
|
28
|
+
# @!attribute [rw] filters
|
29
|
+
# @return [OptionsMap] a set of options for handling the filters form.
|
30
|
+
# @see FilterSet
|
31
|
+
nested_options :filters, FilterSet.options
|
32
|
+
|
33
|
+
# @!attribute [rw] default_params
|
34
|
+
# @return [Hash] the Hash of parameters to use when no filters are
|
35
|
+
# defined by users.
|
36
|
+
option :default_params, default: {}
|
37
|
+
|
38
|
+
# @!attribute [rw] default_filter_type
|
39
|
+
# @return [Class<Filter>] the default subclass of {Filter} to use unless
|
40
|
+
# one is specified.
|
41
|
+
# @see .filter
|
42
|
+
option :default_filter_type, default: Filter
|
43
|
+
|
44
|
+
# @!endgroup
|
45
|
+
end
|
46
|
+
|
47
|
+
class_methods do
|
48
|
+
# Defines a new Filter that will be added to this Query.
|
49
|
+
#
|
50
|
+
# @example Adding a filter to match the +name+ column to the input value exactly
|
51
|
+
# filter :name
|
52
|
+
#
|
53
|
+
# @example Adding a filter to find things with a name that contains the value
|
54
|
+
# filter :name,
|
55
|
+
# ->(scope, value) { scope.where("name ILIKE ?", "%#{value}%") }
|
56
|
+
#
|
57
|
+
# @example Overriding the type of filter object
|
58
|
+
# filter :name,
|
59
|
+
# type: ContainsTextFilter
|
60
|
+
#
|
61
|
+
# @example Overriding the renderer used for a specific filter
|
62
|
+
# # Instead of rendering "filters/contains_text_filter", this would
|
63
|
+
# # render "example/partial" in the filters form.
|
64
|
+
# filter :name,
|
65
|
+
# type: ContainsTextFilter,
|
66
|
+
# partial: "example/partial"
|
67
|
+
#
|
68
|
+
# @example Add a filter only if the user has permissions
|
69
|
+
# filter :name,
|
70
|
+
# type: ContainsTextFilter,
|
71
|
+
# if: -> { Current.user.has_permission? }
|
72
|
+
#
|
73
|
+
# @param name [Symbol]
|
74
|
+
# @param query [Proc] The filter's {QueryBuilder} proc. This proc should
|
75
|
+
# receive two parameters: the query's current +scope+ and the filter's
|
76
|
+
# +value+ and should return a modified +scope+.
|
77
|
+
#
|
78
|
+
# By default, this will add a simple
|
79
|
+
# {https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-where +where+} clause
|
80
|
+
# for matching a column with the same +name+ as the filter having an
|
81
|
+
# exact match on the proc's input +value+.
|
82
|
+
# @param type [Class<Column>] The type of filter to use. Defaults to
|
83
|
+
# whatever is set as the {default_filter_type}.
|
84
|
+
# @param opts [Hash] Any other options required by the +type+.
|
85
|
+
# @return [void]
|
86
|
+
def filter(name, query = ->(scope, value) { scope.where(name => value) }, type: options[:default_filter_type], **opts)
|
87
|
+
filter_definitions << {type: type, name: name, query: query, options: opts}
|
88
|
+
end
|
89
|
+
|
90
|
+
# @api private
|
91
|
+
# @return [Array<Hash>]
|
92
|
+
def filter_definitions
|
93
|
+
@filter_definitions ||= []
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# @return [Hash] the input parameters including the filters.
|
98
|
+
attr_reader :params
|
99
|
+
|
100
|
+
# @return [FilterSet] the set of filters defined on this object.
|
101
|
+
def filters
|
102
|
+
return @filters if defined?(@filters)
|
103
|
+
|
104
|
+
values = params.fetch(options[:filters][:param], options[:default_params])
|
105
|
+
|
106
|
+
filters = self.class.filter_definitions.map do |dfn|
|
107
|
+
name = dfn[:name]
|
108
|
+
dfn[:type].new(name, value: values[name], context: self, **dfn[:options], &dfn[:query])
|
109
|
+
end
|
110
|
+
|
111
|
+
filter_options = {submit_url: url, reset_url: reset_url}
|
112
|
+
.merge(options[:filters])
|
113
|
+
.compact
|
114
|
+
|
115
|
+
@filters = FilterSet.new(filters, filter_options)
|
116
|
+
end
|
117
|
+
|
118
|
+
# @return [String, nil] the URL to reset the filters and go back to the
|
119
|
+
# initial state. Defaults to removing the configured {FilterSet#param
|
120
|
+
# filters' param name} from the query string.
|
121
|
+
def reset_url
|
122
|
+
@filters_reset_url ||= override_query_params(
|
123
|
+
options[:filters][:param] => nil
|
124
|
+
)
|
125
|
+
end
|
126
|
+
|
127
|
+
# @overload initialize(params = {}, options = {})
|
128
|
+
# @param params [Hash] the Hash of params submitted by the user. These will
|
129
|
+
# be accessible within the Query as {params}.
|
130
|
+
# @param options [Hash] the Hash of {options} to configure this object.
|
131
|
+
# @see HasOptions#initialize
|
132
|
+
def initialize(params = {}, *args, &block)
|
133
|
+
super(*args, &block)
|
134
|
+
@params = params
|
135
|
+
end
|
136
|
+
|
137
|
+
# Applies all defined filters to the query being built.
|
138
|
+
#
|
139
|
+
# @return (see HasDefaultImplementation#run)
|
140
|
+
def run
|
141
|
+
filters.call(super)
|
142
|
+
end
|
143
|
+
|
144
|
+
# (see BuildsDataTable#to_data_table)
|
145
|
+
def to_data_table
|
146
|
+
super.merge(filters: filters)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
data/lib/dtb/has_i18n.rb
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
require "i18n"
|
5
|
+
|
6
|
+
module DTB
|
7
|
+
# This mixin provides a helper to lookup translations using the I18n gem's
|
8
|
+
# configured backends.
|
9
|
+
#
|
10
|
+
# By default, given a name and namespace, it will look up the translation
|
11
|
+
# under +{namespace}.{name}+. You can also give it a context object, and then
|
12
|
+
# if that object implements +ActiveModel::Translation+, it will first try to
|
13
|
+
# look it up using the +ActiveModel::Translation+ inheritance chain.
|
14
|
+
#
|
15
|
+
# Because {Query} objects implement +ActiveModel::Translation+, all lookups
|
16
|
+
# performed in the context of a Query object will use this strategy. See the
|
17
|
+
# examples below.
|
18
|
+
#
|
19
|
+
# Finally, a default can be provided, and it will be returned if none of the
|
20
|
+
# searched keys contain a translation.
|
21
|
+
#
|
22
|
+
# @example Looking up a translation without a context object
|
23
|
+
# # Because no context object is given, this will only look for `labels.foo`
|
24
|
+
# i18n_lookup(:foo, :labels)
|
25
|
+
#
|
26
|
+
# @example Looking up a translation with a plain context object
|
27
|
+
# # In this case, `context` does not implement `ActiveModel::Translation`,
|
28
|
+
# # so it will again, only look for `labels.foo`.
|
29
|
+
# i18n_lookup(:foo, :labels, context: Object.new)
|
30
|
+
#
|
31
|
+
# @example Looking up a translation within a Query object.
|
32
|
+
# # Queries implement ActiveModel::Translation, and provide a base i18n
|
33
|
+
# # scope of `queries`. If we have this query object:
|
34
|
+
# class SomeQuery < DTB::Query
|
35
|
+
# end
|
36
|
+
#
|
37
|
+
# # Then this will look up the translation in this priority order:
|
38
|
+
# #
|
39
|
+
# # 1. queries.labels.some_query.foo
|
40
|
+
# # 2. queries.labels.dtb/query.foo
|
41
|
+
# # 3. labels.foo
|
42
|
+
# #
|
43
|
+
# i18n_lookup(:foo, :labels, context: SomeQuery.new)
|
44
|
+
#
|
45
|
+
# @see https://api.rubyonrails.org/classes/ActiveModel/Translation.html
|
46
|
+
module HasI18n
|
47
|
+
extend ActiveSupport::Concern
|
48
|
+
|
49
|
+
# Look for a translation in the configured I18n backend.
|
50
|
+
#
|
51
|
+
# @param name [Symbol] the name of an attribute.
|
52
|
+
# @param namespace [Symbol] a namespace within the i18n sources.
|
53
|
+
# @param default [Object, nil] what to return if the given +name+/+namespace+
|
54
|
+
# combination isn't found.
|
55
|
+
# @param context [Class<ActiveModel::Translation>, nil] a context object to
|
56
|
+
# lookup translations following an inheritance chain.
|
57
|
+
# @see https://api.rubyonrails.org/classes/ActiveModel/Translation.html
|
58
|
+
def i18n_lookup(name, namespace, default: nil, context: nil)
|
59
|
+
defaults = []
|
60
|
+
|
61
|
+
if defined?(ActiveModel::Translation) && context.class.is_a?(ActiveModel::Translation)
|
62
|
+
scope = "#{context.class.i18n_scope}.#{namespace}"
|
63
|
+
|
64
|
+
defaults.concat(context.class.lookup_ancestors
|
65
|
+
.map { |klass| :"#{scope}.#{klass.model_name.i18n_key}.#{name}" })
|
66
|
+
end
|
67
|
+
|
68
|
+
defaults << :"#{namespace}.#{name}" << default
|
69
|
+
|
70
|
+
I18n.translate(defaults.shift, default: defaults)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
require "active_support/core_ext/class/attribute"
|
5
|
+
require "active_support/core_ext/hash/deep_merge"
|
6
|
+
require_relative "options_map"
|
7
|
+
|
8
|
+
module DTB
|
9
|
+
# Mixin that provides classes with the ability to define options that can be
|
10
|
+
# validated when initializing the object.
|
11
|
+
module HasOptions
|
12
|
+
extend ActiveSupport::Concern
|
13
|
+
|
14
|
+
included do
|
15
|
+
# @!attribute [rw] options
|
16
|
+
# @return [OptionsMap]
|
17
|
+
class_attribute :options, instance_predicate: false
|
18
|
+
|
19
|
+
self.options = OptionsMap.new
|
20
|
+
end
|
21
|
+
|
22
|
+
class_methods do
|
23
|
+
# Adds a valid option to this class, optionally marking it as required, or
|
24
|
+
# setting a default value.
|
25
|
+
#
|
26
|
+
# @example Defining options
|
27
|
+
# class SomeObject
|
28
|
+
# include DTB::HasOptions
|
29
|
+
#
|
30
|
+
# option :foo, required: true
|
31
|
+
# option :bar, default: 1
|
32
|
+
# end
|
33
|
+
#
|
34
|
+
# obj = SomeObject.new(foo: "test")
|
35
|
+
# obj.options #=> {foo: "test", bar: 1}
|
36
|
+
#
|
37
|
+
# @param name [Symbol]
|
38
|
+
# @param default The default value.
|
39
|
+
# @param required [Boolean] Whether to validate that the option is set
|
40
|
+
# when instantiating the object.
|
41
|
+
# @return [void]
|
42
|
+
#
|
43
|
+
# @!macro [attach] option
|
44
|
+
# @option options $1
|
45
|
+
def option(name, default: OptionsMap::UNSET_OPTION, required: false)
|
46
|
+
self.options = options.define(name, default: default, required: required)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Adds a set of nested options to this class, matching a nested schema.
|
50
|
+
#
|
51
|
+
# @example Defining nested options
|
52
|
+
# class Component
|
53
|
+
# include DTB::HasOptions
|
54
|
+
#
|
55
|
+
# option :foo
|
56
|
+
# option :bar, default: "test"
|
57
|
+
# end
|
58
|
+
#
|
59
|
+
# class Container
|
60
|
+
# include DTB::HasOptions
|
61
|
+
#
|
62
|
+
# option :baz
|
63
|
+
# nested_options :component, Component.options
|
64
|
+
# end
|
65
|
+
#
|
66
|
+
# container = Container.new(baz: 1, component: {foo: 2})
|
67
|
+
# container.options #=> {baz: 1, component: {foo: 2, bar: "test"}}
|
68
|
+
#
|
69
|
+
# @param name [Symbol] The name to nest the options under.
|
70
|
+
# @param opts [OptionsMap] A map of options to use as a schema and default
|
71
|
+
# values.
|
72
|
+
# @return [void]
|
73
|
+
def nested_options(name, opts = OptionsMap.new)
|
74
|
+
self.options = options.nest(name, opts)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# @param opts [Hash] An Options Hash. Options need to conform to the schema
|
79
|
+
# defined via calls to {.option} and {.nested_options}.
|
80
|
+
# @raise [UnknownOptionsError] if given an option that was not defined via
|
81
|
+
# {.option} or {.nested_options}.
|
82
|
+
# @raise [MissingOptionsError] if missing an option marked as +required+ via
|
83
|
+
# {.option}
|
84
|
+
def initialize(opts = {})
|
85
|
+
self.options = options.deep_merge(opts).validate!
|
86
|
+
options.freeze
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
data/lib/dtb/has_url.rb
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "uri"
|
4
|
+
require "active_support/concern"
|
5
|
+
require "active_support/core_ext/hash/indifferent_access"
|
6
|
+
require "active_support/core_ext/hash/deep_merge"
|
7
|
+
require "active_support/core_ext/object/blank"
|
8
|
+
require "active_support/core_ext/object/to_query"
|
9
|
+
require "rack/utils"
|
10
|
+
require_relative "has_options"
|
11
|
+
|
12
|
+
module DTB
|
13
|
+
# Allows objects to support having a URL, and provides a modifier method to
|
14
|
+
# change the query params on that URL (or any other URL-like String). This is
|
15
|
+
# useful to set a base URL in the options, and then be able to derive multiple
|
16
|
+
# URLs from that one.
|
17
|
+
module HasUrl
|
18
|
+
extend ActiveSupport::Concern
|
19
|
+
include HasOptions
|
20
|
+
|
21
|
+
included do
|
22
|
+
# @!group Options
|
23
|
+
|
24
|
+
# @!attribute [rw] url
|
25
|
+
# @return [String, nil] A Base URL
|
26
|
+
# @see HasFilters
|
27
|
+
option :url
|
28
|
+
|
29
|
+
# @!endgroup
|
30
|
+
end
|
31
|
+
|
32
|
+
def url
|
33
|
+
@url ||= options[:url]
|
34
|
+
end
|
35
|
+
|
36
|
+
# Returns a copy of the given URL (by default, the Base URL set via the
|
37
|
+
# +:url+ option), with overridden query parameters.
|
38
|
+
#
|
39
|
+
# @example Add a query parameter
|
40
|
+
# object.options[:url] = "/test?foo=1"
|
41
|
+
# object.override_query_params(bar: 2) #=> "/test?foo=1&bar=2"
|
42
|
+
#
|
43
|
+
# @example Remove query parameters
|
44
|
+
# object.options[:url] = "/test?foo=1"
|
45
|
+
# object.override_query_params(foo: nil) #=> "/test"
|
46
|
+
#
|
47
|
+
# @example Override a different URL
|
48
|
+
# object.override_query_params("/list", foo: 1) #=> "/list?foo=1"
|
49
|
+
#
|
50
|
+
# @param base_url [String, nil] The URL to modify. If +nil+, this method does
|
51
|
+
# nothing and returns +nil+.
|
52
|
+
# @param query [Hash] A Hash of query parameters to use.
|
53
|
+
# @return [String, nil]
|
54
|
+
def override_query_params(base_url = url, query = {})
|
55
|
+
base_url, query = url, base_url if base_url.is_a?(Hash)
|
56
|
+
return if base_url.nil?
|
57
|
+
|
58
|
+
uri = URI.parse(base_url)
|
59
|
+
params = Rack::Utils.parse_nested_query(uri.query).with_indifferent_access
|
60
|
+
uri.query = params.deep_merge(query).compact.to_query.presence
|
61
|
+
uri.to_s
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/object/deep_dup"
|
4
|
+
require_relative "errors"
|
5
|
+
|
6
|
+
module DTB
|
7
|
+
# Extends +Hash+ to allow for a lightweight "schema" of sorts. You can define
|
8
|
+
# which keys are allowed and which keys are required to be present, and then
|
9
|
+
# validate that the Hash meets this criteria.
|
10
|
+
#
|
11
|
+
# options = OptionsMap.new
|
12
|
+
# options.define!(:foo, required: true)
|
13
|
+
# options.define!(:bar, default: 1)
|
14
|
+
#
|
15
|
+
# options #=> { bar: 1 }
|
16
|
+
# options.validate! #=> raises MissingOptionsError
|
17
|
+
#
|
18
|
+
# options.update(foo: 2)
|
19
|
+
# options.validate! #=> options
|
20
|
+
#
|
21
|
+
# Option Maps can also define "nested" maps of options, by using another
|
22
|
+
# +OptionsMap+ as a template. This is useful for top level objects that accept
|
23
|
+
# options for nested objects.
|
24
|
+
#
|
25
|
+
# component_options = OptionsMap.new
|
26
|
+
# component_options.define!(:foo, required: true)
|
27
|
+
#
|
28
|
+
# top_level_options = OptionsMap.new
|
29
|
+
# top_level_options.define!(:bar, default: true)
|
30
|
+
# top_level_options.nest!(:component, component_options)
|
31
|
+
#
|
32
|
+
# top_level_options.update(bar: false, component: {foo: true})
|
33
|
+
# top_level_options.validate! #=> top_level_options
|
34
|
+
#
|
35
|
+
# @api private
|
36
|
+
# @see HasOptions
|
37
|
+
class OptionsMap < Hash
|
38
|
+
# @return [Set] The defined valid options.
|
39
|
+
attr_reader :valid_keys
|
40
|
+
|
41
|
+
# @return [Set] The options defined as required.
|
42
|
+
attr_reader :required_keys
|
43
|
+
|
44
|
+
def initialize(*) # :nodoc:
|
45
|
+
super
|
46
|
+
@valid_keys = Set.new
|
47
|
+
@required_keys = Set.new
|
48
|
+
@nested_options = {}
|
49
|
+
end
|
50
|
+
|
51
|
+
def initialize_copy(other) # :nodoc:
|
52
|
+
super
|
53
|
+
@valid_keys = other.valid_keys.dup
|
54
|
+
@required_keys = other.required_keys.dup
|
55
|
+
end
|
56
|
+
|
57
|
+
# Returns a copy of the options map with a new option defined.
|
58
|
+
#
|
59
|
+
# @param (see #define!)
|
60
|
+
# @return [OptionsMap] A new instance.
|
61
|
+
#
|
62
|
+
# @see HasOptions#option
|
63
|
+
def define(name, default: UNSET_OPTION, required: false)
|
64
|
+
deep_dup.define!(name, default: default, required: required)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Defines a new option in this OptionsMap.
|
68
|
+
#
|
69
|
+
# @param name [Symbol]
|
70
|
+
# @param default [Object] A default value. If given, the options Hash will
|
71
|
+
# be updated to include this option with this value.
|
72
|
+
# @param required [Boolean]
|
73
|
+
# @return [self]
|
74
|
+
def define!(name, default: UNSET_OPTION, required: false)
|
75
|
+
valid_keys << name
|
76
|
+
required_keys << name if required
|
77
|
+
update(name => default) if default != UNSET_OPTION
|
78
|
+
self
|
79
|
+
end
|
80
|
+
|
81
|
+
# Returns a copy of the options map which allows a nested set of options
|
82
|
+
# that conforms to a specific schema.
|
83
|
+
#
|
84
|
+
# @param (see #nest!)
|
85
|
+
# @return [OptionsMap] A new instance.
|
86
|
+
#
|
87
|
+
# @see HasOptions#nested_options
|
88
|
+
def nest(name, options = self.class.new)
|
89
|
+
deep_dup.nest!(name, options)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Defines a new set of nested options.
|
93
|
+
#
|
94
|
+
# @example
|
95
|
+
#
|
96
|
+
# component_options = OptionsMap.new
|
97
|
+
# component_options.define!(:foo)
|
98
|
+
# component_options.define!(:bar)
|
99
|
+
#
|
100
|
+
# top_level_options = OptionsMap.new
|
101
|
+
# top_level_options.define!(:qux)
|
102
|
+
# top_level_options.nest!(:nested, component_options)
|
103
|
+
#
|
104
|
+
# top_level_options.update(qux: 1, nested: {foo: 2, bar: 3})
|
105
|
+
#
|
106
|
+
# @param name [Symbol]
|
107
|
+
# @param options [OptionsMap] The schema for the nested options.
|
108
|
+
# @return [self]
|
109
|
+
def nest!(name, options = self.class.new)
|
110
|
+
valid_keys << name
|
111
|
+
@nested_options[name] = options
|
112
|
+
self[name] = options.deep_dup
|
113
|
+
self
|
114
|
+
end
|
115
|
+
|
116
|
+
# Enforces that all keys are defined as an option or nested options, that
|
117
|
+
# the required keys are all defined (irrespective of their value), and that
|
118
|
+
# all nested options hashes are equally valid.
|
119
|
+
#
|
120
|
+
# @return [self]
|
121
|
+
# @raise [UnknownOptionsError] if the Hash has any key that wasn't defined
|
122
|
+
# as an option, or if any nested options Hash has this problem.
|
123
|
+
# @raise [MissingOptionsError] if any of the required keys aren't defined or
|
124
|
+
# if any nested options Hash has this problem.
|
125
|
+
def validate!
|
126
|
+
fail UnknownOptionsError.new(self) if (keys.to_set - valid_keys).any?
|
127
|
+
fail MissingOptionsError.new(self) if (required_keys & keys) != required_keys
|
128
|
+
|
129
|
+
@nested_options.each do |key, schema|
|
130
|
+
options = self[key]
|
131
|
+
options = schema.merge(self[key]) unless options.respond_to?(:validate!)
|
132
|
+
options.validate!
|
133
|
+
end
|
134
|
+
|
135
|
+
self
|
136
|
+
end
|
137
|
+
|
138
|
+
# The default value for an option, which can be ignored. This allows specifying
|
139
|
+
# +nil+ as a valid default.
|
140
|
+
#
|
141
|
+
# @example
|
142
|
+
# options = OptionsMap.new
|
143
|
+
# options.define!(:foo, required: true)
|
144
|
+
# options.define!(:bar, default: nil)
|
145
|
+
# options #=> {bar: nil}
|
146
|
+
#
|
147
|
+
UNSET_OPTION = Object.new
|
148
|
+
end
|
149
|
+
end
|