Module: OpenLoam::CustomFieldIndex
- Defined in:
- lib/open_loam/custom_field_index.rb
Overview
The read-model index for custom fields (OpenLoam::CustomFieldValue) — a typed-EAV projection that makes custom-field filter/sort/search index-backed.
OpenLoam::CustomFieldIndex.filter(DamageReport, "severity", "eq", "critical") # a relation
OpenLoam::CustomFieldIndex.order(DamageReport, "severity", :asc)
OpenLoam::CustomFieldIndex.reindex(DamageReport) # rebuild
SAFETY: field_key must name a real OpenLoam::FieldDefinition (no arbitrary
keys); values are cast to the declared type; queries are tenant-scoped
throughout (index rows carry tenant_id, the model relation is default-scoped),
so a filter can't reach across tenants. Encrypted data lives in real columns,
never in a custom field — nothing encrypted is projected here.
Constant Summary collapse
- OPS =
%w[eq neq gt gte lt lte contains present blank].freeze
- NUMERIC_PREDICATES =
Numeric comparisons hit the indexed value_number column. Built through Arel rather than an interpolated fragment:
opreaches here from params, and a comparison operator cannot be a bound parameter, so the only safe form is one that never becomes a string. { "gt" => :gt, "gte" => :gteq, "lt" => :lt, "lte" => :lteq }.freeze
Class Method Summary collapse
-
.base(model) ⇒ Object
--- internals ---.
- .clear_pending(tenant_id, model_name) ⇒ Object
-
.coverage(model, field_key) ⇒ Object
How complete the index is for a field: how many live records HAVE a value (the authoritative JSON column) vs how many index rows exist.
- .covered?(model, field_key) ⇒ Boolean
- .expected(model, field_key) ⇒ Object
-
.filter(model, field_key, op, value = nil) ⇒ Object
--- query API ---.
-
.index_filter(model, field_key, op, value) ⇒ Object
The index-backed relation (used when coverage is complete).
- .index_type(model_or_class) ⇒ Object
- .indexed(model, field_key) ⇒ Object
-
.json_filter(model, field_key, op, value) ⇒ Object
Authoritative correct filter over the JSON column — DB-agnostic (reads each record's cast custom_field), used only as the fallback when the index is incomplete.
- .json_match?(record, field_key, op, value) ⇒ Boolean
- .numeric(rows, value, op) ⇒ Object
-
.order(model, field_key, dir = :asc) ⇒ Object
Order a model's records by an indexed custom field (value_text — good for dictionary/string fields, the common case; numeric-aware ordering is a follow-up).
- .partial=(value) ⇒ Object
-
.partial? ⇒ Boolean
Was the last filter/order served over an incomplete index? The admin surfaces this as an honest "results may be incomplete, reindexing…".
- .pending_reindex ⇒ Object
- .predicate(rows, op, value) ⇒ Object
-
.project(record) ⇒ Object
Re-project one record's custom fields (delete + insert).
- .refuse_unknown_field!(model, field_key) ⇒ Object
-
.refuse_unreadable_field!(model, field_key) ⇒ Object
THE oracle guard: filtering or sorting on a custom field the current role may not read would let a user infer restricted values by observing which records match.
- .reindex(model) ⇒ Object
- .remove(record) ⇒ Object
- .reset_pending! ⇒ Object
- .row_for(record, definition, type) ⇒ Object
- .sanitize_like(value) ⇒ Object
-
.schedule_reindex(model) ⇒ Object
Enqueue a background reindex to heal a gap — DEDUPED so a hot gappy field doesn't enqueue one per request.
Class Method Details
.base(model) ⇒ Object
--- internals ---
190 191 192 |
# File 'lib/open_loam/custom_field_index.rb', line 190 def base(model) OpenLoam::CustomFieldValue.where(indexable_type: index_type(model)) # tenant-scoped by default_scope end |
.clear_pending(tenant_id, model_name) ⇒ Object
153 154 155 |
# File 'lib/open_loam/custom_field_index.rb', line 153 def clear_pending(tenant_id, model_name) pending_reindex.delete([ tenant_id, model_name ]) end |
.coverage(model, field_key) ⇒ Object
How complete the index is for a field: how many live records HAVE a value (the authoritative JSON column) vs how many index rows exist. The trust signal — "9,980 of 10,000 indexed" tells an operator whether the index is complete or drifting (legacy data, a bulk write that bypassed the hook, a field def added before a backfill). Counting expected does a JSON pass, so it's a periodic READOUT, never a per-row query cost.
109 110 111 112 113 114 115 |
# File 'lib/open_loam/custom_field_index.rb', line 109 def coverage(model, field_key) field_key = field_key.to_s exp = expected(model, field_key) got = indexed(model, field_key) { field_key: field_key, expected: exp, indexed: got, complete: got >= exp, ratio: exp.zero? ? 1.0 : (got.to_f / exp) } end |
.covered?(model, field_key) ⇒ Boolean
117 118 119 |
# File 'lib/open_loam/custom_field_index.rb', line 117 def covered?(model, field_key) coverage(model, field_key)[:complete] end |
.expected(model, field_key) ⇒ Object
125 126 127 128 129 130 |
# File 'lib/open_loam/custom_field_index.rb', line 125 def expected(model, field_key) return 0 unless model.column_names.include?("custom_fields") key = field_key.to_s model.pluck(:custom_fields).count { |cf| cf.is_a?(Hash) && cf[key].to_s.strip != "" } end |
.filter(model, field_key, op, value = nil) ⇒ Object
--- query API ---
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/open_loam/custom_field_index.rb', line 46 def filter(model, field_key, op, value = nil) field_key = field_key.to_s refuse_unknown_field!(model, field_key) refuse_unreadable_field!(model, field_key) raise OpenLoam::Error, "unknown custom-field op #{op.inspect}" unless OPS.include?(op.to_s) if covered?(model, field_key) self.partial = false index_filter(model, field_key, op, value) else # CORRECTNESS OVER SPEED: the index is known-incomplete for this field # (coverage gap), so serve an authoritative JSON-scan result — never a # silently-partial index set — AND enqueue a background reindex (deduped) # to heal the gap so subsequent queries are fast again. `partial?` lets # the caller surface an honest "results may be incomplete, reindexing…". self.partial = true schedule_reindex(model) json_filter(model, field_key, op, value) end end |
.index_filter(model, field_key, op, value) ⇒ Object
The index-backed relation (used when coverage is complete).
68 69 70 71 72 73 74 75 76 77 |
# File 'lib/open_loam/custom_field_index.rb', line 68 def index_filter(model, field_key, op, value) rows = base(model).where(field_key: field_key) if op.to_s == "blank" present_ids = rows.where.not(value_text: [ nil, "" ]).select(:indexable_id) return model.where.not(id: present_ids) end model.where(id: predicate(rows, op.to_s, value).select(:indexable_id)) end |
.index_type(model_or_class) ⇒ Object
194 195 196 197 |
# File 'lib/open_loam/custom_field_index.rb', line 194 def index_type(model_or_class) klass = model_or_class.is_a?(Class) ? model_or_class : model_or_class.class klass.base_class.name end |
.indexed(model, field_key) ⇒ Object
121 122 123 |
# File 'lib/open_loam/custom_field_index.rb', line 121 def indexed(model, field_key) base(model).where(field_key: field_key.to_s).where.not(value_text: [ nil, "" ]).count end |
.json_filter(model, field_key, op, value) ⇒ Object
Authoritative correct filter over the JSON column — DB-agnostic (reads each record's cast custom_field), used only as the fallback when the index is incomplete. Slow (a scan), which is exactly why the heal runs.
168 169 170 171 |
# File 'lib/open_loam/custom_field_index.rb', line 168 def json_filter(model, field_key, op, value) ids = model.find_each.select { |record| json_match?(record, field_key, op, value) }.map(&:id) model.where(id: ids) end |
.json_match?(record, field_key, op, value) ⇒ Boolean
173 174 175 176 177 178 179 180 181 182 183 184 185 186 |
# File 'lib/open_loam/custom_field_index.rb', line 173 def json_match?(record, field_key, op, value) actual = (record.custom_field(field_key) rescue nil) case op.to_s when "eq" then actual.to_s == value.to_s when "neq" then actual.to_s != value.to_s when "present" then actual.to_s.strip != "" when "blank" then actual.to_s.strip == "" when "contains" then actual.to_s.include?(value.to_s) when "gt" then actual.to_f > value.to_f when "gte" then actual.to_f >= value.to_f when "lt" then actual.to_f < value.to_f when "lte" then actual.to_f <= value.to_f end end |
.numeric(rows, value, op) ⇒ Object
215 216 217 218 |
# File 'lib/open_loam/custom_field_index.rb', line 215 def numeric(rows, value, op) column = OpenLoam::CustomFieldValue.arel_table[:value_number] rows.where(column.public_send(NUMERIC_PREDICATES.fetch(op), value.to_f)) end |
.order(model, field_key, dir = :asc) ⇒ Object
Order a model's records by an indexed custom field (value_text — good for dictionary/string fields, the common case; numeric-aware ordering is a follow-up). Records with no value sort last (LEFT JOIN + NULLs).
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/open_loam/custom_field_index.rb', line 82 def order(model, field_key, dir = :asc) refuse_unknown_field!(model, field_key.to_s) refuse_unreadable_field!(model, field_key.to_s) # The LEFT JOIN keeps EVERY record (un-indexed ones sort as NULL), so an # incomplete index mis-orders the gap but never drops a row. Flag it and # heal in the background. unless covered?(model, field_key.to_s) self.partial = true schedule_reindex(model) end table = OpenLoam::CustomFieldValue.table_name conn = model.connection join = "LEFT JOIN #{table} ON #{table}.indexable_type = #{conn.quote(index_type(model))} " \ "AND #{table}.indexable_id = #{model.table_name}.id " \ "AND #{table}.field_key = #{conn.quote(field_key.to_s)} " \ "AND #{table}.tenant_id = #{conn.quote(OpenLoam.tenant!.id)}" model.joins(join).order(Arel.sql("#{table}.value_text #{dir.to_s.casecmp('desc').zero? ? 'DESC' : 'ASC'}")) end |
.partial=(value) ⇒ Object
138 139 140 |
# File 'lib/open_loam/custom_field_index.rb', line 138 def partial=(value) Thread.current[:open_loam_index_partial] = value end |
.partial? ⇒ Boolean
Was the last filter/order served over an incomplete index? The admin surfaces this as an honest "results may be incomplete, reindexing…".
134 135 136 |
# File 'lib/open_loam/custom_field_index.rb', line 134 def partial? Thread.current[:open_loam_index_partial] == true end |
.pending_reindex ⇒ Object
161 162 163 |
# File 'lib/open_loam/custom_field_index.rb', line 161 def pending_reindex @pending_reindex ||= Set.new end |
.predicate(rows, op, value) ⇒ Object
199 200 201 202 203 204 205 206 207 |
# File 'lib/open_loam/custom_field_index.rb', line 199 def predicate(rows, op, value) case op when "eq" then rows.where(value_text: value.to_s) when "neq" then rows.where.not(value_text: value.to_s) when "contains" then rows.where("value_text LIKE ?", "%#{sanitize_like(value)}%") when "present" then rows.where.not(value_text: [ nil, "" ]) when "gt", "gte", "lt", "lte" then numeric(rows, value, op) end end |
.project(record) ⇒ Object
Re-project one record's custom fields (delete + insert). Soft-delete keeps the rows (the filter's base scope hides the record anyway — the same decision as the L-912 search token driver); a hard destroy removes them.
24 25 26 27 28 29 30 31 32 33 |
# File 'lib/open_loam/custom_field_index.rb', line 24 def project(record) model = record.class return unless model.respond_to?(:custom_field_definitions) type = index_type(model) OpenLoam::CustomFieldValue.where(indexable_type: type, indexable_id: record.id).delete_all rows = model.custom_field_definitions.filter_map { |definition| row_for(record, definition, type) } OpenLoam::CustomFieldValue.insert_all(rows) if rows.any? end |
.refuse_unknown_field!(model, field_key) ⇒ Object
239 240 241 242 |
# File 'lib/open_loam/custom_field_index.rb', line 239 def refuse_unknown_field!(model, field_key) known = model.respond_to?(:custom_field_definitions) && model.custom_field_definitions.exists?(name: field_key) raise OpenLoam::Error, "no custom field #{field_key.inspect} on #{model.name}" unless known end |
.refuse_unreadable_field!(model, field_key) ⇒ Object
THE oracle guard: filtering or sorting on a custom field the current role may not read would let a user infer restricted values by observing which records match. Enforced against the current actor's role; a system/background context (no actor — reindex, a business rule) is trusted and not gated.
248 249 250 251 252 253 254 255 256 257 258 259 |
# File 'lib/open_loam/custom_field_index.rb', line 248 def refuse_unreadable_field!(model, field_key) actor = OpenLoam::Current.actor return if actor.nil? definition = model.custom_field_definitions.find_by(name: field_key.to_s) return if definition.nil? # unknown field is refuse_unknown_field!'s job role = OpenLoam::Membership.find_by(user_id: actor.id)&.role return if definition.readable_by?(role) raise OpenLoam::FieldAccessError, "custom field #{field_key.inspect} is not readable by #{role.inspect}" end |
.reindex(model) ⇒ Object
39 40 41 42 |
# File 'lib/open_loam/custom_field_index.rb', line 39 def reindex(model) OpenLoam::CustomFieldValue.where(indexable_type: index_type(model)).delete_all model.find_each { |record| project(record) } end |
.remove(record) ⇒ Object
35 36 37 |
# File 'lib/open_loam/custom_field_index.rb', line 35 def remove(record) OpenLoam::CustomFieldValue.where(indexable_type: index_type(record.class), indexable_id: record.id).delete_all end |
.reset_pending! ⇒ Object
157 158 159 |
# File 'lib/open_loam/custom_field_index.rb', line 157 def reset_pending! @pending_reindex = Set.new end |
.row_for(record, definition, type) ⇒ Object
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/open_loam/custom_field_index.rb', line 220 def row_for(record, definition, type) value = (record.custom_field(definition.name) rescue nil) return nil if value.nil? # ALL value columns are present (nil when unused) so insert_all sees a # uniform key set across every row. row = { tenant_id: record.tenant_id, indexable_type: type, indexable_id: record.id, field_key: definition.name, value_text: value.to_s, value_number: nil, value_boolean: nil, value_datetime: nil } case definition.field_type when "integer", "decimal" then row[:value_number] = value.to_f when "boolean" then row[:value_boolean] = ActiveModel::Type::Boolean.new.cast(value) when "date", "datetime" then row[:value_datetime] = (Time.zone.parse(value.to_s) rescue nil) end row end |
.sanitize_like(value) ⇒ Object
261 262 263 |
# File 'lib/open_loam/custom_field_index.rb', line 261 def sanitize_like(value) value.to_s.gsub(/[\\%_]/) { |c| "\\#{c}" } end |
.schedule_reindex(model) ⇒ Object
Enqueue a background reindex to heal a gap — DEDUPED so a hot gappy field doesn't enqueue one per request. In-process dedup here (single-process prototype); a DB/cache marker is the multi-process path.
145 146 147 148 149 150 151 |
# File 'lib/open_loam/custom_field_index.rb', line 145 def schedule_reindex(model) key = [ OpenLoam.tenant!.id, model.base_class.name ] return if pending_reindex.include?(key) pending_reindex << key OpenLoam::CustomFieldReindexJob.perform_later(OpenLoam.tenant!.id, model.base_class.name) end |