Active Record で feature options を試してみる

先日読んだ「システム開発・刷新のためのデータモデル大全 著:渡部幸三」を読んでいました。
その中で、feature optionsという考え方が紹介されていました。
ある対象の属性群を別のテーブルで持つという考え方のものとなり、似たようなものを考えこそすれ、十分検討されたものだと感じました。

この考え方を踏まえ Active Record で試すとどうなるか確認します。

サンプルコード

後述するテストより先コードを見たければこちら。

サンプルコード

参考

- システム開発・刷新のためのデータモデル大全 著:渡部幸三

feature options 導入 1

想定テーブル

以下のようなテーブルでカラムがあることを想定します。

  • products
    • id
    • name
    • price
    • created_at
    • updated_at
  • Product_options
    • id
    • product_id
    • feature_category
    • option_value
    • created_at
    • updated_at

マイグレーション

以下設定でマイグレーションを作成。

db/migrate/20250516144446_add_products.rb
1
2
3
4
5
6
7
8
9
10
class AddProducts < ActiveRecord::Migration[8.0]
def change
create_table :products do |t|
t.string :name, null: false
t.decimal :price, precision: 10, scale: 2, null: false

t.timestamps
end
end
end
db/migrate/20250516144501_add_product_options.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
class AddProductOptions < ActiveRecord::Migration[8.0]
def change
create_table :product_options do |t|
t.references :product, null: false, foreign_key: true
t.string :feature_category, null: false
t.string :option_value, null: false

t.timestamps
end

add_index :product_options, [:product_id, :feature_category], unique: true
end
end

以上を適用。

1
$ bundle exec rails db:migrate

想定データ

以下のデータが入っているものとします(created_at、updated_atは省略)。

products テーブル

id name price
1 商品A 1000
2 商品B 2000

product_options テーブル

id product_id feature_category option_value
1 1 color RED
2 1 size L
3 2 color BLUE
4 2 size M
db/seeds.rb
1
2
3
4
5
6
7
8
9
10
11
Product.create([
{ name: "商品A", price: 1000 },
{ name: "商品B", price: 2000 }
])

ProductOption.create([
{ product_id: 1, feature_category: "color", option_value: "RED" },
{ product_id: 1, feature_category: "size", option_value: "L" },
{ product_id: 2, feature_category: "color", option_value: "BLUE" },
{ product_id: 2, feature_category: "size", option_value: "M" }
])

以上を適用。

1
$ bundle exec rails db:seed

データ引き当て

データ引き当てに当たって以下のような構造で一発引き当てしたい。

id name price color size
1 商品A 1000 RED L

以下のように実装できる。

