dtb 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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