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.
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+ require_relative "filter_set"
5
+ require_relative "query_builder_set"
6
+ require_relative "empty_state"
7
+
8
+ module DTB
9
+ # Data Tables act as presenter objects for the data returned from a {Query}.
10
+ # Queries pass data to this object, which is then passed to the view layer,
11
+ # providing methods to access the different components that should be rendered
12
+ # on the page.
13
+ #
14
+ # DataTables provide also a method (.build) to run the Query and turn it into
15
+ # a DataTable in one pass, since most likely that's what you will need on most
16
+ # endpoints that use these objects.
17
+ #
18
+ # @example build a data table in the controller
19
+ # def index
20
+ # @data_table = DTB::DataTable.build SomeQuery, params
21
+ # end
22
+ #
23
+ # @example render a data table on the view
24
+ # <%= render partial: @data_table.filters, as: :filters %>
25
+ #
26
+ # <% if @data_table.any? %>
27
+ # <table>
28
+ # <thead>
29
+ # <%= @data_table.columns.renderable.each do |column| %>
30
+ # <th><%= column.header %>
31
+ # <% end %>
32
+ # </thead>
33
+ # <tbody>
34
+ # <%= render partial: @data_table.rows %>
35
+ # </tbody>
36
+ # </table>
37
+ # <% else %>
38
+ # <%= render partial: @data_table.empty_state,
39
+ # as: :empty_state,
40
+ # locals: { data_table: @data_table } %>
41
+ # <% end %>
42
+ class DataTable
43
+ # @overload build(query_class, params = {}, options = {})
44
+ # @param query_class [Class<Query>] a Query class to run and turn into a
45
+ # data table.
46
+ # @param params [Hash] Any user-supplied params (such as filters to apply)
47
+ # @param options [Hash] Any options to customize this query.
48
+ # @raise (see HasOptions#initialize)
49
+ # @return [Datatable] the data table with the results of running the query.
50
+ #
51
+ # @overload build(query)
52
+ # @param query [Query] an instance of a Query which may or may not have
53
+ # been run yet.
54
+ # @return [Datatable] the data table with the results of running the query.
55
+ #
56
+ # @overload build(object, ...)
57
+ # @param object [#to_data_table] an object that implements +#to_data_table+.
58
+ # @param ... [Array<Object>] any parameters that should be forwarded to
59
+ # the +object+'s +#to_data_table+ method.
60
+ # @return [Datatable] the data table with the results of running the query.
61
+ # @see BuildsDataTable
62
+ def self.build(query, ...)
63
+ new(**query.to_data_table(...))
64
+ end
65
+
66
+ # @!method any?
67
+ # @return [Boolean] whether there are any rows to render.
68
+ # @!method empty?
69
+ # @return [Boolean] whether there are no rows to render.
70
+ # @!method each
71
+ # @yield each row of the query results
72
+ delegate :any?, :empty?, :each, to: :rows
73
+
74
+ # @return [Enumerable] the list of objects to render as rows of the table.
75
+ attr_reader :rows
76
+
77
+ # @return [QueryBuilderSet] the list of columns used for this query.
78
+ attr_reader :columns
79
+
80
+ # @return [FilterSet] the list of filters used for this query.
81
+ attr_reader :filters
82
+
83
+ # @return [Hash] the options used to configure the query.
84
+ attr_reader :options
85
+
86
+ # @return [EmptyState] the {EmptyState} object to use if there are no rows
87
+ # to render.
88
+ attr_reader :empty_state
89
+
90
+ # @param rows [Enumerable] a list of objects to generate the rows of the table.
91
+ # @param columns [QueryBuilderSet] the list of columns used for this query.
92
+ # Defaults to an empty set.
93
+ # @param filters [FilterSet] the list of filters used for this query.
94
+ # Defaults to an empty set.
95
+ # @param empty_state [EmptyState] the object to get information from if
96
+ # there are no rows. Defaults to an unconfigured {EmptyState}.
97
+ # @param options [Hash] the options used to configure the query.
98
+ # @param renderable [Renderable, nil] the source of rendering options for this
99
+ # data table. Normally, a Query object, or the same object that was used
100
+ # to generate the {#rows}. If +nil+, calling {#renderer} will return +nil+.
101
+ def initialize(
102
+ rows:,
103
+ columns: NO_COLUMNS,
104
+ filters: NO_FILTERS,
105
+ empty_state: DEFAULT_EMPTY_STATE,
106
+ options: {},
107
+ renderable: nil
108
+ )
109
+ @rows = rows
110
+ @columns = columns
111
+ @filters = filters
112
+ @empty_state = empty_state
113
+ @options = options
114
+ @renderable = renderable
115
+ end
116
+
117
+ # @return [Boolean] whether any of the filters was applied to get the
118
+ # current results.
119
+ def filtered?
120
+ @filtered ||= filters.applied.any?
121
+ end
122
+
123
+ # (see Renderable#renderer)
124
+ def renderer(**opts)
125
+ opts = opts.merge(rendering_options)
126
+ @renderable&.renderer(**opts)
127
+ end
128
+
129
+ # (see Renderable#rendering_options)
130
+ def rendering_options
131
+ {data_table: self}
132
+ end
133
+
134
+ NO_COLUMNS = QueryBuilderSet.new
135
+ private_constant :NO_COLUMNS
136
+
137
+ NO_FILTERS = FilterSet.new
138
+ private_constant :NO_FILTERS
139
+
140
+ DEFAULT_EMPTY_STATE = EmptyState.new
141
+ private_constant :DEFAULT_EMPTY_STATE
142
+ end
143
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "has_options"
4
+ require_relative "has_i18n"
5
+ require_relative "renderable"
6
+
7
+ module DTB
8
+ # The Empty State encapsulates the data you might want to render when a query
9
+ # returns no results. For each query, this will derive from your i18n sources
10
+ # three things:
11
+ #
12
+ # * a {title} to show in the page (for example "No results!")
13
+ # * an {explanation} to indicate why the query returned no results (this is
14
+ # useful to present on a "blank slate" scenario, when the underlying
15
+ # storage doesn't have data yet.)
16
+ # * a call to action to {update_filters} in the case the user has applied
17
+ # filters and that resulted in the results being empty.
18
+ #
19
+ # The names of those methods is purely informational and doesn't carry any
20
+ # behavior with it. You are welcome to use these strings in any way you want,
21
+ # or not at all.
22
+ #
23
+ # == Rendering the Empty State
24
+ #
25
+ # Each empty state is a {Renderable} object, and as such, you can define how
26
+ # to render it via the {#render_with} option, and by then calling the
27
+ # {#renderer} method.
28
+ #
29
+ # @see HasEmptyState
30
+ class EmptyState
31
+ include HasOptions
32
+ include HasI18n
33
+ include Renderable
34
+
35
+ # @!group Options
36
+
37
+ # @!attribute [rw] context
38
+ # @return [Object, nil] the Object to use as context to evaluate the i18n
39
+ # sources.
40
+ # ` @see HasI18n#i18n_lookup
41
+ option :context
42
+
43
+ # @!endgroup
44
+
45
+ # Determine the "title" of the default empty state container that is
46
+ # rendered. This looks up a translation under the +empty_states+ namespace,
47
+ # optionally using this empty state's #context.
48
+ #
49
+ # @return [String]
50
+ # @see #i18n_lookup
51
+ def title
52
+ i18n_lookup(:title, :empty_states, context: options[:context])
53
+ end
54
+
55
+ # Determine the "explanation" to render when a query has no results to show.
56
+ # This looks up a translation under the +empty_states+ namespace, optionally
57
+ # using this empty state's #context.
58
+ #
59
+ # @return [String]
60
+ # @see #i18n_lookup
61
+ def explanation
62
+ i18n_lookup(:explanation, :empty_states, context: options[:context], default: "")
63
+ end
64
+
65
+ # Determine the explanation to render when a query has no results to show
66
+ # and filters are applied. This should be a call to action to let users know
67
+ # they should relax the filters. This looks up a translation under the
68
+ # +empty_states+ namespace, optionally using this empty state's #context.
69
+ #
70
+ # @return [String]
71
+ # @see #i18n_lookup
72
+ def update_filters
73
+ i18n_lookup(:update_filters, :empty_states, context: options[:context], default: "")
74
+ end
75
+ end
76
+ end
data/lib/dtb/errors.rb ADDED
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTB
4
+ # Mixin for other errors raised from this library, so that you can always
5
+ # rescue +DTB::Error+.
6
+ module Error; end
7
+
8
+ # Raised when initializing an object that supports options with an option that
9
+ # hasn't been defined.
10
+ #
11
+ # @see HasOptions
12
+ # @see OptionsMap
13
+ class UnknownOptionsError < ArgumentError
14
+ include DTB::Error
15
+
16
+ # @return [OptionsMap] The invalid options hash.
17
+ attr_reader :options
18
+
19
+ # @return [Set] The options that are defined for this hash.
20
+ attr_reader :valid_options
21
+
22
+ # @return [Set] The options that are present in the Hash that aren't defined.
23
+ attr_reader :unknown_options
24
+
25
+ # @api private
26
+ def initialize(options)
27
+ unknown = options.keys.to_set - options.valid_keys
28
+ super(format(MESSAGE, unknown.to_a, options.valid_keys.to_a))
29
+ @options = options
30
+ @valid_options = options.valid_keys
31
+ @unknown_options = unknown
32
+ end
33
+
34
+ MESSAGE = "Unknown options: %p. Valid options are: %p"
35
+ private_constant :MESSAGE
36
+ end
37
+
38
+ # Raised when initializing an object that supports options without passing all
39
+ # the required options.
40
+ #
41
+ # @see HasOptions
42
+ # @see OptionsMap
43
+ class MissingOptionsError < ArgumentError
44
+ include DTB::Error
45
+
46
+ # @return [OptionsMap] The invalid options hash.
47
+ attr_reader :options
48
+
49
+ # @return [Set] The options that are required for this Hash.
50
+ attr_reader :required_options
51
+
52
+ # @return [Set] The required options that are missing from this Hash.
53
+ attr_reader :missing_options
54
+
55
+ # @api private
56
+ def initialize(options)
57
+ missing = options.required_keys - options.keys
58
+ super(format(MESSAGE, missing.to_a, options))
59
+ @options = options
60
+ @required_options = options.required_keys
61
+ @missing_options = missing
62
+ end
63
+
64
+ MESSAGE = "Missing required options: %p. Options given were: %p"
65
+ private_constant :MESSAGE
66
+ end
67
+
68
+ # rubocop:disable Lint/InheritException
69
+
70
+ # Extends +NotImplementedError+ to be catchable as a library error via
71
+ # +rescue DTB::Error+. Normally you wouldn't rescue this in code, though,
72
+ # but rather use it to get failing tests / exceptions while developing.
73
+ class NotImplementedError < ::NotImplementedError
74
+ include DTB::Error
75
+ end
76
+ # rubocop:enable Lint/InheritException
77
+ end
data/lib/dtb/filter.rb ADDED
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+ require "active_support/core_ext/string/inflections"
5
+ require_relative "query_builder"
6
+ require_relative "has_options"
7
+ require_relative "renderable"
8
+
9
+ module DTB
10
+ # Filters allow setting conditions on a query, which are optionally applied
11
+ # depending on user input. You are meant to subclass this and define your own
12
+ # specialized filters based on your application's needs.
13
+ #
14
+ # == Query Builders
15
+ #
16
+ # The filter, as other query builders, depends on a Proc that accepts both a
17
+ # +scope+ and the user provided +value+ for this filter. As other
18
+ # {QueryBuilder Query Builders}, filters respond to {#call}, which evaluates
19
+ # the proc only if the value is present.
20
+ #
21
+ # with_value = Filter.new(:name, value: "Jane Doe") do |scope, value|
22
+ # scope.where(name: value)
23
+ # end
24
+ # without_value = Filter.new(:name, value: nil) do |scope, value|
25
+ # scope.where(name: value)
26
+ # end
27
+ #
28
+ # scope = User.all
29
+ # without_value.call(scope) #=> User.all
30
+ # with_value.call(scope) #=> User.all.where(name: "Jane Doe")
31
+ #
32
+ # == Value Sanitization
33
+ #
34
+ # By default, the value is passed as-is to the Proc. You might want to format
35
+ # it or sanitize it in any other way:
36
+ #
37
+ # filter = Filter.new(
38
+ # :name,
39
+ # value: " string ",
40
+ # sanitize: ->(value) { value&.strip&.upcase }
41
+ # ) { |scope, value| scope.where(name => value) }
42
+ #
43
+ # filter.call(User.all) #=> User.all.where(name: "STRING")
44
+ #
45
+ # *NOTE*: Keep in mind that the value received by +sanitize+ might be +nil+.
46
+ #
47
+ # == Default Values
48
+ #
49
+ # Usually you want filters to run only when the user supplies a value, but
50
+ # sometimes you want the query to always be filtered in some way, with the
51
+ # user having control on the specific value of the filter.
52
+ #
53
+ # For example, a query might always return a window of time, but the user
54
+ # could choose whether that's "last week", "last month", or "last year", and
55
+ # by default you want this to be "last week".
56
+ #
57
+ # # if the user sends 30 (i.e. last 30 days), we will use that value.
58
+ # filter = Filter.new(:name, value: 30, default: 7) do |scope, value|
59
+ # scope.where("created_at > ?", value.days.ago)
60
+ # end
61
+ #
62
+ # # if the user doesn't set this filter, we will use 7 as the default value.
63
+ # filter = Filter.new(:name, value: nil, default: 7) do |scope, value|
64
+ # scope.where("created_at > ?", value.days.ago)
65
+ # end
66
+ #
67
+ # If the given default is a Proc/lambda, it will be evaluated, and the return
68
+ # value of the Proc will be used as the default:
69
+ #
70
+ # # the default value will be the current user's currency
71
+ # filter = Filter.new(:currency, default: -> { Current.user.currency })
72
+ #
73
+ # == Rendering filters
74
+ #
75
+ # To render a filter in the view, you can call its {#renderer} method, and
76
+ # pass the output to the +render+ helper:
77
+ #
78
+ # <%= render filter.renderer %>
79
+ #
80
+ # To configure how that renderer behaves, Filters accept a +rendes_with+
81
+ # option that defines how they can be rendered. This lets you render different
82
+ # widgets for each filter, where you can customize the form control used (i.e.
83
+ # a text field vs a number field vs a select box).
84
+ #
85
+ # By default, filters are rendered using a partial template named after the
86
+ # filter's class. For example, a +SelectFilter+ would be rendered in the
87
+ # +"filters/select_filter"+ partial. The partial receives a local named
88
+ # +filter+ with the filter object.
89
+ #
90
+ # Alternatively, you can pass a callable to +render_with+ that returns valid
91
+ # attributes for ActionView's +render+ method. This could be a Hash (i.e. to
92
+ # +render+ a custom partial with extra options) or it could be an object that
93
+ # responds to +render_in+.
94
+ #
95
+ # Finally, you can just pass a Class. If you do, DTB will insantiate it with a
96
+ # +filter+ keyword, and return the instance. This is useful when using
97
+ # component libraries such as ViewComponent or Phlex.
98
+ #
99
+ # class SelectFilter < DTB::Filter
100
+ # option :render_with, default: SelectFilterComponent
101
+ # end
102
+ #
103
+ # == Passing extra options to the renderer
104
+ #
105
+ # Whatever options you pass to the {#renderer} method, they will be
106
+ # forwarded to the configured renderer via {#render_with}. For example,
107
+ # given:
108
+ #
109
+ # class SelectFilter < DTB::Filter
110
+ # option :render_with, default: SelectFilterComponent
111
+ # end
112
+ #
113
+ # The following two statements are equivalent
114
+ #
115
+ # <%= render filter.renderer(class: "custom-class") %>
116
+ # <%= render SelectFilterComponent.new(filter: filter, class: "custom-class") %>
117
+ #
118
+ # == Overriding the options passed to the renderer
119
+ #
120
+ # The default options passed to the rendered are the return value of the
121
+ # {#rendering_options} method. You can always override it to customize how the
122
+ # object is passed to the renderer, or to pass other options that you always
123
+ # need to include (rather than passing them on every {#renderer}) invocation.
124
+ #
125
+ # @example Overriding the rendering options
126
+ # class AutocompleteFilter < DTB::Filter
127
+ # option :render_with, default: AutocompleteFilterComponent
128
+ #
129
+ # def rendering_options
130
+ # # super here returns `{filter: self}`
131
+ # {url: autocomplete_url}.update(super)
132
+ # end
133
+ # end
134
+ #
135
+ # @see HasFilters
136
+ class Filter < QueryBuilder
137
+ include HasOptions
138
+ include Renderable
139
+
140
+ # @!group Options
141
+
142
+ # @!attribute [rw] value
143
+ # @return [Object, nil] the user-supplied value for this filter.
144
+ option :value, required: true
145
+
146
+ # @!attribute [rw] sanitize
147
+ # @return [Proc] a Proc to sanitize the user input. Defaults to a Proc
148
+ # that returns the input value.
149
+ option :sanitize, default: IDENT, required: true
150
+
151
+ # @!attribute [rw] default
152
+ # @return [Object, Proc nil] a default value to use if the user supplies a
153
+ # blank value. If given a Proc, it will be evaluated and its return
154
+ # value used as the default.
155
+ option :default
156
+
157
+ # @!attribute [rw] render_with
158
+ # @see Renderable#render_with
159
+ option :render_with,
160
+ default: ->(filter:, **opts) {
161
+ {partial: "filters/#{filter.class.name.underscore}", locals: {filter: filter, **opts}}
162
+ },
163
+ required: true
164
+
165
+ # @!endgroup
166
+
167
+ # Applies the Proc if the value given by the user is present, and the filter
168
+ # isn't turned off in another way (e.g. via +if+/+unless+) settings.
169
+ #
170
+ # @param scope (see QueryBuilder#call)
171
+ # @return (see QueryBuilder#call)
172
+ # @raise (see QueryBuilder#call)
173
+ def call(scope)
174
+ super(scope, value).tap do
175
+ # We only want to consider this filter applied if it has a _custom_
176
+ # value set, not if it's just using the default value.
177
+ @applied = false if @applied && sanitized_value.blank?
178
+ end
179
+ end
180
+
181
+ # @return [Object, nil] the value used to decide if the filter should be
182
+ # applied. This can be a user supplied value (after sanitizing), or the
183
+ # default value, if set.
184
+ def value
185
+ sanitized_value.presence || default_value
186
+ end
187
+
188
+ # Determine the content of the +<label>+ tag that should be shown when
189
+ # rendering this filter. This will look up the translation under the
190
+ # +filters+ namespace.
191
+ #
192
+ # @return [String]
193
+ # @see QueryBuilder#i18n_lookup
194
+ def label
195
+ i18n_lookup(:filters)
196
+ end
197
+
198
+ # Determine the content of the +placeholder+ attribute that should be used
199
+ # when rendering this filter. This will look up the translation under the
200
+ # +placeholders+ namespace.
201
+ #
202
+ # @return [String]
203
+ # @see QueryBuilder#i18n_lookup
204
+ def placeholder
205
+ i18n_lookup(:placeholders, default: "")
206
+ end
207
+
208
+ # @api private
209
+ def evaluate?
210
+ value.present? && super
211
+ end
212
+
213
+ private def rendering_options
214
+ {filter: self}
215
+ end
216
+
217
+ private def default_value
218
+ if options[:default].respond_to?(:call)
219
+ evaluate(with: options[:default])
220
+ else
221
+ options[:default]
222
+ end
223
+ end
224
+
225
+ private def sanitized_value
226
+ options[:sanitize].call(options[:value])
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "query_builder_set"
4
+ require_relative "renderable"
5
+
6
+ module DTB
7
+ # Filter sets extend {QueryBuilderSet QueryBuilder sets} by adding a few
8
+ # options to help render the filters form.
9
+ #
10
+ # == Rendering the filters form
11
+ #
12
+ # Start by defining a partial for your form. Default location is
13
+ # +filters/filters+, so +app/views/filters/_filters.html.erb+ is a good place
14
+ # to start, with at least these components:
15
+ #
16
+ # <%= form_with method: :get, scope: filters.namespace, url: filters.submit_url do |form| %>
17
+ # <% filters.each do |filter| %>
18
+ # <%= render partial: filter, locals: { form: form } %>
19
+ # <% end %>
20
+ #
21
+ # <%= form.submit %>
22
+ #
23
+ # <% if filters.reset_url.present? %>
24
+ # <%= form.link_to t(".reset"), filters.reset_url, class: "btn" %>
25
+ # <% end %>
26
+ # <% end %>
27
+ #
28
+ class FilterSet < QueryBuilderSet
29
+ include Renderable
30
+
31
+ # @!group Options
32
+
33
+ # @!attribute [rw] param
34
+ # This is the name of the query string parameter used to group filters in
35
+ # the form. {HasFilters} uses this to determine which sub-Hash of the
36
+ # parameters object to use as the values for filters.
37
+ # @return [Symbol] the name of the top-level param name. Defaults to
38
+ # +:filters+.
39
+ # @see #namespace
40
+ option :param, default: :filters, required: true
41
+
42
+ # @!attribute [rw] renders_with (see Renderable#renders_with)
43
+ option :render_with, default: "filters/filters", required: true
44
+
45
+ # @!attribute [rw] submit_url
46
+ # @return [String] the URL to submit the filters form to.
47
+ option :submit_url
48
+
49
+ # @!attribute [rw] reset_url
50
+ # @return [String, nil] the URL to reset the filters form to.
51
+ option :reset_url
52
+
53
+ # @!endgroup
54
+
55
+ # The keyword to use as the namespace for all form fields when rendering the
56
+ # filters form.
57
+ #
58
+ # @example Rendering the filters form with +form_with+
59
+ # <%= form_with scope: filters.namespace, url: filters.submit_url do |form| %>
60
+ # ...
61
+ # <% end %>
62
+ #
63
+ # @example Rendering the filters form with +form_for+
64
+ # <%= form_for filters.namespace, url: filters.submit_url do |form| %>
65
+ # ...
66
+ # <% end %>
67
+ #
68
+ # @return [Symbol]
69
+ # @see #param
70
+ def namespace
71
+ options[:param]
72
+ end
73
+
74
+ def submit_url
75
+ options[:submit_url]
76
+ end
77
+
78
+ def reset_url
79
+ options[:reset_url]
80
+ end
81
+
82
+ private def rendering_options
83
+ {filters: self}
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require_relative "column"
5
+ require_relative "has_options"
6
+ require_relative "query_builder_set"
7
+
8
+ module DTB
9
+ # This mixin provides {Query Queries} with a set of column objects that can be
10
+ # used to modify the query and to render in the view. Including this module
11
+ # gives you access to the {.column} class method, which you can use to define
12
+ # columns in your query.
13
+ #
14
+ # @example (see .column)
15
+ module HasColumns
16
+ extend ActiveSupport::Concern
17
+ include HasDefaultImplementation
18
+ include BuildsDataTable
19
+ include HasOptions
20
+
21
+ included do
22
+ # @!group Options
23
+
24
+ # @!attribute [rw] default_column_type
25
+ # The default class for columns added. Defaults to {Column}.
26
+ option :default_column_type, default: Column
27
+
28
+ # @!endgroup
29
+ end
30
+
31
+ class_methods do
32
+ # Defines a new Column that will be added to this Query.
33
+ #
34
+ # @example Adding a column that references an associated resource
35
+ # column :author_id,
36
+ # ->(scope) { scope.select(:author_id).includes(:author) }
37
+ #
38
+ # @example Adding a column that doesn't modify the database query but is rendered
39
+ # column :actions, database: false
40
+ #
41
+ # @param name [Symbol]
42
+ # @param query [Proc] The {QueryBuilder} proc.
43
+ # @param type [Class<Column>] The type of column to use. Defaults to
44
+ # whatever is set as the {default_column_type}.
45
+ # @param opts [Hash] Any other options required by the +type+.
46
+ # @return [void]
47
+ def column(name, query = ->(scope) { scope.select(name) }, type: options[:default_column_type], **opts)
48
+ column_definitions << {type: type, name: name, query: query, options: opts}
49
+ end
50
+
51
+ # @api private
52
+ # @return [Array<Hash>]
53
+ def column_definitions
54
+ @column_definitions ||= []
55
+ end
56
+ end
57
+
58
+ # @return [QueryBuilderSet] the set of columns defined on this object.
59
+ def columns
60
+ return @columns if defined?(@columns)
61
+
62
+ columns = self.class.column_definitions.map do |dfn|
63
+ dfn[:type].new(dfn[:name], context: self, **dfn[:options], &dfn[:query])
64
+ end
65
+
66
+ @columns = QueryBuilderSet.new(columns)
67
+ end
68
+
69
+ # Applies all defined columns to the query being built.
70
+ #
71
+ # @return (see HasDefaultImplementation#run)
72
+ def run
73
+ columns.call(super)
74
+ end
75
+
76
+ # (see BuildsDataTable#to_data_table)
77
+ def to_data_table
78
+ super.merge(columns: columns)
79
+ end
80
+ end
81
+ end