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,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class Text < Base
|
|
6
|
+
value_column :string_value
|
|
7
|
+
|
|
8
|
+
store_accessor :options, :min_length, :max_length, :pattern
|
|
9
|
+
|
|
10
|
+
validates :min_length, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
|
|
11
|
+
validates :max_length, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
|
|
12
|
+
validate :max_gte_min_length
|
|
13
|
+
validate :validate_pattern_syntax
|
|
14
|
+
|
|
15
|
+
def cast(raw)
|
|
16
|
+
[raw&.to_s, false]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate_typed_value(record, val)
|
|
20
|
+
validate_length(record, val)
|
|
21
|
+
validate_pattern(record, val) if pattern.present?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def max_gte_min_length
|
|
27
|
+
return unless min_length && max_length
|
|
28
|
+
|
|
29
|
+
errors.add(:max_length, "must be >= min_length") if max_length < min_length
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def validate_pattern_syntax
|
|
33
|
+
return if pattern.blank?
|
|
34
|
+
|
|
35
|
+
Regexp.new(pattern)
|
|
36
|
+
rescue RegexpError => e
|
|
37
|
+
errors.add(:pattern, "is invalid: #{e.message}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class TextArray < Base
|
|
6
|
+
value_column :json_value
|
|
7
|
+
# :contains was declared here but QueryBuilder implements it with
|
|
8
|
+
# Arel `matches` (SQL LIKE), which doesn't work against jsonb arrays.
|
|
9
|
+
# Use :any_eq for element membership (it maps to JSONB @>).
|
|
10
|
+
operators :any_eq, :all_eq, :is_null, :is_not_null
|
|
11
|
+
|
|
12
|
+
store_accessor :options, :min_size, :max_size
|
|
13
|
+
|
|
14
|
+
def array_field? = true
|
|
15
|
+
|
|
16
|
+
def cast(raw)
|
|
17
|
+
return [nil, false] if raw.nil?
|
|
18
|
+
|
|
19
|
+
# Drop nil/blank/whitespace-only elements so required-check and size
|
|
20
|
+
# validation compare against real content rather than HTML form stubs
|
|
21
|
+
# (e.g. an empty row in a dynamic list posts as "" or " ").
|
|
22
|
+
elements = Array(raw).filter_map do |v|
|
|
23
|
+
next nil if v.nil?
|
|
24
|
+
|
|
25
|
+
s = v.to_s
|
|
26
|
+
s.strip.empty? ? nil : s
|
|
27
|
+
end
|
|
28
|
+
[elements.presence, false]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_typed_value(record, val)
|
|
32
|
+
validate_array_size(record, val)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module TypedEAV
|
|
6
|
+
module Field
|
|
7
|
+
class Url < Base
|
|
8
|
+
value_column :string_value
|
|
9
|
+
|
|
10
|
+
store_accessor :options, :min_length, :max_length, :pattern
|
|
11
|
+
validate :validate_pattern_syntax
|
|
12
|
+
|
|
13
|
+
URL_FORMAT = /\A#{URI::DEFAULT_PARSER.make_regexp(%w[http https])}\z/
|
|
14
|
+
|
|
15
|
+
def cast(raw)
|
|
16
|
+
[raw&.to_s&.strip, false]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def url_format_valid?(val)
|
|
20
|
+
URL_FORMAT.match?(val)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate_typed_value(record, val)
|
|
24
|
+
validate_length(record, val)
|
|
25
|
+
validate_pattern(record, val) if pattern.present?
|
|
26
|
+
record.errors.add(:value, "is not a valid URL") unless url_format_valid?(val)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def validate_pattern_syntax
|
|
32
|
+
return if pattern.blank?
|
|
33
|
+
|
|
34
|
+
Regexp.new(pattern)
|
|
35
|
+
rescue RegexpError => e
|
|
36
|
+
errors.add(:pattern, "is invalid: #{e.message}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
class Option < ApplicationRecord
|
|
5
|
+
self.table_name = "typed_eav_options"
|
|
6
|
+
|
|
7
|
+
belongs_to :field,
|
|
8
|
+
class_name: "TypedEAV::Field::Base",
|
|
9
|
+
inverse_of: :field_options
|
|
10
|
+
|
|
11
|
+
validates :label, presence: true
|
|
12
|
+
validates :value, presence: true, uniqueness: { scope: :field_id }
|
|
13
|
+
|
|
14
|
+
scope :sorted, -> { order(sort_order: :asc, label: :asc, id: :asc) }
|
|
15
|
+
|
|
16
|
+
after_commit :clear_field_option_cache
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def clear_field_option_cache
|
|
21
|
+
field&.clear_option_cache!
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
class Section < ApplicationRecord
|
|
5
|
+
self.table_name = "typed_eav_sections"
|
|
6
|
+
|
|
7
|
+
has_many :fields,
|
|
8
|
+
class_name: "TypedEAV::Field::Base",
|
|
9
|
+
inverse_of: :section,
|
|
10
|
+
dependent: :nullify
|
|
11
|
+
|
|
12
|
+
validates :name, presence: true
|
|
13
|
+
validates :code, presence: true, uniqueness: { scope: %i[entity_type scope] }
|
|
14
|
+
validates :entity_type, presence: true
|
|
15
|
+
|
|
16
|
+
scope :active, -> { where(active: true) }
|
|
17
|
+
# Mirror Field::Base.for_entity: scoped rows plus global (scope=NULL) rows
|
|
18
|
+
# so global sections are visible across partitions while scoped sections
|
|
19
|
+
# stay isolated. Pass the section's scope key as a string.
|
|
20
|
+
scope :for_entity, lambda { |entity_type, scope: nil|
|
|
21
|
+
where(entity_type: entity_type, scope: [scope, nil].uniq)
|
|
22
|
+
}
|
|
23
|
+
scope :sorted, -> { order(sort_order: :asc, name: :asc) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
class Value < ApplicationRecord
|
|
5
|
+
self.table_name = "typed_eav_values"
|
|
6
|
+
|
|
7
|
+
# ── Associations ──
|
|
8
|
+
|
|
9
|
+
belongs_to :entity, polymorphic: true, inverse_of: :typed_values
|
|
10
|
+
belongs_to :field,
|
|
11
|
+
class_name: "TypedEAV::Field::Base",
|
|
12
|
+
inverse_of: :values
|
|
13
|
+
|
|
14
|
+
# ── Validations ──
|
|
15
|
+
|
|
16
|
+
validates :field, uniqueness: { scope: %i[entity_type entity_id] }
|
|
17
|
+
validate :validate_value
|
|
18
|
+
validate :validate_entity_matches_field
|
|
19
|
+
validate :validate_field_scope_matches_entity
|
|
20
|
+
validate :validate_json_size
|
|
21
|
+
|
|
22
|
+
# ── Value access ──
|
|
23
|
+
#
|
|
24
|
+
# The magic here is that we delegate to the correct typed column
|
|
25
|
+
# based on what the field type declares. ActiveRecord handles all
|
|
26
|
+
# casting through the column's type (schema-inferred).
|
|
27
|
+
#
|
|
28
|
+
# So `value = "42"` on an integer field writes 42 to integer_value,
|
|
29
|
+
# and `value` reads it back as a Ruby Integer. No custom caster needed
|
|
30
|
+
# for storage - the database column type IS the caster.
|
|
31
|
+
|
|
32
|
+
def value
|
|
33
|
+
return nil unless field
|
|
34
|
+
|
|
35
|
+
self[value_column]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def value=(val)
|
|
39
|
+
if field
|
|
40
|
+
# Cast through the field type, then write to the native column.
|
|
41
|
+
# Rails will further cast via the column type on save.
|
|
42
|
+
casted, invalid = field.cast(val)
|
|
43
|
+
self[value_column] = casted
|
|
44
|
+
@cast_was_invalid = invalid
|
|
45
|
+
else
|
|
46
|
+
# Field not yet assigned - stash for later
|
|
47
|
+
@pending_value = val
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Which column this value lives in
|
|
52
|
+
def value_column
|
|
53
|
+
field.class.value_column
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# ── Callbacks ──
|
|
57
|
+
|
|
58
|
+
after_initialize :apply_pending_value
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def apply_pending_value
|
|
63
|
+
return unless @pending_value && field
|
|
64
|
+
|
|
65
|
+
self.value = @pending_value
|
|
66
|
+
@pending_value = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_value
|
|
70
|
+
return unless field
|
|
71
|
+
|
|
72
|
+
if @cast_was_invalid
|
|
73
|
+
errors.add(:value, :invalid)
|
|
74
|
+
@cast_was_invalid = false
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
val = value
|
|
79
|
+
|
|
80
|
+
# Required check. Treat blank strings and empty arrays as missing so
|
|
81
|
+
# required fields can't be saved as effectively empty.
|
|
82
|
+
if field.required? && blank_typed_value?(val)
|
|
83
|
+
errors.add(:value, :blank)
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
return if val.nil?
|
|
88
|
+
|
|
89
|
+
# Delegate to the field type's own validation (polymorphic dispatch).
|
|
90
|
+
# Each Field::* class implements validate_typed_value(record, val)
|
|
91
|
+
# with its type-specific constraints; shared helpers live on Field::Base.
|
|
92
|
+
field.validate_typed_value(self, val)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def blank_typed_value?(val)
|
|
96
|
+
return true if val.nil?
|
|
97
|
+
# Whitespace-only strings count as blank even inside arrays so a
|
|
98
|
+
# required TextArray can't slip through with `[" "]` or `["", nil]`.
|
|
99
|
+
return val.all? { |e| blank_array_element?(e) } if val.is_a?(Array)
|
|
100
|
+
return val.strip.empty? if val.is_a?(String)
|
|
101
|
+
|
|
102
|
+
false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def blank_array_element?(element)
|
|
106
|
+
return true if element.nil?
|
|
107
|
+
return element.strip.empty? if element.is_a?(String)
|
|
108
|
+
|
|
109
|
+
element.respond_to?(:empty?) && element.empty?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
MAX_JSON_BYTES = 1_000_000 # 1MB
|
|
113
|
+
private_constant :MAX_JSON_BYTES
|
|
114
|
+
|
|
115
|
+
def validate_json_size
|
|
116
|
+
return unless field && value_column == :json_value
|
|
117
|
+
|
|
118
|
+
val = self[:json_value]
|
|
119
|
+
return if val.nil?
|
|
120
|
+
|
|
121
|
+
return unless val.to_json.bytesize > MAX_JSON_BYTES
|
|
122
|
+
|
|
123
|
+
errors.add(:value, "is too large (maximum 1MB)")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_entity_matches_field
|
|
127
|
+
return unless field && entity_type
|
|
128
|
+
return if entity_type == field.entity_type
|
|
129
|
+
|
|
130
|
+
errors.add(:entity, :invalid)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Cross-tenant guard: when nested attributes let a client submit a raw
|
|
134
|
+
# field_id, the entity_type match above is not enough — another tenant's
|
|
135
|
+
# field with the same entity_type but a different scope would still
|
|
136
|
+
# attach. Reject unless the field's scope matches the entity's
|
|
137
|
+
# typed_eav_scope (globals, scope=NULL, remain shared).
|
|
138
|
+
def validate_field_scope_matches_entity
|
|
139
|
+
return unless field && entity
|
|
140
|
+
return if field.scope.nil?
|
|
141
|
+
return unless entity.respond_to?(:typed_eav_scope)
|
|
142
|
+
|
|
143
|
+
entity_scope = entity.typed_eav_scope
|
|
144
|
+
return if entity_scope && field.scope == entity_scope.to_s
|
|
145
|
+
|
|
146
|
+
errors.add(:field, :invalid)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateTypedEAVTables < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
# ──────────────────────────────────────────────────
|
|
6
|
+
# Sections: optional UI grouping for fields
|
|
7
|
+
# ──────────────────────────────────────────────────
|
|
8
|
+
create_table :typed_eav_sections do |t|
|
|
9
|
+
t.string :name, null: false
|
|
10
|
+
t.string :code, null: false
|
|
11
|
+
t.string :entity_type, null: false
|
|
12
|
+
t.string :scope
|
|
13
|
+
t.integer :sort_order
|
|
14
|
+
t.boolean :active, null: false, default: true
|
|
15
|
+
|
|
16
|
+
t.timestamps
|
|
17
|
+
|
|
18
|
+
# Paired partial unique indexes. PostgreSQL treats NULLs as distinct in
|
|
19
|
+
# a plain unique index, so a single `[entity_type, code, scope]` index
|
|
20
|
+
# would allow duplicate global (scope IS NULL) rows. Split into
|
|
21
|
+
# (scope NOT NULL) and (scope IS NULL) partials to protect both.
|
|
22
|
+
t.index %i[entity_type code scope],
|
|
23
|
+
unique: true,
|
|
24
|
+
where: "scope IS NOT NULL",
|
|
25
|
+
name: "idx_te_sections_unique_scoped"
|
|
26
|
+
t.index %i[entity_type code],
|
|
27
|
+
unique: true,
|
|
28
|
+
where: "scope IS NULL",
|
|
29
|
+
name: "idx_te_sections_unique_global"
|
|
30
|
+
t.index %i[entity_type active], name: "idx_te_sections_entity_active"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# ──────────────────────────────────────────────────
|
|
34
|
+
# Field definitions (STI via `type` column)
|
|
35
|
+
# ──────────────────────────────────────────────────
|
|
36
|
+
create_table :typed_eav_fields do |t|
|
|
37
|
+
t.string :name, null: false
|
|
38
|
+
t.string :type, null: false # STI: TypedEAV::Field::Integer, etc.
|
|
39
|
+
t.string :entity_type, null: false # polymorphic target model name
|
|
40
|
+
t.string :scope # optional tenant/context scoping
|
|
41
|
+
|
|
42
|
+
t.references :section, foreign_key: { to_table: :typed_eav_sections, on_delete: :nullify }
|
|
43
|
+
|
|
44
|
+
t.boolean :required, null: false, default: false
|
|
45
|
+
t.integer :sort_order
|
|
46
|
+
|
|
47
|
+
# Field-type-specific configuration (min/max/precision/allowed_values/etc.)
|
|
48
|
+
t.jsonb :options, null: false, default: {}
|
|
49
|
+
|
|
50
|
+
# Default value stored in the matching typed column format
|
|
51
|
+
t.jsonb :default_value_meta, null: false, default: {}
|
|
52
|
+
|
|
53
|
+
t.timestamps
|
|
54
|
+
|
|
55
|
+
# Paired partial unique indexes — see sections table comment for why
|
|
56
|
+
# scope=NULL rows need their own partial index on PostgreSQL.
|
|
57
|
+
t.index %i[name entity_type scope],
|
|
58
|
+
unique: true,
|
|
59
|
+
where: "scope IS NOT NULL",
|
|
60
|
+
name: "idx_te_fields_unique_scoped"
|
|
61
|
+
t.index %i[name entity_type],
|
|
62
|
+
unique: true,
|
|
63
|
+
where: "scope IS NULL",
|
|
64
|
+
name: "idx_te_fields_unique_global"
|
|
65
|
+
t.index :entity_type
|
|
66
|
+
t.index %i[entity_type scope sort_order name], name: "idx_te_fields_lookup"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ──────────────────────────────────────────────────
|
|
70
|
+
# Options for select/enum fields
|
|
71
|
+
# ──────────────────────────────────────────────────
|
|
72
|
+
create_table :typed_eav_options do |t|
|
|
73
|
+
t.references :field, null: false, foreign_key: { to_table: :typed_eav_fields, on_delete: :cascade }
|
|
74
|
+
t.string :label, null: false
|
|
75
|
+
t.string :value, null: false
|
|
76
|
+
t.integer :sort_order
|
|
77
|
+
|
|
78
|
+
t.timestamps
|
|
79
|
+
|
|
80
|
+
t.index %i[field_id value], unique: true, name: "idx_te_options_field_value"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# ──────────────────────────────────────────────────
|
|
84
|
+
# Values: one row per entity+field, typed columns
|
|
85
|
+
# ──────────────────────────────────────────────────
|
|
86
|
+
create_table :typed_eav_values do |t|
|
|
87
|
+
t.references :entity, polymorphic: true, null: false, index: true
|
|
88
|
+
t.references :field, null: false, foreign_key: { to_table: :typed_eav_fields, on_delete: :cascade }
|
|
89
|
+
|
|
90
|
+
# ── Typed storage columns ──
|
|
91
|
+
# All value columns are nullable on purpose: only one is populated per
|
|
92
|
+
# row (the one matching the field's type), and a NULL in the others is
|
|
93
|
+
# the "this column doesn't apply" marker. The Rails/ThreeStateBooleanColumn
|
|
94
|
+
# warning doesn't apply to EAV-style tables.
|
|
95
|
+
t.text :string_value
|
|
96
|
+
t.text :text_value
|
|
97
|
+
t.boolean :boolean_value # rubocop:disable Rails/ThreeStateBooleanColumn
|
|
98
|
+
t.bigint :integer_value
|
|
99
|
+
t.decimal :decimal_value, precision: 30, scale: 10
|
|
100
|
+
t.date :date_value
|
|
101
|
+
t.datetime :datetime_value
|
|
102
|
+
t.jsonb :json_value
|
|
103
|
+
|
|
104
|
+
t.timestamps
|
|
105
|
+
|
|
106
|
+
# Uniqueness: one value per entity per field
|
|
107
|
+
t.index %i[entity_type entity_id field_id],
|
|
108
|
+
unique: true,
|
|
109
|
+
name: "idx_te_values_entity_field"
|
|
110
|
+
|
|
111
|
+
# Query performance: field + typed column indexes (covering for index-only scans)
|
|
112
|
+
t.index %i[field_id integer_value], name: "idx_te_values_field_int", include: %i[entity_id entity_type]
|
|
113
|
+
t.index %i[field_id decimal_value], name: "idx_te_values_field_dec", include: %i[entity_id entity_type]
|
|
114
|
+
t.index %i[field_id date_value], name: "idx_te_values_field_date", include: %i[entity_id entity_type]
|
|
115
|
+
t.index %i[field_id datetime_value], name: "idx_te_values_field_dt", include: %i[entity_id entity_type]
|
|
116
|
+
t.index %i[field_id boolean_value], name: "idx_te_values_field_bool", include: %i[entity_id entity_type]
|
|
117
|
+
t.index %i[field_id string_value],
|
|
118
|
+
name: "idx_te_values_field_str",
|
|
119
|
+
using: :btree,
|
|
120
|
+
opclass: { string_value: :text_pattern_ops },
|
|
121
|
+
include: %i[entity_id entity_type]
|
|
122
|
+
|
|
123
|
+
# Partial GIN index for JSONB containment (`@>`) used by :any_eq /
|
|
124
|
+
# :all_eq on array/multi-select fields. NULL-heavy rows (scalar field
|
|
125
|
+
# values) stay out of the index.
|
|
126
|
+
t.index :json_value,
|
|
127
|
+
using: :gin,
|
|
128
|
+
where: "json_value IS NOT NULL",
|
|
129
|
+
name: "idx_te_values_json_gin"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module TypedEAV
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
8
|
+
namespace "typed_eav:install"
|
|
9
|
+
source_root File.expand_path("../../..", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Copies TypedEAV migrations to your application"
|
|
12
|
+
|
|
13
|
+
def copy_migrations
|
|
14
|
+
rake "typed_eav:install:migrations"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def show_post_install
|
|
18
|
+
say ""
|
|
19
|
+
say "TypedEAV installed. Next steps:", :green
|
|
20
|
+
say ""
|
|
21
|
+
say " 1. Run migrations: bin/rails db:migrate"
|
|
22
|
+
say " 2. Add to a model: has_typed_eav"
|
|
23
|
+
say " 3. Generate scaffold: bin/rails generate typed_eav:scaffold"
|
|
24
|
+
say ""
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module TypedEAV
|
|
6
|
+
module Generators
|
|
7
|
+
class ScaffoldGenerator < ::Rails::Generators::Base
|
|
8
|
+
namespace "typed_eav:scaffold"
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Generates controller, views, helper, and Stimulus controllers for managing typed fields"
|
|
12
|
+
|
|
13
|
+
def copy_controller
|
|
14
|
+
copy_file "controllers/typed_eav_controller.rb", "app/controllers/typed_eav_controller.rb"
|
|
15
|
+
copy_file "controllers/concerns/typed_eav_controller_concern.rb",
|
|
16
|
+
"app/controllers/concerns/typed_eav_controller_concern.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def copy_initializer
|
|
20
|
+
copy_file "config/initializers/typed_eav.rb", "config/initializers/typed_eav.rb"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def copy_helper
|
|
24
|
+
copy_file "helpers/typed_eav_helper.rb", "app/helpers/typed_eav_helper.rb"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def copy_views
|
|
28
|
+
directory "views", "app/views"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def copy_javascript
|
|
32
|
+
directory "javascript/controllers", "app/javascript/controllers"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def add_routes
|
|
36
|
+
route <<~ROUTES
|
|
37
|
+
resources :typed_eav_fields, controller: "typed_eav" do
|
|
38
|
+
resources :field_options, controller: "typed_eav", only: [] do
|
|
39
|
+
collection do
|
|
40
|
+
post :add_option
|
|
41
|
+
delete :remove_option
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
ROUTES
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def show_post_install
|
|
49
|
+
say ""
|
|
50
|
+
say "Scaffold generated. You can now manage fields at /typed_eav_fields", :green
|
|
51
|
+
say ""
|
|
52
|
+
say "Next steps:", :yellow
|
|
53
|
+
say ""
|
|
54
|
+
say " 1. WIRE THE ADMIN AUTH HOOK (security-critical):", :red
|
|
55
|
+
say ""
|
|
56
|
+
say " Edit app/controllers/typed_eav_controller.rb and replace"
|
|
57
|
+
say " `authorize_typed_eav_admin!` with your auth check. The"
|
|
58
|
+
say " default returns `head :not_found` (fail-closed). Defining"
|
|
59
|
+
say " this method in ApplicationController does NOT override it."
|
|
60
|
+
say ""
|
|
61
|
+
say " def authorize_typed_eav_admin!"
|
|
62
|
+
say " return if current_user&.admin?"
|
|
63
|
+
say " head :not_found"
|
|
64
|
+
say " end"
|
|
65
|
+
say ""
|
|
66
|
+
say " 2. Configure ambient scope resolution for multi-tenancy:", :yellow
|
|
67
|
+
say ""
|
|
68
|
+
say " Edit config/initializers/typed_eav.rb and uncomment the"
|
|
69
|
+
say " scope_resolver pattern that matches your app (acts_as_tenant"
|
|
70
|
+
say " is auto-detected; no config needed if you use it)."
|
|
71
|
+
say ""
|
|
72
|
+
say " 3. Include the concern in controllers that use typed-eav",
|
|
73
|
+
:yellow
|
|
74
|
+
say " search params (your host model's controller, usually):",
|
|
75
|
+
:yellow
|
|
76
|
+
say ""
|
|
77
|
+
say " class ProductsController < ApplicationController"
|
|
78
|
+
say " include TypedEAVControllerConcern"
|
|
79
|
+
say " helper TypedEAVHelper"
|
|
80
|
+
say " ..."
|
|
81
|
+
say " end"
|
|
82
|
+
say ""
|
|
83
|
+
say " 4. Render typed field inputs in your entity forms:", :yellow
|
|
84
|
+
say ""
|
|
85
|
+
say " <%= render_typed_value_inputs(form: f, record: @record) %>"
|
|
86
|
+
say ""
|
|
87
|
+
say " Permit nested attributes in your host controller", :yellow
|
|
88
|
+
say " (the `value: []` form is required for array/multi-select):",
|
|
89
|
+
:yellow
|
|
90
|
+
say ""
|
|
91
|
+
say " params.require(:contact).permit("
|
|
92
|
+
say " :name,"
|
|
93
|
+
say " typed_values_attributes: ["
|
|
94
|
+
say " :id, :field_id, :_destroy, :value, { value: [] }"
|
|
95
|
+
say " ]"
|
|
96
|
+
say " )"
|
|
97
|
+
say ""
|
|
98
|
+
say " 5. Add a search form to filter entities by typed fields:",
|
|
99
|
+
:yellow
|
|
100
|
+
say ""
|
|
101
|
+
say " <%= render_typed_eav_search(fields: Model.typed_eav_definitions, url: search_path) %>"
|
|
102
|
+
say ""
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# TypedEAV configuration.
|
|
4
|
+
#
|
|
5
|
+
# `scope_resolver` is the single integration point for multi-tenancy /
|
|
6
|
+
# partitioning. It's a callable that returns the current partition value
|
|
7
|
+
# (a tenant id, account id, workspace id — whatever your app uses) or nil.
|
|
8
|
+
#
|
|
9
|
+
# Class-level queries like `Contact.where_typed_eav(...)` consult this
|
|
10
|
+
# resolver when no explicit `scope:` kwarg or `TypedEAV.with_scope(...)`
|
|
11
|
+
# block is active. If the resolver returns nil and the model declared
|
|
12
|
+
# `has_typed_eav scope_method: ...`, queries raise
|
|
13
|
+
# `TypedEAV::ScopeRequired` (fail-closed).
|
|
14
|
+
#
|
|
15
|
+
# Pick ONE of the patterns below and uncomment it:
|
|
16
|
+
|
|
17
|
+
TypedEAV.configure do |c|
|
|
18
|
+
# --- DEFAULT ---
|
|
19
|
+
# If the `acts_as_tenant` gem is loaded, the default resolver reads
|
|
20
|
+
# `ActsAsTenant.current_tenant` with zero configuration. If you use AAT,
|
|
21
|
+
# no change is needed here.
|
|
22
|
+
|
|
23
|
+
# --- Rails CurrentAttributes ---
|
|
24
|
+
# c.scope_resolver = -> { Current.account&.id }
|
|
25
|
+
|
|
26
|
+
# --- Custom Current-like class ---
|
|
27
|
+
# c.scope_resolver = -> { MyApp::Tenancy.current_workspace_id }
|
|
28
|
+
|
|
29
|
+
# --- Subdomain / session / thread-local ---
|
|
30
|
+
# c.scope_resolver = -> { Thread.current[:org_id] }
|
|
31
|
+
|
|
32
|
+
# --- Disable ambient resolution entirely (explicit `scope:` kwarg only) ---
|
|
33
|
+
# c.scope_resolver = nil
|
|
34
|
+
|
|
35
|
+
# --- Fail-closed mode ---
|
|
36
|
+
# When true (default), scoped-model queries raise if no scope resolves.
|
|
37
|
+
# Set to false for gradual adoption — when no scope resolves, queries
|
|
38
|
+
# see ONLY global fields (those defined with `scope: nil`), not other
|
|
39
|
+
# partitions' fields. To query across all partitions, use the explicit
|
|
40
|
+
# escape hatch: `TypedEAV.unscoped { ... }`.
|
|
41
|
+
# c.require_scope = true
|
|
42
|
+
|
|
43
|
+
# --- Custom field types ---
|
|
44
|
+
# c.register_field_type :phone, "MyApp::Fields::Phone"
|
|
45
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAVControllerConcern
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
helper_method :typed_eav_filter_params
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Permitted filter params for search forms.
|
|
11
|
+
# Expects: params[:f] = [{ n: "field_name", op: "eq", v: "value" }, ...]
|
|
12
|
+
def typed_eav_filter_params
|
|
13
|
+
@typed_eav_filter_params ||=
|
|
14
|
+
params.permit(f: [:n, :name, :op, :operator, :v, :value, { v: [], value: [] }])[:f] || {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Strip leading blank element from array params (HTML multi-select quirk)
|
|
20
|
+
def compact_array_param(value)
|
|
21
|
+
return value unless value.is_a?(Array)
|
|
22
|
+
value.first == "" ? value[1..] : value
|
|
23
|
+
end
|
|
24
|
+
end
|