typed_eav 0.1.0 → 0.2.1

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/README.md +634 -2
  4. data/app/models/typed_eav/field/base.rb +552 -6
  5. data/app/models/typed_eav/field/currency.rb +125 -0
  6. data/app/models/typed_eav/field/file.rb +98 -0
  7. data/app/models/typed_eav/field/image.rb +152 -0
  8. data/app/models/typed_eav/field/percentage.rb +100 -0
  9. data/app/models/typed_eav/field/reference.rb +230 -0
  10. data/app/models/typed_eav/section.rb +114 -4
  11. data/app/models/typed_eav/value.rb +461 -11
  12. data/app/models/typed_eav/value_version.rb +96 -0
  13. data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
  14. data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
  15. data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
  16. data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
  17. data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
  18. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
  19. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
  20. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
  21. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
  22. data/lib/typed_eav/bulk_write.rb +147 -0
  23. data/lib/typed_eav/column_mapping.rb +46 -0
  24. data/lib/typed_eav/config.rb +215 -19
  25. data/lib/typed_eav/csv_mapper.rb +158 -0
  26. data/lib/typed_eav/currency_storage_contract.rb +46 -0
  27. data/lib/typed_eav/engine.rb +117 -0
  28. data/lib/typed_eav/event_dispatcher.rb +151 -0
  29. data/lib/typed_eav/field_storage_contract.rb +68 -0
  30. data/lib/typed_eav/has_typed_eav.rb +455 -58
  31. data/lib/typed_eav/partition.rb +64 -0
  32. data/lib/typed_eav/query_builder.rb +39 -3
  33. data/lib/typed_eav/registry.rb +48 -9
  34. data/lib/typed_eav/schema_portability.rb +250 -0
  35. data/lib/typed_eav/version.rb +1 -1
  36. data/lib/typed_eav/versioned.rb +73 -0
  37. data/lib/typed_eav/versioning/subscriber.rb +161 -0
  38. data/lib/typed_eav/versioning.rb +94 -0
  39. data/lib/typed_eav.rb +180 -12
  40. metadata +36 -2
@@ -31,7 +31,7 @@ class TypedEAVController < ApplicationController
31
31
  before_action :set_field, only: %i[show edit update destroy]
32
32
 
33
33
  def index
34
- @fields = scoped_fields.order(:entity_type, :scope, :sort_order, :name)
34
+ @fields = scoped_fields.order(:entity_type, :scope, :parent_scope, :sort_order, :name)
35
35
  end
36
36
 
37
37
  def show; end
@@ -45,19 +45,20 @@ class TypedEAVController < ApplicationController
45
45
 
46
46
  def create
47
47
  type_class = resolve_type_class(params.dig(:typed_eav_field, :field_type) || params[:type])
48
- ambient = ensure_scope!
48
+ partition = ensure_partition!
49
49
  attrs = field_params(type_class, creating: true)
50
- attrs[:scope] = ambient
50
+ attrs[:scope] = partition[:scope]
51
+ attrs[:parent_scope] = partition[:parent_scope]
51
52
  attrs[:section_id] = verified_section_id(
52
53
  params.dig(:typed_eav_field, :section_id),
53
54
  attrs[:entity_type],
54
- ambient
55
+ partition,
55
56
  )
56
57
  @field = type_class.new(attrs)
57
58
 
58
59
  if @field.save
59
60
  redirect_to edit_typed_eav_field_path(@field), status: :see_other,
60
- notice: "Field created."
61
+ notice: "Field created."
61
62
  else
62
63
  render :new, status: :unprocessable_content
63
64
  end
@@ -68,12 +69,12 @@ class TypedEAVController < ApplicationController
68
69
  attrs[:section_id] = verified_section_id(
69
70
  params.dig(:typed_eav_field, :section_id),
70
71
  @field.entity_type,
71
- @field.scope
72
+ field_partition(@field),
72
73
  )
73
74
 
74
75
  if @field.update(attrs)
75
76
  redirect_to edit_typed_eav_field_path(@field), status: :see_other,
