Module: TypedEAV::HasTypedEAV::ClassQueryMethods

Defined in:
lib/typed_eav/has_typed_eav.rb

Overview

──────────────────────────────────────────────────Class-level query methods ──────────────────────────────────────────────────

Constant Summary collapse

UNSET_SCOPE =

Sentinel for the ‘scope:` kwarg default. Distinguishes “kwarg not passed → resolve from ambient” (UNSET_SCOPE) from “explicitly nil →filter to global-only fields” (preserves prior behavior).

Object.new.freeze
ALL_SCOPES =

Sentinel returned by ‘resolve_scope` inside an `unscoped { }` block. Signals the caller to skip the scope filter entirely (return fields across all partitions, not just global).

Object.new.freeze

Instance Method Summary collapse

Instance Method Details

#typed_eav_definitions(scope: UNSET_SCOPE) ⇒ Object

Returns field definitions for this entity type.

‘scope:` behavior:

- omitted        → resolve from ambient (`with_scope` → resolver → raise/nil)
- passed a value → use verbatim (explicit override; admin/test path)
- passed nil     → filter to global-only fields (prior behavior preserved)


230
231
232
233
234
235
236
237
# File 'lib/typed_eav/has_typed_eav.rb', line 230

def typed_eav_definitions(scope: UNSET_SCOPE)
  resolved = resolve_scope(scope)
  if resolved.equal?(ALL_SCOPES)
    TypedEAV::Field::Base.where(entity_type: name)
  else
    TypedEAV::Field::Base.for_entity(name, scope: resolved)
  end
end

#where_typed_eav(*filters, scope: UNSET_SCOPE) ⇒ Object

Query by custom field values. Accepts an array of filter hashes or a hash of hashes (from form params).

Each filter needs:

:name or :n    - the field name
:op or :operator - the operator (default: :eq)
:value or :v   - the comparison value

Contact.where_typed_eav(
  { name: "age", op: :gt, value: 21 },
  { name: "city", value: "Portland" }   # op defaults to :eq
)

rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity – input normalization + multimap branch + filter dispatch genuinely belong together; splitting hurts readability of the scope-collision logic.



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/typed_eav/has_typed_eav.rb', line 120

def where_typed_eav(*filters, scope: UNSET_SCOPE)
  # Normalize input: accept splat args, a single array, a single filter hash,
  # a hash-of-hashes (form params), or ActionController::Parameters.
  filters = filters.map { |f| f.respond_to?(:to_unsafe_h) ? f.to_unsafe_h : f }

  if filters.size == 1
    inner = filters.first
    inner = inner.to_unsafe_h if inner.respond_to?(:to_unsafe_h)

    if inner.is_a?(Array)
      filters = inner
    elsif inner.is_a?(Hash)
      # A single filter hash has keys like :name/:n, :op, :value/:v.
      # A hash-of-hashes (form params) has values that are all hashes.
      filter_keys = %i[name n op operator value v].map(&:to_s)
      filters = if inner.keys.any? { |k| filter_keys.include?(k.to_s) }
                  [inner]
                else
                  inner.values
                end
    end
  end

  filters = Array(filters)

  # Resolve the scope once so we can branch on whether we're inside
  # `TypedEAV.unscoped { }` (ALL_SCOPES) or a normal single-scope
  # query. Under ALL_SCOPES the same name can legitimately appear
  # across multiple tenant partitions; collapsing to one definition
  # would silently drop all but one tenant's matches. See the
  # multimap branch below.
  resolved = resolve_scope(scope)
  all_scopes = resolved.equal?(ALL_SCOPES)

  defs = if all_scopes
           TypedEAV::Field::Base.where(entity_type: name)
         else
           TypedEAV::Field::Base.for_entity(name, scope: resolved)
         end

  if all_scopes
    fields_multimap = HasTypedEAV.definitions_multimap_by_name(defs)

    filters.inject(all) do |query, filter|
      filter = filter.to_h.with_indifferent_access

      name     = filter[:n] || filter[:name]
      operator = (filter[:op] || filter[:operator] || :eq).to_sym
      value    = filter.key?(:v) ? filter[:v] : filter[:value]

      matching_fields = fields_multimap[name.to_s]
      unless matching_fields&.any?
        raise ArgumentError, "Unknown typed field '#{name}' for #{self.name}. " \
                             "Available fields: #{fields_multimap.keys.join(", ")}"
      end

      # OR-across all field_ids that share this name (across tenants),
      # while preserving AND between filters via the chained `.where`.
      # Use the underlying Value scope (`.filter(...)`) and pluck
      # entity_ids — `entity_ids` returns a relation, and pluck collapses
      # it to a plain integer array we can union across tenants.
      union_ids = matching_fields.flat_map do |f|
        TypedEAV::QueryBuilder.filter(f, operator, value).pluck(:entity_id)
      end.uniq

      query.where(id: union_ids)
    end
  else
    fields_by_name = HasTypedEAV.definitions_by_name(defs)

    filters.inject(all) do |query, filter|
      filter = filter.to_h.with_indifferent_access

      name     = filter[:n] || filter[:name]
      operator = (filter[:op] || filter[:operator] || :eq).to_sym
      value    = filter.key?(:v) ? filter[:v] : filter[:value]

      field = fields_by_name[name.to_s]
      unless field
        raise ArgumentError, "Unknown typed field '#{name}' for #{self.name}. " \
                             "Available fields: #{fields_by_name.keys.join(", ")}"
      end

      matching_ids = TypedEAV::QueryBuilder.entity_ids(field, operator, value)
      query.where(id: matching_ids)
    end
  end
end

#with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE) ⇒ Object

Shorthand for single-field queries.

Contact.with_field("age", :gt, 21)
Contact.with_field("active", true)      # op defaults to :eq
Contact.with_field("name", :contains, "smith")


215
216
217
218
219
220
221
222
# File 'lib/typed_eav/has_typed_eav.rb', line 215

def with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE)
  if value.nil? && !operator_or_value.is_a?(Symbol)
    # Two-arg form: with_field("name", "value") implies :eq
    where_typed_eav({ name: name, op: :eq, value: operator_or_value }, scope: scope)
  else
    where_typed_eav({ name: name, op: operator_or_value, value: value }, scope: scope)
  end
end