rage_arch 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/LICENSE +21 -0
- data/README.md +190 -0
- data/lib/generators/rage_arch/ar_dep_generator.rb +74 -0
- data/lib/generators/rage_arch/dep_generator.rb +120 -0
- data/lib/generators/rage_arch/dep_switch_generator.rb +224 -0
- data/lib/generators/rage_arch/install_generator.rb +64 -0
- data/lib/generators/rage_arch/scaffold_generator.rb +133 -0
- data/lib/generators/rage_arch/templates/ar_dep.rb.tt +46 -0
- data/lib/generators/rage_arch/templates/dep.rb.tt +16 -0
- data/lib/generators/rage_arch/templates/rage_arch.rb.tt +15 -0
- data/lib/generators/rage_arch/templates/scaffold/api_controller.rb.tt +39 -0
- data/lib/generators/rage_arch/templates/scaffold/controller.rb.tt +56 -0
- data/lib/generators/rage_arch/templates/scaffold/create.rb.tt +14 -0
- data/lib/generators/rage_arch/templates/scaffold/destroy.rb.tt +15 -0
- data/lib/generators/rage_arch/templates/scaffold/list.rb.tt +13 -0
- data/lib/generators/rage_arch/templates/scaffold/new.rb.tt +13 -0
- data/lib/generators/rage_arch/templates/scaffold/post_repo.rb.tt +35 -0
- data/lib/generators/rage_arch/templates/scaffold/show.rb.tt +14 -0
- data/lib/generators/rage_arch/templates/scaffold/update.rb.tt +15 -0
- data/lib/generators/rage_arch/templates/use_case.rb.tt +18 -0
- data/lib/generators/rage_arch/use_case_generator.rb +33 -0
- data/lib/rage_arch/container.rb +38 -0
- data/lib/rage_arch/controller.rb +22 -0
- data/lib/rage_arch/dep.rb +9 -0
- data/lib/rage_arch/dep_scanner.rb +95 -0
- data/lib/rage_arch/deps/active_record.rb +45 -0
- data/lib/rage_arch/event_publisher.rb +59 -0
- data/lib/rage_arch/fake_event_publisher.rb +37 -0
- data/lib/rage_arch/railtie.rb +23 -0
- data/lib/rage_arch/result.rb +31 -0
- data/lib/rage_arch/rspec_matchers.rb +94 -0
- data/lib/rage_arch/use_case.rb +252 -0
- data/lib/rage_arch/version.rb +5 -0
- data/lib/rage_arch.rb +97 -0
- metadata +133 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a0f57a7080e54d4fd38967f95f88a3e3af2a1a3716665acc79e3b2383fd3b6a4
|
|
4
|
+
data.tar.gz: '052118de065d6e08bf9c61eacce620d964b8d13f35c745c33d01e5df15515d54'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0ed85c8a86183eee47cd39bdbcb9685832a73f2d731a4a7e7ba2f4a6b88c47718418b0fae81d1059ef9be628715817a3747999e61fdba971dd8fc3a4f1cabd14
|
|
7
|
+
data.tar.gz: c3692202b97ad9e8c2ba501a1b033b5b2e6fa2cad0de41b6bdbc9f607131be2d43b11694d2f7c8fd85103a93ae2c0dff6c136d6948967e8177fcd91fc57dfbcd
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Rage Corp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Rage
|
|
2
|
+
|
|
3
|
+
**Clean Architecture Light for Rails.** Business logic in testable use cases, thin controllers, and models free of callbacks.
|
|
4
|
+
|
|
5
|
+
## Core concepts
|
|
6
|
+
|
|
7
|
+
- **Controllers** only orchestrate — no business logic
|
|
8
|
+
- **Models** stay clean — no business callbacks
|
|
9
|
+
- **Use cases** hold all logic, with injected dependencies and typed results
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bundle install
|
|
15
|
+
rails g rage:install
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Creates `config/initializers/rage.rb`, `app/use_cases/`, `app/deps/`, and injects `include Rage::Controller` into `ApplicationController`.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Components
|
|
23
|
+
|
|
24
|
+
### `Rage::Result` — typed result object
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
result = Rage::Result.success(order)
|
|
28
|
+
result.success? # => true
|
|
29
|
+
result.value # => order
|
|
30
|
+
|
|
31
|
+
result = Rage::Result.failure(["Validation error"])
|
|
32
|
+
result.failure? # => true
|
|
33
|
+
result.errors # => ["Validation error"]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
### `Rage::UseCase::Base` — use cases
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
class CreateOrder < Rage::UseCase::Base
|
|
42
|
+
use_case_symbol :create_order
|
|
43
|
+
deps :order_store, :notifications # injected by symbol
|
|
44
|
+
|
|
45
|
+
def call(params = {})
|
|
46
|
+
order = order_store.build(params)
|
|
47
|
+
return failure(order.errors) unless order_store.save(order)
|
|
48
|
+
notifications.notify(:order_created, order)
|
|
49
|
+
success(order)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Build and run manually:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
use_case = Rage::UseCase::Base.build(:create_order)
|
|
58
|
+
result = use_case.call(reference: "REF-1", total_cents: 1000)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
### `Rage::Container` — dependency registration
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
Rage.register(:order_store, MyApp::Deps::OrderStore.new)
|
|
67
|
+
Rage.register_ar(:user_store, User) # automatic ActiveRecord wrapper
|
|
68
|
+
Rage.resolve(:order_store)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
### `Rage::Controller` — thin controller mixin
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
def create
|
|
77
|
+
run :users_register, register_params,
|
|
78
|
+
success: ->(r) { session[:user_id] = r.value[:user].id; redirect_to root_path, notice: "Created." },
|
|
79
|
+
failure: ->(r) { flash_errors(r); render :new, status: :unprocessable_entity }
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- `run(symbol, params, success:, failure:)` — runs the use case and calls the matching block
|
|
84
|
+
- `run_result(symbol, params)` — runs and returns the `Result` directly
|
|
85
|
+
- `flash_errors(result)` — sets `flash.now[:alert]` from `result.errors`
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
### `Rage::EventPublisher` — domain events
|
|
90
|
+
|
|
91
|
+
Every use case automatically publishes an event when it finishes. Other use cases subscribe to react:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
class Notifications::SendPostCreatedEmail < Rage::UseCase::Base
|
|
95
|
+
use_case_symbol :send_post_created_email
|
|
96
|
+
deps :mailer
|
|
97
|
+
subscribe :posts_create # runs when :posts_create event is published
|
|
98
|
+
|
|
99
|
+
def call(payload = {})
|
|
100
|
+
return success unless payload[:success]
|
|
101
|
+
mailer.send_post_created(payload[:value][:post])
|
|
102
|
+
success
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Subscribe to multiple events or everything:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
subscribe :post_created, :post_updated
|
|
111
|
+
subscribe :all # payload includes :event with the event name
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Opt out of auto-publish for a specific use case:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
skip_auto_publish
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
### Orchestration — use cases calling use cases
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class CreateOrderWithNotification < Rage::UseCase::Base
|
|
126
|
+
use_case_symbol :create_order_with_notification
|
|
127
|
+
deps :order_store
|
|
128
|
+
use_cases :orders_create, :notifications_send
|
|
129
|
+
|
|
130
|
+
def call(params = {})
|
|
131
|
+
result = orders_create.call(params)
|
|
132
|
+
return result unless result.success?
|
|
133
|
+
notifications_send.call(order_id: result.value[:order].id, type: :order_created)
|
|
134
|
+
result
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Generators
|
|
142
|
+
|
|
143
|
+
| Command | What it does |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `rails g rage:install` | Initial setup (initializer, directories, controller mixin) |
|
|
146
|
+
| `rails g rage:scaffold Post title:string` | Full CRUD: model, use cases, dep, controller, views, routes |
|
|
147
|
+
| `rails g rage:scaffold Post title:string --api` | Same but API-only (JSON responses) |
|
|
148
|
+
| `rails g rage:scaffold Post title:string --skip-model` | Skip model/migration if it already exists |
|
|
149
|
+
| `rails g rage:use_case CreateOrder` | Generates a base use case file |
|
|
150
|
+
| `rails g rage:dep post_store` | Generates a dep class by scanning method calls in use cases |
|
|
151
|
+
| `rails g rage:ar_dep post_store Post` | Generates a dep that wraps an ActiveRecord model |
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Testing
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# spec/rails_helper.rb
|
|
159
|
+
require "rage/rspec_matchers"
|
|
160
|
+
require "rage/fake_event_publisher"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Result matchers:**
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
expect(result).to succeed_with(post: a_kind_of(Post))
|
|
167
|
+
expect(result).to fail_with_errors(["Not found"])
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Fake event publisher:**
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
publisher = Rage::FakeEventPublisher.new
|
|
174
|
+
Rage.register(:event_publisher, publisher)
|
|
175
|
+
# ... run use case ...
|
|
176
|
+
expect(publisher.published).to include(hash_including(event: :post_created))
|
|
177
|
+
publisher.clear
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Documentation
|
|
183
|
+
|
|
184
|
+
- [`doc/REFERENCE.md`](doc/REFERENCE.md) — Full API reference with all options and examples
|
|
185
|
+
- [`doc/DOCUMENTATION.md`](doc/DOCUMENTATION.md) — Detailed behaviour (use cases, deps, events, config)
|
|
186
|
+
- [`doc/DAY_OF_WORK.md`](doc/DAY_OF_WORK.md) — Quick reference for common tasks
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rage_arch/dep_scanner"
|
|
5
|
+
|
|
6
|
+
module RageArch
|
|
7
|
+
module Generators
|
|
8
|
+
class ArDepGenerator < ::Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
argument :symbol_arg, type: :string, required: true, banner: "SYMBOL"
|
|
12
|
+
argument :model_arg, type: :string, required: true, banner: "MODEL"
|
|
13
|
+
|
|
14
|
+
desc "Create a dep class that wraps an Active Record model (build, find, save, update, destroy, list). Scans use cases for extra method calls and adds stubs for them. Example: rails g rage_arch:ar_dep post_store Post"
|
|
15
|
+
def create_ar_dep
|
|
16
|
+
@extra_methods = extra_methods
|
|
17
|
+
template "ar_dep.rb.tt", File.join("app/deps", module_dir, "#{dep_file_name}.rb")
|
|
18
|
+
say "Register in config/initializers/rage_arch.rb: Rage.register(:#{symbol_name}, #{full_class_name}.new)", :green
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
STANDARD_AR_METHODS = %i[build find save update destroy list].freeze
|
|
22
|
+
|
|
23
|
+
# Methods that use cases call on this dep but are not in the standard AR adapter
|
|
24
|
+
def extra_methods
|
|
25
|
+
detected = scanner.methods_for(symbol_name).to_a
|
|
26
|
+
(detected - STANDARD_AR_METHODS).sort
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def symbol_name
|
|
30
|
+
symbol_arg.to_s.underscore
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def model_name
|
|
34
|
+
model_arg.camelize
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def module_dir
|
|
38
|
+
inferred_module_dir || use_case_folder
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def module_name
|
|
42
|
+
module_dir.camelize
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def use_case_folder
|
|
46
|
+
scanner.folder_for(symbol_name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def inferred_module_dir
|
|
50
|
+
entity = symbol_name.split("_").first
|
|
51
|
+
entity.pluralize
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def dep_file_name
|
|
55
|
+
symbol_name
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def class_name
|
|
59
|
+
symbol_name.camelize
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def full_class_name
|
|
63
|
+
"#{module_name}::#{class_name}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def scanner
|
|
67
|
+
@scanner ||= begin
|
|
68
|
+
root = destination_root
|
|
69
|
+
RageArch::DepScanner.new(File.join(root, "app", "use_cases")).tap(&:scan)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rage_arch/dep_scanner"
|
|
5
|
+
|
|
6
|
+
module RageArch
|
|
7
|
+
module Generators
|
|
8
|
+
class DepGenerator < ::Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
argument :symbol_arg, type: :string, required: true, banner: "SYMBOL"
|
|
12
|
+
argument :class_name_arg, type: :string, optional: true, banner: "[CLASS_NAME]",
|
|
13
|
+
default: nil
|
|
14
|
+
|
|
15
|
+
desc "Create a dep class in app/deps/, grouped by folder. The folder is inferred from the symbol first (post_store → posts, like_store → likes). If the symbol has no clear entity, the folder is taken from use cases that reference it. Optional CLASS_NAME is the generated class (e.g. CsvPostStore); default is SYMBOL.camelize. If the file already exists, only adds stub methods that are missing (detected from use cases but not yet in the class)."
|
|
16
|
+
def create_dep
|
|
17
|
+
@methods = detected_methods
|
|
18
|
+
if @methods.empty?
|
|
19
|
+
say "No method calls found for dep :#{symbol_name} in app/use_cases/. Creating with a single stub.", :yellow
|
|
20
|
+
@methods = [:call]
|
|
21
|
+
end
|
|
22
|
+
target_path = File.join("app/deps", module_dir, "#{dep_file_name}.rb")
|
|
23
|
+
if File.exist?(File.join(destination_root, target_path))
|
|
24
|
+
add_missing_methods_only(target_path)
|
|
25
|
+
else
|
|
26
|
+
template "dep.rb.tt", target_path
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# When the dep file already exists, parse it for existing method names and insert only stubs for missing ones.
|
|
31
|
+
def add_missing_methods_only(relative_path)
|
|
32
|
+
full_path = File.join(destination_root, relative_path)
|
|
33
|
+
content = File.read(full_path)
|
|
34
|
+
existing = content.scan(/^\s+def\s+(\w+)\s*[\(\s]/m).flatten.uniq.map(&:to_sym)
|
|
35
|
+
missing = @methods - existing
|
|
36
|
+
if missing.empty?
|
|
37
|
+
say "All detected methods already present in #{relative_path}.", :green
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
indent = content[/^(\s+)def\s+\w+/m, 1] || " "
|
|
41
|
+
stubs = missing.map { |m| method_stub(m, indent) }.join("\n")
|
|
42
|
+
# Insert before the class's closing end (last indented "end" before the file's final "end").
|
|
43
|
+
lines = content.lines
|
|
44
|
+
insert_at = lines.length - 1
|
|
45
|
+
insert_at -= 1 while insert_at >= 0 && lines[insert_at] =~ /^\s*$/
|
|
46
|
+
insert_at -= 1 while insert_at > 0 && lines[insert_at] !~ /^\s+end\s*$/
|
|
47
|
+
new_content = lines[0...insert_at].join + "\n#{stubs}\n" + lines[insert_at..].join
|
|
48
|
+
File.write(full_path, new_content)
|
|
49
|
+
say "Added #{missing.size} method(s) to #{relative_path}: #{missing.map(&:to_s).join(', ')}", :green
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def method_stub(method_name, indent = " ")
|
|
53
|
+
body_indent = indent + " "
|
|
54
|
+
<<~RUBY.strip
|
|
55
|
+
#{indent}def #{method_name}(*args, **kwargs)
|
|
56
|
+
#{body_indent}# TODO: implement
|
|
57
|
+
#{body_indent}raise NotImplementedError, "#{full_class_name}##{method_name}"
|
|
58
|
+
#{indent}end
|
|
59
|
+
RUBY
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def symbol_name
|
|
63
|
+
symbol_arg.to_s.underscore
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Folder from use cases that reference this symbol (e.g. likes/create.rb → "likes"), or inferred from symbol.
|
|
67
|
+
# Prefer inferred from symbol (post_store → posts) so the dep lives with its domain, not only where it's referenced.
|
|
68
|
+
def module_dir
|
|
69
|
+
inferred_module_dir || use_case_folder
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Module name for the class (e.g. Likes, Posts). Matches Zeitwerk: app/deps/likes/ → Likes::
|
|
73
|
+
def module_name
|
|
74
|
+
module_dir.camelize
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# When use cases in app/use_cases/likes/*.rb reference this symbol, returns "likes"
|
|
78
|
+
def use_case_folder
|
|
79
|
+
scanner.folder_for(symbol_name)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Fallback when no use case references the symbol: like_store → "likes", post_store → "posts"
|
|
83
|
+
def inferred_module_dir
|
|
84
|
+
entity = symbol_name.split("_").first
|
|
85
|
+
entity.pluralize
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def class_name
|
|
89
|
+
if class_name_arg.present?
|
|
90
|
+
class_name_arg.camelize
|
|
91
|
+
else
|
|
92
|
+
symbol_name.camelize
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def dep_file_name
|
|
97
|
+
if class_name_arg.present?
|
|
98
|
+
class_name_arg.underscore
|
|
99
|
+
else
|
|
100
|
+
symbol_name
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def full_class_name
|
|
105
|
+
"#{module_name}::#{class_name}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def detected_methods
|
|
109
|
+
scanner.methods_for(symbol_name).to_a.sort
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def scanner
|
|
113
|
+
@scanner ||= begin
|
|
114
|
+
root = destination_root
|
|
115
|
+
RageArch::DepScanner.new(File.join(root, "app", "use_cases")).tap(&:scan)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module RageArch
|
|
6
|
+
module Generators
|
|
7
|
+
class DepSwitchGenerator < ::Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
argument :symbol_arg, type: :string, required: true, banner: "SYMBOL"
|
|
11
|
+
argument :class_name_arg, type: :string, optional: true, banner: "[CLASS_NAME]",
|
|
12
|
+
default: nil
|
|
13
|
+
|
|
14
|
+
desc "List implementations for a dep symbol and set which one is active in config/initializers/rage_arch.rb"
|
|
15
|
+
def switch_dep
|
|
16
|
+
symbol = symbol_name
|
|
17
|
+
options = build_options(symbol)
|
|
18
|
+
if options.empty?
|
|
19
|
+
say "No implementations found for :#{symbol}.", :red
|
|
20
|
+
say "Create a dep with: rails g rage_arch:dep #{symbol} [ClassName]"
|
|
21
|
+
say "Or add Rage.register_ar(:#{symbol}, Model) in config/initializers/rage_arch.rb", :red
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
chosen = if class_name_arg.present?
|
|
25
|
+
resolve_requested(symbol, class_name_arg, options)
|
|
26
|
+
elsif options.size == 1
|
|
27
|
+
options.first
|
|
28
|
+
else
|
|
29
|
+
ask_choice(symbol, options)
|
|
30
|
+
end
|
|
31
|
+
return unless chosen
|
|
32
|
+
|
|
33
|
+
update_initializer(symbol, chosen)
|
|
34
|
+
msg = chosen[:type] == :ar ?
|
|
35
|
+
"Registered :#{symbol} -> Active Record (#{chosen[:model]}) in config/initializers/rage_arch.rb" :
|
|
36
|
+
"Registered :#{symbol} -> #{chosen[:name]}.new in config/initializers/rage_arch.rb"
|
|
37
|
+
say msg, :green
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def symbol_name
|
|
41
|
+
symbol_arg.to_s.underscore
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def deps_path
|
|
47
|
+
File.join(destination_root, "app", "deps")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initializer_path
|
|
51
|
+
File.join(destination_root, "config", "initializers", "rage.rb")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def find_ar_registration(symbol)
|
|
55
|
+
path = initializer_path
|
|
56
|
+
return nil unless File.exist?(path)
|
|
57
|
+
content = File.read(path)
|
|
58
|
+
# Match active or commented: Rage.register_ar(:post_store, Post) or Rage.register_ar :post_store, Post
|
|
59
|
+
content.each_line do |line|
|
|
60
|
+
stripped = line.strip
|
|
61
|
+
next if stripped.start_with?("#")
|
|
62
|
+
if stripped =~ /Rage\.register_ar\s*\(\s*:\s*#{Regexp.escape(symbol)}\s*,\s*(\w+)\s*\)/ ||
|
|
63
|
+
stripped =~ /Rage\.register_ar\s+:\s*#{Regexp.escape(symbol)}\s*,\s*(\w+)/
|
|
64
|
+
return $1
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
# Also check commented lines so "default" stays as option after switching away
|
|
68
|
+
content.each_line do |line|
|
|
69
|
+
stripped = line.strip.delete_prefix("#").strip
|
|
70
|
+
next if stripped.empty?
|
|
71
|
+
if stripped =~ /Rage\.register_ar\s*\(\s*:\s*#{Regexp.escape(symbol)}\s*,\s*(\w+)\s*\)/ ||
|
|
72
|
+
stripped =~ /Rage\.register_ar\s+:\s*#{Regexp.escape(symbol)}\s*,\s*(\w+)/
|
|
73
|
+
return $1
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def find_dep_classes(symbol)
|
|
80
|
+
return [] unless File.directory?(deps_path)
|
|
81
|
+
|
|
82
|
+
Dir[File.join(deps_path, "**", "*.rb")].filter_map do |path|
|
|
83
|
+
base = File.basename(path, ".rb")
|
|
84
|
+
# symbol.rb, symbol_suffix.rb (e.g. post_store_mysql), or prefix_symbol.rb (e.g. mysql_post_store)
|
|
85
|
+
next unless base == symbol || base.start_with?("#{symbol}_") || base.end_with?("_#{symbol}")
|
|
86
|
+
path_to_class_name(path)
|
|
87
|
+
end.sort
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Derives full constant from path (Zeitwerk convention):
|
|
91
|
+
# app/deps/posts/post_store.rb → Posts::PostStore
|
|
92
|
+
# app/deps/post_store.rb (flat, legacy) → PostStore
|
|
93
|
+
def path_to_class_name(path)
|
|
94
|
+
base = File.join(destination_root, "app", "deps")
|
|
95
|
+
relative = path.sub(/\A#{Regexp.escape(base + File::SEPARATOR)}/, "").sub(/\.rb\z/, "")
|
|
96
|
+
parts = relative.split(File::SEPARATOR).map(&:camelize)
|
|
97
|
+
if parts.size > 1
|
|
98
|
+
parts.join("::")
|
|
99
|
+
else
|
|
100
|
+
parts.first
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_options(symbol)
|
|
105
|
+
options = []
|
|
106
|
+
ar_model = find_ar_registration(symbol)
|
|
107
|
+
options << { type: :ar, model: ar_model } if ar_model
|
|
108
|
+
find_dep_classes(symbol).each { |name| options << { type: :class, name: name } }
|
|
109
|
+
options
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def display_option(opt)
|
|
113
|
+
opt[:type] == :ar ? "Active Record (#{opt[:model]})" : opt[:name]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def resolve_requested(symbol, requested, options)
|
|
117
|
+
raw = requested.to_s.strip
|
|
118
|
+
return nil if raw.empty?
|
|
119
|
+
normalized = raw.camelize
|
|
120
|
+
# Allow "ar" or "activerecord" (any case) to select Active Record default
|
|
121
|
+
if raw.downcase == "ar" || raw.downcase == "activerecord"
|
|
122
|
+
opt = options.find { |o| o[:type] == :ar }
|
|
123
|
+
unless opt
|
|
124
|
+
say "No Active Record registration found for :#{symbol}. Available: #{options.map { |o| display_option(o) }.join(', ')}", :red
|
|
125
|
+
return nil
|
|
126
|
+
end
|
|
127
|
+
return opt
|
|
128
|
+
end
|
|
129
|
+
opt = options.find { |o| o[:type] == :class && (o[:name] == normalized || o[:name].end_with?("::#{normalized}")) }
|
|
130
|
+
unless opt
|
|
131
|
+
say "Unknown implementation '#{requested}' for :#{symbol}. Available: #{options.map { |o| display_option(o) }.join(', ')}", :red
|
|
132
|
+
return nil
|
|
133
|
+
end
|
|
134
|
+
opt
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def ask_choice(symbol, options)
|
|
138
|
+
say "Implementations for :#{symbol}:", :green
|
|
139
|
+
options.each_with_index do |opt, i|
|
|
140
|
+
say " #{i + 1}. #{display_option(opt)}"
|
|
141
|
+
end
|
|
142
|
+
choice = ask("\nWhich one to activate? [1-#{options.size}]: ", :green)
|
|
143
|
+
idx = choice.to_i
|
|
144
|
+
unless idx.between?(1, options.size)
|
|
145
|
+
say "Invalid choice.", :red
|
|
146
|
+
return nil
|
|
147
|
+
end
|
|
148
|
+
options[idx - 1]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def update_initializer(symbol, option)
|
|
152
|
+
path = initializer_path
|
|
153
|
+
unless File.exist?(path)
|
|
154
|
+
say "config/initializers/rage_arch.rb not found.", :red
|
|
155
|
+
return
|
|
156
|
+
end
|
|
157
|
+
content = File.read(path)
|
|
158
|
+
# Comment out every active registration for this symbol (do not remove)
|
|
159
|
+
content = comment_line_matching(content, symbol, :register)
|
|
160
|
+
content = comment_line_matching(content, symbol, :register_ar)
|
|
161
|
+
# Uncomment or add the chosen registration
|
|
162
|
+
chosen_line = option[:type] == :ar ?
|
|
163
|
+
"Rage.register_ar(:#{symbol}, #{option[:model]})" :
|
|
164
|
+
"Rage.register(:#{symbol}, #{option[:name]}.new)"
|
|
165
|
+
content = uncomment_or_add(content, symbol, option, chosen_line)
|
|
166
|
+
File.write(path, content)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def comment_line_matching(content, symbol, form)
|
|
170
|
+
if form == :register
|
|
171
|
+
content.gsub(/^(\s*)(Rage\.register\(:#{Regexp.escape(symbol)},\s*\S+\.new\))\s*$/, '\1# \2')
|
|
172
|
+
else
|
|
173
|
+
# register_ar with parens or with space
|
|
174
|
+
content
|
|
175
|
+
.gsub(/^(\s*)(Rage\.register_ar\s*\(\s*:\s*#{Regexp.escape(symbol)}\s*,\s*\S+\s*\))\s*$/, '\1# \2')
|
|
176
|
+
.gsub(/^(\s*)(Rage\.register_ar\s+:\s*#{Regexp.escape(symbol)}\s*,\s*\S+)\s*$/, '\1# \2')
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def uncomment_or_add(content, symbol, option, chosen_line)
|
|
181
|
+
if option[:type] == :ar
|
|
182
|
+
model = option[:model]
|
|
183
|
+
# Uncomment if there is a commented register_ar line for this symbol and model
|
|
184
|
+
content = content.gsub(
|
|
185
|
+
/^(\s*)#\s*(Rage\.register_ar\s*\(\s*:\s*#{Regexp.escape(symbol)}\s*,\s*#{Regexp.escape(model)}\s*\))\s*$/,
|
|
186
|
+
'\1\2'
|
|
187
|
+
)
|
|
188
|
+
content = content.gsub(
|
|
189
|
+
/^(\s*)#\s*(Rage\.register_ar\s+:\s*#{Regexp.escape(symbol)}\s*,\s*#{Regexp.escape(model)})\s*$/,
|
|
190
|
+
'\1\2'
|
|
191
|
+
)
|
|
192
|
+
else
|
|
193
|
+
class_name = option[:name]
|
|
194
|
+
# Uncomment if there is a commented Rage.register line for this symbol and class
|
|
195
|
+
content = content.gsub(
|
|
196
|
+
/^(\s*)#\s*(Rage\.register\(:#{Regexp.escape(symbol)},\s*#{Regexp.escape(class_name)}\.new\))\s*$/,
|
|
197
|
+
'\1\2'
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
# If chosen line is still not present as active, add it inside after_initialize
|
|
201
|
+
return content if chosen_already_active?(content, symbol, option, chosen_line)
|
|
202
|
+
|
|
203
|
+
if content =~ /Rails\.application\.config\.after_initialize\s+do/
|
|
204
|
+
content = content.sub(/(Rails\.application\.config\.after_initialize\s+do)\n/, "\\1\n #{chosen_line}\n")
|
|
205
|
+
else
|
|
206
|
+
content = content.rstrip + "\n\nRails.application.config.after_initialize do\n #{chosen_line}\nend\n"
|
|
207
|
+
end
|
|
208
|
+
content
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def chosen_already_active?(content, symbol, option, chosen_line)
|
|
212
|
+
content.lines.any? do |line|
|
|
213
|
+
next false if line.lstrip.start_with?("#")
|
|
214
|
+
if option[:type] == :ar
|
|
215
|
+
model = option[:model]
|
|
216
|
+
line.include?("register_ar") && line.include?(":#{symbol}") && line.include?(model.to_s)
|
|
217
|
+
else
|
|
218
|
+
line.include?(chosen_line)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|