76
- notice: "Field updated."
77
+ notice: "Field updated."
77
78
  else
78
79
  render :edit, status: :unprocessable_content
79
80
  end
@@ -94,7 +95,7 @@ class TypedEAVController < ApplicationController
94
95
  @field.field_options.create!(
95
96
  label: params[:option_label],
96
97
  value: params[:option_value],
97
- sort_order: next_order
98
+ sort_order: next_order,
98
99
  )
99
100
  end
100
101
  redirect_to edit_typed_eav_field_path(@field), status: :see_other
@@ -120,73 +121,77 @@ class TypedEAVController < ApplicationController
120
121
  @field = scoped_fields.find(params[:id])
121
122
  end
122
123
 
123
- # Base relation filtered to the current ambient scope. Fields with
124
- # scope=NULL (global fields visible to all partitions) are always
125
- # included. Fail-closed semantics:
126
- # - unscoped? -> ALL fields across every scope (admin override)
127
- # - scope present -> fields for that scope UNION globals
128
- # - scope nil, require_scope=true -> raise TypedEAV::ScopeRequired
129
- # - scope nil, require_scope=false -> globals only (scope=NULL); never
130
- # leaks other tenants' rows.
124
+ # Base relation filtered through TypedEAV's partition seam. Fields with the
125
+ # global tuple `(scope=NULL, parent_scope=NULL)` are visible to every
126
+ # partition. Fail-closed semantics:
127
+ # - unscoped? -> ALL fields across every partition
128
+ # - [scope, parent_scope] present -> global + scope + full-tuple fields
129
+ # - no tuple, require_scope=true -> raise TypedEAV::ScopeRequired
130
+ # - no tuple, require_scope=false -> global fields only; never leaks
131
+ # other tenants' rows.
131
132
  # Use `TypedEAV.with_scope(value) { }` or configure
132
- # `TypedEAV.config.scope_resolver` to set the scope.
133
+ # `TypedEAV.config.scope_resolver` to set the tuple.
133
134
  def scoped_fields
134
- # Inside `TypedEAV.unscoped { }` the caller has explicitly opted into
135
- # cross-scope visibility (admin/migration paths). Skip the scope filter
136
- # entirely so the scaffold remains usable instead of raising under
137
- # require_scope=true.
138
- return TypedEAV::Field::Base.all if TypedEAV.unscoped?
135
+ partition = current_partition!
136
+ return TypedEAV::Field::Base.all if partition[:mode] == :all_partitions
139
137
 
140
- scope = TypedEAV.current_scope
141
- return TypedEAV::Field::Base.where(scope: [scope, nil]) if scope
138
+ TypedEAV::Field::Base.where(id: visible_field_ids(partition))
139
+ end
142
140
 
143
- if TypedEAV.config.require_scope
144
- raise TypedEAV::ScopeRequired,
145
- "TypedEAV.current_scope is nil and require_scope is enabled; " \
146
- "wrap the request in TypedEAV.with_scope(value) { } or configure " \
147
- "TypedEAV.config.scope_resolver."
148
- end
141
+ # Resolve the ambient tuple for writes. Mirrors `scoped_fields` semantics:
142
+ # - unscoped? -> returns the global tuple
143
+ # - [scope, parent_scope] present -> returns that tuple
144
+ # - no tuple, require_scope=true -> raises TypedEAV::ScopeRequired
145
+ # - no tuple, require_scope=false -> returns the global tuple
146
+ def ensure_partition!
147
+ current_partition!.slice(:scope, :parent_scope)
148
+ end
149
149
 
150
- TypedEAV::Field::Base.where(scope: nil)
150
+ # Server-side verification that the requested section exists within the same
151
+ # partition tuple as the field being created or updated, including global
152
+ # sections visible to that tuple.
153
+ # Returns nil if `id` is blank. Raises ActiveRecord::RecordNotFound (Rails
154
+ # renders 404) if the id does not belong to a section the caller can see,
155
+ # blocking cross-tenant assignment via a forged section_id.
156
+ def verified_section_id(id, entity_type, partition)
157
+ return nil if id.blank?
158
+
159
+ TypedEAV::Partition.find_visible_section!(
160
+ id,
161
+ entity_type: entity_type,
162
+ **partition,
163
+ ).id
151
164
  end
