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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +494 -0
  5. data/Rakefile +13 -0
  6. data/app/models/typed_eav/application_record.rb +7 -0
  7. data/app/models/typed_eav/field/base.rb +234 -0
  8. data/app/models/typed_eav/field/boolean.rb +22 -0
  9. data/app/models/typed_eav/field/color.rb +16 -0
  10. data/app/models/typed_eav/field/date.rb +24 -0
  11. data/app/models/typed_eav/field/date_array.rb +34 -0
  12. data/app/models/typed_eav/field/date_time.rb +29 -0
  13. data/app/models/typed_eav/field/decimal.rb +30 -0
  14. data/app/models/typed_eav/field/decimal_array.rb +31 -0
  15. data/app/models/typed_eav/field/email.rb +40 -0
  16. data/app/models/typed_eav/field/integer.rb +30 -0
  17. data/app/models/typed_eav/field/integer_array.rb +68 -0
  18. data/app/models/typed_eav/field/json.rb +26 -0
  19. data/app/models/typed_eav/field/long_text.rb +19 -0
  20. data/app/models/typed_eav/field/multi_select.rb +41 -0
  21. data/app/models/typed_eav/field/select.rb +28 -0
  22. data/app/models/typed_eav/field/text.rb +41 -0
  23. data/app/models/typed_eav/field/text_array.rb +36 -0
  24. data/app/models/typed_eav/field/url.rb +40 -0
  25. data/app/models/typed_eav/option.rb +24 -0
  26. data/app/models/typed_eav/section.rb +25 -0
  27. data/app/models/typed_eav/value.rb +149 -0
  28. data/db/migrate/20260330000000_create_typed_eav_tables.rb +132 -0
  29. data/lib/generators/typed_eav/install/install_generator.rb +28 -0
  30. data/lib/generators/typed_eav/scaffold/scaffold_generator.rb +106 -0
  31. data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +45 -0
  32. data/lib/generators/typed_eav/scaffold/templates/controllers/concerns/typed_eav_controller_concern.rb +24 -0
  33. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +231 -0
  34. data/lib/generators/typed_eav/scaffold/templates/helpers/typed_eav_helper.rb +150 -0
  35. data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/array_field_controller.js +64 -0
  36. data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/typed_eav_form_controller.js +32 -0
  37. data/lib/generators/typed_eav/scaffold/templates/views/shared/_array_field.html.erb +23 -0
  38. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/edit.html.erb +47 -0
  39. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/finders/_form.html.erb +80 -0
  40. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_boolean.html.erb +12 -0
  41. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_color.html.erb +11 -0
  42. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +57 -0
  43. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date.html.erb +21 -0
  44. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_array.html.erb +16 -0
  45. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_time.html.erb +21 -0
  46. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal.html.erb +21 -0
  47. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal_array.html.erb +16 -0
  48. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_email.html.erb +11 -0
  49. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer.html.erb +21 -0
  50. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer_array.html.erb +16 -0
  51. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_json.html.erb +11 -0
  52. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_long_text.html.erb +21 -0
  53. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_multi_select.html.erb +6 -0
  54. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_select.html.erb +14 -0
  55. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text.html.erb +26 -0
  56. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text_array.html.erb +16 -0
  57. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_url.html.erb +11 -0
  58. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +42 -0
  59. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/new.html.erb +7 -0
  60. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +44 -0
  61. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_boolean.html.erb +10 -0
  62. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_color.html.erb +4 -0
  63. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date.html.erb +6 -0
  64. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_array.html.erb +9 -0
  65. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_time.html.erb +5 -0
  66. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal.html.erb +6 -0
  67. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal_array.html.erb +9 -0
  68. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_email.html.erb +5 -0
  69. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer.html.erb +6 -0
  70. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer_array.html.erb +9 -0
  71. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_json.html.erb +7 -0
  72. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_long_text.html.erb +7 -0
  73. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_multi_select.html.erb +7 -0
  74. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_select.html.erb +7 -0
  75. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text.html.erb +6 -0
  76. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text_array.html.erb +9 -0
  77. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_url.html.erb +5 -0
  78. data/lib/typed_eav/column_mapping.rb +64 -0
  79. data/lib/typed_eav/config.rb +91 -0
  80. data/lib/typed_eav/engine.rb +20 -0
  81. data/lib/typed_eav/has_typed_eav.rb +484 -0
  82. data/lib/typed_eav/query_builder.rb +133 -0
  83. data/lib/typed_eav/registry.rb +52 -0
  84. data/lib/typed_eav/version.rb +5 -0
  85. data/lib/typed_eav.rb +86 -0
  86. 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