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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +139 -0
- data/lib/eav_field/attribute_definition.rb +42 -0
- data/lib/eav_field/errors.rb +9 -0
- data/lib/eav_field/field_set.rb +41 -0
- data/lib/eav_field/has_fields.rb +130 -0
- data/lib/eav_field/railtie.rb +9 -0
- data/lib/eav_field/types.rb +146 -0
- data/lib/eav_field/value.rb +49 -0
- data/lib/eav_field/version.rb +5 -0
- data/lib/eav_field.rb +20 -0
- data/lib/generators/eav_field/install/install_generator.rb +28 -0
- data/lib/generators/eav_field/install/templates/create_eav_field_tables.rb.tt +36 -0
- metadata +123 -0
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,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,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
|
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: []
|