152
165
 
153
- # Resolve the ambient scope for writes. Mirrors `scoped_fields` semantics:
154
- # - unscoped? -> returns nil (admin override; new field is global)
155
- # - scope present -> returns the scope value
156
- # - scope nil, require_scope=true -> raises TypedEAV::ScopeRequired
157
- # - scope nil, require_scope=false -> returns nil (global-field creation)
158
- def ensure_scope!
159
- # Inside `TypedEAV.unscoped { }` we deliberately bypass the require_scope
160
- # guard so admin tools can create global fields without first declaring an
161
- # ambient scope. Wrap in `with_scope` to write into a specific tenant.
162
- return nil if TypedEAV.unscoped?
166
+ def current_partition!
167
+ return { scope: nil, parent_scope: nil, mode: :all_partitions } if TypedEAV.unscoped?
163
168
 
164
- scope = TypedEAV.current_scope
165
- return scope if scope
169
+ tuple = TypedEAV.current_scope
170
+ return { scope: tuple.first, parent_scope: tuple.last, mode: :partition } if tuple
166
171
 
167
172
  if TypedEAV.config.require_scope
168
173
  raise TypedEAV::ScopeRequired,
169
- "TypedEAV.current_scope is nil and require_scope is enabled; " \
170
- "wrap the request in TypedEAV.with_scope(value) { } or configure " \
171
- "TypedEAV.config.scope_resolver."
174
+ "TypedEAV.current_scope is nil and require_scope is enabled; " \
175
+ "wrap the request in TypedEAV.with_scope(value) { } or configure " \
176
+ "TypedEAV.config.scope_resolver."
172
177
  end
173
178
 
174
- nil
179
+ { scope: nil, parent_scope: nil, mode: :partition }
175
180
  end
176
181
 
177
- # Server-side verification that the requested section exists within the
178
- # caller's entity_type + scope (or globals). `Section.for_entity` unions
179
- # scope=NULL globals with the scoped rows, matching field visibility.
180
- # Returns nil if `id` is blank. Raises ActiveRecord::RecordNotFound (Rails
181
- # renders 404) if the id does not belong to a section the caller can see,
182
- # blocking cross-tenant assignment via a forged section_id.
183
- def verified_section_id(id, entity_type, scope)
184
- return nil if id.blank?
185
- TypedEAV::Section.for_entity(entity_type, scope: scope).find(id).id
182
+ def field_partition(field)
183
+ { scope: field.scope, parent_scope: field.parent_scope, mode: :partition }
184
+ end
185
+
186
+ def visible_field_ids(partition)
187
+ TypedEAV::Field::Base.distinct.pluck(:entity_type).flat_map do |entity_type|
188
+ TypedEAV::Partition.visible_fields(entity_type: entity_type, **partition).pluck(:id)
189
+ end
186
190
  end
187
191
 
188
192
  def resolve_type_class(type_name)
189
193
  return TypedEAV::Field::Text if type_name.blank?
194
+
190
195
  TypedEAV.config.field_class_for(type_name)
191
196
  rescue ArgumentError
192
197
  TypedEAV::Field::Text
@@ -195,11 +200,12 @@ class TypedEAVController < ApplicationController
195
200
  # Data-driven permitted params based on what the field type exposes via
196
201
  # store_accessor. Much cleaner than a massive case statement per type.
197
202
  #
198
- # NOTE: `scope` and `section_id` are intentionally NOT in the permit list.
199
- # `scope` is derived server-side from the ambient scope in `create`; a
203
+ # NOTE: `scope`, `parent_scope`, and `section_id` are intentionally NOT in
204
+ # the permit list. `scope` and `parent_scope` are derived server-side from
205
+ # the ambient partition tuple in `create`; a
200
206
  # client-supplied value would let any authenticated user write into another
