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.
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