rubyn-code 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 +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Rails: ActionCable (WebSockets)
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
ActionCable integrates WebSockets into Rails for real-time features — live chat, notifications, live updates, and collaborative editing. Use channels for bi-directional communication and Turbo Streams for server-pushed HTML updates.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# app/channels/application_cable/connection.rb
|
|
9
|
+
module ApplicationCable
|
|
10
|
+
class Connection < ActionCable::Connection::Base
|
|
11
|
+
identified_by :current_user
|
|
12
|
+
|
|
13
|
+
def connect
|
|
14
|
+
self.current_user = find_verified_user
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def find_verified_user
|
|
20
|
+
if (user = User.find_by(id: cookies.encrypted[:user_id]))
|
|
21
|
+
user
|
|
22
|
+
else
|
|
23
|
+
reject_unauthorized_connection
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# app/channels/order_updates_channel.rb
|
|
32
|
+
class OrderUpdatesChannel < ApplicationCable::Channel
|
|
33
|
+
def subscribed
|
|
34
|
+
order = current_user.orders.find(params[:order_id])
|
|
35
|
+
stream_for order
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def unsubscribed
|
|
39
|
+
# Cleanup when client disconnects
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Broadcasting from anywhere in the app
|
|
44
|
+
OrderUpdatesChannel.broadcast_to(order, {
|
|
45
|
+
type: "status_changed",
|
|
46
|
+
status: order.status,
|
|
47
|
+
updated_at: order.updated_at.iso8601
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Turbo Streams over ActionCable (The Rails 7+ Way)
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# Model broadcasts — simplest approach
|
|
55
|
+
class Order < ApplicationRecord
|
|
56
|
+
after_create_commit -> { broadcast_prepend_to "orders", target: "orders_list" }
|
|
57
|
+
after_update_commit -> { broadcast_replace_to "orders" }
|
|
58
|
+
after_destroy_commit -> { broadcast_remove_to "orders" }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Or broadcast from a service object (preferred — keeps model clean)
|
|
62
|
+
class Orders::ShipService
|
|
63
|
+
def call(order)
|
|
64
|
+
order.update!(status: :shipped, shipped_at: Time.current)
|
|
65
|
+
|
|
66
|
+
# Push update to all subscribers
|
|
67
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
68
|
+
"order_#{order.id}",
|
|
69
|
+
target: "order_#{order.id}",
|
|
70
|
+
partial: "orders/order",
|
|
71
|
+
locals: { order: order }
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Push to the orders list page too
|
|
75
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
76
|
+
"orders",
|
|
77
|
+
target: "order_#{order.id}",
|
|
78
|
+
partial: "orders/order_row",
|
|
79
|
+
locals: { order: order }
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```erb
|
|
86
|
+
<%# View — subscribe to updates %>
|
|
87
|
+
<%= turbo_stream_from "orders" %>
|
|
88
|
+
|
|
89
|
+
<div id="orders_list">
|
|
90
|
+
<%= render @orders %>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<%# Individual order page %>
|
|
94
|
+
<%= turbo_stream_from "order_#{@order.id}" %>
|
|
95
|
+
|
|
96
|
+
<div id="order_<%= @order.id %>">
|
|
97
|
+
<%= render @order %>
|
|
98
|
+
</div>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Custom Channel for Interactive Features
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# app/channels/notifications_channel.rb
|
|
105
|
+
class NotificationsChannel < ApplicationCable::Channel
|
|
106
|
+
def subscribed
|
|
107
|
+
stream_for current_user
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Send notifications from anywhere
|
|
112
|
+
class NotificationService
|
|
113
|
+
def self.push(user, message:, type: :info)
|
|
114
|
+
NotificationsChannel.broadcast_to(user, {
|
|
115
|
+
type: type,
|
|
116
|
+
message: message,
|
|
117
|
+
timestamp: Time.current.iso8601
|
|
118
|
+
})
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Usage
|
|
123
|
+
NotificationService.push(user, message: "Your order shipped!", type: :success)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```javascript
|
|
127
|
+
// app/javascript/channels/notifications_channel.js
|
|
128
|
+
import consumer from "./consumer"
|
|
129
|
+
|
|
130
|
+
consumer.subscriptions.create("NotificationsChannel", {
|
|
131
|
+
received(data) {
|
|
132
|
+
const toast = document.createElement("div")
|
|
133
|
+
toast.className = `toast toast-${data.type}`
|
|
134
|
+
toast.textContent = data.message
|
|
135
|
+
document.getElementById("notifications").appendChild(toast)
|
|
136
|
+
|
|
137
|
+
setTimeout(() => toast.remove(), 5000)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Why This Is Good
|
|
143
|
+
|
|
144
|
+
- **Turbo Streams over ActionCable is zero-JavaScript real-time.** Server pushes HTML, Turbo applies it. No custom JS for most use cases.
|
|
145
|
+
- **`broadcast_to` uses the model as the channel key.** `stream_for order` and `broadcast_to(order, ...)` — the channel routing is automatic and scoped.
|
|
146
|
+
- **Authentication via cookies.** The WebSocket connection inherits the user's session. No separate auth token needed for web apps.
|
|
147
|
+
- **Scales with Redis.** In production, ActionCable uses Redis as the pub/sub backend. Multiple app servers share the same broadcast channel.
|
|
148
|
+
|
|
149
|
+
## When To Apply
|
|
150
|
+
|
|
151
|
+
- **Live updates** — order status changes, dashboard metrics, admin activity feeds.
|
|
152
|
+
- **Notifications** — real-time toasts, badge counts, alert banners.
|
|
153
|
+
- **Collaborative features** — shared editing, presence indicators, live cursors.
|
|
154
|
+
- **Turbo Stream broadcasts** — the simplest path. Use this before building custom channels.
|
|
155
|
+
|
|
156
|
+
## When NOT To Apply
|
|
157
|
+
|
|
158
|
+
- **Polling works fine.** If data changes once per minute and freshness isn't critical, a 30-second poll is simpler than WebSockets.
|
|
159
|
+
- **API-only apps without a frontend.** Use webhooks or SSE instead.
|
|
160
|
+
- **High-frequency data streams** (stock tickers, game state at 60fps). ActionCable adds overhead per message — consider a dedicated WebSocket server.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Rails: ActiveRecord Best Practices
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Use scopes for reusable query fragments, `find_by` over `where.first`, `exists?` over loading records to check presence, and `pluck` when you only need column values. Keep models focused on data access and validation, not business logic.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class Order < ApplicationRecord
|
|
9
|
+
# Scopes: named, chainable, readable
|
|
10
|
+
scope :recent, -> { where(created_at: 30.days.ago..) }
|
|
11
|
+
scope :pending, -> { where(status: :pending) }
|
|
12
|
+
scope :shipped, -> { where(status: :shipped) }
|
|
13
|
+
scope :for_user, ->(user) { where(user: user) }
|
|
14
|
+
scope :high_value, -> { where("total >= ?", 200) }
|
|
15
|
+
scope :by_newest, -> { order(created_at: :desc) }
|
|
16
|
+
|
|
17
|
+
# Scopes compose naturally
|
|
18
|
+
# Order.for_user(user).pending.recent.by_newest
|
|
19
|
+
|
|
20
|
+
# Efficient existence checks
|
|
21
|
+
def self.any_pending_for?(user)
|
|
22
|
+
for_user(user).pending.exists?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Efficient counting
|
|
26
|
+
def self.total_revenue
|
|
27
|
+
sum(:total)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Efficient value extraction
|
|
31
|
+
def self.recent_emails
|
|
32
|
+
recent.joins(:user).pluck("users.email")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# CORRECT: Efficient queries
|
|
39
|
+
user = User.find_by(email: "alice@example.com") # Returns nil if not found
|
|
40
|
+
user = User.find_by!(email: "alice@example.com") # Raises RecordNotFound
|
|
41
|
+
|
|
42
|
+
order_exists = Order.where(user: user).exists? # SELECT 1 ... LIMIT 1
|
|
43
|
+
order_count = user.orders.pending.count # SELECT COUNT(*)
|
|
44
|
+
totals = Order.pending.pluck(:total) # SELECT total — returns array of values
|
|
45
|
+
|
|
46
|
+
# Batch processing for large datasets
|
|
47
|
+
Order.pending.find_each(batch_size: 500) do |order|
|
|
48
|
+
Orders::ProcessService.call(order)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Bulk operations without instantiating records
|
|
52
|
+
Order.where(status: :draft, created_at: ..30.days.ago).delete_all
|
|
53
|
+
Order.pending.update_all(status: :cancelled, cancelled_at: Time.current)
|
|
54
|
+
|
|
55
|
+
# insert_all for bulk creation (Rails 6+)
|
|
56
|
+
Order.insert_all([
|
|
57
|
+
{ user_id: 1, total: 100, status: :pending, created_at: Time.current, updated_at: Time.current },
|
|
58
|
+
{ user_id: 2, total: 200, status: :pending, created_at: Time.current, updated_at: Time.current }
|
|
59
|
+
])
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Why This Is Good
|
|
63
|
+
|
|
64
|
+
- **Scopes are chainable and composable.** `Order.pending.recent.high_value` reads like a sentence and generates a single SQL query. Each scope is a reusable building block.
|
|
65
|
+
- **`exists?` runs `SELECT 1 LIMIT 1`.** It doesn't load records into memory. Checking if a user has pending orders costs one lightweight query regardless of how many orders exist.
|
|
66
|
+
- **`pluck` skips model instantiation.** `Order.pluck(:total)` returns `[100, 200, 300]` without creating Order objects. For 10,000 records, this is dramatically faster and uses a fraction of the memory.
|
|
67
|
+
- **`find_each` prevents memory bloat.** Loading 100,000 orders with `.all.each` allocates all of them simultaneously. `find_each` loads 1,000 at a time (configurable) and GCs between batches.
|
|
68
|
+
- **`update_all` and `delete_all` execute single SQL statements.** No callbacks, no instantiation, no N individual UPDATE queries. For bulk operations on thousands of records, this is orders of magnitude faster.
|
|
69
|
+
|
|
70
|
+
## Anti-Pattern
|
|
71
|
+
|
|
72
|
+
Loading full records when you only need a check, a count, or a column value:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# BAD: Loads ALL orders into memory to check if any exist
|
|
76
|
+
if user.orders.where(status: :pending).to_a.any?
|
|
77
|
+
# ...
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# BAD: Loads ALL records to count them
|
|
81
|
+
total = Order.where(status: :pending).to_a.length
|
|
82
|
+
|
|
83
|
+
# BAD: Loads full AR objects to get one column
|
|
84
|
+
emails = User.where(active: true).map(&:email)
|
|
85
|
+
|
|
86
|
+
# BAD: where().first instead of find_by
|
|
87
|
+
user = User.where(email: "alice@example.com").first
|
|
88
|
+
|
|
89
|
+
# BAD: Processing large datasets without batching
|
|
90
|
+
Order.all.each do |order|
|
|
91
|
+
order.recalculate_total!
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# BAD: N individual updates
|
|
95
|
+
Order.pending.each do |order|
|
|
96
|
+
order.update(status: :cancelled)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# BAD: default_scope — almost always a mistake
|
|
100
|
+
class Order < ApplicationRecord
|
|
101
|
+
default_scope { where(deleted: false) }
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Why This Is Bad
|
|
106
|
+
|
|
107
|
+
- **`.to_a.any?` loads every matching record.** 5,000 pending orders? That's 5,000 ActiveRecord objects instantiated, then thrown away after checking `any?`. `exists?` does the same check with zero objects loaded.
|
|
108
|
+
- **`.to_a.length` vs `.count`.** Loading 10,000 records to count them uses ~100MB of memory. `COUNT(*)` uses zero Ruby memory and returns instantly.
|
|
109
|
+
- **`.map(&:email)` instantiates every User.** For 50,000 users, that's 50,000 ActiveRecord objects in memory. `pluck(:email)` returns a simple array of strings with no model instantiation.
|
|
110
|
+
- **`.where().first` generates `ORDER BY id LIMIT 1`.** `find_by` generates `LIMIT 1` without the sort. On large tables without an index on the filter column, the sort is expensive.
|
|
111
|
+
- **Iterating without batching** loads the entire result set into memory at once. For large tables this can exhaust available RAM.
|
|
112
|
+
- **N individual updates** execute N separate UPDATE statements. Updating 1,000 orders takes 1,000 round trips to the database. `update_all` does it in one.
|
|
113
|
+
- **`default_scope` poisons every query.** Every `Order.find`, `Order.count`, `Order.joins` silently includes `WHERE deleted = false`. Forgetting to `unscope` it causes subtle bugs. Soft deletes should use explicit scopes or gems like `discard`.
|
|
114
|
+
|
|
115
|
+
## When To Apply
|
|
116
|
+
|
|
117
|
+
- **Every ActiveRecord query should be as efficient as possible.** Use the cheapest operation that satisfies the need: `exists?` > `count` > `pluck` > `select` > loading full records.
|
|
118
|
+
- **Scopes for any query used in more than one place.** If two controllers filter by pending status, define `scope :pending`.
|
|
119
|
+
- **`find_each` for any iteration over more than 100 records.**
|
|
120
|
+
- **`update_all`/`delete_all` for bulk operations** where you don't need callbacks or validations.
|
|
121
|
+
|
|
122
|
+
## When NOT To Apply
|
|
123
|
+
|
|
124
|
+
- **Small datasets where clarity wins.** If you have 10 records and `.map(&:name)` is more readable than `.pluck(:name)` in context, the performance difference is negligible.
|
|
125
|
+
- **When you need callbacks to fire.** `update_all` skips callbacks and validations. If the model's `after_update` callback must run, iterate and save individually (but consider whether the callback should be a service object instead).
|
|
126
|
+
- **Don't over-scope.** A scope used in exactly one place adds indirection without reuse benefit. An inline `where` is fine for one-off queries.
|
|
127
|
+
|
|
128
|
+
## Edge Cases
|
|
129
|
+
|
|
130
|
+
**Scopes vs class methods:**
|
|
131
|
+
Scopes always return a relation (even when the condition is nil). Class methods can return nil, breaking chains.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# Scope: always chainable even when condition is nil
|
|
135
|
+
scope :by_status, ->(status) { where(status: status) if status.present? }
|
|
136
|
+
|
|
137
|
+
# Class method: can break the chain if it returns nil
|
|
138
|
+
def self.by_status(status)
|
|
139
|
+
return none unless status.present? # Must return a relation, not nil
|
|
140
|
+
where(status: status)
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**`select` vs `pluck`:**
|
|
145
|
+
`select` returns ActiveRecord objects with limited attributes. `pluck` returns raw arrays. Use `select` when you need methods on the model. Use `pluck` when you just need values.
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
Order.select(:id, :total).each { |o| o.total } # AR objects, can call methods
|
|
149
|
+
Order.pluck(:id, :total) # [[1, 100], [2, 200]] — raw arrays
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Counter caches for frequently counted associations:**
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
# Migration
|
|
156
|
+
add_column :users, :orders_count, :integer, default: 0
|
|
157
|
+
|
|
158
|
+
# Model
|
|
159
|
+
class Order < ApplicationRecord
|
|
160
|
+
belongs_to :user, counter_cache: true
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Now user.orders_count is a column read, not a COUNT(*) query
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**`find_or_create_by` race conditions:**
|
|
167
|
+
Use `create_or_find_by` (Rails 6+) with a unique database constraint to handle concurrency:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
# Safe under concurrency with a unique index on email
|
|
171
|
+
user = User.create_or_find_by(email: "alice@example.com") do |u|
|
|
172
|
+
u.name = "Alice"
|
|
173
|
+
end
|
|
174
|
+
```
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Rails: Active Storage
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Active Storage handles file uploads in Rails — attaching files to models, processing variants (thumbnails, resizes), and storing them on local disk, S3, GCS, or Azure. Configure it once, use it through a clean model API.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Setup
|
|
9
|
+
# rails active_storage:install
|
|
10
|
+
# rails db:migrate
|
|
11
|
+
|
|
12
|
+
# config/storage.yml
|
|
13
|
+
local:
|
|
14
|
+
service: Disk
|
|
15
|
+
root: <%= Rails.root.join("storage") %>
|
|
16
|
+
|
|
17
|
+
amazon:
|
|
18
|
+
service: S3
|
|
19
|
+
access_key_id: <%= ENV["AWS_ACCESS_KEY_ID"] %>
|
|
20
|
+
secret_access_key: <%= ENV["AWS_SECRET_ACCESS_KEY"] %>
|
|
21
|
+
region: us-east-1
|
|
22
|
+
bucket: rubyn-uploads
|
|
23
|
+
|
|
24
|
+
# config/environments/development.rb
|
|
25
|
+
config.active_storage.service = :local
|
|
26
|
+
|
|
27
|
+
# config/environments/production.rb
|
|
28
|
+
config.active_storage.service = :amazon
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Model Attachments
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
class User < ApplicationRecord
|
|
35
|
+
has_one_attached :avatar
|
|
36
|
+
has_one_attached :resume
|
|
37
|
+
|
|
38
|
+
# Validations (use activestorage-validator gem or custom)
|
|
39
|
+
validate :avatar_format
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def avatar_format
|
|
44
|
+
return unless avatar.attached?
|
|
45
|
+
|
|
46
|
+
unless avatar.content_type.in?(%w[image/png image/jpeg image/webp])
|
|
47
|
+
errors.add(:avatar, "must be PNG, JPEG, or WebP")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if avatar.byte_size > 5.megabytes
|
|
51
|
+
errors.add(:avatar, "must be under 5MB")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class Order < ApplicationRecord
|
|
57
|
+
has_many_attached :documents # Multiple files
|
|
58
|
+
|
|
59
|
+
has_one_attached :invoice_pdf
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Controller and Form
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
class UsersController < ApplicationController
|
|
67
|
+
def update
|
|
68
|
+
@user = current_user
|
|
69
|
+
|
|
70
|
+
if @user.update(user_params)
|
|
71
|
+
redirect_to @user, notice: "Profile updated."
|
|
72
|
+
else
|
|
73
|
+
render :edit, status: :unprocessable_entity
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def user_params
|
|
80
|
+
params.require(:user).permit(:name, :email, :avatar)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```erb
|
|
86
|
+
<%# Form — standard file field, nothing special %>
|
|
87
|
+
<%= form_with model: @user do |f| %>
|
|
88
|
+
<%= f.file_field :avatar, accept: "image/png,image/jpeg,image/webp" %>
|
|
89
|
+
|
|
90
|
+
<% if @user.avatar.attached? %>
|
|
91
|
+
<%= image_tag @user.avatar.variant(resize_to_limit: [200, 200]) %>
|
|
92
|
+
<%= button_to "Remove", purge_avatar_user_path(@user), method: :delete %>
|
|
93
|
+
<% end %>
|
|
94
|
+
|
|
95
|
+
<%= f.submit "Save" %>
|
|
96
|
+
<% end %>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Variants (Image Processing)
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# Requires: gem "image_processing", "~> 1.2"
|
|
103
|
+
|
|
104
|
+
class User < ApplicationRecord
|
|
105
|
+
has_one_attached :avatar do |attachable|
|
|
106
|
+
attachable.variant :thumb, resize_to_fill: [100, 100]
|
|
107
|
+
attachable.variant :medium, resize_to_limit: [300, 300]
|
|
108
|
+
attachable.variant :large, resize_to_limit: [800, 800]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Usage in views
|
|
113
|
+
<%= image_tag @user.avatar.variant(:thumb) %>
|
|
114
|
+
<%= image_tag @user.avatar.variant(:medium) %>
|
|
115
|
+
|
|
116
|
+
# Custom one-off variant
|
|
117
|
+
<%= image_tag @user.avatar.variant(resize_to_limit: [150, 150], format: :webp) %>
|
|
118
|
+
|
|
119
|
+
# Check before rendering
|
|
120
|
+
<% if @user.avatar.attached? %>
|
|
121
|
+
<%= image_tag @user.avatar.variant(:thumb) %>
|
|
122
|
+
<% else %>
|
|
123
|
+
<%= image_tag "default_avatar.png" %>
|
|
124
|
+
<% end %>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Direct Uploads (Client-Side)
|
|
128
|
+
|
|
129
|
+
```javascript
|
|
130
|
+
// app/javascript/application.js
|
|
131
|
+
import * as ActiveStorage from "@rails/activestorage"
|
|
132
|
+
ActiveStorage.start()
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```erb
|
|
136
|
+
<%# Direct upload — file goes straight to storage, not through your server %>
|
|
137
|
+
<%= form.file_field :avatar, direct_upload: true %>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Direct uploads send the file directly to S3/GCS from the browser. Your server only receives the signed blob ID, not the file bytes. This keeps your web server fast and avoids upload timeouts.
|
|
141
|
+
|
|
142
|
+
### Service Objects for Complex Uploads
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
# When upload involves processing, validation, or multiple steps
|
|
146
|
+
class Documents::UploadService
|
|
147
|
+
def self.call(order, file)
|
|
148
|
+
new(order, file).call
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def initialize(order, file)
|
|
152
|
+
@order = order
|
|
153
|
+
@file = file
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def call
|
|
157
|
+
validate_file!
|
|
158
|
+
@order.documents.attach(@file)
|
|
159
|
+
process_document(@order.documents.last)
|
|
160
|
+
Result.new(success: true)
|
|
161
|
+
rescue ActiveStorage::IntegrityError => e
|
|
162
|
+
Result.new(success: false, error: "File corrupted: #{e.message}")
|
|
163
|
+
rescue DocumentTooLargeError => e
|
|
164
|
+
Result.new(success: false, error: e.message)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def validate_file!
|
|
170
|
+
raise DocumentTooLargeError, "File exceeds 25MB" if @file.size > 25.megabytes
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def process_document(attachment)
|
|
174
|
+
# Extract text, generate preview, scan for viruses — async
|
|
175
|
+
DocumentProcessingJob.perform_later(attachment.id)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Why This Is Good
|
|
181
|
+
|
|
182
|
+
- **One API for every storage backend.** Develop with local disk, deploy with S3. Change one line in config, not your code.
|
|
183
|
+
- **Variants are lazy.** `variant(:thumb)` doesn't process the image until it's first requested. After that, the processed variant is cached.
|
|
184
|
+
- **Direct uploads offload your server.** Large files go straight to S3 from the browser. Your Rails app never touches the bytes.
|
|
185
|
+
- **Attachment validations on the model.** File type and size checks happen before save, with standard error messages on the model.
|
|
186
|
+
- **Named variants are reusable.** Define `:thumb`, `:medium`, `:large` once on the model, use them everywhere in views.
|
|
187
|
+
|
|
188
|
+
## Anti-Pattern
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
# BAD: Processing uploads in the controller
|
|
192
|
+
def create
|
|
193
|
+
file = params[:document]
|
|
194
|
+
File.open(Rails.root.join("uploads", file.original_filename), "wb") do |f|
|
|
195
|
+
f.write(file.read)
|
|
196
|
+
end
|
|
197
|
+
# Manual file management, no cleanup, no variants, no cloud storage
|
|
198
|
+
|
|
199
|
+
# BAD: Synchronous processing on upload
|
|
200
|
+
@user.avatar.attach(params[:avatar])
|
|
201
|
+
ImageOptimizer.new(@user.avatar).optimize! # Blocks the request for 5 seconds
|
|
202
|
+
ThumbnailGenerator.new(@user.avatar).generate! # Another 3 seconds
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## When To Apply
|
|
207
|
+
|
|
208
|
+
- **Every file upload in a Rails app.** Active Storage replaces CarrierWave, Paperclip, and Shrine for most use cases.
|
|
209
|
+
- **User avatars, document uploads, image galleries.** Standard Active Storage with variants.
|
|
210
|
+
- **Large files (>10MB).** Use direct uploads to avoid tying up web workers.
|
|
211
|
+
|
|
212
|
+
## When NOT To Apply
|
|
213
|
+
|
|
214
|
+
- **Extremely complex image processing pipelines.** If you need 20+ variant types, watermarking, face detection — consider Shrine or a dedicated image service.
|
|
215
|
+
- **Non-Rails apps.** Active Storage is Rails-only. Use Shrine or direct S3 SDK calls for Sinatra/plain Ruby.
|
|
216
|
+
- **Temporary file processing.** If you're processing a CSV and discarding it, don't attach it to a model. Just use `Tempfile`.
|
|
217
|
+
|
|
218
|
+
## Edge Cases
|
|
219
|
+
|
|
220
|
+
**Purging attachments:**
|
|
221
|
+
```ruby
|
|
222
|
+
@user.avatar.purge # Deletes synchronously
|
|
223
|
+
@user.avatar.purge_later # Deletes via background job (preferred)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Preloading to avoid N+1:**
|
|
227
|
+
```ruby
|
|
228
|
+
# BAD: N+1 on avatars
|
|
229
|
+
users.each { |u| image_tag u.avatar } # Each avatar is a separate query
|
|
230
|
+
|
|
231
|
+
# GOOD: Preload
|
|
232
|
+
users = User.with_attached_avatar
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**Attaching from a URL:**
|
|
236
|
+
```ruby
|
|
237
|
+
@user.avatar.attach(
|
|
238
|
+
io: URI.open("https://example.com/photo.jpg"),
|
|
239
|
+
filename: "photo.jpg",
|
|
240
|
+
content_type: "image/jpeg"
|
|
241
|
+
)
|
|
242
|
+
```
|