typed_eav 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/MIT-LICENSE +21 -0
- data/README.md +494 -0
- data/Rakefile +13 -0
- data/app/models/typed_eav/application_record.rb +7 -0
- data/app/models/typed_eav/field/base.rb +234 -0
- data/app/models/typed_eav/field/boolean.rb +22 -0
- data/app/models/typed_eav/field/color.rb +16 -0
- data/app/models/typed_eav/field/date.rb +24 -0
- data/app/models/typed_eav/field/date_array.rb +34 -0
- data/app/models/typed_eav/field/date_time.rb +29 -0
- data/app/models/typed_eav/field/decimal.rb +30 -0
- data/app/models/typed_eav/field/decimal_array.rb +31 -0
- data/app/models/typed_eav/field/email.rb +40 -0
- data/app/models/typed_eav/field/integer.rb +30 -0
- data/app/models/typed_eav/field/integer_array.rb +68 -0
- data/app/models/typed_eav/field/json.rb +26 -0
- data/app/models/typed_eav/field/long_text.rb +19 -0
- data/app/models/typed_eav/field/multi_select.rb +41 -0
- data/app/models/typed_eav/field/select.rb +28 -0
- data/app/models/typed_eav/field/text.rb +41 -0
- data/app/models/typed_eav/field/text_array.rb +36 -0
- data/app/models/typed_eav/field/url.rb +40 -0
- data/app/models/typed_eav/option.rb +24 -0
- data/app/models/typed_eav/section.rb +25 -0
- data/app/models/typed_eav/value.rb +149 -0
- data/db/migrate/20260330000000_create_typed_eav_tables.rb +132 -0
- data/lib/generators/typed_eav/install/install_generator.rb +28 -0
- data/lib/generators/typed_eav/scaffold/scaffold_generator.rb +106 -0
- data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +45 -0
- data/lib/generators/typed_eav/scaffold/templates/controllers/concerns/typed_eav_controller_concern.rb +24 -0
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +231 -0
- data/lib/generators/typed_eav/scaffold/templates/helpers/typed_eav_helper.rb +150 -0
- data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/array_field_controller.js +64 -0
- data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/typed_eav_form_controller.js +32 -0
- data/lib/generators/typed_eav/scaffold/templates/views/shared/_array_field.html.erb +23 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/edit.html.erb +47 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/finders/_form.html.erb +80 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_boolean.html.erb +12 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_color.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +57 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_time.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_email.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_json.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_long_text.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_multi_select.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_select.html.erb +14 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text.html.erb +26 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_url.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +42 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/new.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +44 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_boolean.html.erb +10 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_color.html.erb +4 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_time.html.erb +5 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_email.html.erb +5 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_json.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_long_text.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_multi_select.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_select.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_url.html.erb +5 -0
- data/lib/typed_eav/column_mapping.rb +64 -0
- data/lib/typed_eav/config.rb +91 -0
- data/lib/typed_eav/engine.rb +20 -0
- data/lib/typed_eav/has_typed_eav.rb +484 -0
- data/lib/typed_eav/query_builder.rb +133 -0
- data/lib/typed_eav/registry.rb +52 -0
- data/lib/typed_eav/version.rb +5 -0
- data/lib/typed_eav.rb +86 -0
- metadata +146 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/configurable"
|
|
4
|
+
|
|
5
|
+
module TypedEAV
|
|
6
|
+
# Gem-level configuration for field type registration.
|
|
7
|
+
#
|
|
8
|
+
# TypedEAV.configure do |c|
|
|
9
|
+
# c.register_field_type :phone, "MyApp::Fields::Phone"
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# Accessible from anywhere via `TypedEAV.config` (which returns this
|
|
13
|
+
# class; class-level `field_types` / `register_field_type` / `field_class_for`
|
|
14
|
+
# / `type_names` methods are defined below).
|
|
15
|
+
class Config
|
|
16
|
+
include ActiveSupport::Configurable
|
|
17
|
+
|
|
18
|
+
# Default ambient-scope resolver. Auto-detects `acts_as_tenant` when
|
|
19
|
+
# loaded so AAT users get zero-config behavior. Apps using any other
|
|
20
|
+
# multi-tenancy primitive (Rails `Current` attributes, a subdomain
|
|
21
|
+
# lookup, a thread-local, etc.) override via `TypedEAV.configure`.
|
|
22
|
+
DEFAULT_SCOPE_RESOLVER = lambda {
|
|
23
|
+
::ActsAsTenant.current_tenant if defined?(::ActsAsTenant)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Map of type names to their STI class names.
|
|
27
|
+
# Add custom types via TypedEAV.configure.
|
|
28
|
+
BUILTIN_FIELD_TYPES = {
|
|
29
|
+
text: "TypedEAV::Field::Text",
|
|
30
|
+
long_text: "TypedEAV::Field::LongText",
|
|
31
|
+
integer: "TypedEAV::Field::Integer",
|
|
32
|
+
decimal: "TypedEAV::Field::Decimal",
|
|
33
|
+
boolean: "TypedEAV::Field::Boolean",
|
|
34
|
+
date: "TypedEAV::Field::Date",
|
|
35
|
+
date_time: "TypedEAV::Field::DateTime",
|
|
36
|
+
select: "TypedEAV::Field::Select",
|
|
37
|
+
multi_select: "TypedEAV::Field::MultiSelect",
|
|
38
|
+
integer_array: "TypedEAV::Field::IntegerArray",
|
|
39
|
+
decimal_array: "TypedEAV::Field::DecimalArray",
|
|
40
|
+
text_array: "TypedEAV::Field::TextArray",
|
|
41
|
+
date_array: "TypedEAV::Field::DateArray",
|
|
42
|
+
email: "TypedEAV::Field::Email",
|
|
43
|
+
url: "TypedEAV::Field::Url",
|
|
44
|
+
color: "TypedEAV::Field::Color",
|
|
45
|
+
json: "TypedEAV::Field::Json",
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# Mutable registry of type_name => class_name pairs. Seeded from
|
|
49
|
+
# BUILTIN_FIELD_TYPES on first access; extended via register_field_type.
|
|
50
|
+
config_accessor(:field_types) { BUILTIN_FIELD_TYPES.dup }
|
|
51
|
+
|
|
52
|
+
# Callable returning the ambient scope (partition key) for class-level
|
|
53
|
+
# queries. Invoked by `TypedEAV.current_scope` when no explicit
|
|
54
|
+
# `scope:` kwarg is passed and no `with_scope` block is active.
|
|
55
|
+
config_accessor :scope_resolver, default: DEFAULT_SCOPE_RESOLVER
|
|
56
|
+
|
|
57
|
+
# When true, class-level queries on a model that declared
|
|
58
|
+
# `has_typed_eav scope_method: ...` raise `TypedEAV::ScopeRequired`
|
|
59
|
+
# if no scope can be resolved (explicit arg, active `with_scope` block,
|
|
60
|
+
# or configured resolver all returned nil). Bypass per-call via
|
|
61
|
+
# `TypedEAV.unscoped { ... }`.
|
|
62
|
+
config_accessor :require_scope, default: true
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
# Register a custom field type.
|
|
66
|
+
def register_field_type(name, class_name)
|
|
67
|
+
field_types[name.to_sym] = class_name
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Resolve a type name to its STI class.
|
|
71
|
+
def field_class_for(type_name)
|
|
72
|
+
class_name = field_types[type_name.to_sym]
|
|
73
|
+
raise ArgumentError, "Unknown field type: #{type_name}" unless class_name
|
|
74
|
+
|
|
75
|
+
class_name.constantize
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# All registered type names.
|
|
79
|
+
def type_names
|
|
80
|
+
field_types.keys
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Restore defaults (test isolation).
|
|
84
|
+
def reset!
|
|
85
|
+
self.field_types = BUILTIN_FIELD_TYPES.dup
|
|
86
|
+
self.scope_resolver = DEFAULT_SCOPE_RESOLVER
|
|
87
|
+
self.require_scope = true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace TypedEAV
|
|
6
|
+
|
|
7
|
+
initializer "typed_eav.autoload" do
|
|
8
|
+
require_relative "column_mapping"
|
|
9
|
+
require_relative "config"
|
|
10
|
+
require_relative "registry"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Make `has_typed_eav` available on all ActiveRecord models
|
|
14
|
+
initializer "typed_eav.active_record" do
|
|
15
|
+
ActiveSupport.on_load(:active_record) do
|
|
16
|
+
include TypedEAV::HasTypedEAV
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
# Include this in any ActiveRecord model to give it typed custom fields.
|
|
5
|
+
#
|
|
6
|
+
# class Contact < ApplicationRecord
|
|
7
|
+
# has_typed_eav
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# class Contact < ApplicationRecord
|
|
11
|
+
# has_typed_eav scope_method: :tenant_id
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# This gives you:
|
|
15
|
+
#
|
|
16
|
+
# # Reading/writing values
|
|
17
|
+
# contact.typed_values # => collection
|
|
18
|
+
# contact.initialize_typed_values # => builds missing values with defaults
|
|
19
|
+
# contact.typed_eav_attributes = [...] # => bulk assign via nested attributes
|
|
20
|
+
#
|
|
21
|
+
# # Querying (the good stuff)
|
|
22
|
+
# Contact.where_typed_eav(
|
|
23
|
+
# { name: "age", op: :gt, value: 21 },
|
|
24
|
+
# { name: "status", op: :eq, value: "active" }
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
# # Or the short form with a hash:
|
|
28
|
+
# Contact.with_field("age", :gt, 21)
|
|
29
|
+
# Contact.with_field("status", "active") # :eq is default
|
|
30
|
+
#
|
|
31
|
+
module HasTypedEAV
|
|
32
|
+
extend ActiveSupport::Concern
|
|
33
|
+
|
|
34
|
+
# Indexes field definitions by name with deterministic collision
|
|
35
|
+
# resolution: when a global (scope=NULL) and a scoped field share a
|
|
36
|
+
# name, the scoped row wins. `for_entity(name, scope:)` returns both
|
|
37
|
+
# rows on a collision, and a bare `index_by(&:name)` would let DB row
|
|
38
|
+
# order pick the winner. Shared by the class-query path
|
|
39
|
+
# (ClassQueryMethods#where_typed_eav) and the instance path
|
|
40
|
+
# (InstanceMethods#typed_eav_defs_by_name) so the two can't drift.
|
|
41
|
+
def self.definitions_by_name(defs)
|
|
42
|
+
defs.to_a.sort_by { |d| d.scope.nil? ? 0 : 1 }.index_by(&:name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Indexes field definitions by name into a multi-map (one name →
|
|
46
|
+
# array of fields). Used by the class-query path under
|
|
47
|
+
# `TypedEAV.unscoped { }`, where the same field name may legitimately
|
|
48
|
+
# exist across multiple tenant partitions and we must OR-across all
|
|
49
|
+
# matching field_ids per filter rather than collapse to a single row.
|
|
50
|
+
def self.definitions_multimap_by_name(defs)
|
|
51
|
+
defs.to_a.group_by(&:name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class_methods do
|
|
55
|
+
# Register this model as having typed fields.
|
|
56
|
+
#
|
|
57
|
+
# Options:
|
|
58
|
+
# scope_method: - method name that returns a scope value (e.g. :tenant_id)
|
|
59
|
+
# for multi-tenant field isolation
|
|
60
|
+
# types: - restrict which field types are allowed (array of symbols)
|
|
61
|
+
# e.g. [:text, :integer, :boolean]
|
|
62
|
+
# default: all types
|
|
63
|
+
# Public DSL macro modeled on `acts_as_*`; renaming would break callers.
|
|
64
|
+
def has_typed_eav(scope_method: nil, types: nil) # rubocop:disable Naming/PredicatePrefix
|
|
65
|
+
# class_attribute rather than cattr_accessor: class variables are
|
|
66
|
+
# copied-on-write across subclasses and reload well under Rails'
|
|
67
|
+
# code reloader. Normalize the types list to strings once so hot
|
|
68
|
+
# paths (type-restriction validation, `typed_eav_attributes=`)
|
|
69
|
+
# don't have to re-map per call.
|
|
70
|
+
class_attribute :typed_eav_scope_method, instance_accessor: false,
|
|
71
|
+
default: scope_method
|
|
72
|
+
class_attribute :allowed_typed_eav_types, instance_accessor: false,
|
|
73
|
+
default: types && types.map(&:to_s).freeze
|
|
74
|
+
|
|
75
|
+
include InstanceMethods
|
|
76
|
+
extend ClassQueryMethods
|
|
77
|
+
|
|
78
|
+
has_many :typed_values,
|
|
79
|
+
class_name: "TypedEAV::Value",
|
|
80
|
+
as: :entity,
|
|
81
|
+
inverse_of: :entity,
|
|
82
|
+
autosave: true,
|
|
83
|
+
dependent: :destroy
|
|
84
|
+
|
|
85
|
+
accepts_nested_attributes_for :typed_values, allow_destroy: true
|
|
86
|
+
|
|
87
|
+
# Register with the global registry
|
|
88
|
+
TypedEAV.registry.register(name, types: types)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ──────────────────────────────────────────────────
|
|
93
|
+
# Class-level query methods
|
|
94
|
+
# ──────────────────────────────────────────────────
|
|
95
|
+
module ClassQueryMethods
|
|
96
|
+
# Sentinel for the `scope:` kwarg default. Distinguishes "kwarg not
|
|
97
|
+
# passed → resolve from ambient" (UNSET_SCOPE) from "explicitly nil →
|
|
98
|
+
# filter to global-only fields" (preserves prior behavior).
|
|
99
|
+
UNSET_SCOPE = Object.new.freeze
|
|
100
|
+
|
|
101
|
+
# Sentinel returned by `resolve_scope` inside an `unscoped { }` block.
|
|
102
|
+
# Signals the caller to skip the scope filter entirely (return fields
|
|
103
|
+
# across all partitions, not just global).
|
|
104
|
+
ALL_SCOPES = Object.new.freeze
|
|
105
|
+
|
|
106
|
+
# Query by custom field values. Accepts an array of filter hashes
|
|
107
|
+
# or a hash of hashes (from form params).
|
|
108
|
+
#
|
|
109
|
+
# Each filter needs:
|
|
110
|
+
# :name or :n - the field name
|
|
111
|
+
# :op or :operator - the operator (default: :eq)
|
|
112
|
+
# :value or :v - the comparison value
|
|
113
|
+
#
|
|
114
|
+
# Contact.where_typed_eav(
|
|
115
|
+
# { name: "age", op: :gt, value: 21 },
|
|
116
|
+
# { name: "city", value: "Portland" } # op defaults to :eq
|
|
117
|
+
# )
|
|
118
|
+
#
|
|
119
|
+
# 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
|
+
def where_typed_eav(*filters, scope: UNSET_SCOPE)
|
|
121
|
+
# Normalize input: accept splat args, a single array, a single filter hash,
|
|
122
|
+
# a hash-of-hashes (form params), or ActionController::Parameters.
|
|
123
|
+
filters = filters.map { |f| f.respond_to?(:to_unsafe_h) ? f.to_unsafe_h : f }
|
|
124
|
+
|
|
125
|
+
if filters.size == 1
|
|
126
|
+
inner = filters.first
|
|
127
|
+
inner = inner.to_unsafe_h if inner.respond_to?(:to_unsafe_h)
|
|
128
|
+
|
|
129
|
+
if inner.is_a?(Array)
|
|
130
|
+
filters = inner
|
|
131
|
+
elsif inner.is_a?(Hash)
|
|
132
|
+
# A single filter hash has keys like :name/:n, :op, :value/:v.
|
|
133
|
+
# A hash-of-hashes (form params) has values that are all hashes.
|
|
134
|
+
filter_keys = %i[name n op operator value v].map(&:to_s)
|
|
135
|
+
filters = if inner.keys.any? { |k| filter_keys.include?(k.to_s) }
|
|
136
|
+
[inner]
|
|
137
|
+
else
|
|
138
|
+
inner.values
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
filters = Array(filters)
|
|
144
|
+
|
|
145
|
+
# Resolve the scope once so we can branch on whether we're inside
|
|
146
|
+
# `TypedEAV.unscoped { }` (ALL_SCOPES) or a normal single-scope
|
|
147
|
+
# query. Under ALL_SCOPES the same name can legitimately appear
|
|
148
|
+
# across multiple tenant partitions; collapsing to one definition
|
|
149
|
+
# would silently drop all but one tenant's matches. See the
|
|
150
|
+
# multimap branch below.
|
|
151
|
+
resolved = resolve_scope(scope)
|
|
152
|
+
all_scopes = resolved.equal?(ALL_SCOPES)
|
|
153
|
+
|
|
154
|
+
defs = if all_scopes
|
|
155
|
+
TypedEAV::Field::Base.where(entity_type: name)
|
|
156
|
+
else
|
|
157
|
+
TypedEAV::Field::Base.for_entity(name, scope: resolved)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
if all_scopes
|
|
161
|
+
fields_multimap = HasTypedEAV.definitions_multimap_by_name(defs)
|
|
162
|
+
|
|
163
|
+
filters.inject(all) do |query, filter|
|
|
164
|
+
filter = filter.to_h.with_indifferent_access
|
|
165
|
+
|
|
166
|
+
name = filter[:n] || filter[:name]
|
|
167
|
+
operator = (filter[:op] || filter[:operator] || :eq).to_sym
|
|
168
|
+
value = filter.key?(:v) ? filter[:v] : filter[:value]
|
|
169
|
+
|
|
170
|
+
matching_fields = fields_multimap[name.to_s]
|
|
171
|
+
unless matching_fields&.any?
|
|
172
|
+
raise ArgumentError, "Unknown typed field '#{name}' for #{self.name}. " \
|
|
173
|
+
"Available fields: #{fields_multimap.keys.join(", ")}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# OR-across all field_ids that share this name (across tenants),
|
|
177
|
+
# while preserving AND between filters via the chained `.where`.
|
|
178
|
+
# Use the underlying Value scope (`.filter(...)`) and pluck
|
|
179
|
+
# entity_ids — `entity_ids` returns a relation, and pluck collapses
|
|
180
|
+
# it to a plain integer array we can union across tenants.
|
|
181
|
+
union_ids = matching_fields.flat_map do |f|
|
|
182
|
+
TypedEAV::QueryBuilder.filter(f, operator, value).pluck(:entity_id)
|
|
183
|
+
end.uniq
|
|
184
|
+
|
|
185
|
+
query.where(id: union_ids)
|
|
186
|
+
end
|
|
187
|
+
else
|
|
188
|
+
fields_by_name = HasTypedEAV.definitions_by_name(defs)
|
|
189
|
+
|
|
190
|
+
filters.inject(all) do |query, filter|
|
|
191
|
+
filter = filter.to_h.with_indifferent_access
|
|
192
|
+
|
|
193
|
+
name = filter[:n] || filter[:name]
|
|
194
|
+
operator = (filter[:op] || filter[:operator] || :eq).to_sym
|
|
195
|
+
value = filter.key?(:v) ? filter[:v] : filter[:value]
|
|
196
|
+
|
|
197
|
+
field = fields_by_name[name.to_s]
|
|
198
|
+
unless field
|
|
199
|
+
raise ArgumentError, "Unknown typed field '#{name}' for #{self.name}. " \
|
|
200
|
+
"Available fields: #{fields_by_name.keys.join(", ")}"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
matching_ids = TypedEAV::QueryBuilder.entity_ids(field, operator, value)
|
|
204
|
+
query.where(id: matching_ids)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Shorthand for single-field queries.
|
|
210
|
+
#
|
|
211
|
+
# Contact.with_field("age", :gt, 21)
|
|
212
|
+
# Contact.with_field("active", true) # op defaults to :eq
|
|
213
|
+
# Contact.with_field("name", :contains, "smith")
|
|
214
|
+
#
|
|
215
|
+
def with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE)
|
|
216
|
+
if value.nil? && !operator_or_value.is_a?(Symbol)
|
|
217
|
+
# Two-arg form: with_field("name", "value") implies :eq
|
|
218
|
+
where_typed_eav({ name: name, op: :eq, value: operator_or_value }, scope: scope)
|
|
219
|
+
else
|
|
220
|
+
where_typed_eav({ name: name, op: operator_or_value, value: value }, scope: scope)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Returns field definitions for this entity type.
|
|
225
|
+
#
|
|
226
|
+
# `scope:` behavior:
|
|
227
|
+
# - omitted → resolve from ambient (`with_scope` → resolver → raise/nil)
|
|
228
|
+
# - passed a value → use verbatim (explicit override; admin/test path)
|
|
229
|
+
# - passed nil → filter to global-only fields (prior behavior preserved)
|
|
230
|
+
def typed_eav_definitions(scope: UNSET_SCOPE)
|
|
231
|
+
resolved = resolve_scope(scope)
|
|
232
|
+
if resolved.equal?(ALL_SCOPES)
|
|
233
|
+
TypedEAV::Field::Base.where(entity_type: name)
|
|
234
|
+
else
|
|
235
|
+
TypedEAV::Field::Base.for_entity(name, scope: resolved)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
# Resolves the scope kwarg into a concrete value for field-definition
|
|
242
|
+
# lookup. See `typed_eav_definitions` docs for kwarg semantics.
|
|
243
|
+
# Raises `TypedEAV::ScopeRequired` when the model declares
|
|
244
|
+
# `scope_method:` but ambient scope can't be resolved and fail-closed
|
|
245
|
+
# mode is enabled.
|
|
246
|
+
def resolve_scope(explicit)
|
|
247
|
+
# Explicit override (including explicit nil) — use verbatim.
|
|
248
|
+
return TypedEAV.normalize_scope(explicit) unless explicit.equal?(UNSET_SCOPE)
|
|
249
|
+
|
|
250
|
+
# Inside `TypedEAV.unscoped { }` — skip the scope filter entirely.
|
|
251
|
+
return ALL_SCOPES if TypedEAV.unscoped?
|
|
252
|
+
|
|
253
|
+
# Models that did NOT opt into scoping must NOT see ambient scope.
|
|
254
|
+
# If the host declared `has_typed_eav` without `scope_method:`, it
|
|
255
|
+
# has no per-instance scope accessor, so `Value#validate_field_scope_matches_entity`
|
|
256
|
+
# would reject any attempt to attach a scoped field anyway. Honoring
|
|
257
|
+
# ambient scope here would surface scoped field definitions that the
|
|
258
|
+
# model can never actually use — confusing in admin/forms — and would
|
|
259
|
+
# leak cross-model ambient state into a model that never opted in.
|
|
260
|
+
# An explicit `scope:` kwarg (handled above) still overrides this, so
|
|
261
|
+
# admin/test paths retain the ability to query arbitrary scopes.
|
|
262
|
+
return nil unless typed_eav_scope_method
|
|
263
|
+
|
|
264
|
+
# Ambient resolver (via `with_scope` stack or configured lambda).
|
|
265
|
+
resolved = TypedEAV.current_scope
|
|
266
|
+
return resolved unless resolved.nil?
|
|
267
|
+
|
|
268
|
+
# Fail-closed: the model opted into scoping (`scope_method:` declared)
|
|
269
|
+
# but nothing resolved. Raise so data can't leak across partitions.
|
|
270
|
+
if typed_eav_scope_method && TypedEAV.config.require_scope
|
|
271
|
+
raise TypedEAV::ScopeRequired,
|
|
272
|
+
"No ambient scope resolvable for #{name}. " \
|
|
273
|
+
"Wrap the call in `TypedEAV.with_scope(value) { ... }`, " \
|
|
274
|
+
"configure `TypedEAV.config.scope_resolver`, or use " \
|
|
275
|
+
"`TypedEAV.unscoped { ... }` to deliberately bypass."
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# ──────────────────────────────────────────────────
|
|
283
|
+
# Instance methods
|
|
284
|
+
# ──────────────────────────────────────────────────
|
|
285
|
+
module InstanceMethods
|
|
286
|
+
# The field definitions available for this record
|
|
287
|
+
def typed_eav_definitions
|
|
288
|
+
self.class.typed_eav_definitions(scope: typed_eav_scope)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Current scope value (for multi-tenant)
|
|
292
|
+
def typed_eav_scope
|
|
293
|
+
return nil unless self.class.typed_eav_scope_method
|
|
294
|
+
|
|
295
|
+
send(self.class.typed_eav_scope_method)&.to_s
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Build missing values with defaults for all available fields.
|
|
299
|
+
# Useful in forms to show all fields even when no value exists yet.
|
|
300
|
+
#
|
|
301
|
+
# Iterates the collision-collapsed view (`typed_eav_defs_by_name`)
|
|
302
|
+
# rather than the raw definitions list. Otherwise, when a record's
|
|
303
|
+
# scope partition has both a global (scope=NULL) and a same-name
|
|
304
|
+
# scoped field, `for_entity` returns BOTH rows and the form would
|
|
305
|
+
# render two inputs for the same name — but only the scoped one
|
|
306
|
+
# round-trips on save (it wins in `typed_eav_defs_by_name`).
|
|
307
|
+
def initialize_typed_values
|
|
308
|
+
existing_field_ids = typed_values.loaded? ? typed_values.map(&:field_id) : typed_values.pluck(:field_id)
|
|
309
|
+
|
|
310
|
+
typed_eav_defs_by_name.each_value do |field|
|
|
311
|
+
next if existing_field_ids.include?(field.id)
|
|
312
|
+
|
|
313
|
+
typed_values.build(field: field, value: field.default_value)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
typed_values
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Bulk assign values by field NAME. Coexists with (rather than replaces)
|
|
320
|
+
# the `accepts_nested_attributes_for :typed_values` setter declared above,
|
|
321
|
+
# which accepts entries keyed by field ID.
|
|
322
|
+
#
|
|
323
|
+
# Why both exist:
|
|
324
|
+
#
|
|
325
|
+
# * The nested-attributes setter (`typed_values_attributes=`) is the
|
|
326
|
+
# standard Rails form contract. HTML form builders emit `field_id`
|
|
327
|
+
# as a hidden input per value row, so when a form posts back, the
|
|
328
|
+
# params look like:
|
|
329
|
+
# { typed_values_attributes: [
|
|
330
|
+
# { id: 12, field_id: 4, value: "40" }, ...
|
|
331
|
+
# ] }
|
|
332
|
+
# `accepts_nested_attributes_for` matches existing values by `id`.
|
|
333
|
+
#
|
|
334
|
+
# * This setter (`typed_eav_attributes=` / `typed_eav=`) takes
|
|
335
|
+
# entries keyed by field *name* and translates them to field IDs
|
|
336
|
+
# before handing off to the nested-attributes setter. It also
|
|
337
|
+
# enforces the `types:` restriction declared on `has_typed_eav`
|
|
338
|
+
# (rejecting entries for disallowed field types) and supports
|
|
339
|
+
# `_destroy: true` for removing a value by name. This is the
|
|
340
|
+
# ergonomic path for console/seed code:
|
|
341
|
+
# record.typed_eav_attributes = [
|
|
342
|
+
# { name: "age", value: 30 },
|
|
343
|
+
# { name: "email", value: "test@example.com" },
|
|
344
|
+
# { name: "old_field", _destroy: true },
|
|
345
|
+
# ]
|
|
346
|
+
#
|
|
347
|
+
# Pick the one that fits: forms -> typed_values_attributes=, scripting
|
|
348
|
+
# -> typed_eav_attributes=. They can't both run in the same save.
|
|
349
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
350
|
+
|
|
351
|
+
# rubocop:disable Metrics/AbcSize -- branches on existing/new/destroy and type-restriction in one place; splitting would obscure the precedence rules.
|
|
352
|
+
def typed_eav_attributes=(attributes)
|
|
353
|
+
attributes = attributes.to_h if attributes.respond_to?(:permitted?)
|
|
354
|
+
attributes = attributes.values if attributes.is_a?(Hash)
|
|
355
|
+
attributes = Array(attributes)
|
|
356
|
+
|
|
357
|
+
fields_by_name = typed_eav_defs_by_name
|
|
358
|
+
values_by_field_id = typed_values.index_by(&:field_id)
|
|
359
|
+
|
|
360
|
+
nested = attributes.filter_map do |attrs|
|
|
361
|
+
attrs = attrs.to_h.with_indifferent_access
|
|
362
|
+
|
|
363
|
+
field = fields_by_name[attrs[:name]]
|
|
364
|
+
next unless field
|
|
365
|
+
|
|
366
|
+
# Enforce type restrictions. Normalized to strings at registration
|
|
367
|
+
# time (see `has_typed_eav`), so no per-call mapping.
|
|
368
|
+
allowed = self.class.allowed_typed_eav_types
|
|
369
|
+
next if allowed&.exclude?(field.field_type_name)
|
|
370
|
+
|
|
371
|
+
existing = values_by_field_id[field.id]
|
|
372
|
+
|
|
373
|
+
if ActiveRecord::Type::Boolean.new.cast(attrs[:_destroy])
|
|
374
|
+
{ id: existing&.id, _destroy: true }
|
|
375
|
+
elsif existing
|
|
376
|
+
{ id: existing.id, value: attrs[:value] }
|
|
377
|
+
else
|
|
378
|
+
typed_values.build(field: field, value: attrs[:value])
|
|
379
|
+
nil # build already added it, skip nested_attributes
|
|
380
|
+
end
|
|
381
|
+
end.compact
|
|
382
|
+
|
|
383
|
+
self.typed_values_attributes = nested if nested.any?
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# rubocop:enable Metrics/AbcSize
|
|
387
|
+
alias typed_eav= typed_eav_attributes=
|
|
388
|
+
|
|
389
|
+
# Get a specific field's value by name. Honors an already-loaded
|
|
390
|
+
# `typed_values` association so list-page callers that preloaded
|
|
391
|
+
# `typed_values: :field` don't trigger a fresh query per record.
|
|
392
|
+
#
|
|
393
|
+
# On a global+scoped name collision, prefer the value bound to the
|
|
394
|
+
# winning field_id (scoped wins). Without this guard, a stray value
|
|
395
|
+
# row attached to a shadowed global field would surface here even
|
|
396
|
+
# though writes route through the scoped winner.
|
|
397
|
+
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity -- name-collision precedence + orphan guard + already-loaded preload reuse.
|
|
398
|
+
def typed_eav_value(name)
|
|
399
|
+
winning = typed_eav_defs_by_name[name.to_s]
|
|
400
|
+
# Skip orphans (`v.field` nil — definition deleted out from under the
|
|
401
|
+
# value via raw SQL or a missing FK cascade) so a stray row can't
|
|
402
|
+
# crash the read path with NoMethodError.
|
|
403
|
+
candidates = loaded_typed_values_with_fields.select { |v| v.field && v.field.name == name.to_s }
|
|
404
|
+
tv = if winning && candidates.any? { |v| (v.field_id || v.field&.id) == winning.id }
|
|
405
|
+
candidates.detect { |v| (v.field_id || v.field&.id) == winning.id }
|
|
406
|
+
else
|
|
407
|
+
candidates.first
|
|
408
|
+
end
|
|
409
|
+
tv&.value
|
|
410
|
+
end
|
|
411
|
+
# rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
|
|
412
|
+
|
|
413
|
+
# Set a specific field's value by name
|
|
414
|
+
def set_typed_eav_value(name, value)
|
|
415
|
+
field = typed_eav_defs_by_name[name.to_s]
|
|
416
|
+
return unless field
|
|
417
|
+
|
|
418
|
+
existing = typed_values.detect { |v| v.field_id == field.id }
|
|
419
|
+
if existing
|
|
420
|
+
existing.value = value
|
|
421
|
+
else
|
|
422
|
+
typed_values.build(field: field, value: value)
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Hash of all field values: { "field_name" => value, ... }. Same
|
|
427
|
+
# preload semantics as `typed_eav_value` — respects already-loaded
|
|
428
|
+
# associations instead of rebuilding the relation.
|
|
429
|
+
#
|
|
430
|
+
# Collision-safe: on a global+scoped name overlap, the value attached
|
|
431
|
+
# to the winning field_id wins (scoped). Without this guard, a stray
|
|
432
|
+
# row tied to a shadowed global field could surface here even though
|
|
433
|
+
# writes route through the scoped winner.
|
|
434
|
+
def typed_eav_hash
|
|
435
|
+
winning_ids_by_name = typed_eav_defs_by_name.transform_values(&:id)
|
|
436
|
+
rows = loaded_typed_values_with_fields
|
|
437
|
+
|
|
438
|
+
rows.each_with_object({}) do |tv, hash|
|
|
439
|
+
# Skip orphans (`tv.field` nil — definition deleted out from under
|
|
440
|
+
# the value) so the hash isn't crashy when stale rows linger.
|
|
441
|
+
next unless tv.field
|
|
442
|
+
|
|
443
|
+
name = tv.field.name
|
|
444
|
+
winning_id = winning_ids_by_name[name]
|
|
445
|
+
effective_id = tv.field_id || tv.field&.id
|
|
446
|
+
|
|
447
|
+
# A winner is registered for this name: only its row is allowed.
|
|
448
|
+
# If no winner is registered (definition deleted while values
|
|
449
|
+
# remain), fall back to first-wins so the hash isn't lossy.
|
|
450
|
+
if winning_id
|
|
451
|
+
hash[name] = tv.value if effective_id == winning_id
|
|
452
|
+
else
|
|
453
|
+
hash[name] = tv.value unless hash.key?(name)
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
private
|
|
459
|
+
|
|
460
|
+
# Returns typed_values with their fields, preferring already-loaded
|
|
461
|
+
# associations. Callers on list pages should preload with
|
|
462
|
+
# `includes(typed_values: :field)`; this method keeps the happy path
|
|
463
|
+
# fast without forcing that contract.
|
|
464
|
+
def loaded_typed_values_with_fields
|
|
465
|
+
if typed_values.loaded?
|
|
466
|
+
# Don't re-query if the caller already preloaded; ensure each value's
|
|
467
|
+
# field is materialized (fall back to per-row load if the nested
|
|
468
|
+
# `:field` was not preloaded).
|
|
469
|
+
typed_values.to_a
|
|
470
|
+
else
|
|
471
|
+
typed_values.includes(:field).to_a
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Field definitions indexed by name with deterministic collision handling:
|
|
476
|
+
# when both a global (scope=NULL) and a scoped field share a name, the
|
|
477
|
+
# scoped definition wins. Delegates to `HasTypedEAV.definitions_by_name`
|
|
478
|
+
# so the class-query path and the instance path share one source of truth.
|
|
479
|
+
def typed_eav_defs_by_name
|
|
480
|
+
HasTypedEAV.definitions_by_name(typed_eav_definitions)
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|