diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4f43c1b..0a513de 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,3 +15,41 @@ jobs:
bundler-cache: true
- name: Run RuboCop
run: bundle exec rubocop
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ env:
+ BUNDLE_PATH: vendor/bundle
+ strategy:
+ fail-fast: false
+ matrix:
+ ruby:
+ - "3.2"
+ - "3.3"
+ - "3.4"
+ appraisal:
+ - rails-7_0
+ - rails-7_1
+ - rails-7_2
+ - rails-8_0
+ - rails-8_1
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby }}
+ bundler-cache: true
+
+ - name: Install system deps
+ run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev pkg-config
+
+ - name: Install Appraisal gemfiles
+ run: bundle exec appraisal install
+
+ - name: Run specs (${{ matrix.appraisal }})
+ run: bundle exec appraisal ${{ matrix.appraisal }} rspec spec
+
+ - name: Run rails sample specs (${{ matrix.appraisal }})
+ run: bundle exec appraisal ${{ matrix.appraisal }} rake rails_sample_spec
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b350041
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+/tmp/
+/.bundle/
+/vendor/bundle/
+/Gemfile.lock
+/gemfiles/*.gemfile.lock
+/.idea/
+/pkg
+*.lock
+*.gem
diff --git a/.rubocop.yml b/.rubocop.yml
index 70ade74..71974c3 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -1,8 +1,14 @@
AllCops:
DisplayCopNames: true
NewCops: enable
+ SuggestExtensions: false
plugins:
+ - rubocop-rspec
+ - rubocop-performance
+ - rubocop-rails
+ - rubocop-rspec_rails
+ - rubocop-factory_bot
### Lint ###
Lint/AmbiguousOperatorPrecedence:
@@ -54,6 +60,9 @@ Style/Lambda:
Style/MultilineBlockChain:
Enabled: false
+Style/OneClassPerFile:
+ Enabled: false
+
Style/StringLiterals:
Enabled: false
@@ -143,3 +152,61 @@ Metrics/ModuleLength:
Metrics/PerceivedComplexity:
Enabled: false
+
+### Rails ###
+Rails:
+ Enabled: true
+
+Rails/Blank:
+ Enabled: false
+
+Rails/Delegate:
+ Enabled: false
+
+Rails/Output:
+ Enabled: false
+
+Rails/SquishedSQLHeredocs:
+ Enabled: false
+
+Rails/RedundantActiveRecordAllMethod:
+ Enabled: false
+
+Rails/DynamicFindBy:
+ AllowedMethods:
+ - find_by_id
+ - find_by_id!
+ - find_by_ids
+ - find_by_ids!
+
+Rails/InverseOf:
+ Enabled: false
+
+Rails/HasManyOrHasOneDependent:
+ Enabled: false
+
+Rails/I18nLocaleTexts:
+ Enabled: false
+
+Rails/FindEach:
+ Enabled: false
+
+### Performance ###
+Performance/CollectionLiteralInLoop:
+ Enabled: false
+
+### RSpec ###
+RSpec/MultipleExpectations:
+ Enabled: false
+
+RSpec/ExampleLength:
+ Enabled: false
+
+RSpec/DescribeClass:
+ Enabled: false
+
+RSpec/MultipleMemoizedHelpers:
+ Enabled: false
+
+RSpec/LetSetup:
+ Enabled: false
diff --git a/Appraisals b/Appraisals
new file mode 100644
index 0000000..b6cf8d4
--- /dev/null
+++ b/Appraisals
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+rails_versions = ENV.fetch("RAILS_VERSIONS", "7.0 7.1 7.2 8.0 8.1").split
+
+rails_versions.each do |version|
+ appraise "rails-#{version.tr('.', '_')}" do
+ gem "rails", "~> #{version}"
+ gem "sqlite3", "~> 2.1"
+ gem "rspec"
+ gem "factory_bot"
+ gem "puma"
+ end
+end
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..7b40bc3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,19 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.0.1] - 2026-05-18
+
+### Fixed
+- `MarshalLoader` no longer raises `Errno::ENOENT` when a dump file is missing; the corresponding table is loaded as an empty record set, matching `QueryLoader`'s fallback for unavailable tables.
+
+## [1.0.0] - 2025-12-26
+
+### Added
+- Initial public release.
+
+[1.0.1]: https://github.com/aktsk/simple_master/compare/v1.0.0...v1.0.1
+[1.0.0]: https://github.com/aktsk/simple_master/releases/tag/v1.0.0
diff --git a/Gemfile b/Gemfile
index 2160871..2bd9e07 100644
--- a/Gemfile
+++ b/Gemfile
@@ -4,4 +4,18 @@ source "https://rubygems.org"
gemspec
+gem "factory_bot", group: :test
+gem "puma", group: :development
+gem "rails", ENV.fetch("RAILS_VERSION", "~> 7.2"), group: :development
+
+gem "rspec", group: :test
gem "rubocop", group: :development
+gem 'rubocop-factory_bot', group: :development
+gem 'rubocop-performance', group: :development
+gem 'rubocop-rails', group: :development
+gem 'rubocop-rspec', group: :development
+gem 'rubocop-rspec_rails', group: :development
+gem "sqlite3", "~> 2.1", group: :development
+
+gem "appraisal", group: :development
+gem "rake", group: :development
diff --git a/Gemfile.lock b/Gemfile.lock
deleted file mode 100644
index a272c17..0000000
--- a/Gemfile.lock
+++ /dev/null
@@ -1,89 +0,0 @@
-PATH
- remote: .
- specs:
- simple_master (0.1.0)
- activerecord (>= 7.0)
- activesupport (>= 7.0)
- request_store (>= 1.0)
-
-GEM
- remote: https://rubygems.org/
- specs:
- activemodel (8.1.1)
- activesupport (= 8.1.1)
- activerecord (8.1.1)
- activemodel (= 8.1.1)
- activesupport (= 8.1.1)
- timeout (>= 0.4.0)
- activesupport (8.1.1)
- base64
- bigdecimal
- concurrent-ruby (~> 1.0, >= 1.3.1)
- connection_pool (>= 2.2.5)
- drb
- i18n (>= 1.6, < 2)
- json
- logger (>= 1.4.2)
- minitest (>= 5.1)
- securerandom (>= 0.3)
- tzinfo (~> 2.0, >= 2.0.5)
- uri (>= 0.13.1)
- ast (2.4.3)
- base64 (0.3.0)
- bigdecimal (4.0.1)
- concurrent-ruby (1.3.6)
- connection_pool (3.0.2)
- drb (2.2.3)
- i18n (1.14.8)
- concurrent-ruby (~> 1.0)
- json (2.18.0)
- language_server-protocol (3.17.0.5)
- lint_roller (1.1.0)
- logger (1.7.0)
- minitest (6.0.0)
- prism (~> 1.5)
- parallel (1.27.0)
- parser (3.3.10.0)
- ast (~> 2.4.1)
- racc
- prism (1.7.0)
- racc (1.8.1)
- rack (3.2.4)
- rainbow (3.1.1)
- regexp_parser (2.11.3)
- request_store (1.7.0)
- rack (>= 1.4)
- rubocop (1.82.0)
- json (~> 2.3)
- language_server-protocol (~> 3.17.0.2)
- lint_roller (~> 1.1.0)
- parallel (~> 1.10)
- parser (>= 3.3.0.2)
- rainbow (>= 2.2.2, < 4.0)
- regexp_parser (>= 2.9.3, < 3.0)
- rubocop-ast (>= 1.48.0, < 2.0)
- ruby-progressbar (~> 1.7)
- unicode-display_width (>= 2.4.0, < 4.0)
- rubocop-ast (1.48.0)
- parser (>= 3.3.7.2)
- prism (~> 1.4)
- ruby-progressbar (1.13.0)
- securerandom (0.4.1)
- timeout (0.6.0)
- tzinfo (2.0.6)
- concurrent-ruby (~> 1.0)
- unicode-display_width (3.2.0)
- unicode-emoji (~> 4.1)
- unicode-emoji (4.2.0)
- uri (1.1.1)
-
-PLATFORMS
- arm64-darwin-24
- x86_64-linux
-
-DEPENDENCIES
- rubocop
- simple_master!
-
-BUNDLED WITH
- 2.7.1
diff --git a/README.ja.md b/README.ja.md
index 04dbfcf..4896578 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -10,5 +10,11 @@
- **馴染みの関連 API で高速参照**: `belongs_to` / `has_many` 風のインターフェースを DB ではなくオンメモリ上で処理し、N+1 を気にしなくてよい速度で動作。
- **COW フレンドリーで多プロセス共有**: レコードは freeze され、Copy-on-Write を活かしてフォークプロセス間でメモリを効率共有できる。
+## ドキュメント
+- 導入ガイド: [English](docs/simple_master_guide_en.md) / [日本語](docs/simple_master_guide_ja.md)
+- カラム仕様: [English](docs/simple_master_columns_en.md) / [日本語](docs/simple_master_columns_ja.md)
+- Dataset / Table: [English](docs/simple_master_dataset_en.md) / [日本語](docs/simple_master_dataset_ja.md)
+- Association: [English](docs/simple_master_associations_en.md) / [日本語](docs/simple_master_associations_ja.md)
+
## ライセンス
MIT ライセンスです。詳細は [LICENSE](LICENSE) を参照してください。
diff --git a/README.md b/README.md
index 8336936..04ddf7c 100644
--- a/README.md
+++ b/README.md
@@ -9,5 +9,11 @@ In game development and other domains, configuration/definition datasets are oft
- **Familiar associations, very fast**: `belongs_to` / `has_many`-style API resolved in memory, fast enough that N+1 is rarely a concern.
- **COW-friendly for multi-process**: records are frozen, making Copy-on-Write efficient when sharing memory across forked processes.
+## Documentation
+- Getting Started Guide: [English](docs/simple_master_guide_en.md) / [日本語](docs/simple_master_guide_ja.md)
+- Columns: [English](docs/simple_master_columns_en.md) / [日本語](docs/simple_master_columns_ja.md)
+- Dataset / Table: [English](docs/simple_master_dataset_en.md) / [日本語](docs/simple_master_dataset_ja.md)
+- Associations: [English](docs/simple_master_associations_en.md) / [日本語](docs/simple_master_associations_ja.md)
+
## License
MIT License. See [LICENSE](LICENSE) for details.
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..b0045d8
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require "bundler/setup"
+require "bundler/gem_tasks"
+require "rspec/core/rake_task"
+
+RSpec::Core::RakeTask.new(:spec)
+RSpec::Core::RakeTask.new(:rails_sample_spec) do |task|
+ task.pattern = "examples/rails_sample/spec/**/*_spec.rb"
+ task.rspec_opts = "--require ./examples/rails_sample/spec/spec_helper --default-path examples/rails_sample/spec"
+end
+
+task default: :spec
diff --git a/docs/simple_master_associations_en.md b/docs/simple_master_associations_en.md
new file mode 100644
index 0000000..0f4873a
--- /dev/null
+++ b/docs/simple_master_associations_en.md
@@ -0,0 +1,51 @@
+# SimpleMaster Associations (English)
+
+> 日本語版: [simple_master_associations_ja.md](simple_master_associations_ja.md)
+
+## Overview
+SimpleMaster provides `belongs_to`, `has_one`, `has_many`, and `has_many :through`.
+
+## Definitions
+```ruby
+class Player < ApplicationRecord
+ belongs_to :level, foreign_key: :lv, primary_key: :lv
+ has_many :player_items
+end
+```
+
+```ruby
+class Reward < ApplicationMaster
+ belongs_to :enemy
+ belongs_to :reward, polymorphic: true
+end
+```
+
+The lookup path depends on whether the target is `SimpleMaster::Master`
+or `ActiveRecord::Base`.
+
+- Master to Master: use `all_by` / `find_by`
+ - These are fast, so values are fetched on each call. Cache in a variable if used often.
+- ActiveRecord: use `simple_master_connection`
+ - `belongs_to_store` / `has_many_store` (RequestStore) caches per request.
+
+## Common options
+### `class_name:`
+- Example: `belongs_to :reward, class_name: "Weapon"`
+- Explicitly sets the target class.
+
+### `foreign_key:`
+- Example: `has_many :players, foreign_key: :lv`
+- Sets the foreign key column.
+
+### `primary_key:`
+- Example: `belongs_to :level, primary_key: :lv`
+- Sets the target primary key (default is `:id`).
+
+## Association types
+- `belongs_to` : `belongs_to :enemy`
+- `belongs_to (polymorphic)` : `belongs_to :reward, polymorphic: true`
+ - Requires `def_column :reward_type, polymorphic_type: true`
+- `has_one` : `has_one :profile`
+- `has_many` : `has_many :players, foreign_key: :lv`
+- `has_many :through` : `has_many :items, through: :player_items`
+ - `source:` can rename the target association
diff --git a/docs/simple_master_associations_ja.md b/docs/simple_master_associations_ja.md
new file mode 100644
index 0000000..e4bd552
--- /dev/null
+++ b/docs/simple_master_associations_ja.md
@@ -0,0 +1,50 @@
+# SimpleMaster Association 仕様 (日本語)
+
+> English version: [simple_master_associations_en.md](simple_master_associations_en.md)
+
+## 全体説明
+SimpleMaster の Association は `belongs_to` / `has_one` / `has_many` / `has_many :through` を提供します。
+
+## 定義方法
+```ruby
+class Player < ApplicationRecord
+ belongs_to :level, foreign_key: :lv, primary_key: :lv
+ has_many :player_items
+end
+```
+
+```ruby
+class Reward < ApplicationMaster
+ belongs_to :enemy
+ belongs_to :reward, polymorphic: true
+end
+```
+
+対象が `SimpleMaster::Master` か `ActiveRecord::Base` かで参照方法が変わります。
+
+- Master 同士: `all_by` / `find_by` を使った参照
+ - 取得が高速なため、都度引き直しとなります。利用時に呼ぶ回数多いなら、変数に格納してください。
+- ActiveRecord: `simple_master_connection` で DB 参照
+ - `belongs_to_store` / `has_many_store` (RequestStore) に保持されるため、リクエストごとにキャッシュが効きます。
+
+## 共通オプション
+### `class_name:`
+- 例: `belongs_to :reward, class_name: "Weapon"`
+- 明示的に参照先クラスを指定します。
+
+### `foreign_key:`
+- 例: `has_many :players, foreign_key: :lv`
+- 外部キー名を指定します。
+
+### `primary_key:`
+- 例: `belongs_to :level, primary_key: :lv`
+- 参照先のキーを指定します(デフォルトは `:id`)。
+
+## Association 種別
+- `belongs_to` : `belongs_to :enemy`
+- `belongs_to (polymorphic)` : `belongs_to :reward, polymorphic: true`
+ - 前提: `def_column :reward_type, polymorphic_type: true`
+- `has_one` : `has_one :profile`
+- `has_many` : `has_many :players, foreign_key: :lv`
+- `has_many :through` : `has_many :items, through: :player_items`
+ - `source:` を指定すると参照先の名前を変更できます。
diff --git a/docs/simple_master_columns_en.md b/docs/simple_master_columns_en.md
new file mode 100644
index 0000000..82f3412
--- /dev/null
+++ b/docs/simple_master_columns_en.md
@@ -0,0 +1,199 @@
+# SimpleMaster Columns (English)
+
+> 日本語版: [simple_master_columns_ja.md](simple_master_columns_ja.md)
+
+## Overview
+SimpleMaster columns are defined with `def_column`. At load time, type conversion,
+cache helpers, and accessor methods are generated.
+The behavior depends on `type` and DSL options.
+
+```ruby
+class Weapon < ApplicationMaster
+ def_column :id
+ def_column :name, type: :string
+ def_column :attack, type: :float
+ def_column :rarity
+
+ enum :rarity, { common: 0, rare: 1, epic: 2 }
+end
+```
+
+## Common options
+### `type:`
+- Example: `def_column :attack, type: :float`
+- See the column type list below.
+
+### `group_key:`
+- Example: `def_column :lv, type: :integer, group_key: true`
+- You can also use `group_key :lv`.
+
+### `db_column_name:`
+- Use when the DB column name differs.
+- Example: `def_column :start_at, type: :time, db_column_name: :start_time`
+
+### `globalize:`
+- Adds locale-aware values using `I18n.locale`.
+- Example: `def_column :name, globalize: true`
+- You can also use `globalize :name`.
+- Translation values live in `@_globalized_name` like `{ en: "Storm Edge", ja: "..." }`.
+- Not supported on `id` / `enum` / `bitmask` / `sti` / `polymorphic_type`.
+- Cannot be used with `group_key`.
+
+## Column types
+
+### id (IdColumn)
+**Usage**
+```ruby
+def_column :id
+```
+**Behavior**
+- Converts to `to_i` on assignment.
+- In tests, updates `id_hash` when changed.
+
+### integer
+**Usage**
+```ruby
+def_column :lv, type: :integer
+```
+**Behavior**
+- Converts to `to_i` on assignment (empty string becomes `nil`).
+
+### float
+**Usage**
+```ruby
+def_column :attack, type: :float
+```
+**Behavior**
+- Converts to `to_f` on assignment (empty string becomes `nil`).
+
+### string
+**Usage**
+```ruby
+def_column :name, type: :string
+```
+**Behavior**
+- Converts to `to_s` on assignment.
+- Values are cached to reuse identical objects (`object_cache`).
+
+### symbol
+**Usage**
+```ruby
+def_column :kind, type: :symbol
+```
+**Behavior**
+- Converts to `to_s` + `to_sym` on assignment.
+- SQL/CSV output uses a string.
+
+### boolean
+**Usage**
+```ruby
+def_column :is_boss, type: :boolean
+```
+**Behavior**
+- Integers use 0/1, strings accept "true" or "1".
+- Adds a `name?` predicate.
+- SQL/CSV output is 0/1.
+
+### json
+**Usage**
+```ruby
+def_column :info, type: :json
+```
+**Options**
+- `symbolize_names: true` converts JSON keys to symbols.
+
+**Behavior**
+- Parses string values with `JSON.parse`.
+- SQL/CSV output uses `JSON.generate`.
+- Non-string assignments are not transformed by `symbolize_names`.
+
+### time
+**Usage**
+```ruby
+def_column :start_at, type: :time
+```
+**Options**
+- `db_type: :time` outputs `HH:MM:SS` only.
+
+**Behavior**
+- Parses strings with `Date._parse` into `Time`.
+- Sub-seconds are truncated.
+
+### enum
+**Usage**
+```ruby
+def_column :rarity, enum: { common: 0, rare: 1, epic: 2 }
+# or
+def_column :rarity
+enum :rarity, { common: 0, rare: 1, epic: 2 }
+```
+**Options**
+- `prefix`, `suffix` add a prefix/suffix to predicates.
+ - `prefix: true` => `rarity_common?`
+ - `suffix: :rarity` => `common_rarity?`
+
+**Behavior**
+- Values are stored as symbols.
+- Adds `rarities` and `rarity_before_type_cast`.
+- Predicate methods (e.g. `common?`) are generated.
+
+### bitmask
+**Usage**
+```ruby
+def_column :flags, type: :integer
+bitmask :flags, as: [:tradeable, :soulbound, :limited]
+```
+**Behavior**
+- Accepts array/symbol/integer and converts to bit integer.
+- `flags` returns an array of symbols.
+- Adds `flags_value` / `flags_value=` for raw integer bits.
+
+### sti (STI type column)
+**Usage**
+```ruby
+def_column :type, sti: true
+```
+**Behavior**
+- Converts `type` to a string.
+- Defines `sti_base_class` and `sti_column`.
+- Loader should resolve classes by `type`.
+
+### polymorphic_type
+**Usage**
+```ruby
+def_column :reward_type, polymorphic_type: true
+```
+**Behavior**
+- Used for `belongs_to polymorphic` type columns.
+- Stores a class name string and sets `reward_type_class`.
+- Empty strings become `nil`.
+
+## Custom column types
+Define custom columns by subclassing `SimpleMaster::Master::Column`.
+If the class name ends with `Column`, the `type` is auto-registered.
+
+```ruby
+class MoneyColumn < SimpleMaster::Master::Column
+ private
+
+ def code_for_conversion
+ <<-RUBY
+ value = value&.to_i
+ RUBY
+ end
+
+ def code_for_sql_value
+ <<-RUBY
+ #{name}
+ RUBY
+ end
+end
+
+class Product < ApplicationMaster
+ def_column :price, type: :money
+end
+```
+
+- Ensure the file is loaded before use.
+- Override `init` if you need custom methods.
+- See [lib/simple_master/master/column.rb](lib/simple_master/master/column.rb).
diff --git a/docs/simple_master_columns_ja.md b/docs/simple_master_columns_ja.md
new file mode 100644
index 0000000..f11f745
--- /dev/null
+++ b/docs/simple_master_columns_ja.md
@@ -0,0 +1,199 @@
+# SimpleMaster カラム仕様 (日本語)
+
+> English version: [simple_master_columns_en.md](simple_master_columns_en.md)
+
+## 全体説明
+SimpleMaster のカラムは `def_column` で定義し、ロード時に型変換・キャッシュ・補助メソッドを自動生成します。
+`type` や各種 DSL によって、変換ルールや追加メソッドが決まります。
+
+```ruby
+class Weapon < ApplicationMaster
+ def_column :id
+ def_column :name, type: :string
+ def_column :attack, type: :float
+ def_column :rarity
+
+ enum :rarity, { common: 0, rare: 1, epic: 2 }
+end
+```
+
+## 共通オプション
+### `type:`
+- 例: `def_column :attack, type: :float`
+- 対応タイプは「カラムタイプ別一覧」を参照してください。
+
+### `group_key:`
+- 例: `def_column :lv, type: :integer, group_key: true`
+- もしくは `group_key :lv` でも指定できます。
+
+### `db_column_name:`
+- DB 側のカラム名が異なる場合に使います。
+- 例: `def_column :start_at, type: :time, db_column_name: :start_time`
+
+### `globalize:`
+- 言語による差分が定義でき、`I18n.locale` に応じた値を返すようになります。
+- 例: `def_column :name, globalize: true`
+- もしくは `globalize :name` でも指定できます。
+- `@_globalized_name` に翻訳文が `{ en: "Storm Edge", ja: "ストームエッジ" }` のように入ります。
+- `id` / `enum` / `bitmask` / `sti` / `polymorphic_type` では利用できません。
+- `group_key` とは併用できません。
+
+## カラムタイプ別一覧
+
+### id (IdColumn)
+**指定方法**
+```ruby
+def_column :id
+```
+**挙動**
+- 代入時に `to_i` で変換。
+- テスト用の更新時に `id_hash` を再構築するための処理が入ります。
+
+### integer
+**指定方法**
+```ruby
+def_column :lv, type: :integer
+```
+**挙動**
+- 代入時に nil 以外は `to_i` で変換されます(空文字は `nil` に)。
+
+### float
+**指定方法**
+```ruby
+def_column :attack, type: :float
+```
+**挙動**
+- 代入時に nil 以外は `to_f` で変換されます(空文字は `nil` に)。
+
+### string
+**指定方法**
+```ruby
+def_column :name, type: :string
+```
+**挙動**
+- 代入時に nil 以外は `to_s` で変換されます。
+- メモリ節約のために、オブジェクトはキャッシュされ、同じ値ならオブジェクトは流用されます。(object_cache)
+
+### symbol
+**指定方法**
+```ruby
+def_column :kind, type: :symbol
+```
+**挙動**
+- 代入時に nil 以外は `to_s` + `to_sym` で変換されます。
+- SQL/CSV 用には文字列として出力されます。
+
+### boolean
+**指定方法**
+```ruby
+def_column :is_boss, type: :boolean
+```
+**挙動**
+- `Integer` は 0/1、`String` は "true" / "1" で判定。
+- `name?` のメソッドが追加されます。
+- SQL/CSV 出力時は 0/1 に変換されます。
+
+### json
+**指定方法**
+```ruby
+def_column :info, type: :json
+```
+**オプション**
+- `symbolize_names: true` を指定すると JSON 文字列をシンボルキーに変換します。
+
+**挙動**
+- 文字列の場合は `JSON.parse`。
+- SQL/CSV 出力時は `JSON.generate` で文字列化されます。
+- 注意点: 文字列以外の代入は、`symbolize_names` によるキー変換は行われません。
+
+### time
+**指定方法**
+```ruby
+def_column :start_at, type: :time
+```
+**オプション**
+- `db_type: :time` を指定すると時刻だけの形式 (`HH:MM:SS`) で出力します。
+
+**挙動**
+- 文字列を `Date._parse` で解析して `Time` に変換します。
+- 小数秒は切り捨てられます。
+
+### enum
+**指定方法**
+```ruby
+def_column :rarity, enum: { common: 0, rare: 1, epic: 2 }
+# or
+def_column :rarity
+enum :rarity, { common: 0, rare: 1, epic: 2 }
+```
+**オプション**
+- `prefix`, `suffix`: 述語メソッドに prefix / suffix を付けられます。
+ - `prefix: true` で `rarity_common?` のようになります。
+ - `suffix: :rarity` で `common_rarity?` のようになります。
+
+**挙動**
+- 値は `Symbol` として扱われます。
+- `rarities` クラスメソッドと `rarity_before_type_cast` が追加されます。
+- 述語メソッド (例: `common?`) が自動生成されます。
+
+### bitmask
+**指定方法**
+```ruby
+def_column :flags, type: :integer
+bitmask :flags, as: [:tradeable, :soulbound, :limited]
+```
+**挙動**
+- 配列/シンボル/整数を受け取り、内部では整数ビットに変換します。
+- `flags` はシンボル配列として返ります。
+- `flags_value` / `flags_value=` が追加されます。ビット列の数値が返ります。
+
+### sti (STIタイプカラム)
+**指定方法**
+```ruby
+def_column :type, sti: true
+```
+**挙動**
+- `type` を文字列に変換します。
+- Loader 側で `type` を見てクラス分岐する運用になります。
+- `sti_base_class` と `sti_column` が定義されます。
+
+### polymorphic_type
+**指定方法**
+```ruby
+def_column :reward_type, polymorphic_type: true
+```
+**挙動**
+- `belongs_to polymorphic` のタイプカラムとして使います。
+- `reward_type` を文字列として保持し、`reward_type_class` を自動で設定します。
+- 空文字は `nil` に変換されます。
+
+## カラムのカスタム定義
+独自のカラム型を追加する場合は `SimpleMaster::Master::Column` を継承します。
+クラス名の末尾が `Column` であれば、自動で `type` が登録されます。
+
+```ruby
+class MoneyColumn < SimpleMaster::Master::Column
+ private
+
+ def code_for_conversion
+ <<-RUBY
+ value = value&.to_i
+ RUBY
+ end
+
+ def code_for_sql_value
+ <<-RUBY
+ #{name}
+ RUBY
+ end
+end
+
+# 利用側
+class Product < ApplicationMaster
+ def_column :price, type: :money
+end
+```
+
+- カスタムカラムのファイルはロード対象に含めてください。
+- `init` をオーバーライドすると、独自メソッドの生成も可能です。
+- 詳しくは [lib/simple_master/master/column.rb](lib/simple_master/master/column.rb) 定義ファイルを直接ご覧ください
diff --git a/docs/simple_master_dataset_en.md b/docs/simple_master_dataset_en.md
new file mode 100644
index 0000000..812cb19
--- /dev/null
+++ b/docs/simple_master_dataset_en.md
@@ -0,0 +1,122 @@
+# SimpleMaster Dataset / Table (English)
+
+> 日本語版: [simple_master_dataset_ja.md](simple_master_dataset_ja.md)
+
+## Overview
+In SimpleMaster, the dataset holds the actual data, and each master class maps to a table.
+The loader reads external data, and the table keeps records and caches.
+
+```
+Dataset
+ ├─ Table (Weapon)
+ ├─ Table (Armor)
+ └─ Table (Level)
+```
+
+## Dataset
+### Role
+- Load each `Table` via `loader`
+- Keep `cache` for class/instance caches
+- Provide diff overrides via `diff`
+
+### Basic usage
+```ruby
+loader = SimpleMaster::Loader::QueryLoader.new
+dataset = SimpleMaster::Storage::Dataset.new(loader: loader)
+dataset.load
+
+SimpleMaster.use_dataset(dataset) do
+ # work with this dataset
+end
+```
+
+### Main API
+- `load` : load all target tables and update caches
+- `reload` : reload or unload depending on table class
+- `unload` : clear tables and cache
+- `duplicate(diff: nil)` : duplicate a dataset (diff included)
+- `table(klass)` : fetch table for a class
+
+### diff
+You can layer changes on top of loader data.
+Set `dataset.diff` to a JSON/Hash and the diff is applied after load.
+`Table.apply_diff` updates `id_hash` and overrides records.
+
+```ruby
+dataset = SimpleMaster::Storage::Dataset.new
+
+dataset.diff = {
+ "weapons" => {
+ "1" => { "name" => "Updated Name" },
+ "2" => nil
+ }
+}
+
+dataset.load
+```
+
+### Dataset cache
+Provides `cache_read` / `cache_fetch` / `cache_write` / `cache_delete`.
+Use it for lightweight external caches. Data stays in memory, so mind the size.
+
+## Table
+### Role
+- Hold record array (`all`)
+- Build `id_hash` / `grouped_hash`
+- Update class/instance caches
+- Keep STI sub tables
+
+### Main data
+- `all` : array of records
+- `id_hash` : `id` => record
+- `grouped_hash` : `group_key` => grouped records
+- `class_method_cache` : results of `cache_class_method`
+- `method_cache` : results of `cache_method`
+
+### STI and sub tables
+When a class uses STI, `sub_table` returns a table per subclass.
+`update_sub_tables` extracts subclasses from `all` and registers them.
+
+## Table types
+### Table (default)
+- Loads all records when the dataset loads
+- Builds `all` / `id_hash` / `grouped_hash` on load
+- Records are frozen, so Copy-on-Write works well
+
+### OndemandTable
+- Builds `all` / `id_hash` / `grouped_hash` on first access
+- Useful for large data or on-demand access
+
+```ruby
+dataset = SimpleMaster::Storage::Dataset.new(
+ table_class: SimpleMaster::Storage::OndemandTable
+)
+```
+
+### TestTable
+- Lightweight table for tests
+- Assumes `update` / `record_updated` diffs
+
+```ruby
+dataset = SimpleMaster::Storage::Dataset.new(
+ table_class: SimpleMaster::Storage::TestTable
+)
+```
+
+## Loader
+A loader implements `read_raw` and `build_records`.
+Besides `QueryLoader` and `MarshalLoader`, you can define your own.
+
+```ruby
+class JsonLoader < SimpleMaster::Loader
+ FIXTURE_DIR = Rails.root.join("fixtures/masters")
+
+ def read_raw(table)
+ File.read(FIXTURE_DIR.join("#{table.klass.table_name}.json"))
+ end
+
+ def build_records(klass, raw)
+ JSON.parse(raw).map { |attrs| klass.new(attrs) }
+ end
+end
+```
diff --git a/docs/simple_master_dataset_ja.md b/docs/simple_master_dataset_ja.md
new file mode 100644
index 0000000..73e5f4e
--- /dev/null
+++ b/docs/simple_master_dataset_ja.md
@@ -0,0 +1,122 @@
+# SimpleMaster Dataset / Table 仕様 (日本語)
+
+> English version: [simple_master_dataset_en.md](simple_master_dataset_en.md)
+
+## 全体説明
+SimpleMaster ではデータの実体を `Dataset` が持ち、各 Master クラスごとに `Table` が対応します。
+`Loader` が外部データを読み込み、`Table` がレコードと各種キャッシュを保持します。
+
+```
+Dataset
+ ├─ Table (Weapon)
+ ├─ Table (Armor)
+ └─ Table (Level)
+```
+
+## Dataset
+### 役割
+- `loader` を使って各 `Table` をロードする
+- `cache` を保持し、クラス/インスタンスのキャッシュに利用する
+- `diff` による差分上書きを提供する
+
+### 基本の使い方
+```ruby
+loader = SimpleMaster::Loader::QueryLoader.new
+dataset = SimpleMaster::Storage::Dataset.new(loader: loader)
+dataset.load
+
+SimpleMaster.use_dataset(dataset) do
+ # dataset を使った処理
+end
+```
+
+### 主なAPI
+- `load` : 全対象テーブルをロードし、キャッシュを更新
+- `reload` : `Table` の種類に応じて再ロード/アンロードを実行
+- `unload` : テーブルとキャッシュをクリア
+- `duplicate(diff: nil)` : dataset を複製 (diff も継承)
+- `table(klass)` : 対象クラスの `Table` を取得
+
+### 差分 (diff)
+Loader から取得するデータの上にさらに変更を自動的に加えられる仕組みです。
+`dataset.diff` に JSON/Hash を設定するとロード後に差分が適用されます。
+`Table.apply_diff` が `id_hash` を更新し、差分レコードを上書きします。
+
+```ruby
+dataset = SimpleMaster::Storage::Dataset.new
+
+dataset.diff = {
+ "weapons" => {
+ "1" => { "name" => "Updated Name" },
+ "2" => nil
+ }
+}
+
+dataset.load
+```
+
+### Dataset キャッシュ
+`cache_read` / `cache_fetch` / `cache_write` / `cache_delete` を用意しています。
+外部参照用の軽量キャッシュに使えます。ただし、メモリに保存されるので、容量にご注意ください。
+
+## Table
+### 役割
+- 対象クラスのレコード配列 (`all`) を保持
+- `id_hash` / `grouped_hash` を構築
+- クラス/インスタンスキャッシュを更新
+- STI サブクラスのサブテーブルを保持
+
+### 主なデータ
+- `all` : レコードの配列
+- `id_hash` : `id` => record
+- `grouped_hash` : `group_key` => grouped records
+- `class_method_cache` : `cache_class_method` の結果
+- `method_cache` : `cache_method` の結果
+
+### STI とサブテーブル
+STI を使うクラスでは、`sub_table` がサブクラスごとの `Table` を返します。
+`update_sub_tables` が `all` からサブクラスを抽出して登録します。
+
+## Table の種類
+### Table (デフォルト)
+- `Dataset` 読み込み時に全件をロードする
+- `load` のタイミングで `all` / `id_hash` / `grouped_hash` を構築
+- 基本的に中身は freeze されるので、Copy-on-Write が効きやすい
+
+### OndemandTable
+- `all` / `id_hash` / `grouped_hash` を初回アクセス時に構築
+- 大規模データやオンデマンド参照で有効
+
+```ruby
+dataset = SimpleMaster::Storage::Dataset.new(
+ table_class: SimpleMaster::Storage::OndemandTable
+)
+```
+
+### TestTable
+- テスト向けの軽量テーブル
+- `update` / `record_updated` による差分更新を前提とする
+
+```ruby
+dataset = SimpleMaster::Storage::Dataset.new(
+ table_class: SimpleMaster::Storage::TestTable
+)
+```
+
+## Loader
+`Loader` は `read_raw` と `build_records` を実装して使います。
+既存の `QueryLoader` / `MarshalLoader` のほか、アプリケーションの要件に応じて Loader を作れます。
+
+```ruby
+class JsonLoader < SimpleMaster::Loader
+ FIXTURE_DIR = Rails.root.join("fixtures/masters")
+
+ def read_raw(table)
+ File.read(FIXTURE_DIR.join("#{table.klass.table_name}.json"))
+ end
+
+ def build_records(klass, raw)
+ JSON.parse(raw).map { |attrs| klass.new(attrs) }
+ end
+end
+```
diff --git a/docs/simple_master_guide_en.md b/docs/simple_master_guide_en.md
new file mode 100644
index 0000000..b72e4b4
--- /dev/null
+++ b/docs/simple_master_guide_en.md
@@ -0,0 +1,142 @@
+# SimpleMaster Getting Started Guide
+
+> 日本語版: [simple_master_guide_ja.md](simple_master_guide_ja.md)
+
+## Purpose
+- Handle master data fast without relying on Rails/ActiveRecord
+- Reference records as Ruby objects and define associations and caches
+- Use without a DB and switch datasets by use case
+
+## Installation
+Add to Gemfile and bundle.
+
+```ruby
+gem "simple_master"
+```
+
+```bash
+bundle install
+```
+
+## Initialization
+Initialize SimpleMaster at boot and load a dataset.
+
+```ruby
+# config/initializers/simple_master.rb
+Rails.application.config.after_initialize do
+ Rails.application.eager_load!
+
+ SimpleMaster.init(for_test: Rails.env.test?)
+
+ loader = SimpleMaster::Loader::QueryLoader.new
+ $current_dataset = SimpleMaster::Storage::Dataset.new(loader: loader)
+ $current_dataset.load
+end
+```
+
+If you load JSON fixtures, use the `JsonLoader` described below.
+
+## Defining Master Classes
+Create masters based on `ApplicationMaster`.
+
+```ruby
+# app/models/application_master.rb
+class ApplicationMaster < SimpleMaster::Master
+ self.abstract_class = true
+end
+```
+
+```ruby
+# app/models/weapon.rb
+class Weapon < ApplicationMaster
+ def_column :id
+ def_column :type, sti: true
+ def_column :name
+ def_column :attack, type: :float
+ def_column :rarity, type: :integer
+
+ enum :rarity, { common: 0, rare: 1, epic: 2 }
+ bitmask :flags, as: [:tradeable, :soulbound, :limited]
+
+ validates :name, presence: true
+ validates :attack, numericality: { greater_than_or_equal_to: 0 }
+end
+```
+
+## Data Loading (DB / Fixture)
+### Load from DB
+Use the default `QueryLoader` to load from DB tables.
+
+```ruby
+loader = SimpleMaster::Loader::QueryLoader.new
+$current_dataset = SimpleMaster::Storage::Dataset.new(loader: loader)
+$current_dataset.load
+```
+
+### Build a Loader (JSON fixtures)
+Example: implement a loader to read JSON.
+
+```ruby
+class JsonLoader < SimpleMaster::Loader
+ FIXTURE_DIR = Rails.root.join("fixtures/masters")
+
+ def read_raw(table)
+ File.read(FIXTURE_DIR.join("#{table.klass.table_name}.json"))
+ end
+
+ def build_records(klass, raw)
+ JSON.parse(raw).map { |attrs| klass.new(attrs) }
+ end
+end
+```
+
+If you use STI, add a branch that resolves the class from `type`
+(see [dummy/lib/json_loader.rb](dummy/lib/json_loader.rb)).
+
+```ruby
+loader = JsonLoader.new
+$current_dataset = SimpleMaster::Storage::Dataset.new(loader: loader)
+$current_dataset.load
+```
+
+## ActiveRecord Integration
+Use the Extension to reference masters from ActiveRecord models.
+
+```ruby
+# app/models/application_record.rb
+class ApplicationRecord < ActiveRecord::Base
+ include SimpleMaster::ActiveRecord::Extension
+end
+```
+
+```ruby
+class Player < ApplicationRecord
+ belongs_to :level, foreign_key: :lv, primary_key: :lv
+ has_many :player_items
+end
+```
+
+## Test Setup
+Create a dataset per example to reset state.
+In tests, `SimpleMaster::Master::Editable` and `TestTable` are useful.
+Example with RSpec:
+
+```ruby
+ApplicationMaster.prepend(SimpleMaster::Master::Editable)
+
+RSpec.configure do |config|
+ config.around do |example|
+ dataset = SimpleMaster::Storage::Dataset.new(table_class: SimpleMaster::Storage::TestTable)
+ SimpleMaster.use_dataset(dataset) { example.run }
+ end
+end
+```
+
+## Useful Methods
+- `SimpleMaster.use_dataset(dataset) { ... }` : temporarily switch dataset
+- `cache_method` / `cache_class_method` : define fast caches
+- `enum` / `bitmask` / `globalize` : column extensions
+
+## Notes
+- Do not use `SimpleMaster::Master::Editable` in production; it is for tests
+- Swap the `Loader` based on your data source
diff --git a/docs/simple_master_guide_ja.md b/docs/simple_master_guide_ja.md
new file mode 100644
index 0000000..6541cc3
--- /dev/null
+++ b/docs/simple_master_guide_ja.md
@@ -0,0 +1,141 @@
+# SimpleMaster 導入ガイド
+
+> English version: [simple_master_guide_en.md](simple_master_guide_en.md)
+
+## 目的
+- マスターデータを Rails/ActiveRecord とは別に高速に扱う
+- Ruby オブジェクトとして参照でき、関連づけやキャッシュを定義できる
+- DB がなくても扱え、用途に応じて複数の dataset を切り替えられる
+
+## インストール
+Gemfile に追加して bundle します。
+
+```ruby
+gem "simple_master"
+```
+
+```bash
+bundle install
+```
+
+## 初期化
+アプリ起動時に SimpleMaster を初期化し、データセットを読み込ませます。
+
+```ruby
+# config/initializers/simple_master.rb
+Rails.application.config.after_initialize do
+ Rails.application.eager_load!
+
+ SimpleMaster.init(for_test: Rails.env.test?)
+
+ loader = SimpleMaster::Loader::QueryLoader.new
+ $current_dataset = SimpleMaster::Storage::Dataset.new(loader: loader)
+ $current_dataset.load
+end
+```
+
+※ JSON fixture を読み込む場合は後述の `JsonLoader` を使います。
+
+## Master クラス定義
+`ApplicationMaster` をベースに Master を作ります。
+
+```ruby
+# app/models/application_master.rb
+class ApplicationMaster < SimpleMaster::Master
+ self.abstract_class = true
+end
+```
+
+```ruby
+# app/models/weapon.rb
+class Weapon < ApplicationMaster
+ def_column :id
+ def_column :type, sti: true
+ def_column :name
+ def_column :attack, type: :float
+ def_column :rarity, type: :integer
+
+ enum :rarity, { common: 0, rare: 1, epic: 2 }
+ bitmask :flags, as: [:tradeable, :soulbound, :limited]
+
+ validates :name, presence: true
+ validates :attack, numericality: { greater_than_or_equal_to: 0 }
+end
+```
+
+## データロード (DB / Fixture)
+### DB から読み込む
+標準の `QueryLoader` で DB のテーブルから読み込みます。
+
+```ruby
+loader = SimpleMaster::Loader::QueryLoader.new
+$current_dataset = SimpleMaster::Storage::Dataset.new(loader: loader)
+$current_dataset.load
+```
+
+### Loader を自作する
+例:独自ローダを用意して JSON を読み込みます。
+
+```ruby
+class JsonLoader < SimpleMaster::Loader
+ FIXTURE_DIR = Rails.root.join("fixtures/masters")
+
+ def read_raw(table)
+ File.read(FIXTURE_DIR.join("#{table.klass.table_name}.json"))
+ end
+
+ def build_records(klass, raw)
+ JSON.parse(raw).map { |attrs| klass.new(attrs) }
+ end
+end
+```
+
+※ STI を使う場合は `type` を見てクラス分岐する実装を追加してください(例: [dummy/lib/json_loader.rb](dummy/lib/json_loader.rb))。
+
+```ruby
+loader = JsonLoader.new
+$current_dataset = SimpleMaster::Storage::Dataset.new(loader: loader)
+$current_dataset.load
+```
+
+## ActiveRecord 連携
+ActiveRecord のモデルから Master を参照する場合は Extension を使います。
+
+```ruby
+# app/models/application_record.rb
+class ApplicationRecord < ActiveRecord::Base
+ include SimpleMaster::ActiveRecord::Extension
+end
+```
+
+```ruby
+class Player < ApplicationRecord
+ belongs_to :level, foreign_key: :lv, primary_key: :lv
+ has_many :player_items
+end
+```
+
+## テスト用設定
+テストケースごとにリセットするため、Dataset を都度作るように設定します。
+またテストでは通常と違い、データの一時保存や関連付け保存等が必要なため、`SimpleMaster::Master::Editable` と `TestTable` 利用するとよりスムーズとなります。
+例えば、RSpec ではこのように設定します。
+
+```ruby
+ApplicationMaster.prepend(SimpleMaster::Master::Editable)
+
+RSpec.configure do |config|
+ config.around do |example|
+ dataset = SimpleMaster::Storage::Dataset.new(table_class: SimpleMaster::Storage::TestTable)
+ SimpleMaster.use_dataset(dataset) { example.run }
+ end
+end
+```
+
+## 便利メソッド
+- `SimpleMaster.use_dataset(dataset) { ... }` : 一時的に dataset を差し替える
+- `cache_method` / `cache_class_method` : 高速なキャッシュを定義
+- `enum` / `bitmask` / `globalize` : カラム拡張
+
+## 補足
+- 本番では `SimpleMaster::Master::Editable` は使わず、テスト用途のみ推奨
+- データソースに合わせて `Loader` を差し替える運用を想定
diff --git a/examples/rails_sample/.gitignore b/examples/rails_sample/.gitignore
new file mode 100644
index 0000000..f88a268
--- /dev/null
+++ b/examples/rails_sample/.gitignore
@@ -0,0 +1,19 @@
+/log/
+/tmp/
+/db/*.sqlite3
+/db/*.sqlite3-*
+/db/*.sqlite3-journal
+/db/*.sqlite3-wal
+/db/*.sqlite3-shm
+/storage/
+/node_modules/
+/vendor/bundle/
+/.bundle/
+/.byebug_history
+/coverage/
+/public/assets/
+/public/packs/
+/public/packs-test/
+/public/webpack/
+/yarn-error.log
+/npm-debug.log
diff --git a/examples/rails_sample/README.md b/examples/rails_sample/README.md
new file mode 100644
index 0000000..6fe5d94
--- /dev/null
+++ b/examples/rails_sample/README.md
@@ -0,0 +1,106 @@
+# Rails Sample App
+
+This sample app demonstrates SimpleMaster in a compact, game-like domain.
+It is used to exercise column casting and association patterns (STI, polymorphic
+`belongs_to`, `has_many`).
+
+## Development Setup
+```bash
+bundle install
+cd examples/rails_sample
+bundle exec rails db:prepare
+bundle exec rails s
+```
+
+## Database Configuration
+Database settings live in `examples/rails_sample/config/database.yml`.
+
+- `development`: sqlite3 at `examples/rails_sample/db/development.sqlite3`
+- `test`: sqlite3 in-memory (`:memory:`)
+- `production`: sqlite3 at `examples/rails_sample/db/production.sqlite3`
+
+If you need a different DB, edit `config/database.yml` or set `DATABASE_URL`.
+
+## Domain Model
+```
+SimpleMaster (masters) ActiveRecord
+
+[Weapon] (STI: Gun, Blade) [Player] --< player_items >-- (polymorphic to Weapon/Armor/Potion)
+[Armor] PlayerItem: belongs_to :item, polymorphic
+[Potion]
+[Level] --< players (lv) >-- [Player]
+[Enemy] --< rewards >-- [Reward] (reward_type/reward_id -> Weapon/Armor/Potion)
+```
+
+## Masters
+- **Weapon** (`Gun`, `Blade`)
+ - `id`
+ - `type`
+ - `name`
+ - `attack` (float)
+ - `info` (json, symbolize_names: true)
+ - `metadata` (json, symbolize_names: false)
+ - `rarity` (enum)
+ - `flags` (bitmask)
+ - Notes: polymorphic target (PlayerItem, Reward)
+- **Armor**
+ - `id`
+ - `name`
+ - `defence` (float)
+ - Notes: polymorphic target
+- **Potion**
+ - `id`
+ - `name`
+ - `hp` (float)
+ - Notes: polymorphic target
+- **Level**
+ - `id`
+ - `lv` (unique)
+ - `attack` (float)
+ - `defence` (float)
+ - `hp` (float)
+ - Associations: `has_many :players` (lv)
+- **Enemy**
+ - `id`
+ - `name`
+ - `is_boss` (boolean)
+ - `start_at` (time)
+ - `end_at` (time)
+ - `attack`
+ - `defence`
+ - `hp`
+ - Associations: `has_many :rewards`
+- **Reward**
+ - `id`
+ - `enemy_id`
+ - `reward_type`
+ - `reward_id`
+ - Associations: `belongs_to :enemy`; polymorphic `belongs_to` Weapon/Armor/Potion
+
+## ActiveRecord
+- **Player**
+ - `id`
+ - `name`
+ - `lv`
+ - Associations: `belongs_to :level` (lv); `has_many :player_items`; `has_many :items, through: :player_items`
+- **PlayerItem**
+ - `player_id`
+ - `item_type`
+ - `item_id`
+ - Associations: `belongs_to :player`; polymorphic `belongs_to :item`
+
+## Fixtures
+Fixtures live in `examples/rails_sample/fixtures/masters`.
+
+- `weapons.json` (STI Gun/Blade), `armors.json`, `potions.json`, `levels.json` (uses `lv` as unique key),
+ `enemies.json`, `rewards.json`
+- Aim to include representative casts (float/json/globalize where useful) and polymorphic/has_many links.
+
+## Related Specs
+- `simple_master/active_record/extension_spec.rb`: AR↔master (`belongs_to_master`)
+- `simple_master/master/item_spec.rb`: column casting and master associations
+- `simple_master/master/filterable_spec.rb`: find/find_by/all_by/all_in
+- `simple_master/master/cache_spec.rb`: cache_method, cache_class_method
+- `simple_master/storage/loader_spec.rb`: STI instantiation, diff application
+- `simple_master/loader/marshal_loader_spec.rb`: Marshal dump/load roundtrip
+- `simple_master/storage/dataset_spec.rb`: dataset cache/diff duplication
diff --git a/examples/rails_sample/Rakefile b/examples/rails_sample/Rakefile
new file mode 100644
index 0000000..c4f9523
--- /dev/null
+++ b/examples/rails_sample/Rakefile
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+require_relative "config/application"
+
+Rails.application.load_tasks
diff --git a/examples/rails_sample/app/controllers/game_controller.rb b/examples/rails_sample/app/controllers/game_controller.rb
new file mode 100644
index 0000000..3f82d94
--- /dev/null
+++ b/examples/rails_sample/app/controllers/game_controller.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+class GameController < ActionController::Base # rubocop:disable Rails/ApplicationController
+ skip_forgery_protection
+
+ def show
+ @player = current_player
+ @now = Time.current
+ @enemies =
+ Enemy.available_at(@now)
+ .sort_by { |enemy| [enemy.is_boss? ? 1 : 0, enemy.attack.to_f + enemy.defence.to_f + enemy.hp.to_f] }
+ @potions = Potion.all
+ end
+
+ def play
+ @player = current_player
+
+ case params[:op]
+ when "create_player"
+ @player = Player.create!(name: player_name, lv: player_level)
+ session[:player_id] = @player.id
+ flash.now[:notice] = "Player created."
+ when "challenge"
+ enemy = Enemy.find_by_id(params[:enemy_id].to_i)
+ if @player && enemy
+ result = @player.challenge_enemy(enemy, at: Time.current)
+ flash[:notice] = battle_message(result)
+ else
+ flash[:notice] = "Missing player or enemy."
+ end
+ when "potion"
+ potion = Potion.find_by_id(params[:potion_id].to_i)
+ flash.now[:notice] = if @player && potion
+ @player.use_potion(potion, at: Time.current) ? "Healed." : "Potion failed."
+ else
+ "Missing player or potion."
+ end
+ when "equip_weapon"
+ weapon = Weapon.find_by_id(params[:weapon_id].to_i)
+ flash.now[:notice] = if @player && weapon
+ @player.equip_weapon(weapon) ? "Weapon equipped." : "Cannot equip weapon."
+ else
+ "Missing player or weapon."
+ end
+ when "equip_armor"
+ armor = Armor.find_by_id(params[:armor_id].to_i)
+ flash.now[:notice] = if @player && armor
+ @player.equip_armor(armor) ? "Armor equipped." : "Cannot equip armor."
+ else
+ "Missing player or armor."
+ end
+ end
+
+ redirect_to root_path
+ end
+
+ private
+
+ def current_player
+ player_id = session[:player_id]
+ return unless player_id
+
+ Player.find_by(id: player_id)
+ end
+
+ def player_name
+ name = params[:name].to_s.strip
+ name.empty? ? "Hero" : name
+ end
+
+ def player_level
+ 1
+ end
+
+ def battle_message(result)
+ return "No player." if result.nil?
+ return "Win! Level up." if result[:ok] && result[:leveled_up]
+ return "Win! Rewards #{result[:rewards].size}" if result[:ok]
+
+ "Lost: #{result[:reason]}"
+ end
+end
diff --git a/examples/rails_sample/app/models/application_master.rb b/examples/rails_sample/app/models/application_master.rb
new file mode 100644
index 0000000..26af50c
--- /dev/null
+++ b/examples/rails_sample/app/models/application_master.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class ApplicationMaster < SimpleMaster::Master
+ self.abstract_class = true
+
+ def self.validate_all_records
+ Thread.current[:errors] = {}
+
+ classes = descendants.reject(&:abstract_class).select(&:base_class?)
+ classes.each do |klass|
+ klass.all.each(&:valid?)
+ end
+
+ Thread.current[:errors]
+ ensure
+ Thread.current[:errors] = {}
+ end
+end
diff --git a/examples/rails_sample/app/models/application_record.rb b/examples/rails_sample/app/models/application_record.rb
new file mode 100644
index 0000000..9ada3ce
--- /dev/null
+++ b/examples/rails_sample/app/models/application_record.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class ApplicationRecord < ActiveRecord::Base
+ self.abstract_class = true
+ include SimpleMaster::ActiveRecord::Extension
+end
diff --git a/examples/rails_sample/app/models/armor.rb b/examples/rails_sample/app/models/armor.rb
new file mode 100644
index 0000000..93d89fd
--- /dev/null
+++ b/examples/rails_sample/app/models/armor.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Armor < ApplicationMaster
+ include ItemReceivable
+
+ def_column :id
+ def_column :icon, type: :string
+ def_column :name, type: :string
+ def_column :defence, type: :float
+
+ validates :name, presence: true
+ validates :defence, numericality: { greater_than_or_equal_to: 0 }
+
+ def self.max_quantity
+ 1
+ end
+end
diff --git a/examples/rails_sample/app/models/concerns/item_receivable.rb b/examples/rails_sample/app/models/concerns/item_receivable.rb
new file mode 100644
index 0000000..7f1a57e
--- /dev/null
+++ b/examples/rails_sample/app/models/concerns/item_receivable.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module ItemReceivable
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def max_quantity
+ nil
+ end
+ end
+
+ included do
+ cache_class_method def self.receivable_sources
+ sources = {}
+ Reward.all.each do |reward|
+ reward_item = reward.reward
+
+ klass = reward_item.class
+ while klass <= self
+ array = sources.fetch(reward_item.id) { sources[reward_item.id] = [] }
+ array << reward.enemy
+
+ klass = klass.superclass
+ end
+ end
+
+ sources.each_value do |array|
+ array.uniq!
+ array.freeze
+ end
+
+ sources
+ end
+
+ # This is an example. `self.class.receivable_sources[id]` may work better here.
+ cache_method def receivable_sources
+ self.class.receivable_sources.fetch(id) { [] }
+ end
+ end
+
+ def self.receivable_item?
+ true
+ end
+end
diff --git a/examples/rails_sample/app/models/enemy.rb b/examples/rails_sample/app/models/enemy.rb
new file mode 100644
index 0000000..29dcbac
--- /dev/null
+++ b/examples/rails_sample/app/models/enemy.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class Enemy < ApplicationMaster
+ def_column :id
+ def_column :name, type: :string
+ def_column :is_boss, type: :boolean
+ def_column :start_at, type: :time
+ def_column :end_at, type: :time, group_key: true
+ def_column :attack, type: :float
+ def_column :defence, type: :float
+ def_column :hp, type: :float
+ def_column :exp, type: :integer
+ def_column :stamina_cost, type: :integer
+
+ has_many :rewards
+
+ validates :name, presence: true
+ validates :attack, :defence, :hp, numericality: { greater_than_or_equal_to: 0 }
+ validates :exp, :stamina_cost, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validate :end_after_start_at
+
+ cache_class_method def self.sorted_end_ats
+ # end_atを降順でソートした配列
+ [nil, *grouped_hash[:end_at].keys.compact.sort!.reverse!]
+ end
+
+ def self.not_ended(time = Time.current)
+ sorted_end_ats
+ .take_while { |end_at| end_at.nil? || end_at > time }
+ .flat_map { |end_at| all_by(:end_at, end_at) }
+ end
+
+ def self.available_at(time = Time.current)
+ not_ended(time).filter { _1.has_started?(time) }
+ end
+
+ def has_started?(time = Time.current)
+ start_at.nil? || start_at <= time
+ end
+
+ def not_ended?(time = Time.current)
+ end_at.nil? || end_at > time
+ end
+
+ def available?(time = Time.current)
+ has_started?(time) && not_ended?(time)
+ end
+
+ private
+
+ def end_after_start_at
+ return if start_at.nil? || end_at.nil?
+ return if end_at > start_at
+
+ errors.add(:end_at, :invalid)
+ end
+end
diff --git a/examples/rails_sample/app/models/level.rb b/examples/rails_sample/app/models/level.rb
new file mode 100644
index 0000000..5a9406d
--- /dev/null
+++ b/examples/rails_sample/app/models/level.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class Level < ApplicationMaster
+ def_column :id
+ def_column :lv, type: :integer, group_key: true
+ def_column :attack, type: :float
+ def_column :defence, type: :float
+ def_column :hp, type: :float
+ def_column :next_exp, type: :integer
+ def_column :hp_recovery_sec, type: :integer
+ def_column :stamina, type: :integer
+ def_column :stamina_recovery_sec, type: :integer
+
+ has_many :players, foreign_key: :lv, primary_key: :lv
+
+ validates :lv, presence: true, numericality: { only_integer: true, greater_than: 0 }
+ validates :attack, :defence, :hp, numericality: { greater_than_or_equal_to: 0 }
+ validates :next_exp, :hp_recovery_sec, :stamina, :stamina_recovery_sec,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
+ cache_class_method def self.max_lv
+ lvs = all.filter_map(&:lv)
+ lvs.max || 0
+ end
+
+ def self.find_by_lv(lv)
+ find_by(:lv, lv)
+ end
+end
diff --git a/examples/rails_sample/app/models/player.rb b/examples/rails_sample/app/models/player.rb
new file mode 100644
index 0000000..ef734c0
--- /dev/null
+++ b/examples/rails_sample/app/models/player.rb
@@ -0,0 +1,251 @@
+# frozen_string_literal: true
+
+class Player < ApplicationRecord
+ belongs_to :level, foreign_key: :lv, primary_key: :lv
+ belongs_to :weapon
+ belongs_to :armor
+ has_many :player_items
+ has_many :items, through: :player_items
+
+ def items
+ player_items.flat_map do |player_item|
+ quantity = player_item.quantity.to_i
+ next [] if quantity <= 0
+ next [] unless player_item.item
+
+ Array.new(quantity, player_item.item)
+ end
+ end
+
+ def equip_weapon(weapon)
+ return false unless weapon.is_a?(Weapon)
+
+ record = find_item_record(weapon)
+ return false unless record&.quantity.to_i.positive?
+
+ update!(weapon_id: weapon.id)
+ true
+ end
+
+ def equip_armor(armor)
+ return false unless armor.is_a?(Armor)
+
+ record = find_item_record(armor)
+ return false unless record&.quantity.to_i.positive?
+
+ update!(armor_id: armor.id)
+ true
+ end
+
+ def attack_power
+ base = level.attack
+ bonus = (weapon&.attack || 0).to_f
+ base + bonus
+ end
+
+ def defence_power
+ base = level.defence
+ bonus = (armor&.defence || 0).to_f
+ base + bonus
+ end
+
+ def challenge_enemy(enemy, at: Time.current)
+ return { ok: false, reason: :out_of_period } unless enemy.available?(at)
+ player_attack = attack_power
+ return { ok: false, reason: :attack_too_low } if player_attack <= enemy.defence.to_f
+
+ cost = stamina_cost_for(enemy)
+ return { ok: false, reason: :not_enough_stamina } unless consume_stamina(cost, at: at)
+
+ apply_hp_regen!(at)
+
+ player_hp = hp.to_f
+ enemy_hp = enemy.hp.to_f
+ player_damage = scaled_damage(player_attack, enemy.defence.to_f)
+ enemy_damage = scaled_damage(enemy.attack.to_f, defence_power)
+
+ while enemy_hp > 0 && player_hp > 0
+ enemy_hp -= player_damage
+ break if enemy_hp <= 0
+
+ player_hp -= enemy_damage
+ end
+
+ self.hp = [player_hp, 0.0].max
+ self.hp_updated_at = at
+
+ if enemy_hp > 0
+ save!
+ return { ok: false, reason: :defeated }
+ end
+
+ gain_exp(enemy.exp.to_i)
+ rewards = receive_rewards(enemy)
+ leveled_up = consume_exp_for_level_up(at: at)
+ cap_resources!(at)
+ save!
+
+ { ok: true, leveled_up: leveled_up, rewards: rewards }
+ end
+
+ def use_potion(potion, at: Time.current)
+ return false unless potion
+ item_record = find_item_record(potion)
+ return false unless item_record
+
+ apply_hp_regen!(at)
+ heal_amount = potion.hp.to_f
+ return false if heal_amount <= 0
+
+ self.hp = [hp.to_f + heal_amount, max_hp].min
+ self.hp_updated_at = at
+ if item_record.quantity.to_i > 1
+ item_record.update!(quantity: item_record.quantity.to_i - 1)
+ else
+ item_record.destroy!
+ end
+ save!
+ true
+ end
+
+ def current_hp(at: Time.current)
+ max = max_hp
+ return 0.0 if max <= 0.0
+
+ base = hp.nil? ? max : hp
+ last = hp_updated_at || at
+ recovered = recovered_amount(base, max, last, at, level.hp_recovery_sec)
+
+ [base + recovered, max].min
+ end
+
+ def current_stamina(at: Time.current)
+ max = max_stamina
+ return 0 if max <= 0
+
+ base = stamina.nil? ? max : stamina
+ last = stamina_updated_at || at
+ recovered = recovered_amount(base, max, last, at, level.stamina_recovery_sec)
+
+ [(base + recovered).to_i, max].min
+ end
+
+ def max_hp
+ level.hp
+ end
+
+ def max_stamina
+ level.stamina
+ end
+
+ def self.find_by_lv(lv)
+ find_by(lv: lv)
+ end
+
+ private
+
+ def scaled_damage(attack, defence)
+ return 0.0 if attack.to_f <= 0.0
+
+ attack.to_f * 100.0 / (100.0 + defence.to_f)
+ end
+
+ def stamina_cost_for(enemy)
+ [enemy.stamina_cost.to_i, 1].max
+ end
+
+ def apply_hp_regen!(at)
+ self.hp = current_hp(at: at)
+ self.hp_updated_at = at
+ end
+
+ def apply_stamina_regen!(at)
+ self.stamina = current_stamina(at: at)
+ self.stamina_updated_at = at
+ end
+
+ def recovered_amount(base, max, from_time, to_time, recovery_sec)
+ return 0.0 if max <= base
+ interval = recovery_sec.to_i
+ return 0.0 if interval <= 0
+
+ elapsed = (to_time - from_time).to_i
+ return 0.0 if elapsed <= 0
+
+ steps = elapsed / interval
+ [steps.to_f, max - base].min
+ end
+
+ def consume_stamina(cost, at:)
+ apply_stamina_regen!(at)
+ return false if stamina.to_i < cost
+
+ self.stamina = stamina.to_i - cost
+ self.stamina_updated_at = at
+ true
+ end
+
+ def gain_exp(amount)
+ self.exp = exp.to_i + amount
+ end
+
+ def consume_exp_for_level_up(at:)
+ leveled_up = false
+
+ loop do
+ current_level = level
+ required = current_level.next_exp.to_i
+ break if required <= 0
+ break if exp.to_i < required
+
+ next_level = Level.find_by(:lv, lv + 1)
+ break unless next_level
+
+ self.exp = exp.to_i - required
+ self.lv = next_level.lv
+ leveled_up = true
+ end
+
+ if leveled_up
+ self.stamina = max_stamina
+ self.stamina_updated_at = at
+ end
+
+ leveled_up
+ end
+
+ def cap_resources!(at)
+ apply_hp_regen!(at)
+ apply_stamina_regen!(at)
+
+ self.hp = [hp.to_f, max_hp].min if max_hp.positive?
+ self.stamina = [stamina.to_i, max_stamina].min if max_stamina.positive?
+ end
+
+ def receive_rewards(enemy)
+ enemy.rewards.filter_map do |reward|
+ next if reward.reward_type.nil? || reward.reward_id.nil?
+
+ add_item(reward.reward_type, reward.reward_id)
+ end
+ end
+
+ def find_item_record(item)
+ player_items.find_by(item: item)
+ end
+
+ def add_item(item_type, item_id, amount = 1)
+ item_class = item_type.safe_constantize
+ unless item_class && item_class <= ItemReceivable
+ fail ArgumentError, "Unsupported item_type: #{item_type}"
+ end
+
+ record = player_items.find_or_initialize_by(item_type: item_type, item_id: item_id)
+ max_quantity = item_class.max_quantity
+ quantity = record.quantity.to_i + amount
+ quantity = [quantity, max_quantity].min if max_quantity
+ record.quantity = quantity
+ record.save!
+ record
+ end
+end
diff --git a/examples/rails_sample/app/models/player_item.rb b/examples/rails_sample/app/models/player_item.rb
new file mode 100644
index 0000000..b4e058d
--- /dev/null
+++ b/examples/rails_sample/app/models/player_item.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class PlayerItem < ApplicationRecord
+ belongs_to :player
+ belongs_to :item, polymorphic: true
+end
diff --git a/examples/rails_sample/app/models/potion.rb b/examples/rails_sample/app/models/potion.rb
new file mode 100644
index 0000000..8c05e1f
--- /dev/null
+++ b/examples/rails_sample/app/models/potion.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Potion < ApplicationMaster
+ include ItemReceivable
+
+ def_column :id
+ def_column :name
+ def_column :hp, type: :float
+
+ globalize :name
+
+ validates :name, presence: true
+ validates :hp, numericality: { greater_than_or_equal_to: 0 }
+end
diff --git a/examples/rails_sample/app/models/reward.rb b/examples/rails_sample/app/models/reward.rb
new file mode 100644
index 0000000..8df4981
--- /dev/null
+++ b/examples/rails_sample/app/models/reward.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class Reward < ApplicationMaster
+ def_column :id
+ def_column :enemy_id, type: :integer, group_key: true
+ def_column :reward_type, polymorphic_type: true, group_key: true
+ def_column :reward_id, type: :integer
+
+ belongs_to :enemy
+ belongs_to :reward, polymorphic: true
+
+ validates :reward_type, presence: true
+ validate :reward_type_receivable
+
+ private
+
+ def reward_type_receivable
+ klass = reward_type.safe_constantize
+ return if klass && klass <= ItemReceivable
+
+ errors.add(:reward_type, :invalid)
+ end
+end
diff --git a/examples/rails_sample/app/models/weapon.rb b/examples/rails_sample/app/models/weapon.rb
new file mode 100644
index 0000000..7a769bd
--- /dev/null
+++ b/examples/rails_sample/app/models/weapon.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class Weapon < ApplicationMaster
+ include ItemReceivable
+
+ RARITY = {
+ common: 0,
+ rare: 1,
+ epic: 2,
+ }.freeze
+
+ def_column :id
+ def_column :type, sti: true
+ def_column :icon, type: :string
+ def_column :name, type: :string
+ def_column :attack, type: :float
+ def_column :info, type: :json, symbolize_names: true
+ def_column :metadata, type: :json, symbolize_names: false
+ def_column :rarity, type: :integer
+ def_column :flags, type: :integer
+
+ globalize :name
+
+ enum :rarity, RARITY
+ bitmask :flags, as: [:tradeable, :soulbound, :limited]
+
+ validates :name, presence: true
+ validates :attack, numericality: { greater_than_or_equal_to: 0 }
+ validates :rarity, inclusion: { in: RARITY.keys }
+
+ cache_method def cached_signature
+ "#{name}-#{rarity}"
+ end
+
+ def self.max_quantity
+ 1
+ end
+end
+
+class Gun < Weapon
+end
+
+class Blade < Weapon
+end
diff --git a/examples/rails_sample/app/views/game/show.html.erb b/examples/rails_sample/app/views/game/show.html.erb
new file mode 100644
index 0000000..0bd9e25
--- /dev/null
+++ b/examples/rails_sample/app/views/game/show.html.erb
@@ -0,0 +1,340 @@
+
+
+
+Dummy Game
+ Player
+ <% if @player %>
+ <% equipped_weapon = @player.weapon %>
+ <% equipped_armor = @player.armor %>
+ <% weapon_icon = equipped_weapon&.icon.to_s.strip %>
+ <% armor_icon = equipped_armor&.icon.to_s.strip %>
+ Enemies
+