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,432 @@
|
|
|
1
|
+
# ActiveRecord Database Constraints vs Model Validations Examples
|
|
2
|
+
|
|
3
|
+
# =============================================================================
|
|
4
|
+
# THE RACE CONDITION PROBLEM
|
|
5
|
+
# =============================================================================
|
|
6
|
+
|
|
7
|
+
# SCENARIO: Two requests create users with same email simultaneously
|
|
8
|
+
#
|
|
9
|
+
# Request 1: SELECT COUNT(*) FROM users WHERE email = 'alice@example.com' → 0
|
|
10
|
+
# Request 2: SELECT COUNT(*) FROM users WHERE email = 'alice@example.com' → 0
|
|
11
|
+
# Request 1: Validation passes, proceeds to save
|
|
12
|
+
# Request 2: Validation passes, proceeds to save
|
|
13
|
+
# Request 1: INSERT INTO users (email) VALUES ('alice@example.com') → Success!
|
|
14
|
+
# Request 2: INSERT INTO users (email) VALUES ('alice@example.com') → Success! DUPLICATE!
|
|
15
|
+
|
|
16
|
+
# =============================================================================
|
|
17
|
+
# THE SOLUTION: DATABASE CONSTRAINTS + MODEL VALIDATIONS
|
|
18
|
+
# =============================================================================
|
|
19
|
+
|
|
20
|
+
# Migration - Data integrity layer
|
|
21
|
+
class CreateUsers < ActiveRecord::Migration[7.2]
|
|
22
|
+
def change
|
|
23
|
+
create_table :users do |t|
|
|
24
|
+
t.string :email, null: false
|
|
25
|
+
t.string :username, null: false
|
|
26
|
+
t.string :encrypted_password, null: false
|
|
27
|
+
t.integer :age
|
|
28
|
+
t.string :role, null: false, default: "member"
|
|
29
|
+
t.timestamps
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Unique indexes prevent race conditions
|
|
33
|
+
add_index :users, :email, unique: true
|
|
34
|
+
add_index :users, :username, unique: true
|
|
35
|
+
|
|
36
|
+
# Check constraints for domain rules (Rails 6.1+)
|
|
37
|
+
add_check_constraint :users, "age >= 0", name: "users_age_non_negative"
|
|
38
|
+
add_check_constraint :users, "role IN ('admin', 'moderator', 'member')",
|
|
39
|
+
name: "users_role_valid"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Model - User experience layer
|
|
44
|
+
class User < ApplicationRecord
|
|
45
|
+
# Validations provide user-friendly error messages
|
|
46
|
+
validates :email,
|
|
47
|
+
presence: true,
|
|
48
|
+
uniqueness: { case_sensitive: false },
|
|
49
|
+
format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
50
|
+
|
|
51
|
+
validates :username,
|
|
52
|
+
presence: true,
|
|
53
|
+
uniqueness: { case_sensitive: false },
|
|
54
|
+
length: { in: 3..30 },
|
|
55
|
+
format: {
|
|
56
|
+
with: /\A[a-z0-9_]+\z/,
|
|
57
|
+
message: "only allows lowercase letters, numbers, and underscores"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
validates :age,
|
|
61
|
+
numericality: { greater_than_or_equal_to: 0, only_integer: true },
|
|
62
|
+
allow_nil: true
|
|
63
|
+
|
|
64
|
+
validates :role,
|
|
65
|
+
inclusion: { in: %w[admin moderator member] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# =============================================================================
|
|
69
|
+
# HANDLING DATABASE CONSTRAINT VIOLATIONS
|
|
70
|
+
# =============================================================================
|
|
71
|
+
|
|
72
|
+
class UsersController < ApplicationController
|
|
73
|
+
def create
|
|
74
|
+
@user = User.new(user_params)
|
|
75
|
+
|
|
76
|
+
if @user.save
|
|
77
|
+
redirect_to @user, notice: "User created successfully"
|
|
78
|
+
else
|
|
79
|
+
render :new, status: :unprocessable_entity
|
|
80
|
+
end
|
|
81
|
+
rescue ActiveRecord::RecordNotUnique => e
|
|
82
|
+
# Handle race condition - database caught a duplicate
|
|
83
|
+
if e.message.include?("email")
|
|
84
|
+
@user.errors.add(:email, "has already been taken")
|
|
85
|
+
elsif e.message.include?("username")
|
|
86
|
+
@user.errors.add(:username, "has already been taken")
|
|
87
|
+
else
|
|
88
|
+
@user.errors.add(:base, "A duplicate record already exists")
|
|
89
|
+
end
|
|
90
|
+
render :new, status: :unprocessable_entity
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# =============================================================================
|
|
95
|
+
# ALTERNATIVE: RESCUE IN MODEL
|
|
96
|
+
# =============================================================================
|
|
97
|
+
|
|
98
|
+
class User < ApplicationRecord
|
|
99
|
+
# ... validations ...
|
|
100
|
+
|
|
101
|
+
def save_with_race_condition_handling
|
|
102
|
+
save
|
|
103
|
+
rescue ActiveRecord::RecordNotUnique => e
|
|
104
|
+
handle_duplicate_error(e)
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def handle_duplicate_error(exception)
|
|
111
|
+
if exception.message.include?("email")
|
|
112
|
+
errors.add(:email, "has already been taken")
|
|
113
|
+
elsif exception.message.include?("username")
|
|
114
|
+
errors.add(:username, "has already been taken")
|
|
115
|
+
else
|
|
116
|
+
errors.add(:base, "A duplicate record already exists")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# =============================================================================
|
|
122
|
+
# create_or_find_by - IDEMPOTENT OPERATIONS
|
|
123
|
+
# =============================================================================
|
|
124
|
+
|
|
125
|
+
class ApiController < ApplicationController
|
|
126
|
+
# For idempotent API endpoints where duplicates are acceptable
|
|
127
|
+
def find_or_create_user
|
|
128
|
+
# find_or_create_by - tries to find first, then creates
|
|
129
|
+
# Still has race condition potential
|
|
130
|
+
user = User.find_or_create_by(email: params[:email]) do |u|
|
|
131
|
+
u.name = params[:name]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
render json: user
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# create_or_find_by - tries to create first, rescues if exists
|
|
138
|
+
# Better for race conditions, but requires unique index
|
|
139
|
+
def create_or_find_user
|
|
140
|
+
user = User.create_or_find_by(email: params[:email]) do |u|
|
|
141
|
+
u.name = params[:name]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
render json: user
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# =============================================================================
|
|
149
|
+
# COMPOSITE UNIQUE CONSTRAINTS
|
|
150
|
+
# =============================================================================
|
|
151
|
+
|
|
152
|
+
class CreateAppliedCoupons < ActiveRecord::Migration[7.2]
|
|
153
|
+
def change
|
|
154
|
+
create_table :applied_coupons do |t|
|
|
155
|
+
t.references :account, null: false, foreign_key: true
|
|
156
|
+
t.references :coupon, null: false, foreign_key: true
|
|
157
|
+
t.timestamps
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# User can only apply each coupon once per account
|
|
161
|
+
add_index :applied_coupons, [:account_id, :coupon_id], unique: true
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
class AppliedCoupon < ApplicationRecord
|
|
166
|
+
belongs_to :account
|
|
167
|
+
belongs_to :coupon
|
|
168
|
+
|
|
169
|
+
validates :coupon_id, uniqueness: { scope: :account_id }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class CouponsController < ApplicationController
|
|
173
|
+
def apply
|
|
174
|
+
@applied = AppliedCoupon.new(account: current_account, coupon: @coupon)
|
|
175
|
+
|
|
176
|
+
if @applied.save
|
|
177
|
+
render json: { success: true }
|
|
178
|
+
else
|
|
179
|
+
render json: { errors: @applied.errors }, status: :unprocessable_entity
|
|
180
|
+
end
|
|
181
|
+
rescue ActiveRecord::RecordNotUnique
|
|
182
|
+
render json: { error: "Coupon already applied" }, status: :unprocessable_entity
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# =============================================================================
|
|
187
|
+
# FOREIGN KEY CONSTRAINTS
|
|
188
|
+
# =============================================================================
|
|
189
|
+
|
|
190
|
+
class CreateOrders < ActiveRecord::Migration[7.2]
|
|
191
|
+
def change
|
|
192
|
+
create_table :orders do |t|
|
|
193
|
+
t.references :user, null: false, foreign_key: true
|
|
194
|
+
t.references :shipping_address, foreign_key: { to_table: :addresses }
|
|
195
|
+
t.timestamps
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
class Order < ApplicationRecord
|
|
201
|
+
belongs_to :user
|
|
202
|
+
belongs_to :shipping_address, class_name: "Address", optional: true
|
|
203
|
+
|
|
204
|
+
validates :user, presence: true
|
|
205
|
+
|
|
206
|
+
# No need to validate shipping_address existence -
|
|
207
|
+
# foreign key ensures referential integrity
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Handling foreign key violations
|
|
211
|
+
class OrdersController < ApplicationController
|
|
212
|
+
def create
|
|
213
|
+
@order = Order.new(order_params)
|
|
214
|
+
@order.save!
|
|
215
|
+
redirect_to @order
|
|
216
|
+
rescue ActiveRecord::InvalidForeignKey => e
|
|
217
|
+
@order.errors.add(:base, "Referenced record no longer exists")
|
|
218
|
+
render :new, status: :unprocessable_entity
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# =============================================================================
|
|
223
|
+
# CHECK CONSTRAINTS (Rails 6.1+)
|
|
224
|
+
# =============================================================================
|
|
225
|
+
|
|
226
|
+
class CreateProducts < ActiveRecord::Migration[7.2]
|
|
227
|
+
def change
|
|
228
|
+
create_table :products do |t|
|
|
229
|
+
t.string :name, null: false
|
|
230
|
+
t.decimal :price, precision: 10, scale: 2, null: false
|
|
231
|
+
t.integer :quantity, null: false, default: 0
|
|
232
|
+
t.string :status, null: false, default: "draft"
|
|
233
|
+
t.timestamps
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Ensure price is positive
|
|
237
|
+
add_check_constraint :products, "price >= 0", name: "products_price_positive"
|
|
238
|
+
|
|
239
|
+
# Ensure quantity is non-negative
|
|
240
|
+
add_check_constraint :products, "quantity >= 0", name: "products_quantity_non_negative"
|
|
241
|
+
|
|
242
|
+
# Ensure valid status
|
|
243
|
+
add_check_constraint :products, "status IN ('draft', 'active', 'discontinued')",
|
|
244
|
+
name: "products_status_valid"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
class Product < ApplicationRecord
|
|
249
|
+
validates :price, numericality: { greater_than_or_equal_to: 0 }
|
|
250
|
+
validates :quantity, numericality: { greater_than_or_equal_to: 0 }
|
|
251
|
+
validates :status, inclusion: { in: %w[draft active discontinued] }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# =============================================================================
|
|
255
|
+
# DECISION FRAMEWORK
|
|
256
|
+
# =============================================================================
|
|
257
|
+
|
|
258
|
+
# Question 1: Am I preventing bad data from being written?
|
|
259
|
+
# YES → Use database constraint
|
|
260
|
+
#
|
|
261
|
+
# Question 2: Am I preventing user-fixable errors?
|
|
262
|
+
# YES → Use model validation
|
|
263
|
+
|
|
264
|
+
class DecisionExample < ApplicationRecord
|
|
265
|
+
# Email uniqueness:
|
|
266
|
+
# Q1: Yes, duplicate emails are bad data → DB unique index
|
|
267
|
+
# Q2: Yes, user can change email → Model validation
|
|
268
|
+
# RESULT: Use BOTH
|
|
269
|
+
|
|
270
|
+
# Price must be positive:
|
|
271
|
+
# Q1: Yes, negative prices are bad data → DB check constraint
|
|
272
|
+
# Q2: Yes, user can fix price → Model validation
|
|
273
|
+
# RESULT: Use BOTH
|
|
274
|
+
|
|
275
|
+
# Encrypted password format:
|
|
276
|
+
# Q1: Yes, invalid format is bad data → Maybe DB constraint
|
|
277
|
+
# Q2: No, user doesn't control encrypted value → NO model validation
|
|
278
|
+
# RESULT: DB only (if needed), handle as app error
|
|
279
|
+
|
|
280
|
+
# Bio length maximum:
|
|
281
|
+
# Q1: Debatable, depends on system → Maybe DB constraint
|
|
282
|
+
# Q2: Yes, user can shorten bio → Model validation
|
|
283
|
+
# RESULT: Model validation sufficient, DB optional
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# =============================================================================
|
|
287
|
+
# WHAT DATABASE CONSTRAINTS PROTECT AGAINST
|
|
288
|
+
# =============================================================================
|
|
289
|
+
|
|
290
|
+
# Methods that SKIP validations (DB constraints still apply):
|
|
291
|
+
class BypassExamples < ApplicationRecord
|
|
292
|
+
def demonstrate_bypass
|
|
293
|
+
# These all skip validations:
|
|
294
|
+
update_attribute(:email, "invalid") # Skips validations
|
|
295
|
+
update_column(:email, "invalid") # Skips validations AND callbacks
|
|
296
|
+
update_columns(email: "invalid", age: -1) # Skips validations AND callbacks
|
|
297
|
+
User.update_all(email: "invalid") # Bulk update, no validations
|
|
298
|
+
|
|
299
|
+
# Bulk inserts skip everything:
|
|
300
|
+
User.insert_all([{ email: "test@example.com" }])
|
|
301
|
+
User.upsert_all([{ email: "test@example.com" }])
|
|
302
|
+
|
|
303
|
+
# delete vs destroy:
|
|
304
|
+
user.delete # Skips callbacks
|
|
305
|
+
User.delete_all # Bulk delete, no callbacks
|
|
306
|
+
|
|
307
|
+
# Raw SQL:
|
|
308
|
+
ActiveRecord::Base.connection.execute("INSERT INTO users ...")
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Database constraints protect against ALL of these!
|
|
313
|
+
|
|
314
|
+
# =============================================================================
|
|
315
|
+
# NOT NULL CONSTRAINTS
|
|
316
|
+
# =============================================================================
|
|
317
|
+
|
|
318
|
+
class CreateAccounts < ActiveRecord::Migration[7.2]
|
|
319
|
+
def change
|
|
320
|
+
create_table :accounts do |t|
|
|
321
|
+
# Critical fields - null: false in DB
|
|
322
|
+
t.string :name, null: false
|
|
323
|
+
t.string :email, null: false
|
|
324
|
+
|
|
325
|
+
# Optional fields - allow null
|
|
326
|
+
t.string :phone
|
|
327
|
+
t.text :bio
|
|
328
|
+
|
|
329
|
+
t.timestamps
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
class Account < ApplicationRecord
|
|
335
|
+
# Match DB constraints for user-friendly errors
|
|
336
|
+
validates :name, presence: true
|
|
337
|
+
validates :email, presence: true
|
|
338
|
+
|
|
339
|
+
# Optional fields - no presence validation
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# =============================================================================
|
|
343
|
+
# FULL PATTERN: CRITICAL USER MODEL
|
|
344
|
+
# =============================================================================
|
|
345
|
+
|
|
346
|
+
# Migration
|
|
347
|
+
class CreateCriticalUsers < ActiveRecord::Migration[7.2]
|
|
348
|
+
def change
|
|
349
|
+
create_table :critical_users do |t|
|
|
350
|
+
# Required fields
|
|
351
|
+
t.string :email, null: false
|
|
352
|
+
t.string :username, null: false
|
|
353
|
+
t.string :encrypted_password, null: false
|
|
354
|
+
|
|
355
|
+
# Optional but constrained
|
|
356
|
+
t.integer :age
|
|
357
|
+
t.decimal :balance, precision: 10, scale: 2, default: 0
|
|
358
|
+
|
|
359
|
+
# Status with valid values
|
|
360
|
+
t.string :status, null: false, default: "pending"
|
|
361
|
+
|
|
362
|
+
t.timestamps
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Unique constraints
|
|
366
|
+
add_index :critical_users, :email, unique: true
|
|
367
|
+
add_index :critical_users, :username, unique: true
|
|
368
|
+
|
|
369
|
+
# Check constraints
|
|
370
|
+
add_check_constraint :critical_users, "age IS NULL OR age >= 0",
|
|
371
|
+
name: "critical_users_age_valid"
|
|
372
|
+
add_check_constraint :critical_users, "balance >= 0",
|
|
373
|
+
name: "critical_users_balance_non_negative"
|
|
374
|
+
add_check_constraint :critical_users,
|
|
375
|
+
"status IN ('pending', 'active', 'suspended', 'deleted')",
|
|
376
|
+
name: "critical_users_status_valid"
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Model
|
|
381
|
+
class CriticalUser < ApplicationRecord
|
|
382
|
+
# User-facing validations with friendly messages
|
|
383
|
+
validates :email,
|
|
384
|
+
presence: true,
|
|
385
|
+
uniqueness: { case_sensitive: false, message: "is already registered" },
|
|
386
|
+
format: { with: URI::MailTo::EMAIL_REGEXP, message: "must be a valid email" }
|
|
387
|
+
|
|
388
|
+
validates :username,
|
|
389
|
+
presence: true,
|
|
390
|
+
uniqueness: { case_sensitive: false },
|
|
391
|
+
length: { in: 3..30, message: "must be between 3 and 30 characters" },
|
|
392
|
+
format: {
|
|
393
|
+
with: /\A[a-z0-9_]+\z/,
|
|
394
|
+
message: "can only contain lowercase letters, numbers, and underscores"
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
validates :age,
|
|
398
|
+
numericality: {
|
|
399
|
+
greater_than_or_equal_to: 0,
|
|
400
|
+
only_integer: true,
|
|
401
|
+
message: "must be a positive number"
|
|
402
|
+
},
|
|
403
|
+
allow_nil: true
|
|
404
|
+
|
|
405
|
+
validates :balance,
|
|
406
|
+
numericality: {
|
|
407
|
+
greater_than_or_equal_to: 0,
|
|
408
|
+
message: "cannot be negative"
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
validates :status,
|
|
412
|
+
inclusion: {
|
|
413
|
+
in: %w[pending active suspended deleted],
|
|
414
|
+
message: "is not a valid status"
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
# Internal validation (not user-controlled)
|
|
418
|
+
validates :encrypted_password, presence: true
|
|
419
|
+
|
|
420
|
+
# Handle race conditions gracefully
|
|
421
|
+
def self.create_safely(attributes)
|
|
422
|
+
create!(attributes)
|
|
423
|
+
rescue ActiveRecord::RecordNotUnique => e
|
|
424
|
+
user = new(attributes)
|
|
425
|
+
if e.message.include?("email")
|
|
426
|
+
user.errors.add(:email, "is already registered")
|
|
427
|
+
elsif e.message.include?("username")
|
|
428
|
+
user.errors.add(:username, "is already taken")
|
|
429
|
+
end
|
|
430
|
+
user
|
|
431
|
+
end
|
|
432
|
+
end
|