lib/tasks/test.rake
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
namespace :test do
desc "テスト用データ引き当て"
task :test_task => :environment do
result = Product.joins(:product_options)
.select("products.id, products.name, products.price,
MAX(CASE WHEN product_options.feature_category = 'color' THEN product_options.option_value END) AS color,
MAX(CASE WHEN product_options.feature_category = 'size' THEN product_options.option_value END) AS size")
.group("products.id, products.name, products.price")
.having("color IS NOT NULL")
.having("size IS NOT NULL")
pp result
# => [#<Product:0x00007fc0e2c10db8 id: 1, name: "商品A", price: 0.1e4, color: "RED", size: "L">,
# #<Product:0x00007fc0e5a73fc0 id: 2, name: "商品B", price: 0.2e4, color: "BLUE", size: "M">]

# 別解として全カラム埋まっていることを前提としないならシンプルに以下のように記述できる
result = Product
#.where(id: 1)
.joins(:product_options)
.select("products.id, products.name, products.price,
MAX(CASE WHEN product_options.feature_category = 'color' THEN product_options.option_value END) AS color,
MAX(CASE WHEN product_options.feature_category = 'size' THEN product_options.option_value END) AS size")
.group("products.id")

pp result
# => [#<Product:0x00007fc0e2c10db8 id: 1, name: "商品A", price: 0.1e4, color: "RED", size: "L">,
# #<Product:0x00007fc0e5a73fc0 id: 2, name: "商品B", price: 0.2e4, color: "BLUE", size: "M">]


result = Product
.where(id: 1)
.joins(:product_options)
.select("products.id, products.name, products.price,
MAX(CASE WHEN product_options.feature_category = 'color' THEN product_options.option_value END) AS color,
MAX(CASE WHEN product_options.feature_category = 'size' THEN product_options.option_value END) AS size")
.group("products.id")

pp result
# => [#<Product:0x00007fd02d2d6e00 id: 1, name: "商品A", price: 0.1e4, color: "RED", size: "L">]
end
end

joins 以後はscopeにまとめて、以下のようにモデルに記述できる。

app/models/product.rb
1
2
3
4
5
6
7
8
9
10
class Product < ApplicationRecord
has_many :product_options, dependent: :destroy

validates :name, presence: true
validates :price, presence: true, numericality: { greater_than_or_equal_to: 0 }

scope :with_product_options, -> { joins(:product_options).select("products.id, products.name, products.price,
MAX(CASE WHEN product_options.feature_category = 'color' THEN product_options.option_value END) AS color,
MAX(CASE WHEN product_options.feature_category = 'size' THEN product_options.option_value END) AS size").group("products.id") }
end
lib/tasks/test.rake
1
2
3
4
5
6
7
8
9
10
11
namespace :test do
desc "テスト用データ引き当て"
task :test_task => :environment do
# 省略
result = Product.where(id: 1).with_product_options

pp result
# => [#<Product:0x00007fd02d2d6e00 id: 1, name: "商品A", price: 0.1e4, color: "RED", size: "L">]
end
end

仮に product_options テーブルに設定されたものを事前に定義できないならば、以下のように動的な定義もできる。

lib/tasks/test.rake
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace :test do
desc "テスト用データ引き当て"
task :test_task => :environment do
# 省略

product_id = 1
feature_category = ProductOption.where(product_id: product_id).pluck(:feature_category)

dynamic_definition_query =
feature_category.map do |category|
"MAX(CASE WHEN product_options.feature_category = '#{category}' THEN product_options.option_value END) AS #{category}"
end

result =
Product.joins(:product_options).select("products.id, products.name, products.price, #{dynamic_definition_query.join(', ')}")
.group("products.id")

pp result
end
end

ただし、このようなものを実行するとき、おそらくProductOption.where(product_id: product_id) で取ってきてならべる方がよく使いそうではある。

feature options 導入 2

値タイプの導入

書籍の記載では、オプションとして持つ値には、値タイプを組み込むこむ設計が記載されているので、こちらを導入します。

db/migrate/20250516144501_add_product_options.rb
1
2
3
4
5
6
7
8
9
class AddValueTypeToProductOptions < ActiveRecord::Migration[8.0]
def up
add_column :product_options, :value_type, :string, null: false, default: 'string'
end

def down
remove_column :product_options, :value_type
end
end

こちらを用意したことで、FO評価式を導入できます。

例として、上げられているものを参考にActive Recordで動かすことを前提とすると以下のようなものです。

  • List (1.0 20. 3.0)
  • Range (1.0 ... 10.0)
  • Script (Stock.while(id: 1))

これを評価し、値を出していくことになります。

テーブル拡張

先のスクリプトの実行を前提とし、任意の先へクエリを叩くために、素材在庫テーブルと製品在庫テーブルを別個に用意します。
(今回はテーブルの構造を合わせていますが、実際は異なるものであるというケースが想定されます。)

db/migrate/20250516163634_add_product_stack.rb
1
2
3
4
5
6
7
8
9
class AddProductStack < ActiveRecord::Migration[8.0]
def change
create_table :product_stacks do |t|
t.integer :stack, null: false

t.timestamps
end
end
end
db/migrate/20250516163651_add_material_stack.rb
1
2
3
4
5
6
7
8
9
class AddMaterialStack < ActiveRecord::Migration[8.0]
def change
create_table :material_stacks do |t|
t.integer :stack, null: false

t.timestamps
end
end
end

下記seedでデータを再定義します。

db/seeds.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Product.destroy_all
ProductOption.destroy_all
ProductStock.destroy_all
MaterialStock.destroy_all

ProductStock.create([{ stock: 10 }])
MaterialStock.create([{ stock: 100 }])

product1 = Product.create([
{ name: "完成品商品A", price: 1000 },
]).first

product2 = Product.create([
{ name: "素材商品B", price: 2000 }
]).first

ProductOption.create!([
{ product_id: product1.id, feature_category: "color", option_value: "RED", value_type: "string" },
{ product_id: product1.id, feature_category: "size", option_value: "L" , value_type: "string" },
{ product_id: product1.id, feature_category: "weight", option_value: "1" , value_type: "integer" },
{ product_id: product1.id, feature_category: "tolerance", option_value: "0.01 ... 0.1" , value_type: "range" },
{ product_id: product1.id, feature_category: "stack", option_value: "ProductStock.find(1).stock" , value_type: "script" },
{ product_id: product2.id, feature_category: "color", option_value: "BLUE", value_type: "string" },
{ product_id: product2.id, feature_category: "size", option_value: "M", value_type: "string" },
{ product_id: product2.id, feature_category: "weight", option_value: "200" , value_type: "integer" },
{ product_id: product2.id, feature_category: "serial", option_value: "1,'0002',0003,\"0004\"" , value_type: "list" },
{ product_id: product2.id, feature_category: "stack", option_value: "MaterialStock.find(1).stock" , value_type: "script" }
])

本書の例として、FO評価式の前提になるListであるとかの定義はデータに持つようでしたが、value_typeにすべて持たせる形を取りました。
serial は"1,'0002',0003,\"0004\"" のような入れ方を実際するとは考えにくいですが、変換の確認のためこのようにします。

データ引き当て

引き当てのため、Productモデルにメソッドを追加します。
product_options に記録された値は、product モデルにはないものですから、method_missing から処理を拾います。
value_type に応じて、任意のデータ型へ変換します。

app/models/product.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
class Product < ApplicationRecord
has_many :product_options, dependent: :destroy

validates :name, presence: true
validates :price, presence: true, numericality: { greater_than_or_equal_to: 0 }

scope :with_product_options, -> {
joins(:product_options)
.select("products.id, products.name, products.price,
MAX(CASE WHEN product_options.feature_category = 'color' THEN product_options.option_value END) AS product_option_value_color,
MAX(CASE WHEN product_options.feature_category = 'color' THEN product_options.value_type END) AS product_option_type_color,
MAX(CASE WHEN product_options.feature_category = 'size' THEN product_options.option_value END) AS product_option_value_size,
MAX(CASE WHEN product_options.feature_category = 'size' THEN product_options.value_type END) AS product_option_type_size,
MAX(CASE WHEN product_options.feature_category = 'weight' THEN product_options.option_value END) AS product_option_value_weight,
MAX(CASE WHEN product_options.feature_category = 'weight' THEN product_options.value_type END) AS product_option_type_weight,
MAX(CASE WHEN product_options.feature_category = 'tolerance' THEN product_options.option_value END) AS product_option_value_tolerance,
MAX(CASE WHEN product_options.feature_category = 'tolerance' THEN product_options.value_type END) AS product_option_type_tolerance,
MAX(CASE WHEN product_options.feature_category = 'serial' THEN product_options.option_value END) AS product_option_value_serial,
MAX(CASE WHEN product_options.feature_category = 'serial' THEN product_options.value_type END) AS product_option_type_serial,
MAX(CASE WHEN product_options.feature_category = 'stack' THEN product_options.option_value END) AS product_option_value_stack,
MAX(CASE WHEN product_options.feature_category = 'stack' THEN product_options.value_type END) AS product_option_type_stack"
)
.group("products.id")
}

# モデルに定義が無い場合に発火するメソッド
def method_missing(method_name, *args, &block)
super unless is_feature_option_value?(method_name)

get_product_option_value(method_name)
end

private
def is_feature_option_value?(method_name)
attributes_hash = self.attributes
attributes_hash.dig(get_product_option_value_key(method_name)).presence &&
attributes_hash.dig(get_product_option_type_key(method_name)).presence &&
["script", "list", "range", "string", "integer"].include?(attributes_hash.dig(get_product_option_type_key(method_name)))
end

def get_product_option_value_key(method_name)
"product_option_value_#{method_name}"
end

def get_product_option_type_key(method_name)
"product_option_type_#{method_name}"
end

def get_product_option_value(method_name)
attributes_hash = self.attributes
org_value = attributes_hash.dig(get_product_option_value_key(method_name))
value_type = attributes_hash.dig(get_product_option_type_key(method_name)).presence

return org_value if value_type == "string"
return org_value.to_i if value_type == "integer"
return get_product_option_range_value(method_name, org_value) if value_type == "range"
return get_product_option_list_value(method_name, org_value) if value_type == "list"
return get_product_option_script_value(method_name, org_value) if value_type == "script"
end

def get_product_option_range_value(method_name, org_value)
mode =
if org_value.include?("...")
"..."
elsif org_value.include?("..")
".."
else
"ERROR"
end

raise ArgumentError, "#{method_name} is not range format" if mode == "ERROR"

range_start, range_end = org_value.split(mode).map(&:strip)
range_start = range_start.to_f if range_start =~ /\A\d+(\.\d+)?\z/
range_end = range_end.to_f if range_end =~ /\A\d+(\.\d+)?\z/

return Range.new(range_start, range_end, true) if mode == "..."
Range.new(range_start, range_end)
end

def get_product_option_list_value(method_name, org_value)
tmp = org_value.split(",").map(&:strip)
raise ArgumentError, "#{method_name} is not list format" if tmp.empty?

tmp.map do |v|
if v =~ /\A\d+(\.\d+)?\z/
v.to_f
elsif v =~ /\A\d+\z/
v.to_i
else
v.tr("'", "")
end
end
end

def get_product_option_script_value(method_name, org_value)
eval(org_value)
end
end
lib/tasks/test.rake
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
namespace :test do
# 省略

desc "テスト用データ引き当て2"
task :test_task2 => :environment do

result = Product
.joins(:product_options)
.with_product_options

pp "--0--"
pp result[0]
pp "result[0].color :#{result[0].color}"
pp "result[0].size :#{result[0].size}"
pp "result[0].weight :#{result[0].weight}"
pp "result[0].tolerance :#{result[0].tolerance}"
pp "result[0].tolerance.cover(0.05) :#{result[0].tolerance.cover?(0.05)}"
pp "result[0].stack :#{result[0].stack}"

pp "--1--"
pp result[1]
pp "result[1].color :#{result[1].color}"
pp "result[1].size :#{result[1].size}"
pp "result[1].weight :#{result[1].weight}"
pp "result[1].serial :#{result[1].serial}"
pp "result[1].stack :#{result[1].stack}"
end
end

実行結果は次のようになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
> bundle exec rails test:test_task2
"--0--"
#<Product:0x00007fde5b89c0b0
id: 37,
name: "完成品商品A",
price: 0.1e4,
product_option_value_color: "RED",
product_option_type_color: "string",
product_option_value_size: "L",
product_option_type_size: "string",
product_option_value_weight: "1",
product_option_type_weight: "integer",
product_option_value_tolerance: "0.01 ... 0.1",
product_option_type_tolerance: "range",
product_option_value_serial: nil,
product_option_type_serial: nil,
product_option_value_stack: "ProductStock.find(1).stock",
product_option_type_stack: "script">
"result[0].color :RED"
"result[0].size :L"
"result[0].weight :1"
"result[0].tolerance :0.01...0.1"
"result[0].tolerance.cover(0.05) :true"
"result[0].stack :10"
"--1--"
#<Product:0x00007fde66884388
id: 38,
name: "素材商品B",
price: 0.2e4,
product_option_value_color: "BLUE",
product_option_type_color: "string",
product_option_value_size: "M",
product_option_type_size: "string",
product_option_value_weight: "200",
product_option_type_weight: "integer",
product_option_value_tolerance: nil,
product_option_type_tolerance: nil,
product_option_value_serial: "1,'0002',0003,\"0004\"",
product_option_type_serial: "list",
product_option_value_stack: "MaterialStock.find(1).stock",
product_option_type_stack: "script">
"result[1].color :BLUE"
"result[1].size :M"
"result[1].weight :200"
"result[1].serial :[1.0, \"0002\", 3.0, \"\\\"0004\\\"\"]"
"result[1].stack :100"

想定した任意のデータ型への変更がおこなわれ、それらの内蔵のメソッドやeval実行した結果を取できています。

ですが、evalを使用している状態、決して安全とは言い難いので対策します。
この実装をしていた際に、get_product_option_script_valueもメソッド名を書いたところで、次のサジェストがあった。

危険性があるので、evalを使用する場合は注意が必要
ここでは、evalを使用しているが、実際のアプリケーションでは
evalを使用しない方法を検討することが望ましい
例えば、セキュリティ上の理由から、evalを使用せずに
スクリプトを実行する方法を検討することが望ましい

とコメントがサジェストされたので、今回やっている実装は結構頻出のもので、学習済みのモノであるようです。

evalの代替とfeature options機能の切り出し

evalを使用しないための対応と、併せてfeature optionsの機能をconcernsに切り出してみます。

Product モデルは、かなりシンプルになります。

app/models/product.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Product < ApplicationRecord
OPTION_CATEGORIES = %w[color size weight tolerance serial stack].freeze
OPTION_SCRIPT_METHODS = {
GET_PRODUCT_STACK_VALUE: Proc.new { |id| ProductStock.find(id).stock },
GET_MATERIAL_STACK_VALUE: Proc.new { |id| MaterialStock.find(id).stock },
}

include FeatureOption

has_many :product_options, dependent: :destroy

validates :name, presence: true
validates :price, presence: true, numericality: { greater_than_or_equal_to: 0 }
end

OPTION_SCRIPT_METHODSに実行可能な処理を登録しておきます。
OPTION_SCRIPT_METHODSをモデルの中に持ってしまいましたが、衝突など起こさないように全体の定数として定義するのが望ましいでしょう。

ロジックのほとんどは、app/models/concerns/feature_option.rbに移動しました。

app/models/concerns/feature_option.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
module FeatureOption
extend ActiveSupport::Concern

included do
# 読み込まれた際にOPTION_CATEGORIESの定義が無ければエラーにする
if defined?(base_class.name.constantize::OPTION_CATEGORIES) != "constant"
raise ArgumentError, "OPTION_CATEGORIES is not defined"
end

scope :with_options, -> (categories = nil) {
## feature options を使用するとき、親のテーブルと小テーブルの名称は以下のようにする。
## 親テーブル名 = products
## 子テーブル名 = [親テーブル名単数形]_options
model_name = self.name.downcase.singularize
table_name = self.table_name

joins("#{model_name}_options".to_sym)
.select("#{table_name}.*, #{get_option_query(model_name, categories)}")
.group("#{table_name}.id")
}
end

class_methods do
def get_option_query(model_name, categories)
target_categories = categories || base_class::OPTION_CATEGORIES
table_name = "#{model_name}_options"

target_categories.map do |category|
"MAX(CASE WHEN #{table_name}.feature_category = '#{category}' THEN #{table_name}.option_value END) AS #{get_option_value_key(model_name, category)},
MAX(CASE WHEN #{table_name}.feature_category = '#{category}' THEN #{table_name}.value_type END) AS #{get_option_type_key(model_name, category)}"
end.join(",")
end

def get_option_value_key(model_name, method_name)
"#{model_name}_option_value_#{method_name}"
end

def get_option_type_key(model_name, method_name)
"#{model_name}_option_type_#{method_name}"
end
end

# モデルに定義が無い場合に発火するメソッド
def method_missing(method_name, *args, &block)
super unless is_feature_option_value?(method_name)

get_option_value(method_name)
end

private

def get_option_value_key(model_name, method_name)
"#{model_name}_option_value_#{method_name}"
end

def get_option_type_key(model_name, method_name)
"#{model_name}_option_type_#{method_name}"
end

def is_feature_option_value?(method_name)
attributes_hash = self.attributes
model_name = self.class.name.downcase.singularize

attributes_hash.dig(self.get_option_value_key(model_name, method_name)).presence &&
attributes_hash.dig(self.get_option_type_key(model_name, method_name)).presence &&
["script", "list", "range", "string", "integer"].include?(attributes_hash.dig(get_option_type_key(model_name, method_name)))
end

def get_option_value(method_name)
attributes_hash = self.attributes
model_name = self.class.name.downcase.singularize
org_value = attributes_hash.dig(self.get_option_value_key(model_name, method_name))
value_type = attributes_hash.dig(self.get_option_type_key(model_name, method_name)).presence

return org_value if value_type == "string"
return org_value.to_i if value_type == "integer"
return get_option_range_value(method_name, org_value) if value_type == "range"
return get_option_list_value(method_name, org_value) if value_type == "list"
return get_option_script_value(method_name, org_value) if value_type == "script"
end

def get_option_range_value(method_name, org_value)
mode =
if org_value.include?("...")
"..."
elsif org_value.include?("..")
".."
else
"ERROR"
end

raise "FeatureOption::NotRangeFormat", "#{method_name} is not range format" if mode == "ERROR"

range_start, range_end = org_value.split(mode).map(&:strip)
range_start = range_start.to_f if range_start =~ /\A\d+(\.\d+)?\z/
range_end = range_end.to_f if range_end =~ /\A\d+(\.\d+)?\z/

return Range.new(range_start, range_end, true) if mode == "..."
Range.new(range_start, range_end)
end

def get_option_list_value(method_name, org_value)
tmp = org_value.split(",").map(&:strip)
raise "FeatureOption::NotListFormat", "#{method_name} is not list format, org_value: #{org_value}, #{e.message}" if tmp.empty?

string_to_value(tmp)
end

def string_to_value(string_list)
string_list.map do |v|
if v =~ /\A\d+(\.\d+)?\z/
v.to_f
elsif v =~ /\A\d+\z/
v.to_i
else
v.tr("'", "")
end
end
end

def get_option_script_value(method_name, org_value)
script_name, args = org_value.split(":").map(&:strip)
if args.present?
args = args.split(",").map(&:strip)
args = string_to_value(args)
end

raise "FeatureOption::NotDefinedOptionScriptMethods" unless self.class.const_defined?(:OPTION_SCRIPT_METHODS)
raise "FeatureOption::NotAllowMethod" unless allow_method_names.include?(script_name)

self.class::OPTION_SCRIPT_METHODS[script_name.to_sym].call(*args)
end

def allow_method_names
self.class::OPTION_SCRIPT_METHODS.keys.map(&:to_s)
end
end

seed の登録データも修正します。
スクリプトを生で書くのではなく、GET_MATERIAL_STACK_VALUE:#{material_stock_1.id} と呼び出しスクリプトとデータにします。

db/seeds.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Product.destroy_all
ProductOption.destroy_all
ProductStock.destroy_all
MaterialStock.destroy_all

product_stock_1 = ProductStock.create([{ stock: 10 }]).first
material_stock_1 = MaterialStock.create([{ stock: 100 }]).first

product1 = Product.create([
{ name: "完成品商品A", price: 1000 },
]).first

product2 = Product.create([
{ name: "素材商品B", price: 2000 }
]).first

ProductOption.create!([
{ product_id: product1.id, feature_category: "color", option_value: "RED", value_type: "string" },
{ product_id: product1.id, feature_category: "size", option_value: "L" , value_type: "string" },
{ product_id: product1.id, feature_category: "weight", option_value: "1" , value_type: "integer" },
{ product_id: product1.id, feature_category: "tolerance", option_value: "0.01 ... 0.1" , value_type: "range" },
{ product_id: product1.id, feature_category: "stack", option_value: "GET_PRODUCT_STACK_VALUE:#{product_stock_1.id}" , value_type: "script" },
{ product_id: product2.id, feature_category: "color", option_value: "BLUE" },
{ product_id: product2.id, feature_category: "size", option_value: "M" },
{ product_id: product2.id, feature_category: "weight", option_value: "200" , value_type: "integer" },
{ product_id: product2.id, feature_category: "serial", option_value: "1,'0002',0003,\"0004\"" , value_type: "list" },
{ product_id: product2.id, feature_category: "stack", option_value: "GET_MATERIAL_STACK_VALUE:#{material_stock_1.id}" , value_type: "script" }
])

これらを呼び出す側は、これまでと変わらずに使えます。

lib/tasks/test.rake
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
namespace :test do
desc "テスト用データ引き当て3"
task :test_task3 => :environment do

# オプションすべてを取得する
result = Product.joins(:product_options).with_options()

pp result[0]
pp "result[0].color :#{result[0].color}"
pp "result[0].size :#{result[0].size}"
pp "result[0].weight :#{result[0].weight}"
pp "result[0].tolerance :#{result[0].tolerance}"
pp "result[0].tolerance.cover(0.05) :#{result[0].tolerance.cover?(0.05)}"
pp "result[0].stack :#{result[0].stack}"

pp "--1--"
# オプションの一部(color)を取得する
result = Product.joins(:product_options).where(id: 51).with_options(["color"]).first
pp "result.color :#{result.color}"
pp "result.size :#{result.size}"
pp "result.weight :#{result.weight}"
pp "result.serial :#{result.serial}"
pp "result.stack :#{result.stack}"
end
end

実行すると次の通りとなります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
> bundle exec rails test:test_task3
"--0--"
#<Product:0x00007f1d57a11b40
id: 51,
name: "完成品商品A",
price: 0.1e4,
created_at: "2025-05-17 08:42:44.126958000 +0000",
updated_at: "2025-05-17 08:42:44.126958000 +0000",
product_option_value_color: "RED",
product_option_type_color: "string",
product_option_value_size: "L",
product_option_type_size: "string",
product_option_value_weight: "1",
product_option_type_weight: "integer",
product_option_value_tolerance: "0.01 ... 0.1",
product_option_type_tolerance: "range",
product_option_value_serial: nil,
product_option_type_serial: nil,
product_option_value_stack: "GET_PRODUCT_STACK_VALUE:25",
product_option_type_stack: "script">
"result[0].color :RED"
"result[0].size :L"
"result[0].weight :1"
"result[0].tolerance :0.01...0.1"
"result[0].tolerance.cover(0.05) :true"
"result[0].stack :10"
"--1--"
"result.color :RED"
bin/rails aborted!
NoMethodError: undefined method 'size' for an instance of Product (NoMethodError)
/usr/src/app/vendor/bundle/ruby/3.4.0/gems/activemodel-8.0.2/lib/active_model/attribute_methods.rb:512:in 'ActiveModel::AttributeMethods#method_missing'
/usr/src/app/vendor/bundle/ruby/3.4.0/gems/activerecord-8.0.2/lib/active_record/attribute_methods.rb:495:in
# 省略

以上で、evalを使用しない方法の導入とconcernsへの切り出しができました。


というわけで、(私が読み取れた内容で)feature optionsを導入しました。
さて、今回の機能導入Rails wayに載っているのか定かでは無いわけですが、より柔軟なデータベース設計には寄与するものと考えます。
カラムを増やすことを回避して拡張できるのは、一定量以上のデータ以上のデータの取り扱いが見えているなら、初期から検討にも価値があるでしょう。

では。

追記(2025/05/17 23:05)

こちら、SQLアンチパターンの Entity Attribute Value に該当するものというところで、推奨されるものではないそうですだ。
取り扱い品種画非常に種類が多い通販であるとか、取り扱い品種が多すぎるメーカーとかでもない限り、避けたい感じと言う所感。