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.
Files changed (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. metadata +397 -0
@@ -0,0 +1,207 @@
1
+ # Rails: Multitenancy
2
+
3
+ ## Pattern
4
+
5
+ Multitenancy allows a single application instance to serve multiple organizations (tenants) with data isolation. Choose row-based tenancy (shared tables with a tenant_id column) for simplicity, or schema-based (separate PostgreSQL schemas per tenant) for stronger isolation.
6
+
7
+ ### Row-Based Tenancy (Most Common)
8
+
9
+ ```ruby
10
+ # Every tenanted model has an organization_id
11
+ class Order < ApplicationRecord
12
+ belongs_to :organization
13
+ belongs_to :user
14
+
15
+ # Default scope is tempting but dangerous — use explicit scoping instead
16
+ end
17
+
18
+ class User < ApplicationRecord
19
+ belongs_to :organization
20
+ has_many :orders
21
+ end
22
+
23
+ # Set the current tenant per request
24
+ class ApplicationController < ActionController::Base
25
+ before_action :set_current_organization
26
+
27
+ private
28
+
29
+ def set_current_organization
30
+ Current.organization = current_user&.organization
31
+ end
32
+ end
33
+
34
+ # Use Current attributes (Rails 5.2+) for request-scoped tenant
35
+ class Current < ActiveSupport::CurrentAttributes
36
+ attribute :user, :organization
37
+ end
38
+ ```
39
+
40
+ ```ruby
41
+ # Scoping all queries to the current tenant
42
+ # Option A: Explicit scoping in controllers
43
+ class OrdersController < ApplicationController
44
+ def index
45
+ @orders = Current.organization.orders.recent
46
+ end
47
+
48
+ def show
49
+ @order = Current.organization.orders.find(params[:id])
50
+ end
51
+ end
52
+
53
+ # Option B: acts_as_tenant gem (automatic scoping)
54
+ # Gemfile: gem "acts_as_tenant"
55
+ class Order < ApplicationRecord
56
+ acts_as_tenant :organization
57
+ # Automatically adds: default_scope { where(organization_id: ActsAsTenant.current_tenant.id) }
58
+ # Automatically validates: validates :organization_id, presence: true
59
+ # Automatically sets: before_validation { self.organization_id = ActsAsTenant.current_tenant.id }
60
+ end
61
+
62
+ # Controller setup
63
+ class ApplicationController < ActionController::Base
64
+ set_current_tenant_through_filter
65
+ before_action :set_tenant
66
+
67
+ private
68
+
69
+ def set_tenant
70
+ set_current_tenant(current_user.organization)
71
+ end
72
+ end
73
+
74
+ # Now ALL queries are automatically scoped — no leaks possible
75
+ Order.all # => WHERE organization_id = 42 (automatic)
76
+ Order.find(123) # => WHERE id = 123 AND organization_id = 42 (automatic)
77
+ ```
78
+
79
+ ### Database Constraints for Safety
80
+
81
+ ```ruby
82
+ # Migration — ensure tenant isolation at the database level
83
+ class AddOrganizationToOrders < ActiveRecord::Migration[8.0]
84
+ def change
85
+ add_reference :orders, :organization, null: false, foreign_key: true, index: true
86
+
87
+ # Composite index for tenant-scoped queries
88
+ add_index :orders, [:organization_id, :created_at]
89
+ add_index :orders, [:organization_id, :status]
90
+
91
+ # Unique constraints scoped to tenant
92
+ add_index :orders, [:organization_id, :reference], unique: true
93
+ # Order references are unique WITHIN an organization, not globally
94
+ end
95
+ end
96
+ ```
97
+
98
+ ### Testing Multitenancy
99
+
100
+ ```ruby
101
+ # RSpec
102
+ RSpec.describe Order, type: :model do
103
+ let(:org_a) { create(:organization) }
104
+ let(:org_b) { create(:organization) }
105
+
106
+ around do |example|
107
+ ActsAsTenant.with_tenant(org_a) { example.run }
108
+ end
109
+
110
+ it "scopes queries to the current tenant" do
111
+ order_a = create(:order, organization: org_a)
112
+
113
+ ActsAsTenant.with_tenant(org_b) do
114
+ order_b = create(:order, organization: org_b)
115
+ expect(Order.all).to eq([order_b]) # Only sees org_b's orders
116
+ expect(Order.all).not_to include(order_a)
117
+ end
118
+
119
+ expect(Order.all).to eq([order_a]) # Back to org_a
120
+ end
121
+ end
122
+
123
+ # Minitest
124
+ class OrderTest < ActiveSupport::TestCase
125
+ setup do
126
+ @org = organizations(:acme)
127
+ ActsAsTenant.current_tenant = @org
128
+ end
129
+
130
+ teardown do
131
+ ActsAsTenant.current_tenant = nil
132
+ end
133
+
134
+ test "orders are scoped to current tenant" do
135
+ order = Order.create!(reference: "ORD-001", user: users(:alice))
136
+ assert_equal @org, order.organization
137
+ end
138
+ end
139
+ ```
140
+
141
+ ### Schema-Based Tenancy (Stronger Isolation)
142
+
143
+ ```ruby
144
+ # Each tenant gets their own PostgreSQL schema
145
+ # Gem: apartment or acts_as_tenant with schema support
146
+
147
+ # With apartment gem (simpler):
148
+ # Gemfile: gem "ros-apartment", require: "apartment"
149
+
150
+ # config/initializers/apartment.rb
151
+ Apartment.configure do |config|
152
+ config.excluded_models = %w[Organization User] # Shared tables
153
+ config.tenant_names = -> { Organization.pluck(:subdomain) }
154
+ end
155
+
156
+ # Switching schemas
157
+ Apartment::Tenant.switch("acme") do
158
+ # All queries hit the "acme" schema
159
+ Order.all # => SELECT * FROM acme.orders
160
+ end
161
+
162
+ # Request middleware sets tenant from subdomain
163
+ class ApplicationController < ActionController::Base
164
+ before_action :set_tenant
165
+
166
+ private
167
+
168
+ def set_tenant
169
+ subdomain = request.subdomain
170
+ organization = Organization.find_by!(subdomain: subdomain)
171
+ Apartment::Tenant.switch!(organization.subdomain)
172
+ end
173
+ end
174
+ ```
175
+
176
+ ## Decision Matrix
177
+
178
+ | Factor | Row-Based | Schema-Based |
179
+ |---|---|---|
180
+ | Setup complexity | Low | Medium-High |
181
+ | Query performance | Good with indexes | Slightly better (smaller tables) |
182
+ | Data isolation | Application-enforced | Database-enforced |
183
+ | Cross-tenant queries | Easy (remove scope) | Hard (must switch schemas) |
184
+ | Tenant count | Unlimited | <1000 (each schema has overhead) |
185
+ | Migrations | Run once | Run per schema |
186
+ | Backups | One database | Per-schema or full DB |
187
+
188
+ **Recommendation:** Start with row-based + `acts_as_tenant`. It's simpler, handles 95% of use cases, and you can migrate to schema-based later if you need stronger isolation.
189
+
190
+ ## When To Apply
191
+
192
+ - **SaaS applications** where multiple companies share one deployment.
193
+ - **Any app with an Organization/Account/Company model** that owns other data.
194
+ - **Rubyn itself** — the API server uses row-based tenancy with organization_id on projects, interactions, and credit_ledger.
195
+
196
+ ## When NOT To Apply
197
+
198
+ - **Single-tenant apps.** If there's only one organization, skip the complexity.
199
+ - **B2C apps without organizations.** Users owning their own data is just `user_id` scoping, not multitenancy.
200
+ - **Don't add tenancy "just in case."** Add it when the second tenant appears, not before.
201
+
202
+ ## Critical Safety Rules
203
+
204
+ 1. **Never use `default_scope` for tenancy manually.** Use `acts_as_tenant` which handles it safely, or use explicit scoping. Hand-rolled default scopes are the #1 source of data leaks.
205
+ 2. **Always scope `find` calls.** `Order.find(params[:id])` without tenant scoping lets any user access any order by guessing IDs.
206
+ 3. **Background jobs must set the tenant.** Jobs run outside the request cycle. Pass `organization_id` to every job and set the tenant in `perform`.
207
+ 4. **Console access defaults to no tenant.** `rails console` has no request context. Use `ActsAsTenant.with_tenant(org) { ... }` explicitly.
@@ -0,0 +1,151 @@
1
+ # Rails: N+1 Query Prevention
2
+
3
+ ## Pattern
4
+
5
+ Always preload associated records before iterating over a collection that accesses those associations. Use `includes` for most cases, `preload` when you need to force separate queries, and `eager_load` when you need to filter or sort by the association.
6
+
7
+ ```ruby
8
+ # CORRECT: Preload associations before iteration
9
+ class OrdersController < ApplicationController
10
+ def index
11
+ @orders = current_user.orders
12
+ .includes(:line_items, :shipping_address, line_items: :product)
13
+ .order(created_at: :desc)
14
+ .page(params[:page])
15
+ end
16
+ end
17
+ ```
18
+
19
+ ```erb
20
+ <%# This now executes 3-4 queries total, not 1 + N + N + N %>
21
+ <% @orders.each do |order| %>
22
+ <p><%= order.shipping_address.city %></p>
23
+ <% order.line_items.each do |item| %>
24
+ <p><%= item.product.name %> x <%= item.quantity %></p>
25
+ <% end %>
26
+ <% end %>
27
+ ```
28
+
29
+ The three preloading methods and when to use each:
30
+
31
+ ```ruby
32
+ # includes: Rails picks the strategy (usually 2 queries, switches to LEFT JOIN if you filter)
33
+ Order.includes(:line_items).where(line_items: { product_id: 5 })
34
+
35
+ # preload: Always separate queries. Use when includes tries a JOIN and you want separate queries.
36
+ Order.preload(:line_items).order(created_at: :desc)
37
+
38
+ # eager_load: Always LEFT OUTER JOIN. Use when you need to WHERE or ORDER BY the association.
39
+ Order.eager_load(:line_items).where("line_items.quantity > ?", 5)
40
+ ```
41
+
42
+ Enable `strict_loading` on models or associations to catch N+1 queries during development:
43
+
44
+ ```ruby
45
+ # On a model — raises if any lazy-loaded association is accessed
46
+ class Order < ApplicationRecord
47
+ self.strict_loading_by_default = true # Rails 7+
48
+
49
+ has_many :line_items
50
+ end
51
+
52
+ # On a specific query
53
+ orders = Order.strict_loading.all
54
+ orders.first.line_items # => raises ActiveRecord::StrictLoadingViolationError
55
+
56
+ # On a specific association
57
+ class Order < ApplicationRecord
58
+ has_many :line_items, strict_loading: true
59
+ end
60
+ ```
61
+
62
+ ## Why This Is Good
63
+
64
+ - **Predictable query count.** With `includes`, a page listing 25 orders with line items and products executes 3-4 queries regardless of how many records exist. Without it, you execute 1 + 25 + 25 + 25 = 76 queries.
65
+ - **Scales linearly.** The query count depends on the number of associations, not the number of records. 25 orders or 2,500 orders — same number of queries.
66
+ - **`strict_loading` catches mistakes early.** Lazy-loaded associations silently work in development but crush production databases. Strict loading turns silent performance bugs into loud development errors.
67
+ - **No code change needed in views/serializers.** The fix is in the query, not in the template. The view code stays the same — it just runs faster.
68
+
69
+ ## Anti-Pattern
70
+
71
+ Loading a collection and letting Rails lazy-load associations on each iteration:
72
+
73
+ ```ruby
74
+ class OrdersController < ApplicationController
75
+ def index
76
+ @orders = current_user.orders.order(created_at: :desc).page(params[:page])
77
+ end
78
+ end
79
+ ```
80
+
81
+ ```erb
82
+ <%# This triggers N+1 queries: 1 for orders, then 1 per order for each association %>
83
+ <% @orders.each do |order| %>
84
+ <p><%= order.user.name %></p> <%# N queries %>
85
+ <p><%= order.shipping_address.city %></p> <%# N queries %>
86
+ <% order.line_items.each do |item| %> <%# N queries %>
87
+ <p><%= item.product.name %></p> <%# N * M queries %>
88
+ <% end %>
89
+ <% end %>
90
+ ```
91
+
92
+ ## Why This Is Bad
93
+
94
+ - **Query count explodes.** 25 orders × 4 associations = 101 queries for one page load. With nested associations (line_items → product), it's even worse.
95
+ - **Invisible in development.** With 5 seed records, 21 queries feel instant. In production with 50 records per page, the same code makes 201 queries and takes 3 seconds.
96
+ - **Database connection saturation.** Each N+1 query is a round trip to the database. At scale, this saturates the connection pool and causes request queuing for other users.
97
+ - **Log noise.** Your development log fills with repetitive SELECT statements, burying actual issues.
98
+
99
+ ## When To Apply
100
+
101
+ - **Every time you iterate over a collection and access an association.** This is not optional. Any `@records.each` that touches an association needs preloading.
102
+ - **In serializers and API responses.** JSON serialization that includes associated data triggers the same N+1 if not preloaded.
103
+ - **In background jobs.** Jobs that process batches of records with associations need preloading too — they just waste database time silently instead of slowing a web response.
104
+ - **In mailer views.** Mailers often render templates with associated data. Preload before passing records to the mailer.
105
+
106
+ ## When NOT To Apply
107
+
108
+ - **Single record lookups.** `Order.find(params[:id])` followed by `@order.line_items` is two queries. That's fine — it's not N+1, it's 1+1.
109
+ - **When you only need IDs.** Use `@order.line_item_ids` which uses a single pluck query. No need to preload full records.
110
+ - **Counter caches.** If you only need `@order.line_items.count`, add a `counter_cache: true` to the association instead of preloading.
111
+
112
+ ## Edge Cases
113
+
114
+ **You're not sure which associations the view will access:**
115
+ Use the `bullet` gem in development. It detects N+1 queries at runtime and tells you exactly which `includes` to add.
116
+
117
+ ```ruby
118
+ # Gemfile
119
+ group :development do
120
+ gem 'bullet'
121
+ end
122
+
123
+ # config/environments/development.rb
124
+ config.after_initialize do
125
+ Bullet.enable = true
126
+ Bullet.alert = true
127
+ Bullet.rails_logger = true
128
+ end
129
+ ```
130
+
131
+ **The association has a scope or condition:**
132
+ `includes` works with scoped associations. Define the scope on the association, not inline.
133
+
134
+ ```ruby
135
+ # Model
136
+ has_many :active_line_items, -> { where(cancelled: false) }, class_name: "LineItem"
137
+
138
+ # Controller
139
+ Order.includes(:active_line_items)
140
+ ```
141
+
142
+ **You preloaded but some records don't have the association:**
143
+ That's fine. `includes` handles empty associations gracefully — it just returns an empty collection. No error, no extra query.
144
+
145
+ **Deeply nested associations:**
146
+ Pass a hash to `includes` for nested preloading. Each level adds one query, not one query per record.
147
+
148
+ ```ruby
149
+ Order.includes(line_items: { product: :category })
150
+ # 4 queries: orders, line_items, products, categories
151
+ ```
@@ -0,0 +1,244 @@
1
+ # Rails: Presenters / View Objects
2
+
3
+ ## Pattern
4
+
5
+ When display logic accumulates in views, helpers, or models, extract it into a presenter — a plain Ruby object that wraps a model and adds formatting, display logic, and view-specific computed values. The model stays focused on data; the presenter handles how data is shown.
6
+
7
+ ```ruby
8
+ # app/presenters/order_presenter.rb
9
+ class OrderPresenter < SimpleDelegator
10
+ def formatted_total
11
+ "$#{format('%.2f', total / 100.0)}"
12
+ end
13
+
14
+ def formatted_date
15
+ created_at.strftime("%B %d, %Y")
16
+ end
17
+
18
+ def status_badge
19
+ color = case status
20
+ when "pending" then "yellow"
21
+ when "confirmed" then "blue"
22
+ when "shipped" then "indigo"
23
+ when "delivered" then "green"
24
+ when "cancelled" then "red"
25
+ else "gray"
26
+ end
27
+ { text: status.titleize, color: color }
28
+ end
29
+
30
+ def shipping_estimate
31
+ return "Delivered" if delivered?
32
+ return "Cancelled" if cancelled?
33
+ return "Ships within 24 hours" if confirmed?
34
+ return "Processing" if pending?
35
+ "Unknown"
36
+ end
37
+
38
+ def credit_card_display
39
+ return "No card on file" unless user.default_payment_method
40
+ "•••• #{user.default_payment_method.last_four}"
41
+ end
42
+
43
+ def line_item_count
44
+ "#{line_items.count} #{'item'.pluralize(line_items.count)}"
45
+ end
46
+
47
+ def can_cancel?
48
+ pending? || confirmed?
49
+ end
50
+
51
+ def can_edit?
52
+ pending?
53
+ end
54
+ end
55
+ ```
56
+
57
+ ```ruby
58
+ # Controller — wrap the model
59
+ class OrdersController < ApplicationController
60
+ def show
61
+ order = current_user.orders.includes(:line_items, :user).find(params[:id])
62
+ @order = OrderPresenter.new(order)
63
+ end
64
+
65
+ def index
66
+ orders = current_user.orders.recent.includes(:line_items)
67
+ @orders = orders.map { |o| OrderPresenter.new(o) }
68
+ end
69
+ end
70
+ ```
71
+
72
+ ```erb
73
+ <%# View — uses presenter methods, no logic in the template %>
74
+ <h1>Order <%= @order.reference %></h1>
75
+ <p>Placed: <%= @order.formatted_date %></p>
76
+ <p>Total: <%= @order.formatted_total %></p>
77
+ <p><%= @order.line_item_count %></p>
78
+
79
+ <span class="badge bg-<%= @order.status_badge[:color] %>">
80
+ <%= @order.status_badge[:text] %>
81
+ </span>
82
+
83
+ <p><%= @order.shipping_estimate %></p>
84
+ <p>Payment: <%= @order.credit_card_display %></p>
85
+
86
+ <% if @order.can_cancel? %>
87
+ <%= button_to "Cancel Order", order_cancellation_path(@order), method: :post %>
88
+ <% end %>
89
+ ```
90
+
91
+ ### SimpleDelegator Explained
92
+
93
+ ```ruby
94
+ # SimpleDelegator forwards ALL method calls to the wrapped object
95
+ class OrderPresenter < SimpleDelegator
96
+ # __getobj__ returns the wrapped Order
97
+ # order.id, order.user, order.status — all work automatically
98
+ # You only define methods for display-specific behavior
99
+
100
+ def formatted_total
101
+ "$#{format('%.2f', total / 100.0)}" # `total` delegates to the Order
102
+ end
103
+ end
104
+
105
+ presenter = OrderPresenter.new(order)
106
+ presenter.id # Delegated to order.id
107
+ presenter.user # Delegated to order.user
108
+ presenter.formatted_total # Defined on presenter
109
+ presenter.is_a?(Order) # true — SimpleDelegator preserves type
110
+ ```
111
+
112
+ ### Collection Presenter
113
+
114
+ ```ruby
115
+ # app/presenters/order_collection_presenter.rb
116
+ class OrderCollectionPresenter
117
+ include Enumerable
118
+
119
+ def initialize(orders)
120
+ @orders = orders
121
+ end
122
+
123
+ def each(&block)
124
+ @orders.map { |o| OrderPresenter.new(o) }.each(&block)
125
+ end
126
+
127
+ def total_revenue
128
+ "$#{format('%.2f', @orders.sum(:total) / 100.0)}"
129
+ end
130
+
131
+ def status_breakdown
132
+ @orders.group(:status).count.transform_keys(&:titleize)
133
+ end
134
+
135
+ def empty_message
136
+ "No orders yet. Your first order will appear here."
137
+ end
138
+ end
139
+
140
+ # Controller
141
+ @orders = OrderCollectionPresenter.new(current_user.orders.recent)
142
+
143
+ # View
144
+ <p>Revenue: <%= @orders.total_revenue %></p>
145
+ <% @orders.each do |order| %>
146
+ <p><%= order.formatted_total %></p>
147
+ <% end %>
148
+ ```
149
+
150
+ ## Why This Is Good
151
+
152
+ - **Models stay clean.** `Order` doesn't need `formatted_total`, `status_badge`, or `shipping_estimate`. Those are display concerns, not data concerns.
153
+ - **Views stay logic-free.** No `<% if order.status == "pending" || order.status == "confirmed" %>` in templates. Just `<% if @order.can_cancel? %>`.
154
+ - **Testable.** `OrderPresenter.new(build_stubbed(:order, total: 19_99)).formatted_total` — fast, isolated, no views or controllers needed.
155
+ - **Reusable across formats.** The same presenter works in HTML views, JSON serializers, mailer templates, and PDF generators.
156
+ - **`SimpleDelegator` is transparent.** The presenter IS the order for all purposes — it responds to every Order method. No explicit delegation for each attribute.
157
+
158
+ ## Anti-Pattern
159
+
160
+ Display logic in the model or scattered across helpers:
161
+
162
+ ```ruby
163
+ # BAD: Display logic on the model
164
+ class Order < ApplicationRecord
165
+ def formatted_total
166
+ "$#{format('%.2f', total / 100.0)}"
167
+ end
168
+
169
+ def status_color
170
+ case status
171
+ when "pending" then "yellow"
172
+ when "shipped" then "blue"
173
+ end
174
+ end
175
+
176
+ def display_date
177
+ created_at.strftime("%B %d, %Y")
178
+ end
179
+ end
180
+ # The model now knows about dollar signs, colors, and date formatting
181
+
182
+ # BAD: Logic in helpers (global namespace, hard to find, hard to test)
183
+ module OrdersHelper
184
+ def order_status_badge(order)
185
+ color = order.status == "pending" ? "yellow" : "green"
186
+ content_tag(:span, order.status.titleize, class: "badge bg-#{color}")
187
+ end
188
+ end
189
+
190
+ # BAD: Logic in views
191
+ <% if order.total > 200_00 %>
192
+ <span class="badge bg-gold">VIP Order</span>
193
+ <% end %>
194
+ <% if order.created_at > 30.days.ago %>
195
+ <span>Recent</span>
196
+ <% end %>
197
+ ```
198
+
199
+ ## When To Apply
200
+
201
+ - **A model has 3+ methods that only exist for display purposes.** `formatted_total`, `display_name`, `status_label` — these are presenter methods.
202
+ - **Views have conditional logic based on model state.** `if order.pending? || order.confirmed?` → extract to `presenter.can_cancel?`.
203
+ - **The same formatting appears in multiple views.** An order's total is formatted in the index, show, email, and PDF. One presenter method, used everywhere.
204
+ - **Helper files are becoming catch-alls.** If `OrdersHelper` has 15 methods, it's a presenter in disguise.
205
+
206
+ ## When NOT To Apply
207
+
208
+ - **One or two simple formatting methods.** If the model only has `def to_s; name; end`, that's fine on the model. Don't create a presenter for one method.
209
+ - **Rails built-in helpers suffice.** `number_to_currency(order.total)` in a view is fine for a single use. A presenter is for when you're repeating the same formatting logic.
210
+ - **API-only apps.** Use serializers instead of presenters. Serializers control the JSON output; presenters control HTML display. Different tools for different formats.
211
+
212
+ ## Edge Cases
213
+
214
+ **Presenter + form helpers:**
215
+ `SimpleDelegator` preserves the wrapped object's class, so `form_with model: @order` works even when `@order` is an `OrderPresenter`. Rails form helpers use the underlying model for URL generation and param naming.
216
+
217
+ **Presenter in serializers (API):**
218
+ Don't use presenters in JSON APIs. Use a dedicated serializer class instead — it controls the exact shape of the JSON output without inheriting display-specific methods.
219
+
220
+ **Nested presenters:**
221
+ ```ruby
222
+ class OrderPresenter < SimpleDelegator
223
+ def presented_line_items
224
+ line_items.map { |li| LineItemPresenter.new(li) }
225
+ end
226
+ end
227
+ ```
228
+
229
+ **Alternative: Plain class instead of SimpleDelegator:**
230
+ ```ruby
231
+ class OrderPresenter
232
+ attr_reader :order
233
+ delegate :id, :reference, :status, :user, :line_items, :created_at, to: :order
234
+
235
+ def initialize(order)
236
+ @order = order
237
+ end
238
+
239
+ def formatted_total
240
+ "$#{format('%.2f', order.total / 100.0)}"
241
+ end
242
+ end
243
+ ```
244
+ This is more explicit (you declare exactly which methods delegate) but more verbose. Use `SimpleDelegator` unless you need to restrict the interface.