201
- # tenant's partition. `section_id` is verified against a scoped Section
202
- # lookup via `verified_section_id` on both create and update.
207
+ # tenant's partition. `section_id` is verified against the partition seam via
208
+ # `verified_section_id` on both create and update.
203
209
  def field_params(type_class, creating:)
204
210
  base = %i[name required sort_order]
205
211
  base += %i[entity_type] if creating
@@ -208,11 +214,11 @@ class TypedEAVController < ApplicationController
208
214
  option_keys = option_keys_for(type_class)
209
215
 
210
216
  # Default value is scalar for most types, array for array types
211
- if type_class.method_defined?(:array_field?) && type_class.allocate.array_field?
212
- permitted = base + option_keys + [default_value: []]
213
- else
214
- permitted = base + option_keys + %i[default_value]
215
- end
217
+ permitted = if type_class.method_defined?(:array_field?) && type_class.allocate.array_field?
218
+ base + option_keys + [{ default_value: [] }]
219
+ else
220
+ base + option_keys + %i[default_value]
221
+ end
216
222
 
217
223
  params.require(:typed_eav_field).permit(*permitted).tap do |attrs|
218
224
  attrs.transform_values! do |value|
@@ -224,6 +230,7 @@ class TypedEAVController < ApplicationController
224
230
  # Introspect which option keys the field type exposes
225
231
  def option_keys_for(type_class)
226
232
  return [] unless type_class.respond_to?(:stored_attributes)
233
+
227
234
  (type_class.stored_attributes[:options] || []).map(&:to_sym)
228
235
  rescue StandardError
229
236
  []
@@ -38,11 +38,21 @@
38
38
 
39
39
  <div>
40
40
  <label>Scope</label>
41
- <%# The controller derives scope from the ambient value (TypedEAV.current_scope)
42
- and never permits it from params. Display it read-only so users can see which
41
+ <%# The controller derives scope from the ambient partition tuple and never
42
+ permits it from params. Display it read-only so users can see which
43
43
  partition this field belongs to without implying they can change it. %>
44
+ <% current_partition = field.new_record? ? TypedEAV.current_scope : nil %>
45
+ <% current_partition_scope = current_partition&.first %>
46
+ <% current_partition_parent_scope = current_partition&.last %>
44
47
  <input type="text"
45
- value="<%= field.scope.presence || (field.new_record? ? (TypedEAV.current_scope || "Global (all scopes)") : "Global (all scopes)") %>"
48
+ value="<%= field.scope.presence || (current_partition_scope.presence || "Global (all scopes)") %>"
49
+ disabled readonly />
50
+ </div>
51
+
52
+ <div>
53
+ <label>Parent Scope</label>
54
+ <input type="text"
55
+ value="<%= field.parent_scope.presence || (current_partition_parent_scope.presence || "Global (all parent scopes)") %>"
46
56
  disabled readonly />
47
57
  </div>
48
58
 
@@ -17,6 +17,7 @@
17
17
  <th>Name</th>
18
18
  <th>Required</th>
19
19
  <th>Scope</th>
20
+ <th>Parent Scope</th>
20
21
  <th>Options</th>
21
22
  <th>Default</th>
22
23
  <th></th>
@@ -32,6 +33,7 @@
32
33
  <td><%= field.name %></td>
33
34
  <td><%= field.required? ? "Yes" : "" %></td>
34
35
  <td><code><%= field.scope.inspect %></code></td>
36
+ <td><code><%= field.parent_scope.inspect %></code></td>
35
37
  <td><code><%= field.options %></code></td>
36
38
  <td><code><%= field.default_value.inspect %></code></td>
37
39
  <td><%= button_to "Delete", typed_eav_field_path(field), method: :delete,
@@ -19,6 +19,9 @@
19
19
  <dt>Scope</dt>
20
20
  <dd><code><%= @field.scope.inspect %></code></dd>
21
21
 
22
+ <dt>Parent Scope</dt>
23
+ <dd><code><%= @field.parent_scope.inspect %></code></dd>
24
+
22
25
  <dt>Options</dt>
23
26
  <dd><code><%= @field.options %></code></dd>
