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,186 @@
|
|
|
1
|
+
# Gems: dry-rb Ecosystem
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
The dry-rb family provides standalone, composable libraries for validation, types, dependency injection, and functional patterns. They're popular in non-Rails Ruby projects and in Rails apps that want more rigor than ActiveModel provides.
|
|
6
|
+
|
|
7
|
+
### dry-validation (Schema Validation)
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem "dry-validation", "~> 1.10"
|
|
12
|
+
|
|
13
|
+
# app/contracts/order_contract.rb
|
|
14
|
+
class OrderContract < Dry::Validation::Contract
|
|
15
|
+
params do
|
|
16
|
+
required(:shipping_address).filled(:string)
|
|
17
|
+
required(:line_items).array(:hash) do
|
|
18
|
+
required(:product_id).filled(:integer)
|
|
19
|
+
required(:quantity).filled(:integer, gt?: 0)
|
|
20
|
+
end
|
|
21
|
+
optional(:notes).maybe(:string)
|
|
22
|
+
optional(:discount_code).maybe(:string, max_size?: 20)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
rule(:line_items) do
|
|
26
|
+
key.failure("must have at least one item") if values[:line_items].empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
rule(:discount_code) do
|
|
30
|
+
if values[:discount_code] && !Discount.active.exists?(code: values[:discount_code])
|
|
31
|
+
key.failure("is not a valid discount code")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Usage
|
|
37
|
+
contract = OrderContract.new
|
|
38
|
+
result = contract.call(params)
|
|
39
|
+
|
|
40
|
+
if result.success?
|
|
41
|
+
# result.to_h contains validated, coerced data
|
|
42
|
+
Orders::CreateService.call(result.to_h, current_user)
|
|
43
|
+
else
|
|
44
|
+
# result.errors.to_h contains error messages by field
|
|
45
|
+
# => { line_items: ["must have at least one item"] }
|
|
46
|
+
render json: { errors: result.errors.to_h }, status: :unprocessable_entity
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### dry-types (Type System)
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# Gemfile
|
|
54
|
+
gem "dry-types", "~> 1.7"
|
|
55
|
+
|
|
56
|
+
# app/types.rb
|
|
57
|
+
module Types
|
|
58
|
+
include Dry.Types()
|
|
59
|
+
|
|
60
|
+
Email = Types::String.constrained(format: URI::MailTo::EMAIL_REGEXP)
|
|
61
|
+
PositiveMoney = Types::Coercible::Integer.constrained(gteq: 0)
|
|
62
|
+
Status = Types::String.enum("pending", "confirmed", "shipped", "delivered", "cancelled")
|
|
63
|
+
CreditAmount = Types::Coercible::Integer.constrained(gteq: 1, lteq: 10_000)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Usage — type checking and coercion
|
|
67
|
+
Types::Email["alice@example.com"] # => "alice@example.com"
|
|
68
|
+
Types::Email["not-an-email"] # => Dry::Types::ConstraintError
|
|
69
|
+
|
|
70
|
+
Types::PositiveMoney[1999] # => 1999
|
|
71
|
+
Types::PositiveMoney[-5] # => Dry::Types::ConstraintError
|
|
72
|
+
|
|
73
|
+
Types::Status["pending"] # => "pending"
|
|
74
|
+
Types::Status["invalid"] # => Dry::Types::ConstraintError
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### dry-monads (Result Type / Railway Programming)
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# Gemfile
|
|
81
|
+
gem "dry-monads", "~> 1.6"
|
|
82
|
+
|
|
83
|
+
# app/services/orders/create_service.rb
|
|
84
|
+
require "dry/monads"
|
|
85
|
+
|
|
86
|
+
class Orders::CreateService
|
|
87
|
+
include Dry::Monads[:result, :do]
|
|
88
|
+
|
|
89
|
+
def call(params, user)
|
|
90
|
+
validated = yield validate(params)
|
|
91
|
+
order = yield create_order(validated, user)
|
|
92
|
+
yield charge_payment(order)
|
|
93
|
+
yield send_confirmation(order)
|
|
94
|
+
|
|
95
|
+
Success(order)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def validate(params)
|
|
101
|
+
result = OrderContract.new.call(params)
|
|
102
|
+
result.success? ? Success(result.to_h) : Failure(result.errors.to_h)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def create_order(params, user)
|
|
106
|
+
order = user.orders.create(params)
|
|
107
|
+
order.persisted? ? Success(order) : Failure(order.errors.full_messages)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def charge_payment(order)
|
|
111
|
+
result = Payments::ChargeService.call(order)
|
|
112
|
+
result.success? ? Success(result) : Failure("Payment failed: #{result.error}")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def send_confirmation(order)
|
|
116
|
+
OrderMailer.confirmation(order).deliver_later
|
|
117
|
+
Success(true)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Controller — pattern match on the result
|
|
122
|
+
result = Orders::CreateService.new.call(params, current_user)
|
|
123
|
+
|
|
124
|
+
case result
|
|
125
|
+
in Dry::Monads::Success(order)
|
|
126
|
+
redirect_to order, notice: "Order placed"
|
|
127
|
+
in Dry::Monads::Failure(errors) if errors.is_a?(Hash)
|
|
128
|
+
@errors = errors
|
|
129
|
+
render :new, status: :unprocessable_entity
|
|
130
|
+
in Dry::Monads::Failure(message)
|
|
131
|
+
flash.now[:alert] = message
|
|
132
|
+
render :new, status: :unprocessable_entity
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The `yield` keyword with `do` notation short-circuits on `Failure` — if `validate` returns `Failure`, the remaining steps never execute. This is the "railway" pattern: success continues down the track, failure jumps to the error track immediately.
|
|
137
|
+
|
|
138
|
+
### dry-struct (Typed Data Objects)
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
# Gemfile
|
|
142
|
+
gem "dry-struct", "~> 1.6"
|
|
143
|
+
|
|
144
|
+
class OrderRequest < Dry::Struct
|
|
145
|
+
attribute :shipping_address, Types::String
|
|
146
|
+
attribute :line_items, Types::Array.of(LineItemRequest)
|
|
147
|
+
attribute :discount_code, Types::String.optional
|
|
148
|
+
attribute :notes, Types::String.optional.default(nil)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
class LineItemRequest < Dry::Struct
|
|
152
|
+
attribute :product_id, Types::Coercible::Integer
|
|
153
|
+
attribute :quantity, Types::Coercible::Integer.constrained(gteq: 1)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Construction validates types automatically
|
|
157
|
+
request = OrderRequest.new(
|
|
158
|
+
shipping_address: "123 Main St",
|
|
159
|
+
line_items: [{ product_id: "42", quantity: "2" }], # Strings coerced to integers
|
|
160
|
+
discount_code: nil
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
request.line_items.first.product_id # => 42 (Integer, coerced from String)
|
|
164
|
+
request.line_items.first.quantity # => 2 (Integer)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Why This Is Good
|
|
168
|
+
|
|
169
|
+
- **dry-validation separates validation from models.** Complex validations (cross-field, external lookups, nested structures) have a dedicated home that's not the ActiveRecord model.
|
|
170
|
+
- **dry-types catch type errors at boundaries.** Instead of `NoMethodError: undefined method 'to_i' for nil` buried in a service, you get `Dry::Types::ConstraintError` at the entry point.
|
|
171
|
+
- **dry-monads make error flow explicit.** Every step returns `Success` or `Failure`. The `do` notation short-circuits on failure. No hidden exception paths, no nil returns.
|
|
172
|
+
- **Composable and standalone.** Each dry-rb gem works independently. Use dry-validation without dry-monads, or dry-types without dry-struct.
|
|
173
|
+
|
|
174
|
+
## When To Apply
|
|
175
|
+
|
|
176
|
+
- **Complex validation beyond what ActiveModel handles.** Nested params, cross-field rules, external lookups, multi-step validation.
|
|
177
|
+
- **Non-Rails Ruby projects.** dry-rb is framework-agnostic. Perfect for Sinatra apps, plain Ruby services, and gems.
|
|
178
|
+
- **Teams that want explicit error handling.** dry-monads forces every failure path to be handled. Nothing slips through silently.
|
|
179
|
+
- **API input validation.** dry-validation is excellent for validating JSON API payloads before they touch ActiveRecord.
|
|
180
|
+
|
|
181
|
+
## When NOT To Apply
|
|
182
|
+
|
|
183
|
+
- **Simple Rails apps with standard validations.** `validates :name, presence: true` is fine. Don't add dry-validation for what ActiveModel already handles.
|
|
184
|
+
- **The team is unfamiliar with functional patterns.** dry-monads' `Success`/`Failure`/`yield` pattern has a learning curve. If the team isn't bought in, it creates friction.
|
|
185
|
+
- **Mixing dry-rb and ActiveModel validations.** Pick one approach per layer. Don't validate params with dry-validation AND re-validate in the model with ActiveModel — you'll get confused about which errors come from where.
|
|
186
|
+
- **Small projects.** The dry-rb gems add dependencies and concepts. For a 10-controller Rails app, they're overkill.
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# Gem: FactoryBot
|
|
2
|
+
|
|
3
|
+
## What It Is
|
|
4
|
+
|
|
5
|
+
FactoryBot creates test data for RSpec and Minitest. It replaces fixtures with programmatic factories that produce valid ActiveRecord objects with minimal boilerplate. It's the most widely used test data library in Rails.
|
|
6
|
+
|
|
7
|
+
## Setup Done Right
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile (test group)
|
|
11
|
+
gem 'factory_bot_rails'
|
|
12
|
+
|
|
13
|
+
# spec/support/factory_bot.rb
|
|
14
|
+
RSpec.configure do |config|
|
|
15
|
+
config.include FactoryBot::Syntax::Methods
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Gotcha #1: Factory Chains Create Way More Records Than You Think
|
|
20
|
+
|
|
21
|
+
Every `belongs_to` association in a factory triggers a `create` of the associated record. A deeply nested factory can create 10+ records when you only asked for 1.
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# These factories look innocent...
|
|
25
|
+
FactoryBot.define do
|
|
26
|
+
factory :line_item do
|
|
27
|
+
order # Creates an order
|
|
28
|
+
product # Creates a product
|
|
29
|
+
quantity { 1 }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
factory :order do
|
|
33
|
+
user # Creates a user
|
|
34
|
+
shipping_address { "123 Main St" }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
factory :user do
|
|
38
|
+
company # Creates a company
|
|
39
|
+
email { "user@example.com" }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
factory :company do
|
|
43
|
+
name { "Acme Inc" }
|
|
44
|
+
plan # Creates a plan
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# This single line...
|
|
49
|
+
create(:line_item)
|
|
50
|
+
# Actually creates: plan → company → user → order → product → line_item
|
|
51
|
+
# That's 6 INSERT statements for ONE line item!
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Fix: Use `build_stubbed` when you don't need the DB, and be deliberate about associations:**
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
# For unit tests — zero database hits
|
|
58
|
+
line_item = build_stubbed(:line_item)
|
|
59
|
+
|
|
60
|
+
# When you need a real record but want to control associations
|
|
61
|
+
user = create(:user, company: nil) # Skip company creation
|
|
62
|
+
order = create(:order, user: user) # Reuse the user
|
|
63
|
+
create(:line_item, order: order) # Only creates product + line_item
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Gotcha #2: Sequences vs Hardcoded Values
|
|
67
|
+
|
|
68
|
+
Unique fields without sequences cause test failures that depend on execution order.
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# WRONG: Hardcoded unique field — second create fails
|
|
72
|
+
factory :user do
|
|
73
|
+
email { "test@example.com" } # Duplicate on second create!
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
create(:user) # Works
|
|
77
|
+
create(:user) # ActiveRecord::RecordNotUnique: email already taken
|
|
78
|
+
|
|
79
|
+
# RIGHT: Use sequences for any unique field
|
|
80
|
+
factory :user do
|
|
81
|
+
sequence(:email) { |n| "user#{n}@example.com" }
|
|
82
|
+
name { "Jane Doe" }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
create(:user).email # "user1@example.com"
|
|
86
|
+
create(:user).email # "user2@example.com"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**The trap:** Tests pass when run individually but fail when run together. The first test creates `test@example.com`, the second tries to create it again. Sequences prevent this.
|
|
90
|
+
|
|
91
|
+
## Gotcha #3: `create` vs `build` vs `build_stubbed` — Wrong Choice Wastes Time
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
# WRONG: Using create when build_stubbed would work
|
|
95
|
+
RSpec.describe Order do
|
|
96
|
+
let(:order) { create(:order) } # Writes to DB unnecessarily
|
|
97
|
+
|
|
98
|
+
it "calculates total" do
|
|
99
|
+
# This test only calls order.total — it never queries the DB
|
|
100
|
+
expect(order.total).to eq(0)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# RIGHT: Match the factory strategy to the test's needs
|
|
105
|
+
RSpec.describe Order do
|
|
106
|
+
let(:order) { build_stubbed(:order, total: 100) } # No DB hit
|
|
107
|
+
|
|
108
|
+
it "calculates total" do
|
|
109
|
+
expect(order.total).to eq(100)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Decision tree:
|
|
115
|
+
- **Does the test query the DB (scopes, joins, reload)?** → `create`
|
|
116
|
+
- **Does the test call `.valid?`, `.save`, or `.errors`?** → `build`
|
|
117
|
+
- **Does the test only call instance methods?** → `build_stubbed`
|
|
118
|
+
|
|
119
|
+
## Gotcha #4: Traits — Compose, Don't Create New Factories
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# WRONG: Separate factories for every variation
|
|
123
|
+
factory :pending_order do
|
|
124
|
+
status { :pending }
|
|
125
|
+
user
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
factory :shipped_order do
|
|
129
|
+
status { :shipped }
|
|
130
|
+
shipped_at { 1.day.ago }
|
|
131
|
+
user
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
factory :high_value_shipped_order do
|
|
135
|
+
status { :shipped }
|
|
136
|
+
shipped_at { 1.day.ago }
|
|
137
|
+
total { 500 }
|
|
138
|
+
user
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# RIGHT: One factory with composable traits
|
|
142
|
+
factory :order do
|
|
143
|
+
user
|
|
144
|
+
sequence(:reference) { |n| "ORD-#{n.to_s.rjust(6, '0')}" }
|
|
145
|
+
status { :pending }
|
|
146
|
+
total { 100 }
|
|
147
|
+
|
|
148
|
+
trait :shipped do
|
|
149
|
+
status { :shipped }
|
|
150
|
+
shipped_at { 1.day.ago }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
trait :cancelled do
|
|
154
|
+
status { :cancelled }
|
|
155
|
+
cancelled_at { 1.hour.ago }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
trait :high_value do
|
|
159
|
+
total { 500 }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
trait :with_line_items do
|
|
163
|
+
transient do
|
|
164
|
+
item_count { 2 }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
after(:create) do |order, ctx|
|
|
168
|
+
create_list(:line_item, ctx.item_count, order: order)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Compose traits as needed
|
|
174
|
+
create(:order, :shipped)
|
|
175
|
+
create(:order, :shipped, :high_value)
|
|
176
|
+
create(:order, :with_line_items, item_count: 5)
|
|
177
|
+
create(:order, :cancelled, total: 0)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Gotcha #5: `after(:create)` Blocks Break `build_stubbed`
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
# WRONG: after(:create) prevents build_stubbed from working
|
|
184
|
+
factory :order do
|
|
185
|
+
after(:create) do |order|
|
|
186
|
+
create(:line_item, order: order) # Only runs on create, not build_stubbed
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
build_stubbed(:order)
|
|
191
|
+
# Works — but has no line items (after(:create) didn't run)
|
|
192
|
+
# Tests that expect line items fail silently
|
|
193
|
+
|
|
194
|
+
# RIGHT: Use traits for optional associations
|
|
195
|
+
factory :order do
|
|
196
|
+
# Base factory has no line items
|
|
197
|
+
|
|
198
|
+
trait :with_line_items do
|
|
199
|
+
after(:create) do |order|
|
|
200
|
+
create_list(:line_item, 2, order: order)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
create(:order, :with_line_items) # Has line items
|
|
206
|
+
build_stubbed(:order) # No line items, but that's expected
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Gotcha #6: Faker Slows Tests and Creates Flaky Failures
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
# WRONG: Faker for every attribute
|
|
213
|
+
factory :user do
|
|
214
|
+
name { Faker::Name.name } # Random every test run
|
|
215
|
+
email { Faker::Internet.email } # Can generate duplicates!
|
|
216
|
+
bio { Faker::Lorem.paragraph(sentence_count: 10) } # Slow, nobody reads it
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# RIGHT: Static defaults, sequences for unique fields
|
|
220
|
+
factory :user do
|
|
221
|
+
name { "Jane Doe" }
|
|
222
|
+
sequence(:email) { |n| "user#{n}@example.com" }
|
|
223
|
+
# No bio — keep it nil unless a test specifically needs it
|
|
224
|
+
end
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**The traps:**
|
|
228
|
+
- Faker generates random data. Your test passes 99% of the time but fails when Faker generates a name over 100 characters (your validation limit).
|
|
229
|
+
- Faker::Internet.email can generate the same email twice. Unlike sequences, it doesn't guarantee uniqueness.
|
|
230
|
+
- Faker is slow — it generates random data on every call. Across 2,000 factory calls, this adds seconds.
|
|
231
|
+
|
|
232
|
+
## Gotcha #7: Lint Your Factories
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
# spec/factories_spec.rb — catches broken factories early
|
|
236
|
+
RSpec.describe "FactoryBot factories" do
|
|
237
|
+
it "has valid factories" do
|
|
238
|
+
FactoryBot.lint(traits: true)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
This creates every factory with every trait and calls `.valid?` on each. It catches:
|
|
244
|
+
- Missing required fields
|
|
245
|
+
- Broken associations
|
|
246
|
+
- Invalid trait combinations
|
|
247
|
+
- Sequences that produce invalid data
|
|
248
|
+
|
|
249
|
+
Run it in CI — it's the first test to fail when someone adds a model validation without updating the factory.
|
|
250
|
+
|
|
251
|
+
## Do's and Don'ts Summary
|
|
252
|
+
|
|
253
|
+
**DO:**
|
|
254
|
+
- Use `build_stubbed` by default, `build` for validation tests, `create` only when the DB is needed
|
|
255
|
+
- Use sequences for every unique field (email, reference, slug)
|
|
256
|
+
- Use traits for variations — compose them, don't create separate factories
|
|
257
|
+
- Use transient attributes for configurable association creation
|
|
258
|
+
- Use static values for non-unique fields
|
|
259
|
+
- Lint your factories in CI
|
|
260
|
+
- Keep base factories minimal — only required fields
|
|
261
|
+
|
|
262
|
+
**DON'T:**
|
|
263
|
+
- Don't use `create` when `build_stubbed` would work
|
|
264
|
+
- Don't use Faker for factory defaults (slow, flaky, non-unique)
|
|
265
|
+
- Don't put `after(:create)` in the base factory — use traits
|
|
266
|
+
- Don't create separate factories for variations — use traits
|
|
267
|
+
- Don't forget to update factories when you add model validations
|
|
268
|
+
- Don't let factories silently create 10+ records via association chains
|