eav_field 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ad4c68d056641acdb72729aa099b55285fb948de48ed41511fdb2472f1a6292c
4
+ data.tar.gz: 74be0f6ec80589ee374f5604fe347d97a5e07bf4933ab7bc5b6ac8cbe7dc8302
5
+ SHA512:
6
+ metadata.gz: afedd539adaa261644b67ab9078edb02e845a1189abfaa31e975d74bc5a8284b361da247564fdc0149cca96812ba75bafd23d0f55c0f5eacc07fa0ff974f6bf6
7
+ data.tar.gz: 359ba7b4509ed4f67723922a37c8849c5ca15ce5ef78abd7f63dbca9ada9040ccea75c165b8359564e95fbdad40cd7a811e6e92b5cac94ef4532cbf9787013db
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-10-09
4
+
5
+ - Add `has_eav_fields` for existing Active Record models.
6
+ - Persist model-scoped definitions and polymorphic typed values.
7
+ - Support string, text, integer, decimal, boolean, date, datetime, and JSON fields.
8
+ - Add defaults, required-field validation, transactional autosave, and equality queries.
9
+ - Provide a Rails installation generator with bigint, UUID, and string owner ID options.
10
+ - Add Minitest integration coverage, RuboCop configuration, and a Rails CI matrix.
11
+ - Release under the MIT License.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mouad Imam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # EavField
2
+
3
+ EavField adds an entity-attribute-value (EAV) system to existing Active Record models. Your model is the entity; database records define its dynamic attributes; separate records store their typed values. Adding a field does not require changing the owner's table.
4
+
5
+ Requires Ruby 3.2+ and Active Record/Railties 7.1–8.x. The gem constrains JSON to 2.x for compatibility with these Rails versions. The development suite uses SQLite and Minitest.
6
+
7
+ ## Install in a Rails application
8
+
9
+ Add the gem to your application's `Gemfile`:
10
+
11
+ ```ruby
12
+ gem "eav_field", "~> 0.1.0"
13
+ ```
14
+
15
+ Then run from the Rails application's root:
16
+
17
+ ```sh
18
+ bundle install
19
+ bin/rails generate eav_field:install
20
+ bin/rails db:migrate
21
+ ```
22
+
23
+ For local gem development, replace the dependency with `gem "eav_field", path: "../eav_field"`, adjusting the path to this checkout.
24
+
25
+ The generator creates `eav_field_definitions` and `eav_field_values`. Owner IDs use `bigint` by default. For PostgreSQL UUID owners, generate with `--entity-id-type=uuid`; for string IDs, use `--entity-id-type=string`. All participating models must have IDs compatible with the selected column type.
26
+
27
+ Opt in on any existing model:
28
+
29
+ ```ruby
30
+ class Product < ApplicationRecord
31
+ has_eav_fields
32
+ end
33
+ ```
34
+
35
+ ## Define and use fields
36
+
37
+ Create definitions after migrating, for example in a Rails console or data migration:
38
+
39
+ ```ruby
40
+ Product.define_eav_field(:color, type: :string, default: "black")
41
+ Product.define_eav_field(:weight, type: :decimal)
42
+ Product.define_eav_field(:available, type: :boolean, required: true, default: false)
43
+ Product.define_eav_field(:metadata, type: :json)
44
+
45
+ product = Product.new(name: "Chair")
46
+ product.eav_fields = { color: "blue", weight: "1.25", metadata: { origin: "MA" } }
47
+ product.save!
48
+
49
+ product.reload.eav_fields[:color] # => "blue"
50
+ product.eav_fields[:weight] # => BigDecimal("1.25")
51
+ product.eav_fields[:available] # => false
52
+ product.eav_fields.to_h # All defined fields, including defaults.
53
+
54
+ product.eav_fields[:color] = "green"
55
+ product.save! # Saves field changes with the owner.
56
+ product.update!(eav_fields: { weight: "1.5" })
57
+ ```
58
+
59
+ Both symbol and string keys work. Hash assignment merges fields; omitted fields keep their values. `read_eav_field`, `write_eav_field`, and `delete_eav_field` are also available directly on the owner.
60
+
61
+ Definitions are scoped to the model's polymorphic base class. Different models can use the same field name with different types. STI subclasses share their base model's definitions. Names use lowercase letters, digits, and underscores, starting with a letter, such as `shipping_weight`.
62
+
63
+ Definitions persist across application restarts. Keep definition creation out of model class bodies. Duplicate definitions raise `ActiveRecord::RecordInvalid`; for repeatable seeds, check `Product.eav_field_definitions.exists?(name: "color")` before defining a field.
64
+
65
+ ## Types and validation
66
+
67
+ | Type | Accepted values |
68
+ | --- | --- |
69
+ | `string` | Strings up to 255 characters |
70
+ | `text` | Longer strings |
71
+ | `integer` | Signed 64-bit integers or whole-number strings |
72
+ | `decimal` | Finite numbers or numeric strings; precision 30, scale 10 |
73
+ | `boolean` | `true`, `false`, `1`, `0`, or their lowercase string forms |
74
+ | `date` | `Date` objects or `YYYY-MM-DD` strings |
75
+ | `datetime` | Time objects or ISO 8601 strings with an explicit offset; stored in UTC |
76
+ | `json` | JSON-compatible objects, arrays, and scalar values; keys become strings |
77
+
78
+ Unknown fields raise `EavField::UnknownField`. Invalid assignments raise `EavField::InvalidValue` immediately. Every value in a batch is checked before any field is changed. Required fields participate in owner validation, and `false` and `0` count as present. Datetimes use microsecond precision. [SQLite's numeric storage](https://sqlite.org/datatype3.html#type_affinity) can lose precision for large decimal values; validate decimal behavior against your application's database.
79
+
80
+ Missing value records return the definition's default without creating a row. Assigning `nil` stores an explicit null override. Delete the override to restore the default:
81
+
82
+ ```ruby
83
+ product.eav_fields.delete(:color)
84
+ product.save!
85
+ product.eav_fields[:color] # => "black"
86
+ ```
87
+
88
+ Definition names, types, and entity scopes are immutable after creation. Defaults and `required` flags can be updated through `Product.eav_field_definitions`. Adding a required definition affects validation of existing owners on their next save.
89
+
90
+ ## Query fields
91
+
92
+ ```ruby
93
+ Product.with_eav_field(:color, "blue")
94
+ Product.where(name: "Chair").with_eav_field(:weight, "1.25")
95
+ Product.with_eav_field(:color, "blue").with_eav_field(:available, false)
96
+ ```
97
+
98
+ These return chainable Active Record relations with typed equality comparisons. Matching defaults include owners without a stored override; explicit null overrides remain distinct from non-null defaults. Queries use persisted values. JSON equality raises `EavField::UnsupportedQuery` because JSON comparisons differ between database adapters.
99
+
100
+ ## Persistence and lifecycle
101
+
102
+ The gem uses [Rails polymorphic associations](https://guides.rubyonrails.org/association_basics.html#polymorphic-associations) and [association autosave](https://api.rubyonrails.org/classes/ActiveRecord/AutosaveAssociation.html) to validate and save values in the owner's transaction. Unique indexes prevent duplicate definitions and duplicate values for an owner and field. The definition foreign key cascades deletion of its values.
103
+
104
+ The EAV tables and participating models must use the same Active Record database connection. Multiple databases and composite owner primary keys are outside the current scope.
105
+
106
+ Destroying an owner deletes its values and preserves definitions. Destroying a definition removes that field's stored values for all owners. Active Record operations that bypass callbacks, such as `delete_all`, also bypass owner cleanup. Polymorphic owner references have no database foreign key; use `destroy`/`destroy_all` when cleanup is required.
107
+
108
+ Definitions are shared across a model rather than scoped per tenant. The gem does not add controllers, an administration UI, or authorization policies.
109
+
110
+ ## Develop and verify
111
+
112
+ Run from this repository's root:
113
+
114
+ ```sh
115
+ bundle install
116
+ bundle exec rake test # Minitest, including real Rails installation and rollback.
117
+ bundle exec rubocop # Lint and check formatting.
118
+ bundle exec rubocop -a # Apply safe formatting corrections.
119
+ bundle exec rake # Tests and lint together.
120
+ bundle exec rake build # Build pkg/eav_field-0.1.0.gem.
121
+ ```
122
+
123
+ To check a different Rails series:
124
+
125
+ ```sh
126
+ RAILS_VERSION="~> 7.1.0" bundle update
127
+ RAILS_VERSION="~> 7.1.0" bundle exec rake test
128
+ bundle update # Restore the default Rails 8.1 development bundle.
129
+ ```
130
+
131
+ Implementation lives in `lib/eav_field/`; generators and migration templates live in `lib/generators/`; tests live in `test/` and use `*_test.rb` filenames. Use two-space Ruby indentation and the committed RuboCop configuration. CI covers Rails 7.1, 7.2, 8.0, and 8.1 on Ruby 3.2 and 3.3.
132
+
133
+ ## Releases
134
+
135
+ Update `lib/eav_field/version.rb` and `CHANGELOG.md`, run the tests and lint, then build the gem. Publish the reviewed package to RubyGems and create a matching `v<VERSION>` Git tag and GitHub release. Release packages are generated in `pkg/` and excluded from Git.
136
+
137
+ ## License and support
138
+
139
+ Released under the [MIT License](LICENSE). Report bugs and request features through [GitHub Issues](https://github.com/Mouados-byte/eav_field/issues). Published versions are available on [RubyGems](https://rubygems.org/gems/eav_field).
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EavField
4
+ class AttributeDefinition < ActiveRecord::Base
5
+ self.table_name = "eav_field_definitions"
6
+
7
+ has_many :values, class_name: "EavField::Value", inverse_of: :attribute_definition, dependent: :destroy
8
+
9
+ validates :entity_type, presence: true, length: { maximum: 255 }
10
+ validates :name, presence: true, length: { maximum: 100 },
11
+ format: { with: /\A[a-z][a-z0-9_]*\z/ }, uniqueness: { scope: :entity_type }
12
+ validates :data_type, inclusion: { in: Types::COLUMNS.keys }
13
+ validates :required, inclusion: { in: [ true, false ] }
14
+
15
+ before_validation :normalize_default
16
+ validate :identity_is_immutable, on: :update
17
+
18
+ def cast(value)
19
+ Types.cast(data_type, value)
20
+ end
21
+
22
+ def default
23
+ cast(default_value)
24
+ end
25
+
26
+ private
27
+
28
+ def normalize_default
29
+ return unless Types::COLUMNS.key?(data_type)
30
+
31
+ self.default_value = Types.dump(data_type, default_value)
32
+ rescue InvalidValue => error
33
+ errors.add(:default_value, error.message)
34
+ end
35
+
36
+ def identity_is_immutable
37
+ %w[ entity_type name data_type ].each do |attribute|
38
+ errors.add(attribute, "cannot change after creation") if will_save_change_to_attribute?(attribute)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EavField
4
+ class Error < StandardError; end
5
+ class UnknownField < Error; end
6
+ class InvalidValue < Error; end
7
+ class UnsupportedQuery < Error; end
8
+ class NotEnabled < Error; end
9
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EavField
4
+ class FieldSet
5
+ include Enumerable
6
+
7
+ def initialize(owner)
8
+ @owner = owner
9
+ end
10
+
11
+ def [](name)
12
+ @owner.read_eav_field(name)
13
+ end
14
+
15
+ def []=(name, value)
16
+ @owner.write_eav_field(name, value)
17
+ end
18
+
19
+ def delete(name)
20
+ @owner.delete_eav_field(name)
21
+ end
22
+
23
+ def fetch(name, *args, &block)
24
+ to_h.fetch(name, *args, &block)
25
+ end
26
+
27
+ def each(&block)
28
+ return enum_for(:each) unless block
29
+
30
+ to_h.each(&block)
31
+ end
32
+
33
+ def to_h
34
+ @owner.eav_fields_snapshot
35
+ end
36
+
37
+ def keys
38
+ to_h.keys
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EavField
4
+ module HasFields
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ def has_eav_fields
9
+ association = reflect_on_association(:eav_values)
10
+ return if association&.class_name == "EavField::Value"
11
+ raise NotEnabled, "#{name} already defines an eav_values association" if association
12
+
13
+ has_many :eav_values, -> { includes(:attribute_definition) }, as: :entity,
14
+ class_name: "EavField::Value", inverse_of: :entity, autosave: true, dependent: :destroy
15
+ validate :validate_required_eav_fields
16
+ end
17
+
18
+ def eav_field_definitions
19
+ ensure_eav_fields_enabled!
20
+ AttributeDefinition.where(entity_type: polymorphic_name)
21
+ end
22
+
23
+ def define_eav_field(name, type:, required: false, default: nil)
24
+ eav_field_definitions.create!(name: name.to_s, data_type: type.to_s,
25
+ required: required, default_value: default)
26
+ end
27
+
28
+ def find_eav_field!(name)
29
+ eav_field_definitions.find_by(name: name.to_s) ||
30
+ raise(UnknownField, "Unknown EAV field #{name.inspect} for #{self.name}")
31
+ end
32
+
33
+ def with_eav_field(name, value)
34
+ definition = find_eav_field!(name)
35
+ raise UnsupportedQuery, "JSON field equality is not portable across databases" if definition.data_type == "json"
36
+
37
+ cast_value = definition.cast(value)
38
+ entries = Value.where(attribute_definition_id: definition.id, entity_type: polymorphic_name)
39
+ matching = entries.where(Types.column_for(definition.data_type) => cast_value).select(:entity_id)
40
+ result = where(primary_key => matching)
41
+ if cast_value == definition.default
42
+ result = result.or(where.not(primary_key => entries.select(:entity_id)))
43
+ end
44
+ result
45
+ end
46
+
47
+ private
48
+
49
+ def ensure_eav_fields_enabled!
50
+ return if reflect_on_association(:eav_values)&.class_name == "EavField::Value"
51
+
52
+ raise NotEnabled, "Call has_eav_fields in #{name} before using EAV fields"
53
+ end
54
+ end
55
+
56
+ def eav_fields
57
+ self.class.send(:ensure_eav_fields_enabled!)
58
+ FieldSet.new(self)
59
+ end
60
+
61
+ # Hash assignment merges fields; omitted fields retain their current values.
62
+ def eav_fields=(fields)
63
+ raise ArgumentError, "eav_fields must be a hash" unless fields.respond_to?(:each_pair)
64
+
65
+ assignments = fields.each_pair.map do |name, value|
66
+ definition = self.class.find_eav_field!(name)
67
+ [ definition, definition.cast(value) ]
68
+ end
69
+ assignments.each { |definition, value| assign_eav_value(definition, value) }
70
+ end
71
+
72
+ def read_eav_field(name)
73
+ definition = self.class.find_eav_field!(name)
74
+ entry = eav_entry_for(definition)
75
+ entry ? entry.value : definition.default
76
+ end
77
+
78
+ def write_eav_field(name, value)
79
+ definition = self.class.find_eav_field!(name)
80
+ assign_eav_value(definition, definition.cast(value))
81
+ value
82
+ end
83
+
84
+ # Removing an override restores the definition's default on the next read.
85
+ def delete_eav_field(name)
86
+ definition = self.class.find_eav_field!(name)
87
+ entry = eav_entry_for(definition)
88
+ previous_value = entry ? entry.value : definition.default
89
+ entry&.mark_for_destruction
90
+ previous_value
91
+ end
92
+
93
+ def eav_fields_snapshot
94
+ entries = eav_values.reject { |entry| entry.marked_for_destruction? || entry.destroyed? }
95
+ .index_by(&:attribute_definition_id)
96
+ self.class.eav_field_definitions.each_with_object({}.with_indifferent_access) do |definition, fields|
97
+ entry = entries[definition.id]
98
+ fields[definition.name] = entry ? entry.value : definition.default
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def eav_entry_for(definition)
105
+ eav_values.detect do |entry|
106
+ entry.attribute_definition_id == definition.id && !entry.marked_for_destruction? && !entry.destroyed?
107
+ end
108
+ end
109
+
110
+ def assign_eav_value(definition, value)
111
+ entry = eav_entry_for(definition)
112
+ unless entry
113
+ deleted_entry = eav_values.detect do |candidate|
114
+ candidate.attribute_definition_id == definition.id && candidate.persisted? && candidate.marked_for_destruction?
115
+ end
116
+ entry = deleted_entry.reload if deleted_entry
117
+ end
118
+ entry ||= eav_values.build(attribute_definition: definition)
119
+ entry.value = value
120
+ end
121
+
122
+ def validate_required_eav_fields
123
+ self.class.eav_field_definitions.where(required: true).each do |definition|
124
+ entry = eav_entry_for(definition)
125
+ value = entry ? entry.value : definition.default
126
+ errors.add("eav_fields.#{definition.name}", "can't be blank") if Types.missing?(value)
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EavField
4
+ class Railtie < Rails::Railtie
5
+ generators do
6
+ require "generators/eav_field/install/install_generator"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+ require "time"
6
+
7
+ module EavField
8
+ module Types
9
+ COLUMNS = {
10
+ "string" => "string_value",
11
+ "text" => "text_value",
12
+ "integer" => "integer_value",
13
+ "decimal" => "decimal_value",
14
+ "boolean" => "boolean_value",
15
+ "date" => "date_value",
16
+ "datetime" => "datetime_value",
17
+ "json" => "json_value"
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ def column_for(type)
23
+ COLUMNS.fetch(type.to_s) { raise InvalidValue, "Unsupported field type: #{type.inspect}" }
24
+ end
25
+
26
+ def cast(type, value)
27
+ column_for(type)
28
+ return if value.nil?
29
+
30
+ case type.to_s
31
+ when "string", "text"
32
+ raise TypeError unless value.is_a?(String)
33
+ raise RangeError if type.to_s == "string" && value.length > 255
34
+
35
+ value
36
+ when "integer"
37
+ cast_integer(value)
38
+ when "decimal"
39
+ cast_decimal(value)
40
+ when "boolean"
41
+ cast_boolean(value)
42
+ when "date"
43
+ cast_date(value)
44
+ when "datetime"
45
+ cast_datetime(value)
46
+ when "json"
47
+ cast_json(value)
48
+ end
49
+ rescue ArgumentError, TypeError, RangeError
50
+ raise InvalidValue, "#{value.inspect} is not a valid #{type} value"
51
+ end
52
+
53
+ def dump(type, value)
54
+ cast_value = cast(type, value)
55
+ return if cast_value.nil?
56
+
57
+ case type.to_s
58
+ when "decimal"
59
+ cast_value.to_s("F")
60
+ when "date"
61
+ cast_value.iso8601
62
+ when "datetime"
63
+ cast_value.iso8601(6)
64
+ else
65
+ cast_value
66
+ end
67
+ end
68
+
69
+ def missing?(value)
70
+ value != false && value.blank?
71
+ end
72
+
73
+ def cast_integer(value)
74
+ number = if value.is_a?(Integer)
75
+ value
76
+ elsif value.is_a?(String) && value.match?(/\A[+-]?\d+\z/)
77
+ Integer(value, 10)
78
+ else
79
+ raise TypeError
80
+ end
81
+ raise RangeError unless (-(2**63)...(2**63)).cover?(number)
82
+
83
+ number
84
+ end
85
+
86
+ def cast_decimal(value)
87
+ raise TypeError unless value.is_a?(String) || value.is_a?(Integer) || value.is_a?(Float) || value.is_a?(BigDecimal)
88
+
89
+ number = BigDecimal(value.to_s)
90
+ raise RangeError unless number.finite? && number.abs < 10**20
91
+ raise RangeError if number.to_s("F").split(".").last.length > 10
92
+
93
+ number
94
+ end
95
+
96
+ def cast_boolean(value)
97
+ return value if value == true || value == false
98
+ return value == 1 if value.is_a?(Integer) && [ 0, 1 ].include?(value)
99
+ return true if value.is_a?(String) && [ "true", "1" ].include?(value)
100
+ return false if value.is_a?(String) && [ "false", "0" ].include?(value)
101
+
102
+ raise TypeError
103
+ end
104
+
105
+ def cast_date(value)
106
+ return value.to_date if value.is_a?(Date)
107
+ raise TypeError unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
108
+
109
+ Date.iso8601(value)
110
+ end
111
+
112
+ def cast_datetime(value)
113
+ return value.getutc.round(6) if value.is_a?(Time)
114
+ return value.utc.round(6) if value.is_a?(ActiveSupport::TimeWithZone)
115
+ return Time.iso8601(value.iso8601(6)).utc if value.is_a?(DateTime)
116
+
117
+ unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})\z/)
118
+ raise TypeError
119
+ end
120
+
121
+ Date.iso8601(value[0, 10])
122
+ Time.iso8601(value).utc.round(6)
123
+ end
124
+
125
+ def cast_json(value)
126
+ case value
127
+ when Hash
128
+ value.to_h do |key, item|
129
+ raise TypeError unless key.is_a?(String) || key.is_a?(Symbol)
130
+
131
+ [ key.to_s, cast_json(item) ]
132
+ end
133
+ when Array
134
+ value.map { |item| cast_json(item) }
135
+ when Float
136
+ raise RangeError unless value.finite?
137
+
138
+ value
139
+ when String, Integer, TrueClass, FalseClass, NilClass
140
+ value
141
+ else
142
+ raise TypeError
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EavField
4
+ class Value < ActiveRecord::Base
5
+ self.table_name = "eav_field_values"
6
+
7
+ belongs_to :attribute_definition, class_name: "EavField::AttributeDefinition", inverse_of: :values, optional: false
8
+ belongs_to :entity, polymorphic: true, inverse_of: :eav_values, optional: false
9
+
10
+ validates :attribute_definition_id, uniqueness: { scope: [ :entity_type, :entity_id ] }
11
+ validate :definition_matches_entity
12
+ validate :typed_columns_are_valid
13
+
14
+ def value
15
+ self[Types.column_for(attribute_definition.data_type)]
16
+ end
17
+
18
+ def value=(input)
19
+ cast_value = attribute_definition.cast(input)
20
+ Types::COLUMNS.each_value { |column| self[column] = nil }
21
+ self[Types.column_for(attribute_definition.data_type)] = cast_value
22
+ end
23
+
24
+ private
25
+
26
+ def definition_matches_entity
27
+ return unless entity && attribute_definition
28
+
29
+ unless entity.class.reflect_on_association(:eav_values) && entity.class.polymorphic_name == attribute_definition.entity_type
30
+ errors.add(:attribute_definition, "belongs to a different entity type")
31
+ end
32
+ end
33
+
34
+ def typed_columns_are_valid
35
+ return unless attribute_definition && Types::COLUMNS.key?(attribute_definition.data_type)
36
+
37
+ active_column = Types.column_for(attribute_definition.data_type)
38
+ if Types::COLUMNS.values.any? { |column| column != active_column && !self[column].nil? }
39
+ errors.add(:value, "must use only the column matching its field type")
40
+ end
41
+ attribute_definition.cast(value)
42
+ if attribute_definition.required? && Types.missing?(value)
43
+ errors.add(:value, "can't be blank")
44
+ end
45
+ rescue InvalidValue => error
46
+ errors.add(:value, error.message)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EavField
4
+ VERSION = "0.1.0"
5
+ end
data/lib/eav_field.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/concern"
5
+ require "active_support/core_ext/object/blank"
6
+ require "active_support/core_ext/hash/indifferent_access"
7
+
8
+ require "eav_field/version"
9
+ require "eav_field/errors"
10
+ require "eav_field/types"
11
+ require "eav_field/attribute_definition"
12
+ require "eav_field/value"
13
+ require "eav_field/field_set"
14
+ require "eav_field/has_fields"
15
+
16
+ ActiveSupport.on_load(:active_record) do
17
+ include EavField::HasFields
18
+ end
19
+
20
+ require "eav_field/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record/migration"
5
+
6
+ module EavField
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ desc "Create the EAV field definitions and values tables."
13
+
14
+ class_option :entity_id_type, type: :string, default: "bigint", enum: %w[ bigint uuid string ],
15
+ desc: "ID type of the existing models that will use EAV fields"
16
+
17
+ def copy_migration
18
+ migration_template "create_eav_field_tables.rb.tt", "db/migrate/create_eav_field_tables.rb"
19
+ end
20
+
21
+ private
22
+
23
+ def migration_version
24
+ "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateEavFieldTables < ActiveRecord::Migration[<%= migration_version %>]
4
+ def change
5
+ create_table :eav_field_definitions do |t|
6
+ t.string :entity_type, null: false
7
+ t.string :name, limit: 100, null: false
8
+ t.string :data_type, limit: 20, null: false
9
+ t.boolean :required, default: false, null: false
10
+ t.json :default_value
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :eav_field_definitions, [ :entity_type, :name ], unique: true,
15
+ name: "index_eav_field_definitions_on_entity_and_name"
16
+
17
+ create_table :eav_field_values do |t|
18
+ t.references :attribute_definition, null: false,
19
+ foreign_key: { to_table: :eav_field_definitions, on_delete: :cascade }
20
+ t.string :entity_type, null: false
21
+ t.column :entity_id, :<%= options[:entity_id_type] %>, null: false
22
+ t.string :string_value
23
+ t.text :text_value
24
+ t.bigint :integer_value
25
+ t.decimal :decimal_value, precision: 30, scale: 10
26
+ t.boolean :boolean_value
27
+ t.date :date_value
28
+ t.datetime :datetime_value, precision: 6
29
+ t.json :json_value
30
+ t.timestamps
31
+ end
32
+
33
+ add_index :eav_field_values, [ :entity_type, :entity_id, :attribute_definition_id ], unique: true,
34
+ name: "index_eav_field_values_on_entity_and_definition"
35
+ end
36
+ end
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: eav_field
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mouad Imam
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.1'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: json
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '3.0'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '2.0'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '3.0'
53
+ - !ruby/object:Gem::Dependency
54
+ name: railties
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '7.1'
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '9.0'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '7.1'
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '9.0'
73
+ description: Add database-defined, validated, queryable fields to existing Rails models
74
+ without altering their tables.
75
+ email:
76
+ executables: []
77
+ extensions: []
78
+ extra_rdoc_files: []
79
+ files:
80
+ - CHANGELOG.md
81
+ - LICENSE
82
+ - README.md
83
+ - lib/eav_field.rb
84
+ - lib/eav_field/attribute_definition.rb
85
+ - lib/eav_field/errors.rb
86
+ - lib/eav_field/field_set.rb
87
+ - lib/eav_field/has_fields.rb
88
+ - lib/eav_field/railtie.rb
89
+ - lib/eav_field/types.rb
90
+ - lib/eav_field/value.rb
91
+ - lib/eav_field/version.rb
92
+ - lib/generators/eav_field/install/install_generator.rb
93
+ - lib/generators/eav_field/install/templates/create_eav_field_tables.rb.tt
94
+ homepage: https://github.com/Mouados-byte/eav_field
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ rubygems_mfa_required: 'true'
99
+ allowed_push_host: https://rubygems.org
100
+ source_code_uri: https://github.com/Mouados-byte/eav_field
101
+ documentation_uri: https://github.com/Mouados-byte/eav_field#readme
102
+ bug_tracker_uri: https://github.com/Mouados-byte/eav_field/issues
103
+ changelog_uri: https://github.com/Mouados-byte/eav_field/blob/v0.1.0/CHANGELOG.md
104
+ post_install_message:
105
+ rdoc_options: []
106
+ require_paths:
107
+ - lib
108
+ required_ruby_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '3.2'
113
+ required_rubygems_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ requirements: []
119
+ rubygems_version: 3.5.22
120
+ signing_key:
121
+ specification_version: 4
122
+ summary: Typed entity-attribute-value fields for Active Record models.
123
+ test_files: []