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,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.
|