24
27
 
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Internal executor for host-class bulk typed-value writes.
5
+ #
6
+ # Host models keep the public `bulk_set_typed_eav_values` API; this module
7
+ # owns the transaction shape, savepoint isolation, error aggregation, field
8
+ # name resolution delegation, and version-group stamping.
9
+ module BulkWrite
10
+ class << self
11
+ def execute(host_class:, records:, values_by_field_name:, version_grouping: :default)
12
+ validate_inputs!(records, values_by_field_name, version_grouping)
13
+
14
+ records = records.to_a
15
+ return { successes: [], errors_by_record: {} } if records.empty?
16
+
17
+ validate_record_classes!(host_class, records)
18
+
19
+ effective_grouping = resolve_grouping(version_grouping)
20
+ vbn = values_by_field_name.transform_keys(&:to_s)
21
+ field_uuids = effective_grouping == :per_field ? vbn.keys.index_with { SecureRandom.uuid } : nil
22
+
23
+ execute_records(records, vbn, effective_grouping, field_uuids)
24
+ end
25
+
26
+ def apply_record_save(record:, vbn:, effective_grouping:, uuids:, accumulator:)
27
+ push_uuid = case effective_grouping
28
+ when :per_record then uuids[:record]
29
+ when :per_field then uuids[:field].values.first
30
+ end
31
+
32
+ do_save = lambda do
33
+ record.typed_eav_attributes = vbn.map { |name, value| { name: name, value: value } }
34
+ stamp_pending_version_group_ids(record, effective_grouping, uuids)
35
+
36
+ if record.save
37
+ accumulator[:successes] << record
38
+ else
39
+ accumulator[:errors_by_record][record] = record.errors.messages.transform_keys(&:to_s)
40
+ raise ActiveRecord::Rollback
41
+ end
42
+ end
43
+
44
+ if push_uuid
45
+ TypedEAV.with_context(version_group_id: push_uuid, &do_save)
46
+ else
47
+ do_save.call
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def execute_records(records, vbn, effective_grouping, field_uuids)
54
+ successes = []
55
+ errors_by_record = {}
56
+
57
+ with_bulk_definitions_memo do
58
+ ActiveRecord::Base.cache do
59
+ ActiveRecord::Base.transaction do
60
+ records.each do |record|
61
+ record_uuid = effective_grouping == :per_record ? SecureRandom.uuid : nil
62
+
63
+ ActiveRecord::Base.transaction(requires_new: true) do
64
+ apply_record_save(
65
+ record: record,
66
+ vbn: vbn,
67
+ effective_grouping: effective_grouping,
68
+ uuids: { record: record_uuid, field: field_uuids },
69
+ accumulator: { successes: successes, errors_by_record: errors_by_record },
70
+ )
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ { successes: successes, errors_by_record: errors_by_record }
78
+ end
79
+
80
+ def validate_inputs!(records, values_by_field_name, version_grouping)
81
+ if records.nil?
82
+ raise ArgumentError,
83
+ "bulk_set_typed_eav_values requires an Enumerable of records, got nil"
84
+ end
85
+
86
+ unless values_by_field_name.is_a?(Hash)
87
+ raise ArgumentError,
88
+ "bulk_set_typed_eav_values requires a Hash of values_by_field_name, " \
89
+ "got #{values_by_field_name.class}"
90
+ end
91
+
92
+ valid_grouping = %i[default per_record per_field none]
93
+ unless valid_grouping.include?(version_grouping)
94
+ raise ArgumentError,
95
+ "version_grouping: #{version_grouping.inspect} is not supported. " \
96
+ "Supported values: #{valid_grouping.map(&:inspect).join(", ")}."
97
+ end
98
+
99
+ return unless %i[per_record per_field].include?(version_grouping) && !TypedEAV.config.versioning
100
+
101
+ raise ArgumentError,
102
+ "version_grouping: #{version_grouping.inspect} was passed but versioning is disabled. " \
103
+ "Set TypedEAV.config.versioning = true in your initializer, or pass " \
104
+ "version_grouping: :none to opt out explicitly, or omit the kwarg to silently no-op."
105
+ end
106
+
107
+ def validate_record_classes!(host_class, records)
108
+ return if records.all?(host_class)
109
+
110
+ classes = records.map { |record| record.class.name }.uniq
111
+ raise ArgumentError,
112
+ "bulk_set_typed_eav_values expects records of class #{host_class.name} (or its subclasses); " \
113
+ "got mixed classes: #{classes.join(", ")}"
114
+ end
115
+
116
+ def resolve_grouping(version_grouping)
117
+ if version_grouping == :default
118
+ TypedEAV.config.versioning ? :per_record : :none
119
+ else
120
+ version_grouping
121
+ end
122
+ end
123
+
124
+ def with_bulk_definitions_memo
125
+ prior_memo = Thread.current[:typed_eav_bulk_defs_memo]
126
+ Thread.current[:typed_eav_bulk_defs_memo] = {}
127
+ yield
128
+ ensure
129
+ Thread.current[:typed_eav_bulk_defs_memo] = prior_memo
130
+ end
131
+
132
+ def stamp_pending_version_group_ids(record, effective_grouping, uuids)
133
+ return if effective_grouping == :none
134
+
135
+ record.typed_values.each do |value|
136
+ next unless value.new_record? || value.changed?
137
+
138
+ uuid = case effective_grouping
139
+ when :per_record then uuids[:record]
140
+ when :per_field then uuids[:field][value.field&.name]
141
+ end
142
+ value.pending_version_group_id = uuid if uuid
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -44,6 +44,52 @@ module TypedEAV
44
44
  @value_column = column_name.to_sym
