statesman_scaffold 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/AGENTS.md +180 -0
- data/CHANGELOG.md +21 -0
- data/CLAUDE.md +126 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/lib/statesman_scaffold/concern.rb +87 -0
- data/lib/statesman_scaffold/generator.rb +80 -0
- data/lib/statesman_scaffold/installer.rb +60 -0
- data/lib/statesman_scaffold/railtie.rb +15 -0
- data/lib/statesman_scaffold/version.rb +5 -0
- data/lib/statesman_scaffold.rb +12 -0
- data/lib/tasks/statesman_scaffold.rake +52 -0
- data/lib/templates/statesman/migration.rb.erb +31 -0
- data/lib/templates/statesman/state_machine.rb.erb +38 -0
- data/lib/templates/statesman/transition.rb.erb +16 -0
- data/llms/overview.md +256 -0
- data/llms/usage.md +375 -0
- metadata +145 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6cbb9daaf63b955b29f50bf88b2bd3289356982558e7fb850c1fb1d0848104a8
|
|
4
|
+
data.tar.gz: 403cf58478d56041e182c84b1d8cbd78d64ea86ca3543b36efb89ebe5f691f23
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1d574af74d46523405ce37bc80a8c1ba1b9dbc71a8e1025f1b29d9586f98fbb141c4d9ca14517298a91ac090eb8b634d0cb28fec186d28db0ac0e44abcad0326
|
|
7
|
+
data.tar.gz: 80e5402b96703129bf738d6b86fcfba25593d9f85048b9a6ba518f4426974973c8b0449768421ea227d08aa8c6ded8ed52e3af8b8c71a72f23630928b43d3a43
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# AGENTS.md — statesman_scaffold
|
|
2
|
+
|
|
3
|
+
> Concise reference for AI coding agents working **on** the `statesman_scaffold`
|
|
4
|
+
> gem or **with** it inside a host Rails application.
|
|
5
|
+
>
|
|
6
|
+
> For end-user documentation see `README.md`.
|
|
7
|
+
> For LLM-optimised usage patterns see `llms/usage.md`.
|
|
8
|
+
> For architecture details see `llms/overview.md`.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## What this gem does
|
|
13
|
+
|
|
14
|
+
`statesman_scaffold` scaffolds [Statesman](https://github.com/gocardless/statesman)
|
|
15
|
+
state machines for ActiveRecord models and provides a `with_state_machine`
|
|
16
|
+
concern that wires up delegation, query scopes, and transition associations.
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# 1. Generate files
|
|
20
|
+
# rails "statesman_scaffold:generate[Project]"
|
|
21
|
+
|
|
22
|
+
# 2. Add to model
|
|
23
|
+
class Project < ApplicationRecord
|
|
24
|
+
with_state_machine
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# 3. Use
|
|
28
|
+
project = Project.create!(name: "Demo")
|
|
29
|
+
project.current_state # => "pending"
|
|
30
|
+
project.transition_to!(:active)
|
|
31
|
+
project.in_state?(:active) # => true
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
It is a **Rails-only** gem (depends on `activesupport >= 7`, `activerecord >= 7`,
|
|
35
|
+
`railties >= 7`, `statesman >= 10`). Works only with
|
|
36
|
+
`Statesman::Adapters::ActiveRecord`.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Quick integration checklist (host app)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bundle add statesman_scaffold
|
|
44
|
+
rails statesman_scaffold:install # creates config/initializers/statesman_scaffold.rb
|
|
45
|
+
rails "statesman_scaffold:generate[ModelName]" # generates files
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
After `install` every model inherits the `with_state_machine` macro. No
|
|
49
|
+
`include` needed.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## `with_state_machine` macro signature
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
with_state_machine(
|
|
57
|
+
transition_suffix: "Transition",
|
|
58
|
+
state_machine_suffix: "StateMachine"
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Parameter | Type | Default | Notes |
|
|
63
|
+
|-----------|------|---------|-------|
|
|
64
|
+
| `transition_suffix` | `String` | `"Transition"` | Suffix for the transition class |
|
|
65
|
+
| `state_machine_suffix` | `String` | `"StateMachine"` | Suffix for the state machine class |
|
|
66
|
+
|
|
67
|
+
The macro expects `Model::StateMachine` and `Model::Transition` to exist.
|
|
68
|
+
Use the `generate` rake task to create them.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Instance methods added by the concern
|
|
73
|
+
|
|
74
|
+
| Method | Delegates to | Description |
|
|
75
|
+
|--------|-------------|-------------|
|
|
76
|
+
| `current_state` | `state_machine` | Returns the current state as a string |
|
|
77
|
+
| `transition_to!(state)` | `state_machine` | Transitions to state; raises on failure |
|
|
78
|
+
| `transition_to(state)` | `state_machine` | Transitions to state; returns `true`/`false` |
|
|
79
|
+
| `can_transition_to?(state)` | `state_machine` | Checks if transition is valid |
|
|
80
|
+
| `in_state?(*states)` | — | Returns `true` if current state matches any given state |
|
|
81
|
+
| `not_in_state?(state)` | — | Returns `true` if current state does not match |
|
|
82
|
+
|
|
83
|
+
Class-level scopes from `Statesman::Adapters::ActiveRecordQueries`:
|
|
84
|
+
- `Model.in_state(:active, :pending)`
|
|
85
|
+
- `Model.not_in_state(:cancelled)`
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Rules agents must follow
|
|
90
|
+
|
|
91
|
+
### Always
|
|
92
|
+
- Run `rails statesman_scaffold:install` before first use in a host app.
|
|
93
|
+
- Call `with_state_machine` **after** any custom `has_many` declarations.
|
|
94
|
+
- Define states, transitions, and callbacks in the generated `StateMachine` class.
|
|
95
|
+
- Add an `after_transition` callback to sync the status column if you have one.
|
|
96
|
+
- Run `rails db:migrate` after generating files.
|
|
97
|
+
- The `statesman` gem must be in the host app's Gemfile.
|
|
98
|
+
|
|
99
|
+
### Never
|
|
100
|
+
- Manually create StateMachine/Transition classes without the generator (or follow the exact template patterns).
|
|
101
|
+
- Call `with_state_machine` more than once on the same model.
|
|
102
|
+
- Define a `has_many :transitions` on the model — `with_state_machine` does it.
|
|
103
|
+
- Stub `Rails.root` in tests — use `Installer.install!(tmpdir)` or `Generator.call(...)` with real paths.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Gem internals (working on the gem itself)
|
|
108
|
+
|
|
109
|
+
### Module map
|
|
110
|
+
|
|
111
|
+
| File | Responsibility |
|
|
112
|
+
|------|----------------|
|
|
113
|
+
| `lib/statesman_scaffold/concern.rb` | `with_state_machine` class macro + `in_state?`/`not_in_state?` |
|
|
114
|
+
| `lib/statesman_scaffold/generator.rb` | ERB template rendering for StateMachine, Transition, migration |
|
|
115
|
+
| `lib/statesman_scaffold/installer.rb` | Creates/removes `config/initializers/statesman_scaffold.rb` |
|
|
116
|
+
| `lib/statesman_scaffold/railtie.rb` | Registers all `statesman_scaffold:*` rake tasks |
|
|
117
|
+
| `lib/tasks/statesman_scaffold.rake` | Rake task implementations (install/uninstall/generate) |
|
|
118
|
+
| `lib/templates/statesman/*.erb` | ERB templates for generated files |
|
|
119
|
+
|
|
120
|
+
### Generated files (per model)
|
|
121
|
+
|
|
122
|
+
| Template | Output | Description |
|
|
123
|
+
|----------|--------|-------------|
|
|
124
|
+
| `state_machine.rb.erb` | `app/models/<model>/state_machine.rb` | Statesman::Machine class with example states |
|
|
125
|
+
| `transition.rb.erb` | `app/models/<model>/transition.rb` | Transition model with validations |
|
|
126
|
+
| `migration.rb.erb` | `db/migrate/TIMESTAMP_create_<model>_transitions.rb` | Migration with indexes |
|
|
127
|
+
|
|
128
|
+
### Test suite
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
bundle exec rake test # Minitest with SQLite in-memory
|
|
132
|
+
bundle exec rake # default task
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Tests live in `test/statesman_scaffold/`. Each test class uses `setup`/`teardown`
|
|
136
|
+
to create and drop SQLite tables so tests are fully isolated.
|
|
137
|
+
|
|
138
|
+
### Adding a new test
|
|
139
|
+
|
|
140
|
+
1. Extend `Minitest::Test` inside the `StatesmanScaffold` module namespace.
|
|
141
|
+
2. Name the file `test/statesman_scaffold/<feature>_test.rb`.
|
|
142
|
+
3. Create tables in `setup`, drop them in `teardown`.
|
|
143
|
+
4. Generator/Installer tests use `Dir.mktmpdir` for filesystem isolation.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Common pitfalls
|
|
148
|
+
|
|
149
|
+
| Symptom | Cause | Fix |
|
|
150
|
+
|---------|-------|-----|
|
|
151
|
+
| `NameError: uninitialized constant Statesman` | Initializer runs before `statesman` gem is loaded | Ensure `gem "statesman"` is in Gemfile; re-run `rails statesman_scaffold:install` (initializer now includes `require "statesman"`) |
|
|
152
|
+
| `NameError: uninitialized constant Model::StateMachine` | StateMachine class not generated or not loaded | Run `rails "statesman_scaffold:generate[Model]"` and ensure file is in autoload path |
|
|
153
|
+
| `Statesman::TransitionFailedError` | Transition not allowed from current state | Check `StateMachine` class transition rules |
|
|
154
|
+
| `ActiveRecord::StatementInvalid` (missing table) | Migration not run | Run `rails db:migrate` |
|
|
155
|
+
| Transitions not persisted | Statesman not configured for AR adapter | Run `rails statesman_scaffold:install` |
|
|
156
|
+
| Status column not updated after transition | No `after_transition` callback | Add `after_transition { |m, t| m.update!(status: t.to_state) }` to StateMachine |
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Dependencies
|
|
161
|
+
|
|
162
|
+
| Gem | Version | Why |
|
|
163
|
+
|-----|---------|-----|
|
|
164
|
+
| `statesman` | `>= 10.0, < 13` | State machine engine |
|
|
165
|
+
| `activerecord` | `>= 7.0, < 9` | AR integration (has_many, migrations) |
|
|
166
|
+
| `activesupport` | `>= 7.0, < 9` | `Concern`, `camelize`, `underscore`, `on_load` |
|
|
167
|
+
| `railties` | `>= 7.0, < 9` | `Rails::Railtie` for exposing rake tasks |
|
|
168
|
+
| `sqlite3` | dev only | In-memory DB for tests |
|
|
169
|
+
| `minitest` | dev only | Test framework |
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Test conventions
|
|
174
|
+
|
|
175
|
+
- Use `Minitest::Test` (not `ActiveSupport::TestCase`)
|
|
176
|
+
- AR schema is created in `setup` with `force: true` and dropped in `teardown`
|
|
177
|
+
- `InstallerTest` and `GeneratorTest` use `Dir.mktmpdir` — always clean up in `teardown`
|
|
178
|
+
- `assert` / `refute` preferred over `assert_equal true/false`
|
|
179
|
+
- Group related tests with comment banners: `# --- install! ---`
|
|
180
|
+
- Test method names describe the exact behaviour: `test_install_creates_the_initializer_file`
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-03-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `StatesmanScaffold::Concern` with the `with_state_machine` class macro
|
|
15
|
+
- `in_state?` and `not_in_state?` instance methods for state checking
|
|
16
|
+
- Delegation of `current_state`, `transition_to!`, `transition_to`, `can_transition_to?`
|
|
17
|
+
- `Statesman::Adapters::ActiveRecordQueries` integration for query scopes
|
|
18
|
+
- `StatesmanScaffold::Generator` for creating StateMachine, Transition, and migration files from ERB templates
|
|
19
|
+
- `StatesmanScaffold::Installer` for creating/removing the Rails initializer
|
|
20
|
+
- `StatesmanScaffold::Railtie` with `statesman_scaffold:install`, `statesman_scaffold:uninstall`, and `statesman_scaffold:generate` rake tasks
|
|
21
|
+
- Support for namespaced models (e.g. `Admin::Order`)
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# StatesmanScaffold -- Claude Project Context
|
|
2
|
+
|
|
3
|
+
> Project-level instructions and context for Claude Code when working inside
|
|
4
|
+
> the `statesman_scaffold` gem repository.
|
|
5
|
+
|
|
6
|
+
## Project overview
|
|
7
|
+
|
|
8
|
+
`statesman_scaffold` is a Ruby gem that scaffolds [Statesman](https://github.com/gocardless/statesman)
|
|
9
|
+
state machines for ActiveRecord models. It generates StateMachine, Transition classes,
|
|
10
|
+
and migrations via rake tasks, and provides a `with_state_machine` concern for wiring
|
|
11
|
+
everything up with a single macro call.
|
|
12
|
+
|
|
13
|
+
## Repository layout
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
lib/
|
|
17
|
+
statesman_scaffold.rb # Entry point -- require this in host apps
|
|
18
|
+
statesman_scaffold/
|
|
19
|
+
concern.rb # Core: with_state_machine macro via ActiveSupport::Concern
|
|
20
|
+
generator.rb # ERB template renderer for state machine files
|
|
21
|
+
installer.rb # Manages config/initializers/statesman_scaffold.rb
|
|
22
|
+
railtie.rb # Rails integration, exposes rake tasks
|
|
23
|
+
version.rb # Version constant
|
|
24
|
+
templates/
|
|
25
|
+
statesman/
|
|
26
|
+
state_machine.rb.erb # Template for StateMachine class
|
|
27
|
+
transition.rb.erb # Template for Transition model
|
|
28
|
+
migration.rb.erb # Template for migration file
|
|
29
|
+
tasks/
|
|
30
|
+
statesman_scaffold.rake # install / uninstall / generate rake tasks
|
|
31
|
+
test/
|
|
32
|
+
test_helper.rb # SQLite in-memory, Statesman AR adapter config
|
|
33
|
+
test_statesman_scaffold.rb # Version number test
|
|
34
|
+
statesman_scaffold/
|
|
35
|
+
concern_test.rb # Tests for with_state_machine macro
|
|
36
|
+
generator_test.rb # Tests for ERB template generation
|
|
37
|
+
installer_test.rb # Tests for Installer class
|
|
38
|
+
llms/
|
|
39
|
+
overview.md # Architecture deep-dive for LLMs
|
|
40
|
+
usage.md # Common patterns and recipes
|
|
41
|
+
AGENTS.md # Concise guide for AI coding agents
|
|
42
|
+
CLAUDE.md # This file
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Development workflow
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
bundle install
|
|
49
|
+
bundle exec rake test # run Minitest suite (SQLite in-memory)
|
|
50
|
+
bundle exec rake # tests (default)
|
|
51
|
+
bin/console # IRB with gem loaded
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Rake tasks (in host Rails apps)
|
|
55
|
+
|
|
56
|
+
| Task | Description |
|
|
57
|
+
|------|-------------|
|
|
58
|
+
| `rails statesman_scaffold:install` | Create `config/initializers/statesman_scaffold.rb` |
|
|
59
|
+
| `rails statesman_scaffold:uninstall` | Remove `config/initializers/statesman_scaffold.rb` |
|
|
60
|
+
| `rails "statesman_scaffold:generate[ModelName]"` | Generate StateMachine, Transition, and migration |
|
|
61
|
+
|
|
62
|
+
## Code style
|
|
63
|
+
|
|
64
|
+
- **Double quotes** throughout (enforced by RuboCop).
|
|
65
|
+
- Frozen string literals on every file.
|
|
66
|
+
- Nested module/class syntax (`module StatesmanScaffold; class Concern`) preferred.
|
|
67
|
+
- Test files use `module StatesmanScaffold; class XxxTest < Minitest::Test` nesting.
|
|
68
|
+
- Metrics limits: `MethodLength: Max: 15`; test files are excluded from metrics.
|
|
69
|
+
|
|
70
|
+
## Key design decisions
|
|
71
|
+
|
|
72
|
+
1. **`with_state_machine` resolves class names dynamically** at call time using
|
|
73
|
+
the model's `name`. It expects `Model::StateMachine` and `Model::Transition`
|
|
74
|
+
to exist (generated via the rake task). Accepts optional `transition_suffix:`
|
|
75
|
+
and `state_machine_suffix:` keyword arguments.
|
|
76
|
+
|
|
77
|
+
2. **Concern delegates** `current_state`, `transition_to!`, `transition_to`, and
|
|
78
|
+
`can_transition_to?` to a lazily-initialized state machine instance.
|
|
79
|
+
|
|
80
|
+
3. **`in_state?` accepts multiple states** and compares via `.to_sym` for
|
|
81
|
+
symbol/string interoperability. `not_in_state?` accepts a single state.
|
|
82
|
+
|
|
83
|
+
4. **Statesman::Adapters::ActiveRecordQueries** is auto-included by
|
|
84
|
+
`with_state_machine`, giving the model class-level `.in_state` and
|
|
85
|
+
`.not_in_state` query scopes.
|
|
86
|
+
|
|
87
|
+
5. **Generator is decoupled from Rails**: `StatesmanScaffold::Generator` takes
|
|
88
|
+
`Pathname` arguments and never references `Rails.root`, making it unit-testable
|
|
89
|
+
with a `Dir.mktmpdir`. Returns a `Result` struct with all output paths.
|
|
90
|
+
|
|
91
|
+
6. **Installer is decoupled from Rails**: `StatesmanScaffold::Installer` takes a
|
|
92
|
+
`Pathname` root and never references `Rails.root`, making it unit-testable
|
|
93
|
+
with a `Dir.mktmpdir`.
|
|
94
|
+
|
|
95
|
+
7. **The generated initializer** does three things:
|
|
96
|
+
- `require "statesman"` (ensures the gem is loaded before configuring)
|
|
97
|
+
- `Statesman.configure { storage_adapter(Statesman::Adapters::ActiveRecord) }`
|
|
98
|
+
- `ActiveSupport.on_load(:active_record) { include StatesmanScaffold::Concern }`
|
|
99
|
+
|
|
100
|
+
8. **Templates use ERB** with `trim_mode: "-"` and `result_with_hash` for clean
|
|
101
|
+
rendering without binding objects.
|
|
102
|
+
|
|
103
|
+
## When adding features
|
|
104
|
+
|
|
105
|
+
- State machine wiring logic belongs in `concern.rb`.
|
|
106
|
+
- New public class-level DSL methods belong inside `class_methods do`.
|
|
107
|
+
- Template changes go in `lib/templates/statesman/`.
|
|
108
|
+
- Update `sig/statesman_scaffold.rbs` when adding or changing public method signatures.
|
|
109
|
+
- Write Minitest tests in `test/statesman_scaffold/` before implementing (TDD).
|
|
110
|
+
- Run `bundle exec rake` before committing -- must be fully green.
|
|
111
|
+
|
|
112
|
+
## Dependency notes
|
|
113
|
+
|
|
114
|
+
- `statesman` provides `Statesman::Machine`, `Statesman::Adapters::ActiveRecord`,
|
|
115
|
+
and `Statesman::Adapters::ActiveRecordQueries`.
|
|
116
|
+
- `activerecord` provides `has_many`, `ActiveRecord::Base`, and migrations.
|
|
117
|
+
- `activesupport` provides `ActiveSupport::Concern`, `String#camelize`,
|
|
118
|
+
`String#underscore`, `String#demodulize`, `String#deconstantize`.
|
|
119
|
+
- `railties` provides `Rails::Railtie` for rake task integration.
|
|
120
|
+
|
|
121
|
+
## Out of scope (do not add unless explicitly requested)
|
|
122
|
+
|
|
123
|
+
- Custom transition metadata helpers.
|
|
124
|
+
- State machine visualization or diagram generation.
|
|
125
|
+
- Support for non-ActiveRecord ORMs.
|
|
126
|
+
- Automatic state column management (users define their own status columns).
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pawel Niemczyk
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# StatesmanScaffold
|
|
2
|
+
|
|
3
|
+
Scaffold [Statesman](https://github.com/gocardless/statesman) state machines for ActiveRecord models. Generates StateMachine, Transition classes, and migrations with a single rake task, plus a `with_state_machine` concern that wires up delegation, query scopes, and transition associations.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
* One rake task generates StateMachine class, Transition model, and migration
|
|
8
|
+
* `with_state_machine` class macro wires up Statesman with a single line
|
|
9
|
+
* Delegates `current_state`, `transition_to!`, `transition_to`, `can_transition_to?` to the state machine
|
|
10
|
+
* Adds `in_state?` and `not_in_state?` convenience predicates
|
|
11
|
+
* Includes `Statesman::Adapters::ActiveRecordQueries` for query scopes
|
|
12
|
+
* Supports namespaced models (e.g. `Admin::Order`)
|
|
13
|
+
* Rails installer that configures Statesman adapter and auto-includes the concern
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Add to your application's `Gemfile`:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "statesman_scaffold"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then run:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bundle install
|
|
27
|
+
rails statesman_scaffold:install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The installer creates `config/initializers/statesman_scaffold.rb` which configures Statesman to use the ActiveRecord adapter and auto-includes `StatesmanScaffold::Concern` into every ActiveRecord model.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
### Generate state machine files
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
rails "statesman_scaffold:generate[Project]"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This creates three files:
|
|
41
|
+
|
|
42
|
+
* `app/models/project/state_machine.rb` – the Statesman state machine class
|
|
43
|
+
* `app/models/project/transition.rb` – the transition model
|
|
44
|
+
* `db/migrate/TIMESTAMP_create_project_transitions.rb` – the migration
|
|
45
|
+
|
|
46
|
+
### Configure your model
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
class Project < ApplicationRecord
|
|
50
|
+
STATUSES = Project::StateMachine.states
|
|
51
|
+
|
|
52
|
+
with_state_machine
|
|
53
|
+
|
|
54
|
+
attribute :status, :string, default: Project::StateMachine.initial_state
|
|
55
|
+
validates :status, inclusion: { in: STATUSES }, allow_nil: true
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Define states and transitions
|
|
60
|
+
|
|
61
|
+
Edit the generated `app/models/project/state_machine.rb`:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
class Project::StateMachine
|
|
65
|
+
include Statesman::Machine
|
|
66
|
+
|
|
67
|
+
state :active, initial: true
|
|
68
|
+
state :pending
|
|
69
|
+
state :skipped
|
|
70
|
+
state :cancelled
|
|
71
|
+
state :done
|
|
72
|
+
|
|
73
|
+
transition from: :active, to: [:pending, :skipped, :cancelled, :done]
|
|
74
|
+
transition from: :pending, to: [:skipped, :cancelled, :done]
|
|
75
|
+
transition from: :skipped, to: [:pending]
|
|
76
|
+
|
|
77
|
+
after_transition do |model, transition|
|
|
78
|
+
model.update!(status: transition.to_state)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Use the state machine
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
project = Project.create!(name: "Demo")
|
|
87
|
+
|
|
88
|
+
project.current_state # => "active"
|
|
89
|
+
project.in_state?(:active) # => true
|
|
90
|
+
project.not_in_state?(:done) # => true
|
|
91
|
+
|
|
92
|
+
project.can_transition_to?(:pending) # => true
|
|
93
|
+
project.transition_to!(:pending)
|
|
94
|
+
project.current_state # => "pending"
|
|
95
|
+
project.status # => "pending"
|
|
96
|
+
|
|
97
|
+
# Non-bang version returns true/false instead of raising
|
|
98
|
+
project.transition_to(:done) # => true
|
|
99
|
+
|
|
100
|
+
# Query scopes (via Statesman::Adapters::ActiveRecordQueries)
|
|
101
|
+
Project.in_state(:active)
|
|
102
|
+
Project.not_in_state(:cancelled)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Namespaced models
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
rails "statesman_scaffold:generate[Admin::Order]"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
This generates `Admin::Order::StateMachine`, `Admin::Order::Transition`, and the corresponding migration with proper table names and foreign keys.
|
|
112
|
+
|
|
113
|
+
## Rake tasks
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
rails statesman_scaffold:install # create config/initializers/statesman_scaffold.rb
|
|
117
|
+
rails statesman_scaffold:uninstall # remove config/initializers/statesman_scaffold.rb
|
|
118
|
+
rails "statesman_scaffold:generate[ModelName]" # generate state machine files
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Manual include (without the Rails initializer)
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
class Project < ApplicationRecord
|
|
125
|
+
include StatesmanScaffold::Concern
|
|
126
|
+
with_state_machine
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
bin/setup # install dependencies
|
|
134
|
+
bundle exec rake # run tests
|
|
135
|
+
bin/console # interactive prompt
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Contributing
|
|
139
|
+
|
|
140
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/pniemczyk/statesman_scaffold.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT -- see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module StatesmanScaffold
|
|
6
|
+
# ActiveSupport::Concern that provides the +with_state_machine+ class macro
|
|
7
|
+
# for wiring up Statesman state machines to ActiveRecord models.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# class Project < ApplicationRecord
|
|
11
|
+
# include StatesmanScaffold::Concern
|
|
12
|
+
# with_state_machine
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# project = Project.create!(name: "Demo")
|
|
16
|
+
# project.current_state # => "pending"
|
|
17
|
+
# project.can_transition_to?(:active) # => true/false
|
|
18
|
+
# project.transition_to!(:active)
|
|
19
|
+
# project.in_state?(:active) # => true
|
|
20
|
+
# project.not_in_state?(:pending) # => true
|
|
21
|
+
module Concern
|
|
22
|
+
extend ActiveSupport::Concern
|
|
23
|
+
|
|
24
|
+
included do
|
|
25
|
+
# Check if the model is currently in any of the given states.
|
|
26
|
+
#
|
|
27
|
+
# @param states [Array<String, Symbol>] one or more state names
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
def in_state?(*states)
|
|
30
|
+
states.any? { |s| current_state.to_sym == s.to_sym }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Check if the model is NOT in the given state.
|
|
34
|
+
#
|
|
35
|
+
# @param state [String, Symbol] a state name
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def not_in_state?(state)
|
|
38
|
+
!in_state?(state)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
delegate :can_transition_to?, :transition_to!, :transition_to, :current_state, to: :state_machine
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class_methods do
|
|
45
|
+
# Wires up the Statesman state machine for this model.
|
|
46
|
+
#
|
|
47
|
+
# Expects nested +StateMachine+ and +Transition+ classes to exist under
|
|
48
|
+
# the model's namespace (e.g. +Project::StateMachine+, +Project::Transition+).
|
|
49
|
+
#
|
|
50
|
+
# Use the +statesman_scaffold:generate+ rake task to create these classes
|
|
51
|
+
# and the corresponding migration.
|
|
52
|
+
#
|
|
53
|
+
# @param transition_suffix [String] suffix for the transition class (default: "Transition")
|
|
54
|
+
# @param state_machine_suffix [String] suffix for the state machine class (default: "StateMachine")
|
|
55
|
+
def with_state_machine(transition_suffix: "Transition", state_machine_suffix: "StateMachine")
|
|
56
|
+
model_name = name
|
|
57
|
+
model_namespace = model_name.deconstantize
|
|
58
|
+
model_base = model_name.demodulize
|
|
59
|
+
|
|
60
|
+
transition_class_name = [model_namespace.presence, model_base, transition_suffix].compact.join("::")
|
|
61
|
+
state_machine_class_name = [model_namespace.presence, model_base, state_machine_suffix].compact.join("::")
|
|
62
|
+
|
|
63
|
+
has_many :transitions,
|
|
64
|
+
autosave: false,
|
|
65
|
+
class_name: transition_class_name,
|
|
66
|
+
dependent: :destroy
|
|
67
|
+
|
|
68
|
+
include ::Statesman::Adapters::ActiveRecordQueries[
|
|
69
|
+
transition_class: transition_class_name.constantize,
|
|
70
|
+
initial_state: state_machine_class_name.constantize.initial_state
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
define_method(:state_machine) do
|
|
74
|
+
instance_variable_get(:@state_machine) ||
|
|
75
|
+
instance_variable_set(
|
|
76
|
+
:@state_machine,
|
|
77
|
+
state_machine_class_name.constantize.new(
|
|
78
|
+
self,
|
|
79
|
+
transition_class: transition_class_name.constantize,
|
|
80
|
+
association_name: :transitions
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module StatesmanScaffold
|
|
7
|
+
# Generates StateMachine, Transition class files, and a migration from ERB
|
|
8
|
+
# templates. This class is intentionally decoupled from Rails so it can be
|
|
9
|
+
# tested without booting a full Rails application.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# StatesmanScaffold::Generator.call(
|
|
13
|
+
# model: "Project",
|
|
14
|
+
# output_path: Pathname.new("app/models/project"),
|
|
15
|
+
# migration_path: Pathname.new("db/migrate"),
|
|
16
|
+
# migration_version: "8.0"
|
|
17
|
+
# )
|
|
18
|
+
class Generator
|
|
19
|
+
TEMPLATE_DIR = Pathname.new(__dir__).join("..", "templates", "statesman").expand_path
|
|
20
|
+
|
|
21
|
+
Result = Struct.new(:state_machine_path, :transition_path, :migration_path, keyword_init: true) # rubocop:disable Style/RedundantStructKeywordInit
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Generate all three files for the given model.
|
|
25
|
+
#
|
|
26
|
+
# @param model [String] PascalCase model name (e.g. "Project", "Admin::Order")
|
|
27
|
+
# @param output_path [Pathname, String] directory for model files
|
|
28
|
+
# @param migration_path [Pathname, String] directory for migration file
|
|
29
|
+
# @param migration_version [String] ActiveRecord migration version (e.g. "8.0")
|
|
30
|
+
# @param timestamp [String, nil] migration timestamp (default: current UTC)
|
|
31
|
+
# @return [Result] paths of created files
|
|
32
|
+
def call(model:, output_path:, migration_path:, migration_version:, timestamp: nil)
|
|
33
|
+
model = model.to_s.camelize
|
|
34
|
+
class_name = model
|
|
35
|
+
output_path = Pathname(output_path)
|
|
36
|
+
migration_path = Pathname(migration_path)
|
|
37
|
+
timestamp ||= Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
38
|
+
|
|
39
|
+
output_path.mkpath
|
|
40
|
+
migration_path.mkpath
|
|
41
|
+
|
|
42
|
+
sm_path = render_template(
|
|
43
|
+
"state_machine.rb.erb",
|
|
44
|
+
output_path.join("state_machine.rb"),
|
|
45
|
+
model: model, class_name: class_name
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
tr_path = render_template(
|
|
49
|
+
"transition.rb.erb",
|
|
50
|
+
output_path.join("transition.rb"),
|
|
51
|
+
model: model, class_name: class_name
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
file_path = model.underscore.tr("/", "_")
|
|
55
|
+
mig_file = "#{timestamp}_create_#{file_path}_transitions.rb"
|
|
56
|
+
mig_path = render_template(
|
|
57
|
+
"migration.rb.erb",
|
|
58
|
+
migration_path.join(mig_file),
|
|
59
|
+
model: model, class_name: class_name, version: migration_version
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
Result.new(
|
|
63
|
+
state_machine_path: sm_path,
|
|
64
|
+
transition_path: tr_path,
|
|
65
|
+
migration_path: mig_path
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def render_template(template_name, output_file, **locals)
|
|
72
|
+
template_path = TEMPLATE_DIR.join(template_name)
|
|
73
|
+
erb = ERB.new(template_path.read, trim_mode: "-")
|
|
74
|
+
content = erb.result_with_hash(locals)
|
|
75
|
+
output_file.write(content)
|
|
76
|
+
output_file
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|