dtb 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/dtb/query.rb ADDED
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/naming"
4
+ require "active_model/translation"
5
+ require_relative "builds_data_table"
6
+ require_relative "has_default_implementation"
7
+ require_relative "has_options"
8
+ require_relative "has_columns"
9
+ require_relative "has_filters"
10
+ require_relative "has_empty_state"
11
+ require_relative "renderable"
12
+
13
+ module DTB
14
+ # Queries are the base classes that allow you to model both what data is
15
+ # fetched from the database and how it is rendered to users.
16
+ #
17
+ # A Query is nothing more than a collection of filters, columns, and an
18
+ # initial scope from which to start querying. For example:
19
+ #
20
+ # class Blog::PostsQuery < DTB::Query
21
+ # column :title, ->(scope) { scope.select(:title, :id) }
22
+ # column :author, ->(scope) { scope.select(:author_id).includes(:author) }
23
+ # column :published, ->(scope) { scope.select(:published_at) }
24
+ # column :actions, database: false
25
+ #
26
+ # filter :title,
27
+ # ->(scope, value) { scope.where("title ILIKE ?", "%#{value}%") }
28
+ # filter :published,
29
+ # ->(scope, value) { scope.where(published: value) }
30
+ #
31
+ # default_scope { Post.all }
32
+ # end
33
+ #
34
+ # == Running a Query
35
+ #
36
+ # This query would start from +Post.all+, then "apply" all columns, by
37
+ # modifying the query to add each clause declared in the columns, and finally
38
+ # look at the input params given when running to decide which filters should
39
+ # be applied.
40
+ #
41
+ # For example, in the below example query, the params include the `title`
42
+ # filter, but not the `published` filter:
43
+ #
44
+ # Blog::PostsQuery.run(filters: {title: "test"}) #=> #<ActiveRecord::Relation ...>
45
+ #
46
+ # # Given those input parameters, that code is equivalent to this:
47
+ # Post.all
48
+ # .select(:title, :id)
49
+ # .select(:author_id).includes(:author)
50
+ # .select(:published_at)
51
+ # .where("title ILIKE ?", "%test%")
52
+ #
53
+ # == Scoping queries to only return authorized data
54
+ #
55
+ # Usually, your queries will be scoped to data visible by a user or account in
56
+ # your system. If you're using +ActiveSupport::CurrentAttributes+, you could
57
+ # set your initial scope with this:
58
+ #
59
+ # default_scope { Current.user.posts }
60
+ #
61
+ # But if you're not, you will need to have access to the +current_user+ or
62
+ # whatever you call it. For this, the recommended approach is to implement
63
+ # a base query object that provides access to this:
64
+ #
65
+ # class ApplicationQuery < DTB::Query
66
+ # attr_reader :current_user
67
+ #
68
+ # def initialize(current_user, *args)
69
+ # super(*args)
70
+ # @current_user = current_user
71
+ # end
72
+ # end
73
+ #
74
+ # class Blog::PostsQuery < ApplicationQuery
75
+ # default_scope { current_user.posts }
76
+ # end
77
+ #
78
+ # All the Procs (the +default_scope+ and the procs attached to columns and
79
+ # filters) are evaluated in the context of the Query class itself, so you have
80
+ # access to its instance methods and variables.
81
+ #
82
+ # == Rendering the Query as a Data Table
83
+ #
84
+ # DTB can easily turn the Query results into a {DataTable} object, which
85
+ # provides some basic structure so templates can render this into a table with
86
+ # all the data, next to a filters panel.
87
+ #
88
+ # You can easily turn a Query into a DataTable:
89
+ #
90
+ # DataTable.build(Blog::PostsQuery, {filters: {title: "test"}})
91
+ #
92
+ # Check out the {DataTable} documentation for more on how to build and
93
+ # customize data tables.
94
+ #
95
+ # @see HasColumns
96
+ # @see HasFilters
97
+ class Query
98
+ extend ActiveModel::Translation
99
+ include HasDefaultImplementation
100
+ include HasOptions
101
+ include BuildsDataTable
102
+ include HasColumns
103
+ include HasFilters
104
+ include HasUrl
105
+ include HasEmptyState
106
+ include Renderable
107
+
108
+ # Provide a base scope of +queries+ for translations. Unless overridde,
109
+ # translations within query objects will be found under
110
+ # +queries.{namespace}.{query_class}+.
111
+ #
112
+ # @see https://api.rubyonrails.org/classes/ActiveModel/Translation.html
113
+ def self.i18n_scope
114
+ :queries
115
+ end
116
+
117
+ # Run the query, returning the results.
118
+ #
119
+ # @param ... [Array<Object>] Any arguments given will be forwarded to
120
+ # #initialize
121
+ # @return (see #run)
122
+ def self.run(...)
123
+ new(...).run
124
+ end
125
+
126
+ # @!method initialize(params = {}, options = {})
127
+ # @param params [Hash] Any user-supplied params (such as filters to apply)
128
+ # @param options [Hash] Any options to customize this query.
129
+ # @raise (see HasOptions#initialize)
130
+
131
+ # @!method run
132
+ # Runs the query, starting from its default scope, if defined, and
133
+ # applying all columns and filters.
134
+ #
135
+ # @return the result of running the query.
136
+ # @raise {NotImplementedError} if no default scope is defined.
137
+
138
+ # @!method to_data_table
139
+ # @return [Hash<Symbol, Object>] a Hash of arguments suitable to pass to
140
+ # {DataTable#initialize}
141
+
142
+ # (see Renderable#rendering_options)
143
+ def rendering_options
144
+ # NOTE: Normally this is exposed through a DataTable, and we don't want to
145
+ # expose query internals when rendering. You can always override this if
146
+ # you do want the Query instance available to the Renderer, though.
147
+ {}
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "has_i18n"
4
+ require_relative "has_options"
5
+
6
+ module DTB
7
+ # Query builders are the "atoms" of a Query. They specify a specific behavior
8
+ # scoped to a single part of the query. For example, a column or a filter.
9
+ # This class is not meant to be used directly, but instead extended with
10
+ # concrete behavior.
11
+ #
12
+ # The central part of a query builder is a Proc that will receive an object
13
+ # (e.g. an ActiveRecord::Relation) and is expected to return that object,
14
+ # modified in whatever way the builder is meant to work.
15
+ #
16
+ # Query builders have an optional "execution context" (usually an instance of
17
+ # the {Query} class) which is used to evaluate their Proc, giving it access to
18
+ # any state / methods in that object.
19
+ #
20
+ # The central interface to query builders is the {#call} method, which given a
21
+ # "scope", will decide if the query builder's Proc should be called, and
22
+ # either return the result of the Proc, or if it doesn't need to evaluate
23
+ # itself, will return the input "scope" as is.
24
+ #
25
+ # In order to decide whether it should be evaluated, query builders rely on
26
+ # the {#evaluate?} method and/or the {#render?} method. {#render?} decides if
27
+ # the atom being defined by this builder is something that should be displayed
28
+ # back to the user, and {#evaluate?} checks if the Proc should be evaluated or
29
+ # skipped.
30
+ #
31
+ # Normally, something that should not be rendered should not be evaluated, so
32
+ # the default behavior is that {#evaluate?} depends on {#render?}. However,
33
+ # you may change this in sub-classes. For a concrete example, if you are not
34
+ # going to display a column in the table to users, it makes no sense to add
35
+ # extra data to users.
36
+ #
37
+ # @abstract
38
+ # @see Column
39
+ # @see Filter
40
+ class QueryBuilder
41
+ include HasOptions
42
+ include HasI18n
43
+
44
+ # @!group Options
45
+
46
+ # @!attribute [rw] context
47
+ # @return [Object, nil] The Object in which the {QueryBuilder}'s proc is
48
+ # evaluated.
49
+ option :context, default: nil
50
+
51
+ # @!attribute [rw] if
52
+ # @return [Proc] A Proc that returns a Boolean. If it returns +false+ then
53
+ # {#call} will skip evaluating the {QueryBuilder}'s proc.
54
+ option :if, default: -> { true }
55
+
56
+ # @!attribute [rw] unless
57
+ # @return [Proc] A Proc that returns a Boolean. If it returns +true+ then
58
+ # {#call} will skip evaluating the {QueryBuilder}'s proc.
59
+ option :unless, default: -> { false }
60
+
61
+ # @!endgroup
62
+
63
+ IDENT = ->(value) { value }
64
+ private_constant :IDENT
65
+
66
+ # @return [Symbol] The name of this QueryBuilder.
67
+ attr_reader :name
68
+
69
+ # @param name [Symbol] The QueryBuilder's name.
70
+ # @param opts [Hash] Any options that need to be set. See also {HasOptions}.
71
+ # @yield [scope, ...] The given block will be used by {#call} to modify
72
+ # the given input scope
73
+ # @raise (see HasOptions#initialize)
74
+ def initialize(name, opts = {}, &query)
75
+ super(opts)
76
+ @name = name
77
+ @query = query
78
+ @applied = false
79
+ end
80
+
81
+ # Evaluates this QueryBuilder's Proc if necessary, returning either the
82
+ # input +scope+ or the output of the Proc.
83
+ #
84
+ # @param scope [Object] the "query" being built.
85
+ # @param ... [Array<Object>] Splat of any other params that are accepted by
86
+ # this QueryBuilder's Proc.
87
+ # @return [Object] the modified "query" or the input +scope+.
88
+ #
89
+ # @see #evaluate?
90
+ def call(scope, ...)
91
+ if evaluate?
92
+ @applied = true
93
+ evaluate(scope, ...)
94
+ else
95
+ scope
96
+ end
97
+ end
98
+
99
+ # Evaluates a Proc in the context of this QueryBuilder's +context+, as given
100
+ # in the options.
101
+ #
102
+ # @param args [Array<Object>] Any arguments will be forwarded to the Proc.
103
+ # @param with [Proc] A Proc. Defaults to this QueryBuilder's main Proc.
104
+ # @api private
105
+ def evaluate(*args, with: @query, **opts)
106
+ options[:context].instance_exec(*args, **opts, &with)
107
+ end
108
+
109
+ # @return [Boolean] Whether this QueryBuilder's Proc has been used or not.
110
+ def applied?
111
+ @applied
112
+ end
113
+
114
+ # Whether the Proc should be evaluated or skipped. By default, this depends
115
+ # on whether the QueryBuilder is meant to be rendered ot not, and on the
116
+ # +if+ and +unless+ options.
117
+ #
118
+ # Subclasses should override this method to provide specific reasons why the
119
+ # QueryBuilder should be skipped or not.
120
+ #
121
+ # @return [Boolean]
122
+ # @see #render?
123
+ def evaluate?
124
+ render?
125
+ end
126
+
127
+ # Whether this QueryBuilder should be displayed in views or not. By default
128
+ # this depends on the +if+ and +unless+ options.
129
+ #
130
+ # Subclasses should override this method to provide specific reasons why the
131
+ # QueryBuilder should be rendered or not.
132
+ #
133
+ # @return [Boolean]
134
+ # @see #evaluate?
135
+ def render?
136
+ evaluate(with: options[:if]) && !evaluate(with: options[:unless])
137
+ end
138
+
139
+ # Finds values in your I18n configuration based on this QueryBuilder's
140
+ # {name} and {context}.
141
+ #
142
+ # @example Looking up strings in the i18n sources
143
+ # class SomeQuery
144
+ # extend ActiveModel::Translation
145
+ #
146
+ # def self.i18n_scope
147
+ # :queries
148
+ # end
149
+ # end
150
+ #
151
+ # builder = QueryBuilder.new(:builder_name, context: SomeQuery.new)
152
+ #
153
+ # # Assuming the current locale is `en`, this will search for:
154
+ # #
155
+ # # en: # Current Locale
156
+ # # queries: # Context's i18n_scope
157
+ # # labels: # Namespace given to this method
158
+ # # some_query: # Context's model_name
159
+ # # builder_name: <value> # This builder's name.
160
+ # #
161
+ # builder.i18n_lookup(:labels)
162
+ #
163
+ # @example i18n_lookup follows the context's inheritance chain
164
+ # class BaseQuery
165
+ # extend ActiveModel::Translation
166
+ #
167
+ # def self.i18n_scope
168
+ # :queries
169
+ # end
170
+ # end
171
+ #
172
+ # class ConcreteQuery < BaseQuery
173
+ # end
174
+ #
175
+ # builder = QueryBuilder.new(:builder_name, context: SomeQuery.new)
176
+ #
177
+ # # Assuming the current locale is `en`, this will first attempt to search
178
+ # # for:
179
+ # #
180
+ # # en.queries.labels.concrete_query.builder_name
181
+ # #
182
+ # # And if no translation is declared, will then look up:
183
+ # #
184
+ # # en.queries.labels.base_query.builder_name
185
+ # #
186
+ # builder.i18n_lookup(:labels)
187
+ #
188
+ # @param namespace [Symbol] A scope to find I18n values in.
189
+ # @param default [String, nil] A default value to render if no value is
190
+ # found in the i18n sources.
191
+ #
192
+ # @see HasI18n#i18n_lookup
193
+ def i18n_lookup(namespace, default: nil)
194
+ super(name, namespace, default: default, context: options[:context])
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+ require_relative "has_options"
5
+
6
+ module DTB
7
+ # These models a set of {QueryBuilder} objects where you can quickly select
8
+ # sub-sets of query builders or extract individual builders.
9
+ #
10
+ # A set can also be evaluated using the method {#call}, which will evaluate
11
+ # each query builder in the set in turn, using each builder's output as the
12
+ # next's buidler's input. This allows, for example, applying every filter or
13
+ # column defined in a query in a single method call.
14
+ class QueryBuilderSet
15
+ include HasOptions
16
+
17
+ # @!method each(&block)
18
+ # Iterate through the builders in the set.
19
+ # @yieldparam builder [QueryBuilder] A QueryBuilder
20
+ # @return [void]
21
+
22
+ # @!method to_a
23
+ # @return [Array<QueryBuilder>] an Array with the contents of the set.
24
+
25
+ # @!method any?
26
+ # @return [Boolean] if the set has at least one {QueryBuilder}.
27
+
28
+ # @!method empty?
29
+ # @return [Boolean] if the set is empty.
30
+
31
+ delegate :each, :to_a, :any?, :empty?, to: :@builders
32
+
33
+ # @param builders [Array<QueryBuilder>]
34
+ # @param opts [Hash] any defined options.
35
+ # @raise (see HasOptions#initialize)
36
+ def initialize(builders = [], opts = {})
37
+ super(opts)
38
+ @builders = builders
39
+ end
40
+
41
+ # Evaluates every {QueryBuilder} in the set if necessary, passing the return
42
+ # value of each as input to the next builder's {QueryBuilder#call} method.
43
+ #
44
+ # @example Applying multiple builders at once.
45
+ #
46
+ # builder_1 = QueryBuilder.new(...)
47
+ # builder_2 = QueryBuilder.new(...)
48
+ # builder_3 = QueryBuilder.new(...)
49
+ #
50
+ # builders = QueryBuilderSet.new([builder_1, builder_2, builder_3])
51
+ #
52
+ # # This will evaluate all three builders
53
+ # result = builders.call(a_scope)
54
+ #
55
+ # # ...and is equivalent to doing this:
56
+ # a_scope = builder_1.call(a_scope)
57
+ # a_scope = builder_2.call(a_scope)
58
+ # result = builder_3.call(a_scope)
59
+ #
60
+ # @param scope (see QueryBuilder#call)
61
+ # @return (see QueryBuilder#call)
62
+ #
63
+ # @see QueryBuilder#call
64
+ def call(scope)
65
+ @builders.reduce(scope) { |current, builder| builder.call(current) }
66
+ end
67
+
68
+ # Filters the set to only those {QueryBuilder}s that should be rendered.
69
+ #
70
+ # @return [QueryBuilderset] a new set.
71
+ def renderable
72
+ self.class.new(@builders.select { |builder| builder.render? }, options)
73
+ end
74
+
75
+ # Filters the set to only those {QueryBuilder}s that have been applied.
76
+ #
77
+ # @return [QueryBuilderset] a new set.
78
+ def applied
79
+ self.class.new(@builders.select { |builder| builder.applied? }, options)
80
+ end
81
+
82
+ # @param name [Symbol]
83
+ # @return [QueryBuilder, nil] a single QueryBuilder, by name, if it's
84
+ # currently in the set, or +nil+ if it's not.
85
+ def [](name)
86
+ @builders.find { |builder| builder.name.to_s == name.to_s }
87
+ end
88
+
89
+ # @param names [Array<Symbol>] Splat of names to filter.
90
+ # @return [QueryBuilderSet] a subset containing only the builders with the
91
+ # names in the input list.
92
+ def slice(*names)
93
+ builders = @builders.select do |builder|
94
+ names.any? { |name| name.to_s == builder.name.to_s }
95
+ end
96
+
97
+ self.class.new(builders, options)
98
+ end
99
+
100
+ # @param names [Array<Symbol>] Splat of names to filter out.
101
+ # @return [QueryBuilderSet] a subset containing only the builders in this
102
+ # set whose name is not in the input list.
103
+ def except(*names)
104
+ builders = @builders.reject do |builder|
105
+ names.any? { |name| name.to_s == builder.name.to_s }
106
+ end
107
+ self.class.new(builders, options)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_model/naming"
5
+ require_relative "has_options"
6
+
7
+ module DTB
8
+ # Provides a simple abstraction for rendering components by setting an option
9
+ # with the object to use for rendering.
10
+ #
11
+ # This povides a {#renderer} method that you can pass to ActionView's
12
+ # +render+, like so:
13
+ #
14
+ # <%= render @object.renderer %>
15
+ #
16
+ # == Rendering partials
17
+ #
18
+ # In the most basic case, you can pass a string to the +:render_with+ option
19
+ # to render the component via a partial. When doing so, the return value of
20
+ # the {#rendering_options} method will be passed as locals.
21
+ #
22
+ # @example Rendering via a partial
23
+ # class SelectFilter < DTB::Filter
24
+ # option :render_with, default: "filters/select_filter"
25
+ # end
26
+ #
27
+ # In that example, +renderer+ will return a Hash that looks like this:
28
+ #
29
+ # {partial: "filters/select_filter", locals: {filter: <the filter object>}}
30
+ #
31
+ # == Rendering components
32
+ #
33
+ # If you're using a component library such as ViewComponent or Phlex, you can
34
+ # pass a component directly to the +:render_with+ option. The component will
35
+ # be instantiated with the object.
36
+ #
37
+ # @example Rendering a ViewComponent
38
+ # class SelectFilter < DTB::Filter
39
+ # option :render_with, default: SelectFilterComponent
40
+ # end
41
+ #
42
+ # In that example, calling +renderer+ will be equivalent to insantiating the
43
+ # component like so:
44
+ #
45
+ # SelectFilterComponent.new(filter: <the filter object>)
46
+ #
47
+ # == Dynamic renderer resolution
48
+ #
49
+ # If you pass a callable to +:render_with+, it will be called with the object
50
+ # as a keyword argument. The callable is expected to return a valid argument
51
+ # for ActionView's +render+ method.
52
+ #
53
+ # @example Dynamic partial selection
54
+ # class SelectFilter < DTB::Filter
55
+ # option :render_with, default: ->(filter:, **opts) {
56
+ # if filter.autocomplete?
57
+ # {partial: "filters/autocomplete_filter", locals: {filter: filter, **opts}}
58
+ # else
59
+ # {partial: "filters/select_filter", locals: {filter: filter, **opts}}
60
+ # end
61
+ # }
62
+ # end
63
+ #
64
+ # == Passing extra options to the renderer
65
+ #
66
+ # Whatever options you pass to the {#renderer} method, they will be
67
+ # forwarded to the configured renderer via {#render_with}.
68
+ #
69
+ # If rendering with a partial, these will be passed as extra locals. If using
70
+ # a component-based renderer, these will be passed as extra keyword arguments
71
+ # to the initializer.
72
+ #
73
+ # @example Passing extra locals to a partial
74
+ # <%= render filter.renderer(css_class: "custom-class") %>
75
+ module Renderable
76
+ extend ActiveSupport::Concern
77
+ include HasOptions
78
+
79
+ included do
80
+ # @!group Options
81
+
82
+ # @!attribute [rw] render_with
83
+ # @return [#call, Class<#render_in>, Hash] an object that can be used to
84
+ # render the Renderable, that will be passed to ActionView's #render.
85
+ # @see #renderer
86
+ # @see #rendering_options
87
+ option :render_with
88
+
89
+ # @!endgroup
90
+ end
91
+
92
+ # Returns an object capable of being rendered by ActionView's +render+,
93
+ # based on what the +:render_with+ option is set to.
94
+ #
95
+ # * If +:render_with+ is a string, it will return a Hash with the +:partial+
96
+ # key set to the string, and the +:locals+ key set to the return value of
97
+ # the {#rendering_options} method, plus any extra options passed to this
98
+ # method.
99
+ #
100
+ # * If +:render_with+ is a class, it will return an instance of that class
101
+ # with the return value of the {#rendering_options} method and any extra
102
+ # options passed to this method as keyword arguments.
103
+ #
104
+ # * If +:render_with+ is a callable, it will call it with the return value
105
+ # of the {#rendering_options} method and any extra options passed to this
106
+ # method as keyword arguments.
107
+ #
108
+ # @param opts [Hash] extra options to pass to the renderer.
109
+ # @return [Hash, #render_in] an object that can be used as an argument to
110
+ # ActionView's +render+ method.
111
+ #
112
+ # @see #rendering_options
113
+ def renderer(**opts)
114
+ render_with = options[:render_with]
115
+ opts = opts.update(rendering_options)
116
+
117
+ if render_with.respond_to?(:call)
118
+ render_with.call(**opts)
119
+ elsif render_with.is_a?(Class)
120
+ render_with.new(**opts)
121
+ elsif render_with.respond_to?(:to_str)
122
+ {partial: render_with, locals: opts}
123
+ else
124
+ render_with
125
+ end
126
+ end
127
+
128
+ # Returns a Hash of options to pass to the renderer. By default, this will
129
+ # include a reference to the Renderable itself, under a key that is derived
130
+ # from its class name, underscored, after removing any class namespace.
131
+ #
132
+ # @example Default rendering options
133
+ # class MyFilter
134
+ # include DTB::Renderable
135
+ # end
136
+ #
137
+ # filter = MyFilter.new
138
+ # filter.rendering_options # => {my_filter: filter}
139
+ #
140
+ # @example Default rendering options in a namespaced object
141
+ # class Admin::Widget
142
+ # include DTB::Renderable
143
+ # end
144
+ #
145
+ # widget = Admin::Widget.new
146
+ # widget.rendering_options # => {widget: widget}
147
+ #
148
+ # @example Overridden rendering options
149
+ # class MyFilter
150
+ # include DTB::Renderable
151
+ #
152
+ # def rendering_options
153
+ # {filter: self, custom: "option"}
154
+ # end
155
+ # end
156
+ #
157
+ # filter = MyFilter.new
158
+ # filter.rendering_options # => {filter: filter, custom: "option"}
159
+ #
160
+ # @return [Hash<Symbol, Object>]
161
+ def rendering_options
162
+ name = ActiveModel::Name.new(self.class).element.underscore.to_sym
163
+ {name => self}
164
+ end
165
+
166
+ # (see BuildsDataTable#to_data_table)
167
+ def to_data_table(*)
168
+ super.merge(renderable: self)
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTB
4
+ # @return [String] the current gem version.
5
+ VERSION = "1.0.0.rc1"
6
+ end
data/lib/dtb.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dtb/data_table"
4
+ require_relative "dtb/query"
5
+ require_relative "dtb/version"
6
+
7
+ module DTB # :nodoc:
8
+ end