45
45
  end
46
46
 
47
+ # All typed columns this field type stores its value across. Defaults to
48
+ # `[value_column]` for single-cell types — every built-in field type as
49
+ # of Phase 04 returns a one-element Array via this default. Phase 05
50
+ # Currency overrides this to return `[:decimal_value, :string_value]`
51
+ # (two-cell type: amount + currency code).
52
+ #
53
+ # Phase 04 versioning's snapshot logic iterates `value_columns` to build
54
+ # the jsonb {col_name => value} hash. Phase 04 also fixes the
55
+ # `Value#_dispatch_value_change_update` filter to use `value_columns.any?`
56
+ # instead of the singular `value_column` — forward-compatible with
57
+ # multi-cell types so a Currency change on the second cell alone still
58
+ # fires the :update event (Scout §3 / Discrepancy D3).
59
+ #
60
+ # The default delegates to `value_column` (the singular), so subclasses
61
+ # that haven't declared `value_column :col_name` raise the same
62
+ # NotImplementedError they always did when `value_columns` is invoked.
63
+ # This matches the existing contract — `value_column`'s raise is the
64
+ # "subclass must declare its column" enforcement; `value_columns`
65
+ # inherits that enforcement transparently.
66
+ def value_columns
67
+ [value_column]
68
+ end
69
+
70
+ # Which physical column this operator acts on. Defaults to `value_column`
71
+ # for single-cell field types — every built-in type as of Phase 04
72
+ # inherits the default and returns the same column for every supported
73
+ # operator.
74
+ #
75
+ # Multi-cell field types (Phase 05: Currency) override this to route
76
+ # different operators to different columns. For example, Currency stores
77
+ # `{amount, currency}` across `decimal_value` + `string_value`; the
78
+ # `:eq` operator targets amount (`decimal_value`); the `:currency_eq`
79
+ # operator targets currency code (`string_value`).
80
+ #
81
+ # Called from `QueryBuilder.filter` AFTER the
82
+ # `supported_operators.include?(operator)` validation gate, so this
83
+ # method is only invoked with operators the field explicitly supports.
84
+ # Subclasses overriding for unsupported operators is a programming
85
+ # error caught by the gate, not by this method.
86
+ #
87
+ # The unused `operator` parameter is intentional in the default —
88
+ # subclasses use it to dispatch.
89
+ def operator_column(_operator)
90
+ value_column
91
+ end
92
+
47
93
  # All operators this field type supports for querying.
48
94
  # Subclasses can override to restrict or extend.
49
95
  def supported_operators