anima-core 0.2.1 → 1.0.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 +4 -4
- data/.reek.yml +27 -1
- data/CHANGELOG.md +19 -0
- data/README.md +213 -43
- data/agents/codebase-analyzer.md +88 -0
- data/agents/codebase-pattern-finder.md +83 -0
- data/agents/documentation-researcher.md +59 -0
- data/agents/thoughts-analyzer.md +102 -0
- data/agents/web-search-researcher.md +71 -0
- data/anima-core.gemspec +3 -0
- data/app/channels/session_channel.rb +195 -45
- data/app/decorators/user_message_decorator.rb +16 -5
- data/app/jobs/agent_request_job.rb +55 -2
- data/app/jobs/analytical_brain_job.rb +33 -0
- data/app/jobs/count_event_tokens_job.rb +15 -4
- data/app/models/concerns/event/broadcasting.rb +81 -0
- data/app/models/event.rb +20 -1
- data/app/models/goal.rb +91 -0
- data/app/models/session.rb +366 -21
- data/config/application.rb +2 -0
- data/config/initializers/event_subscribers.rb +0 -1
- data/config/routes.rb +0 -6
- data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
- data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
- data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
- data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
- data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
- data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
- data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
- data/db/migrate/20260315140843_create_goals.rb +16 -0
- data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
- data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
- data/lib/agent_loop.rb +65 -6
- data/lib/agents/definition.rb +116 -0
- data/lib/agents/registry.rb +106 -0
- data/lib/analytical_brain/runner.rb +276 -0
- data/lib/analytical_brain/tools/activate_skill.rb +52 -0
- data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
- data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
- data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
- data/lib/analytical_brain/tools/finish_goal.rb +62 -0
- data/lib/analytical_brain/tools/read_workflow.rb +58 -0
- data/lib/analytical_brain/tools/rename_session.rb +63 -0
- data/lib/analytical_brain/tools/set_goal.rb +60 -0
- data/lib/analytical_brain/tools/update_goal.rb +60 -0
- data/lib/analytical_brain.rb +23 -0
- data/lib/anima/cli/mcp/secrets.rb +76 -0
- data/lib/anima/cli/mcp.rb +197 -0
- data/lib/anima/cli.rb +5 -40
- data/lib/anima/installer.rb +168 -0
- data/lib/anima/settings.rb +226 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +9 -0
- data/lib/credential_store.rb +103 -0
- data/lib/environment_probe.rb +232 -0
- data/lib/events/subscribers/persister.rb +1 -0
- data/lib/events/user_message.rb +17 -0
- data/lib/llm/client.rb +29 -10
- data/lib/mcp/client_manager.rb +86 -0
- data/lib/mcp/config.rb +213 -0
- data/lib/mcp/health_check.rb +77 -0
- data/lib/mcp/secrets.rb +73 -0
- data/lib/mcp/stdio_transport.rb +206 -0
- data/lib/providers/anthropic.rb +11 -20
- data/lib/shell_session.rb +11 -10
- data/lib/skills/definition.rb +97 -0
- data/lib/skills/registry.rb +105 -0
- data/lib/tools/edit.rb +226 -0
- data/lib/tools/mcp_tool.rb +114 -0
- data/lib/tools/read.rb +151 -0
- data/lib/tools/registry.rb +14 -12
- data/lib/tools/request_feature.rb +121 -0
- data/lib/tools/return_result.rb +81 -0
- data/lib/tools/spawn_specialist.rb +109 -0
- data/lib/tools/spawn_subagent.rb +111 -0
- data/lib/tools/subagent_prompts.rb +12 -0
- data/lib/tools/web_get.rb +8 -9
- data/lib/tools/write.rb +86 -0
- data/lib/tui/app.rb +985 -26
- data/lib/tui/cable_client.rb +69 -31
- data/lib/tui/message_store.rb +103 -8
- data/lib/tui/screens/chat.rb +293 -45
- data/lib/workflows/definition.rb +97 -0
- data/lib/workflows/registry.rb +89 -0
- data/skills/activerecord/SKILL.md +255 -0
- data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
- data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
- data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
- data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
- data/skills/activerecord/examples/associations/self_referential.rb +302 -0
- data/skills/activerecord/examples/associations/through_associations.rb +203 -0
- data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
- data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
- data/skills/activerecord/examples/basics/inheritance.rb +377 -0
- data/skills/activerecord/examples/basics/type_casting.rb +317 -0
- data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
- data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
- data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
- data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
- data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
- data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
- data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
- data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
- data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
- data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
- data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
- data/skills/activerecord/examples/querying/optimization.rb +275 -0
- data/skills/activerecord/examples/querying/scopes.rb +260 -0
- data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
- data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
- data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
- data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
- data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
- data/skills/activerecord/references/associations.md +709 -0
- data/skills/activerecord/references/basics.md +622 -0
- data/skills/activerecord/references/callbacks.md +738 -0
- data/skills/activerecord/references/migrations.md +657 -0
- data/skills/activerecord/references/querying.md +655 -0
- data/skills/activerecord/references/validations.md +596 -0
- data/skills/dragonruby/SKILL.md +250 -0
- data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
- data/skills/dragonruby/examples/audio/background_music.rb +29 -0
- data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
- data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
- data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
- data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
- data/skills/dragonruby/examples/core/hello_world.rb +24 -0
- data/skills/dragonruby/examples/core/labels.rb +22 -0
- data/skills/dragonruby/examples/core/sprites.rb +35 -0
- data/skills/dragonruby/examples/core/state_management.rb +29 -0
- data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
- data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
- data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
- data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
- data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
- data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
- data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
- data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
- data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
- data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
- data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
- data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
- data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
- data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
- data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
- data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
- data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
- data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
- data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
- data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
- data/skills/dragonruby/examples/input/controller_input.rb +28 -0
- data/skills/dragonruby/examples/input/directional_input.rb +24 -0
- data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
- data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
- data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
- data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
- data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
- data/skills/dragonruby/examples/rendering/labels.rb +32 -0
- data/skills/dragonruby/examples/rendering/layering.rb +51 -0
- data/skills/dragonruby/examples/rendering/solids.rb +61 -0
- data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
- data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
- data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
- data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
- data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
- data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
- data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
- data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
- data/skills/dragonruby/references/audio.md +396 -0
- data/skills/dragonruby/references/core.md +385 -0
- data/skills/dragonruby/references/distribution.md +434 -0
- data/skills/dragonruby/references/entities.md +516 -0
- data/skills/dragonruby/references/game-logic/persistence.md +386 -0
- data/skills/dragonruby/references/game-logic/state.md +389 -0
- data/skills/dragonruby/references/input.md +414 -0
- data/skills/dragonruby/references/rendering/animation.md +467 -0
- data/skills/dragonruby/references/rendering/primitives.md +403 -0
- data/skills/dragonruby/references/scenes.md +443 -0
- data/skills/draper-decorators/SKILL.md +344 -0
- data/skills/draper-decorators/examples/application_decorator.rb +61 -0
- data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
- data/skills/draper-decorators/examples/model_decorator.rb +152 -0
- data/skills/draper-decorators/references/anti-patterns.md +640 -0
- data/skills/draper-decorators/references/patterns.md +507 -0
- data/skills/draper-decorators/references/testing.md +559 -0
- data/skills/gh-issue.md +182 -0
- data/skills/mcp-server/SKILL.md +177 -0
- data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
- data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
- data/skills/mcp-server/examples/http_client.rb +48 -0
- data/skills/mcp-server/examples/http_server.rb +97 -0
- data/skills/mcp-server/examples/rails_integration.rb +88 -0
- data/skills/mcp-server/examples/stdio_server.rb +108 -0
- data/skills/mcp-server/examples/streaming_client.rb +95 -0
- data/skills/mcp-server/references/gotchas.md +183 -0
- data/skills/mcp-server/references/prompts.md +98 -0
- data/skills/mcp-server/references/resources.md +53 -0
- data/skills/mcp-server/references/server.md +140 -0
- data/skills/mcp-server/references/tools.md +146 -0
- data/skills/mcp-server/references/transport.md +104 -0
- data/skills/ratatui-ruby/SKILL.md +315 -0
- data/skills/ratatui-ruby/references/core-concepts.md +340 -0
- data/skills/ratatui-ruby/references/events.md +387 -0
- data/skills/ratatui-ruby/references/frameworks.md +522 -0
- data/skills/ratatui-ruby/references/layout.md +423 -0
- data/skills/ratatui-ruby/references/styling.md +268 -0
- data/skills/ratatui-ruby/references/testing.md +433 -0
- data/skills/ratatui-ruby/references/widgets.md +532 -0
- data/skills/rspec/SKILL.md +340 -0
- data/skills/rspec/examples/core/basic_structure.rb +69 -0
- data/skills/rspec/examples/core/configuration.rb +126 -0
- data/skills/rspec/examples/core/hooks.rb +126 -0
- data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
- data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
- data/skills/rspec/examples/core/shared_examples.rb +145 -0
- data/skills/rspec/examples/factory_bot/associations.rb +314 -0
- data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
- data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
- data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
- data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
- data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
- data/skills/rspec/examples/factory_bot/traits.rb +293 -0
- data/skills/rspec/examples/factory_bot/transients.rb +229 -0
- data/skills/rspec/examples/matchers/change.rb +115 -0
- data/skills/rspec/examples/matchers/collections.rb +154 -0
- data/skills/rspec/examples/matchers/comparisons.rb +79 -0
- data/skills/rspec/examples/matchers/composing.rb +155 -0
- data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
- data/skills/rspec/examples/matchers/equality.rb +58 -0
- data/skills/rspec/examples/matchers/errors.rb +136 -0
- data/skills/rspec/examples/matchers/output.rb +103 -0
- data/skills/rspec/examples/matchers/predicates.rb +87 -0
- data/skills/rspec/examples/matchers/truthiness.rb +101 -0
- data/skills/rspec/examples/matchers/types.rb +82 -0
- data/skills/rspec/examples/matchers/yield.rb +147 -0
- data/skills/rspec/examples/mocks/any_instance.rb +172 -0
- data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
- data/skills/rspec/examples/mocks/constants.rb +177 -0
- data/skills/rspec/examples/mocks/doubles.rb +139 -0
- data/skills/rspec/examples/mocks/expectations.rb +137 -0
- data/skills/rspec/examples/mocks/message_chains.rb +173 -0
- data/skills/rspec/examples/mocks/ordering.rb +144 -0
- data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
- data/skills/rspec/examples/mocks/responses.rb +223 -0
- data/skills/rspec/examples/mocks/spies.rb +149 -0
- data/skills/rspec/examples/mocks/stubbing.rb +133 -0
- data/skills/rspec/examples/rails/channels.rb +250 -0
- data/skills/rspec/examples/rails/controller_specs.rb +302 -0
- data/skills/rspec/examples/rails/helper_specs.rb +245 -0
- data/skills/rspec/examples/rails/job_specs.rb +256 -0
- data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
- data/skills/rspec/examples/rails/matchers.rb +374 -0
- data/skills/rspec/examples/rails/model_specs.rb +193 -0
- data/skills/rspec/examples/rails/request_specs.rb +275 -0
- data/skills/rspec/examples/rails/routing_specs.rb +276 -0
- data/skills/rspec/examples/rails/system_specs.rb +294 -0
- data/skills/rspec/examples/rails/transactions.rb +254 -0
- data/skills/rspec/examples/rails/view_specs.rb +252 -0
- data/skills/rspec/references/core.md +816 -0
- data/skills/rspec/references/factory_bot.md +641 -0
- data/skills/rspec/references/matchers.md +516 -0
- data/skills/rspec/references/mocks.md +381 -0
- data/skills/rspec/references/rails.md +528 -0
- data/templates/soul.md +40 -0
- data/workflows/commit.md +45 -0
- data/workflows/create_handoff.md +98 -0
- data/workflows/create_note.md +82 -0
- data/workflows/create_plan.md +457 -0
- data/workflows/decompose_ticket.md +109 -0
- data/workflows/feature.md +91 -0
- data/workflows/implement_plan.md +87 -0
- data/workflows/iterate_plan.md +247 -0
- data/workflows/research_codebase.md +210 -0
- data/workflows/resume_handoff.md +217 -0
- data/workflows/review_pr.md +320 -0
- data/workflows/thoughts_init.md +71 -0
- data/workflows/validate_plan.md +166 -0
- metadata +290 -3
- data/app/controllers/api/sessions_controller.rb +0 -25
- data/lib/events/subscribers/action_cable_bridge.rb +0 -59
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
# ActiveRecord Validations Reference
|
|
2
|
+
|
|
3
|
+
Comprehensive reference for model validations: built-in validators, conditional validations, custom validators, validation contexts, and the critical distinction between model validations and database constraints.
|
|
4
|
+
|
|
5
|
+
## Built-in Validators
|
|
6
|
+
|
|
7
|
+
### Presence
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
validates :name, presence: true
|
|
11
|
+
validates :title, :body, presence: true # Multiple attributes
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
**Boolean Fields Gotcha**: `false.blank? == true`, so presence validation fails on `false`:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# WRONG - fails when field is false
|
|
18
|
+
validates :active, presence: true
|
|
19
|
+
|
|
20
|
+
# CORRECT - use inclusion for booleans
|
|
21
|
+
validates :active, inclusion: { in: [true, false] }
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Uniqueness
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
validates :email, uniqueness: true
|
|
28
|
+
validates :username, uniqueness: { case_sensitive: false }
|
|
29
|
+
validates :code, uniqueness: { scope: :account_id } # Per-account uniqueness
|
|
30
|
+
validates :slug, uniqueness: { scope: [:category_id, :year] } # Composite scope
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Critical**: Always pair with database unique index. See [Uniqueness Race Conditions](#uniqueness-race-conditions).
|
|
34
|
+
|
|
35
|
+
### Format
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
validates :legacy_code, format: { with: /\A[A-Z]{3}-\d{4}\z/ }
|
|
39
|
+
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Security Warning**: Use `\A` and `\z` for string boundaries, NOT `^` and `$`:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# WRONG - ^ and $ match line boundaries, vulnerable to injection
|
|
46
|
+
validates :code, format: { with: /^[a-z]+$/ }
|
|
47
|
+
|
|
48
|
+
# CORRECT - \A and \z match string boundaries
|
|
49
|
+
validates :code, format: { with: /\A[a-z]+\z/ }
|
|
50
|
+
|
|
51
|
+
# If multiline is intentional, be explicit
|
|
52
|
+
validates :bio, format: { with: /^[a-z]+$/, multiline: true }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Length
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
validates :password, length: { minimum: 8 }
|
|
59
|
+
validates :bio, length: { maximum: 500 }
|
|
60
|
+
validates :pin, length: { is: 6 }
|
|
61
|
+
validates :username, length: { in: 3..20 } # Range
|
|
62
|
+
validates :content, length: { minimum: 10, too_short: "must have at least %{count} characters" }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Note**: `:maximum` alone allows nil by default (unlike `:minimum` or `:is`).
|
|
66
|
+
|
|
67
|
+
### Numericality
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
validates :age, numericality: true
|
|
71
|
+
validates :quantity, numericality: { only_integer: true }
|
|
72
|
+
validates :price, numericality: { greater_than: 0 }
|
|
73
|
+
validates :age, numericality: { greater_than_or_equal_to: 18, less_than: 150 }
|
|
74
|
+
validates :discount, numericality: { in: 0..100 }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| Option | Description |
|
|
78
|
+
|--------|-------------|
|
|
79
|
+
| `only_integer` | Must be integer (uses regex) |
|
|
80
|
+
| `only_numeric` | Must be Numeric instance (no string parsing) |
|
|
81
|
+
| `greater_than` | > value |
|
|
82
|
+
| `greater_than_or_equal_to` | >= value |
|
|
83
|
+
| `less_than` | < value |
|
|
84
|
+
| `less_than_or_equal_to` | <= value |
|
|
85
|
+
| `equal_to` | == value |
|
|
86
|
+
| `in` | Within range |
|
|
87
|
+
| `other_than` | != value |
|
|
88
|
+
| `odd` / `even` | Must be odd/even |
|
|
89
|
+
|
|
90
|
+
### Inclusion / Exclusion
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
validates :status, inclusion: { in: %w[draft published archived] }
|
|
94
|
+
validates :role, inclusion: { in: %w[admin user guest], message: "%{value} is not valid" }
|
|
95
|
+
validates :legacy_code, exclusion: { in: %w[RESERVED SYSTEM] }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Performance**: Rails uses `cover?` for numeric/time ranges (fast), `include?` for others.
|
|
99
|
+
|
|
100
|
+
### Confirmation
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
validates :password, confirmation: true
|
|
104
|
+
validates :password_confirmation, presence: true, if: :password_changed? # Required!
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Note**: Confirmation only validates if `_confirmation` field is non-nil. Add explicit presence check.
|
|
108
|
+
|
|
109
|
+
### Acceptance
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
validates :terms_of_service, acceptance: true
|
|
113
|
+
validates :eula, acceptance: { accept: ["yes", "1", true] }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Default accepts `"1"` (from HTML checkbox) and `true`.
|
|
117
|
+
|
|
118
|
+
### Associated
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
validates :author, presence: true # Ensure association exists
|
|
122
|
+
validates_associated :chapters # Validate associated records too
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Warning**: Never use `validates_associated` on both ends of an association - causes infinite recursion.
|
|
126
|
+
|
|
127
|
+
### Comparison
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
validates :end_date, comparison: { greater_than: :start_date }
|
|
131
|
+
validates :password, comparison: { other_than: :username }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Conditional Validations
|
|
135
|
+
|
|
136
|
+
### Using :if and :unless
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
# Symbol (method name) - preferred for readability
|
|
140
|
+
validates :card_number, presence: true, if: :paid_with_card?
|
|
141
|
+
|
|
142
|
+
# Proc/Lambda - for one-liners
|
|
143
|
+
validates :password, confirmation: true, unless: -> { password.blank? }
|
|
144
|
+
|
|
145
|
+
# Multiple conditions (all must be true)
|
|
146
|
+
validates :discount, presence: true,
|
|
147
|
+
if: [:premium_user?, :promotional_period?]
|
|
148
|
+
|
|
149
|
+
# Array with mixed types
|
|
150
|
+
validates :special_field, presence: true,
|
|
151
|
+
if: [:admin?, -> { feature_enabled?(:beta) }]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Grouping with with_options
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
with_options if: :is_admin? do |admin|
|
|
158
|
+
admin.validates :password, length: { minimum: 10 }
|
|
159
|
+
admin.validates :email, format: { with: /@company\.com\z/ }
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Dynamic allow_nil / allow_blank
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
validates :nickname, length: { minimum: 3 },
|
|
167
|
+
allow_blank: -> { signup_step < 3 }
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Custom Validators
|
|
171
|
+
|
|
172
|
+
### Inline Validation Method
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
class Invoice < ApplicationRecord
|
|
176
|
+
validate :total_matches_line_items
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def total_matches_line_items
|
|
181
|
+
calculated = line_items.sum(&:amount)
|
|
182
|
+
return if total == calculated
|
|
183
|
+
|
|
184
|
+
errors.add(:total, "doesn't match line items (expected #{calculated})")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### EachValidator Class (Reusable)
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# app/validators/email_validator.rb
|
|
193
|
+
class EmailValidator < ActiveModel::EachValidator
|
|
194
|
+
def validate_each(record, attribute, value)
|
|
195
|
+
return if value.blank? && options[:allow_blank]
|
|
196
|
+
|
|
197
|
+
unless URI::MailTo::EMAIL_REGEXP.match?(value)
|
|
198
|
+
record.errors.add(attribute, options[:message] || "is not a valid email")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Usage in model
|
|
204
|
+
class User < ApplicationRecord
|
|
205
|
+
validates :email, email: true
|
|
206
|
+
validates :backup_email, email: { allow_blank: true }
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Full Validator Class (Record-Level)
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
# app/validators/date_range_validator.rb
|
|
214
|
+
class DateRangeValidator < ActiveModel::Validator
|
|
215
|
+
def validate(record)
|
|
216
|
+
return unless record.start_date && record.end_date
|
|
217
|
+
|
|
218
|
+
if record.end_date <= record.start_date
|
|
219
|
+
record.errors.add(:end_date, "must be after start date")
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Usage
|
|
225
|
+
class Event < ApplicationRecord
|
|
226
|
+
validates_with DateRangeValidator
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Validation Contexts
|
|
231
|
+
|
|
232
|
+
### Built-in Contexts
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
validates :email, uniqueness: true, on: :create # Only on new records
|
|
236
|
+
validates :reason, presence: true, on: :update # Only on updates
|
|
237
|
+
validates :name, presence: true # Always (no :on option)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Custom Contexts
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
class Article < ApplicationRecord
|
|
244
|
+
validates :title, presence: true
|
|
245
|
+
validates :body, presence: true
|
|
246
|
+
validates :published_at, presence: true, on: :publish
|
|
247
|
+
validates :reviewer_id, presence: true, on: :publish
|
|
248
|
+
|
|
249
|
+
def publish!
|
|
250
|
+
self.published_at = Time.current
|
|
251
|
+
save!(context: :publish)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Usage
|
|
256
|
+
article.valid? # Checks title, body only
|
|
257
|
+
article.valid?(:publish) # Checks title, body, published_at, reviewer_id
|
|
258
|
+
article.publish! # Runs :publish context validations
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Multiple Contexts
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
validates :secret_key, presence: true, on: [:create, :regenerate]
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Context Behavior Matrix
|
|
268
|
+
|
|
269
|
+
| Validation | `.valid?` | `.valid?(:create)` | `.valid?(:update)` | `.valid?(:custom)` |
|
|
270
|
+
|------------|-----------|--------------------|--------------------|---------------------|
|
|
271
|
+
| No `on:` | Runs | Runs | Runs | Runs |
|
|
272
|
+
| `on: :create` | Runs | Runs | Skips | Skips |
|
|
273
|
+
| `on: :update` | Runs | Skips | Runs | Skips |
|
|
274
|
+
| `on: :custom` | Runs | Skips | Skips | Runs |
|
|
275
|
+
|
|
276
|
+
**Note**: Validations without `on:` run in ALL contexts.
|
|
277
|
+
|
|
278
|
+
## Validation vs Database Constraint
|
|
279
|
+
|
|
280
|
+
### Decision Framework
|
|
281
|
+
|
|
282
|
+
Ask two questions:
|
|
283
|
+
|
|
284
|
+
1. **"Am I preventing bad data from being written?"** → Use database constraint
|
|
285
|
+
2. **"Am I preventing user-fixable errors?"** → Use model validation
|
|
286
|
+
|
|
287
|
+
**Best practice**: Use both for critical fields.
|
|
288
|
+
|
|
289
|
+
### Comparison
|
|
290
|
+
|
|
291
|
+
| Aspect | Model Validation | Database Constraint |
|
|
292
|
+
|--------|------------------|---------------------|
|
|
293
|
+
| User-friendly errors | Yes | Cryptic errors |
|
|
294
|
+
| Race condition safe | No | Yes |
|
|
295
|
+
| Can be bypassed | Yes (`update_all`, `insert_all`) | No |
|
|
296
|
+
| Database agnostic | Yes | May vary |
|
|
297
|
+
| Easy to test | Yes | Harder |
|
|
298
|
+
| Data integrity guarantee | Weak | Strong |
|
|
299
|
+
|
|
300
|
+
### Implementation Pattern
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
# Migration - database constraints (data integrity)
|
|
304
|
+
class CreateUsers < ActiveRecord::Migration[7.2]
|
|
305
|
+
def change
|
|
306
|
+
create_table :users do |t|
|
|
307
|
+
t.string :email, null: false
|
|
308
|
+
t.string :username, null: false
|
|
309
|
+
t.integer :age
|
|
310
|
+
t.timestamps
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
add_index :users, :email, unique: true
|
|
314
|
+
add_index :users, :username, unique: true
|
|
315
|
+
add_check_constraint :users, "age >= 18", name: "users_age_check"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Model - validations (user experience)
|
|
320
|
+
class User < ApplicationRecord
|
|
321
|
+
validates :email, presence: true, uniqueness: true,
|
|
322
|
+
format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
323
|
+
validates :username, presence: true, uniqueness: { case_sensitive: false },
|
|
324
|
+
length: { in: 3..20 }
|
|
325
|
+
validates :age, numericality: { greater_than_or_equal_to: 18 }, allow_nil: true
|
|
326
|
+
end
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### When to Use Database Constraints
|
|
330
|
+
|
|
331
|
+
- **Always** for uniqueness (race conditions)
|
|
332
|
+
- **Always** for NOT NULL on critical fields
|
|
333
|
+
- **Always** for foreign keys (referential integrity)
|
|
334
|
+
- When data can be written outside Rails (other apps, raw SQL, imports)
|
|
335
|
+
- When validation bypass methods are used (`update_all`, `insert_all`, etc.)
|
|
336
|
+
|
|
337
|
+
### When Model Validation Is Enough
|
|
338
|
+
|
|
339
|
+
- Format validations (regex patterns)
|
|
340
|
+
- Complex business logic validations
|
|
341
|
+
- Validations that depend on other objects
|
|
342
|
+
- Validations needing user-friendly error messages
|
|
343
|
+
|
|
344
|
+
## Uniqueness Race Conditions
|
|
345
|
+
|
|
346
|
+
### The Problem
|
|
347
|
+
|
|
348
|
+
```
|
|
349
|
+
Request 1: Check DB → "alice@example.com" doesn't exist → Continue
|
|
350
|
+
Request 2: Check DB → "alice@example.com" doesn't exist → Continue
|
|
351
|
+
Request 1: INSERT INTO users (email) VALUES ('alice@example.com') ✓
|
|
352
|
+
Request 2: INSERT INTO users (email) VALUES ('alice@example.com') ✓ DUPLICATE!
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### The Solution
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
# Migration - REQUIRED
|
|
359
|
+
add_index :users, :email, unique: true
|
|
360
|
+
|
|
361
|
+
# Model - for user-friendly errors
|
|
362
|
+
validates :email, uniqueness: true
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Handling the Exception
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
def create
|
|
369
|
+
@user = User.new(user_params)
|
|
370
|
+
@user.save!
|
|
371
|
+
rescue ActiveRecord::RecordNotUnique
|
|
372
|
+
@user.errors.add(:email, "has already been taken")
|
|
373
|
+
render :new, status: :unprocessable_entity
|
|
374
|
+
end
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Alternative: find_or_create_by
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
# Idempotent - won't create duplicates
|
|
381
|
+
user = User.find_or_create_by(email: params[:email]) do |u|
|
|
382
|
+
u.name = params[:name]
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# With race condition handling
|
|
386
|
+
user = User.create_or_find_by(email: params[:email]) do |u|
|
|
387
|
+
u.name = params[:name]
|
|
388
|
+
end
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
## Strict Validations
|
|
392
|
+
|
|
393
|
+
Raise exception instead of adding error:
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
validates :api_key, presence: true, strict: true
|
|
397
|
+
# Raises ActiveModel::StrictValidationFailed
|
|
398
|
+
|
|
399
|
+
validates :token, length: { is: 32 }, strict: TokenLengthException
|
|
400
|
+
# Raises TokenLengthException
|
|
401
|
+
|
|
402
|
+
validates! :internal_id, presence: true # Same as strict: true
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Use for programmer errors (not user input errors).
|
|
406
|
+
|
|
407
|
+
## Anti-Patterns
|
|
408
|
+
|
|
409
|
+
### 1. Uniqueness Without Database Index
|
|
410
|
+
|
|
411
|
+
```ruby
|
|
412
|
+
# WRONG - race conditions possible
|
|
413
|
+
validates :email, uniqueness: true
|
|
414
|
+
|
|
415
|
+
# CORRECT - add unique index in migration
|
|
416
|
+
add_index :users, :email, unique: true
|
|
417
|
+
validates :email, uniqueness: true
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### 2. Presence on Boolean Fields
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
# WRONG - false.blank? == true
|
|
424
|
+
validates :active, presence: true
|
|
425
|
+
|
|
426
|
+
# CORRECT
|
|
427
|
+
validates :active, inclusion: { in: [true, false] }
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### 3. Format With ^ and $
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
# WRONG - vulnerable to multiline injection
|
|
434
|
+
validates :code, format: { with: /^[a-z]+$/ }
|
|
435
|
+
|
|
436
|
+
# CORRECT
|
|
437
|
+
validates :code, format: { with: /\A[a-z]+\z/ }
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### 4. Bidirectional validates_associated
|
|
441
|
+
|
|
442
|
+
```ruby
|
|
443
|
+
# WRONG - infinite loop
|
|
444
|
+
class Author < ApplicationRecord
|
|
445
|
+
has_many :books
|
|
446
|
+
validates_associated :books
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
class Book < ApplicationRecord
|
|
450
|
+
belongs_to :author
|
|
451
|
+
validates_associated :author # Don't do this!
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# CORRECT - validate in one direction only
|
|
455
|
+
class Author < ApplicationRecord
|
|
456
|
+
has_many :books
|
|
457
|
+
validates_associated :books
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
class Book < ApplicationRecord
|
|
461
|
+
belongs_to :author
|
|
462
|
+
validates :author, presence: true
|
|
463
|
+
end
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### 5. Confirmation Without Presence
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
# WRONG - nil confirmation always passes
|
|
470
|
+
validates :password, confirmation: true
|
|
471
|
+
|
|
472
|
+
# CORRECT
|
|
473
|
+
validates :password, confirmation: true
|
|
474
|
+
validates :password_confirmation, presence: true, if: :password_changed?
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### 6. Overusing Conditionals
|
|
478
|
+
|
|
479
|
+
```ruby
|
|
480
|
+
# WRONG - hard to follow
|
|
481
|
+
validates :field1, presence: true, if: :condition_a?
|
|
482
|
+
validates :field1, length: { minimum: 5 }, if: :condition_b?
|
|
483
|
+
validates :field2, presence: true, if: :condition_a?
|
|
484
|
+
validates :field2, format: { with: /.../ }, unless: :condition_c?
|
|
485
|
+
|
|
486
|
+
# BETTER - use validation contexts
|
|
487
|
+
validates :field1, :field2, presence: true, on: :step_one
|
|
488
|
+
validates :field1, length: { minimum: 5 }, on: :step_two
|
|
489
|
+
|
|
490
|
+
# Or use form objects for complex multi-step forms
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### 7. Validating Auto-Generated Fields
|
|
494
|
+
|
|
495
|
+
```ruby
|
|
496
|
+
# WRONG - validates internal implementation
|
|
497
|
+
validates :encrypted_password, presence: true
|
|
498
|
+
validates :uuid, format: { with: UUID_REGEX }
|
|
499
|
+
|
|
500
|
+
# CORRECT - validate user-provided input
|
|
501
|
+
validates :password, presence: true, on: :create
|
|
502
|
+
# Let the model generate uuid internally
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
## Validation Callbacks
|
|
506
|
+
|
|
507
|
+
### before_validation
|
|
508
|
+
|
|
509
|
+
```ruby
|
|
510
|
+
class User < ApplicationRecord
|
|
511
|
+
before_validation :normalize_email
|
|
512
|
+
|
|
513
|
+
private
|
|
514
|
+
|
|
515
|
+
def normalize_email
|
|
516
|
+
self.email = email&.downcase&.strip
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
**Note**: `before_validation` can throw `:abort` to halt, but `validate` methods cannot halt the chain.
|
|
522
|
+
|
|
523
|
+
### Halting Validation
|
|
524
|
+
|
|
525
|
+
```ruby
|
|
526
|
+
before_validation :check_preconditions
|
|
527
|
+
|
|
528
|
+
private
|
|
529
|
+
|
|
530
|
+
def check_preconditions
|
|
531
|
+
throw(:abort) if skip_validation_flag
|
|
532
|
+
end
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
## Error Messages
|
|
536
|
+
|
|
537
|
+
### Customizing Messages
|
|
538
|
+
|
|
539
|
+
```ruby
|
|
540
|
+
validates :name, presence: { message: "is required" }
|
|
541
|
+
validates :age, numericality: { message: "must be a number" }
|
|
542
|
+
validates :size, inclusion: { in: %w[S M L], message: "%{value} is not a valid size" }
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
### Available Interpolations
|
|
546
|
+
|
|
547
|
+
| Variable | Description |
|
|
548
|
+
|----------|-------------|
|
|
549
|
+
| `%{value}` | Current attribute value |
|
|
550
|
+
| `%{attribute}` | Attribute name |
|
|
551
|
+
| `%{model}` | Model name |
|
|
552
|
+
| `%{count}` | Length/size constraint |
|
|
553
|
+
|
|
554
|
+
### errors API
|
|
555
|
+
|
|
556
|
+
```ruby
|
|
557
|
+
user.valid? # Run validations, return boolean
|
|
558
|
+
user.invalid? # Inverse of valid?
|
|
559
|
+
user.errors # ActiveModel::Errors object
|
|
560
|
+
user.errors[:email] # Array of errors for :email
|
|
561
|
+
user.errors.full_messages # ["Email can't be blank", "Email is invalid"]
|
|
562
|
+
user.errors.add(:base, "...") # Add error not tied to attribute
|
|
563
|
+
user.errors.clear # Remove all errors
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
## Performance Tips
|
|
567
|
+
|
|
568
|
+
### Avoid N+1 in Uniqueness
|
|
569
|
+
|
|
570
|
+
```ruby
|
|
571
|
+
# Potentially slow - queries DB on every update
|
|
572
|
+
validates :email, uniqueness: true
|
|
573
|
+
|
|
574
|
+
# Better - only check on create if email can't change
|
|
575
|
+
validates :email, uniqueness: true, on: :create
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
### Use Scoped Uniqueness
|
|
579
|
+
|
|
580
|
+
```ruby
|
|
581
|
+
# Scoped queries are faster with proper indexes
|
|
582
|
+
validates :slug, uniqueness: { scope: :account_id }
|
|
583
|
+
|
|
584
|
+
# Migration
|
|
585
|
+
add_index :articles, [:account_id, :slug], unique: true
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
### Prefer Database for Heavy Validations
|
|
589
|
+
|
|
590
|
+
```ruby
|
|
591
|
+
# Complex validation in Ruby - runs in app
|
|
592
|
+
validate :complex_business_rule
|
|
593
|
+
|
|
594
|
+
# Better for simple checks - use DB constraints
|
|
595
|
+
add_check_constraint :orders, "total >= 0", name: "orders_total_positive"
|
|
596
|
+
```
|