source_monitor 0.2.0 → 0.3.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/.claude/agents/rails-concern.md +464 -0
- data/.claude/agents/rails-controller.md +424 -0
- data/.claude/agents/rails-hotwire.md +446 -0
- data/.claude/agents/rails-implement.md +374 -0
- data/.claude/agents/rails-job.md +334 -0
- data/.claude/agents/rails-lint.md +294 -0
- data/.claude/agents/rails-mailer.md +371 -0
- data/.claude/agents/rails-migration.md +449 -0
- data/.claude/agents/rails-model.md +420 -0
- data/.claude/agents/rails-policy.md +443 -0
- data/.claude/agents/rails-presenter.md +427 -0
- data/.claude/agents/rails-query.md +412 -0
- data/.claude/agents/rails-review.md +490 -0
- data/.claude/agents/rails-service.md +458 -0
- data/.claude/agents/rails-state-records.md +465 -0
- data/.claude/agents/rails-tdd.md +314 -0
- data/.claude/agents/rails-test.md +441 -0
- data/.claude/agents/rails-view-component.md +418 -0
- data/.claude/hooks/block-secrets.sh +52 -0
- data/.claude/settings.json +85 -0
- data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
- data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
- data/.claude/skills/active-storage-setup/SKILL.md +311 -0
- data/.claude/skills/api-versioning/SKILL.md +294 -0
- data/.claude/skills/authentication-flow/SKILL.md +335 -0
- data/.claude/skills/authentication-flow/reference/current.md +248 -0
- data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
- data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
- data/.claude/skills/authorization-pundit/SKILL.md +462 -0
- data/.claude/skills/caching-strategies/SKILL.md +350 -0
- data/.claude/skills/database-migrations/SKILL.md +354 -0
- data/.claude/skills/form-object-patterns/SKILL.md +399 -0
- data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
- data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
- data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
- data/.claude/skills/i18n-patterns/SKILL.md +320 -0
- data/.claude/skills/install/SKILL.md +367 -0
- data/.claude/skills/performance-optimization/SKILL.md +311 -0
- data/.claude/skills/rails-architecture/SKILL.md +259 -0
- data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
- data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
- data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
- data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
- data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
- data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
- data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
- data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
- data/.claude/skills/rails-concern/SKILL.md +399 -0
- data/.claude/skills/rails-controller/SKILL.md +336 -0
- data/.claude/skills/rails-model-generator/SKILL.md +321 -0
- data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
- data/.claude/skills/rails-presenter/SKILL.md +274 -0
- data/.claude/skills/rails-query-object/SKILL.md +289 -0
- data/.claude/skills/rails-service-object/SKILL.md +349 -0
- data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
- data/.claude/skills/tdd-cycle/SKILL.md +359 -0
- data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +2 -0
- data/.ruby-version +1 -1
- data/.vbw-planning/.notification-log.jsonl +192 -0
- data/.vbw-planning/.session-log.jsonl +871 -0
- data/.vbw-planning/PROJECT.md +51 -0
- data/.vbw-planning/REQUIREMENTS.md +50 -0
- data/.vbw-planning/SHIPPED.md +28 -0
- data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
- data/.vbw-planning/codebase/CONCERNS.md +99 -0
- data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
- data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
- data/.vbw-planning/codebase/INDEX.md +86 -0
- data/.vbw-planning/codebase/META.md +42 -0
- data/.vbw-planning/codebase/PATTERNS.md +262 -0
- data/.vbw-planning/codebase/STACK.md +101 -0
- data/.vbw-planning/codebase/STRUCTURE.md +324 -0
- data/.vbw-planning/codebase/TESTING.md +154 -0
- data/.vbw-planning/config.json +12 -0
- data/.vbw-planning/discovery.json +24 -0
- data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
- data/.vbw-planning/milestones/default/STATE.md +83 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +179 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +114 -101
- data/Rakefile +2 -0
- data/app/assets/builds/source_monitor/application.css +2076 -0
- data/app/assets/builds/source_monitor/application.js +2758 -0
- data/app/assets/builds/source_monitor/application.js.map +7 -0
- data/app/controllers/source_monitor/application_controller.rb +2 -0
- data/app/controllers/source_monitor/health_controller.rb +2 -0
- data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
- data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
- data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
- data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
- data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
- data/app/controllers/source_monitor/items_controller.rb +2 -0
- data/app/controllers/source_monitor/sources_controller.rb +0 -14
- data/app/helpers/source_monitor/application_helper.rb +4 -112
- data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
- data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
- data/app/jobs/source_monitor/application_job.rb +2 -0
- data/app/models/source_monitor/application_record.rb +2 -0
- data/app/models/source_monitor/log_entry.rb +0 -2
- data/config/coverage_baseline.json +217 -1862
- data/config/routes.rb +2 -0
- data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
- data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
- data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
- data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
- data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
- data/lib/source_monitor/assets/bundler.rb +2 -0
- data/lib/source_monitor/assets.rb +2 -0
- data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
- data/lib/source_monitor/configuration/events.rb +60 -0
- data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
- data/lib/source_monitor/configuration/health_settings.rb +27 -0
- data/lib/source_monitor/configuration/http_settings.rb +43 -0
- data/lib/source_monitor/configuration/model_definition.rb +108 -0
- data/lib/source_monitor/configuration/models.rb +36 -0
- data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
- data/lib/source_monitor/configuration/retention_settings.rb +45 -0
- data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
- data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
- data/lib/source_monitor/configuration/validation_definition.rb +32 -0
- data/lib/source_monitor/configuration.rb +12 -579
- data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
- data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
- data/lib/source_monitor/dashboard/queries.rb +2 -195
- data/lib/source_monitor/engine.rb +2 -0
- data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
- data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
- data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
- data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
- data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
- data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
- data/lib/source_monitor/items/item_creator.rb +28 -455
- data/lib/source_monitor/setup/bundle_installer.rb +2 -0
- data/lib/source_monitor/setup/cli.rb +2 -0
- data/lib/source_monitor/setup/dependency_checker.rb +2 -0
- data/lib/source_monitor/setup/detectors.rb +2 -0
- data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
- data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
- data/lib/source_monitor/setup/install_generator.rb +2 -0
- data/lib/source_monitor/setup/migration_installer.rb +2 -0
- data/lib/source_monitor/setup/node_installer.rb +2 -0
- data/lib/source_monitor/setup/prompter.rb +2 -0
- data/lib/source_monitor/setup/requirements.rb +2 -0
- data/lib/source_monitor/setup/shell_runner.rb +2 -0
- data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/printer.rb +2 -0
- data/lib/source_monitor/setup/verification/result.rb +2 -0
- data/lib/source_monitor/setup/verification/runner.rb +2 -0
- data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
- data/lib/source_monitor/setup/workflow.rb +2 -0
- data/lib/source_monitor/version.rb +3 -1
- data/lib/source_monitor.rb +140 -58
- data/lib/tasks/source_monitor_assets.rake +2 -0
- data/lib/tasks/source_monitor_setup.rake +2 -0
- data/lib/tasks/source_monitor_tasks.rake +2 -0
- data/source_monitor.gemspec +3 -1
- metadata +144 -4
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-model-generator
|
|
3
|
+
description: Creates Rails models using TDD approach - test first, then migration, then model. Use when creating new models, adding model validations, defining associations, or setting up database tables.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Rails Model Generator (TDD Approach)
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
This skill creates models the TDD way:
|
|
12
|
+
1. Define requirements (attributes, validations, associations)
|
|
13
|
+
2. Write model test with expected behavior (RED)
|
|
14
|
+
3. Create fixtures for test data
|
|
15
|
+
4. Generate migration
|
|
16
|
+
5. Implement model to pass tests (GREEN)
|
|
17
|
+
6. Refactor if needed
|
|
18
|
+
|
|
19
|
+
## Workflow Checklist
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
Model Creation Progress:
|
|
23
|
+
- [ ] Step 1: Define requirements (attributes, validations, associations)
|
|
24
|
+
- [ ] Step 2: Create model test (RED)
|
|
25
|
+
- [ ] Step 3: Create fixtures
|
|
26
|
+
- [ ] Step 4: Run test (should fail - no model/table)
|
|
27
|
+
- [ ] Step 5: Generate migration
|
|
28
|
+
- [ ] Step 6: Run migration
|
|
29
|
+
- [ ] Step 7: Create model file (empty)
|
|
30
|
+
- [ ] Step 8: Run test (should fail - no validations)
|
|
31
|
+
- [ ] Step 9: Add validations and associations
|
|
32
|
+
- [ ] Step 10: Run test (GREEN)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Step 1: Requirements Template
|
|
36
|
+
|
|
37
|
+
Before writing code, define the model:
|
|
38
|
+
|
|
39
|
+
```markdown
|
|
40
|
+
## Model: [ModelName]
|
|
41
|
+
|
|
42
|
+
### Table: [table_name]
|
|
43
|
+
|
|
44
|
+
### Attributes
|
|
45
|
+
| Name | Type | Constraints | Default |
|
|
46
|
+
|------|------|-------------|---------|
|
|
47
|
+
| name | string | required, unique | - |
|
|
48
|
+
| email | string | required, unique, email format | - |
|
|
49
|
+
| status | integer | enum | 0 (pending) |
|
|
50
|
+
| organization_id | bigint | foreign key | - |
|
|
51
|
+
|
|
52
|
+
### Associations
|
|
53
|
+
- belongs_to :organization
|
|
54
|
+
- has_many :posts, dependent: :destroy
|
|
55
|
+
- has_one :profile, dependent: :destroy
|
|
56
|
+
|
|
57
|
+
### Validations
|
|
58
|
+
- name: presence, uniqueness, length(max: 100)
|
|
59
|
+
- email: presence, uniqueness, format(email)
|
|
60
|
+
- status: inclusion in enum values
|
|
61
|
+
|
|
62
|
+
### Scopes
|
|
63
|
+
- active: status = active
|
|
64
|
+
- recent: ordered by created_at desc
|
|
65
|
+
- by_organization(org): where organization_id = org.id
|
|
66
|
+
|
|
67
|
+
### Instance Methods
|
|
68
|
+
- full_name: combines first_name and last_name
|
|
69
|
+
- active?: checks if status is active
|
|
70
|
+
|
|
71
|
+
### Callbacks
|
|
72
|
+
- before_save :normalize_email
|
|
73
|
+
- after_create :send_welcome_email
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Step 2: Create Model Test
|
|
77
|
+
|
|
78
|
+
Location: `test/models/[model_name]_test.rb`
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
# frozen_string_literal: true
|
|
82
|
+
|
|
83
|
+
require "test_helper"
|
|
84
|
+
|
|
85
|
+
class ModelNameTest < ActiveSupport::TestCase
|
|
86
|
+
# === Associations ===
|
|
87
|
+
test "belongs to organization" do
|
|
88
|
+
model = model_names(:one)
|
|
89
|
+
assert_respond_to model, :organization
|
|
90
|
+
assert_instance_of Organization, model.organization
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
test "has many posts" do
|
|
94
|
+
model = model_names(:one)
|
|
95
|
+
assert_respond_to model, :posts
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# === Validations ===
|
|
99
|
+
test "requires name" do
|
|
100
|
+
model = ModelName.new(name: nil)
|
|
101
|
+
assert_not model.valid?
|
|
102
|
+
assert_includes model.errors[:name], "can't be blank"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
test "requires unique email (case insensitive)" do
|
|
106
|
+
existing = model_names(:one)
|
|
107
|
+
model = ModelName.new(email: existing.email.upcase)
|
|
108
|
+
assert_not model.valid?
|
|
109
|
+
assert_includes model.errors[:email], "has already been taken"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
test "validates name length max 100" do
|
|
113
|
+
model = ModelName.new(name: "a" * 101)
|
|
114
|
+
assert_not model.valid?
|
|
115
|
+
assert model.errors[:name].any? { |e| e.include?("too long") }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# === Scopes ===
|
|
119
|
+
test ".active returns only active records" do
|
|
120
|
+
active_record = model_names(:active_one)
|
|
121
|
+
inactive_record = model_names(:inactive_one)
|
|
122
|
+
|
|
123
|
+
results = ModelName.active
|
|
124
|
+
assert_includes results, active_record
|
|
125
|
+
assert_not_includes results, inactive_record
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# === Instance Methods ===
|
|
129
|
+
test "#full_name returns combined name" do
|
|
130
|
+
model = ModelName.new(first_name: "John", last_name: "Doe")
|
|
131
|
+
assert_equal "John Doe", model.full_name
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Step 3: Create Fixtures
|
|
137
|
+
|
|
138
|
+
Location: `test/fixtures/[model_name_plural].yml`
|
|
139
|
+
|
|
140
|
+
```yaml
|
|
141
|
+
# test/fixtures/model_names.yml
|
|
142
|
+
one:
|
|
143
|
+
name: "Test Model One"
|
|
144
|
+
email: "model-one@example.com"
|
|
145
|
+
status: 0
|
|
146
|
+
organization: one
|
|
147
|
+
|
|
148
|
+
two:
|
|
149
|
+
name: "Test Model Two"
|
|
150
|
+
email: "model-two@example.com"
|
|
151
|
+
status: 0
|
|
152
|
+
organization: one
|
|
153
|
+
|
|
154
|
+
active_one:
|
|
155
|
+
name: "Active Model"
|
|
156
|
+
email: "active@example.com"
|
|
157
|
+
status: 1
|
|
158
|
+
organization: one
|
|
159
|
+
|
|
160
|
+
inactive_one:
|
|
161
|
+
name: "Inactive Model"
|
|
162
|
+
email: "inactive@example.com"
|
|
163
|
+
status: 2
|
|
164
|
+
organization: one
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Step 4: Run Test (Verify RED)
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
bin/rails test test/models/model_name_test.rb
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Expected: Failure because model/table doesn't exist.
|
|
174
|
+
|
|
175
|
+
## Step 5: Generate Migration
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
bin/rails generate migration CreateModelNames \
|
|
179
|
+
name:string \
|
|
180
|
+
email:string:uniq \
|
|
181
|
+
status:integer \
|
|
182
|
+
organization:references
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Review the generated migration and add:
|
|
186
|
+
- Null constraints: `null: false`
|
|
187
|
+
- Defaults: `default: 0`
|
|
188
|
+
- Indexes: `add_index :table, :column`
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
# db/migrate/YYYYMMDDHHMMSS_create_model_names.rb
|
|
192
|
+
class CreateModelNames < ActiveRecord::Migration[8.0]
|
|
193
|
+
def change
|
|
194
|
+
create_table :model_names do |t|
|
|
195
|
+
t.string :name, null: false
|
|
196
|
+
t.string :email, null: false
|
|
197
|
+
t.integer :status, null: false, default: 0
|
|
198
|
+
t.references :organization, null: false, foreign_key: true
|
|
199
|
+
|
|
200
|
+
t.timestamps
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
add_index :model_names, :email, unique: true
|
|
204
|
+
add_index :model_names, :status
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Step 6: Run Migration
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
bin/rails db:migrate
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Verify with:
|
|
216
|
+
```bash
|
|
217
|
+
bin/rails db:migrate:status
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Step 7: Create Model File
|
|
221
|
+
|
|
222
|
+
Location: `app/models/[model_name].rb`
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
# frozen_string_literal: true
|
|
226
|
+
|
|
227
|
+
class ModelName < ApplicationRecord
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Step 8: Run Test (Still RED)
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
bin/rails test test/models/model_name_test.rb
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Expected: Failures for missing validations/associations.
|
|
238
|
+
|
|
239
|
+
## Step 9: Add Validations & Associations
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
# frozen_string_literal: true
|
|
243
|
+
|
|
244
|
+
class ModelName < ApplicationRecord
|
|
245
|
+
# === Associations ===
|
|
246
|
+
belongs_to :organization
|
|
247
|
+
has_many :posts, dependent: :destroy
|
|
248
|
+
|
|
249
|
+
# === Enums ===
|
|
250
|
+
enum :status, { pending: 0, active: 1, suspended: 2 }
|
|
251
|
+
|
|
252
|
+
# === Validations ===
|
|
253
|
+
validates :name, presence: true,
|
|
254
|
+
uniqueness: true,
|
|
255
|
+
length: { maximum: 100 }
|
|
256
|
+
validates :email, presence: true,
|
|
257
|
+
uniqueness: { case_sensitive: false },
|
|
258
|
+
format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
259
|
+
|
|
260
|
+
# === Scopes ===
|
|
261
|
+
scope :active, -> { where(status: :active) }
|
|
262
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
263
|
+
|
|
264
|
+
# === Instance Methods ===
|
|
265
|
+
def full_name
|
|
266
|
+
"#{first_name} #{last_name}".strip
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Step 10: Run Test (GREEN)
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
bin/rails test test/models/model_name_test.rb
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
All tests should pass.
|
|
278
|
+
|
|
279
|
+
## References
|
|
280
|
+
|
|
281
|
+
- See [reference/validations.md](reference/validations.md) for validation patterns
|
|
282
|
+
|
|
283
|
+
## Common Patterns
|
|
284
|
+
|
|
285
|
+
### Enum with Validation
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
enum :status, { draft: 0, published: 1, archived: 2 }
|
|
289
|
+
validates :status, inclusion: { in: statuses.keys }
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Polymorphic Association
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
belongs_to :commentable, polymorphic: true
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### Counter Cache
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
belongs_to :organization, counter_cache: true
|
|
302
|
+
# Add: organization.posts_count column
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Soft Delete
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
scope :active, -> { where(deleted_at: nil) }
|
|
309
|
+
scope :deleted, -> { where.not(deleted_at: nil) }
|
|
310
|
+
|
|
311
|
+
def soft_delete
|
|
312
|
+
update(deleted_at: Time.current)
|
|
313
|
+
end
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Normalizes (Rails 7.1+)
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
normalizes :email, with: -> { _1.strip.downcase }
|
|
320
|
+
normalizes :phone, with: -> { _1.gsub(/\D/, "") }
|
|
321
|
+
```
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# Rails Validation Patterns Reference
|
|
2
|
+
|
|
3
|
+
## Standard Validations
|
|
4
|
+
|
|
5
|
+
### Presence
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
validates :name, presence: true
|
|
9
|
+
validates :email, presence: { message: "is required" }
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
**Test:**
|
|
13
|
+
```ruby
|
|
14
|
+
test "requires name" do
|
|
15
|
+
record = Model.new(valid_attributes.except(:name))
|
|
16
|
+
assert_not record.valid?
|
|
17
|
+
assert record.errors[:name].any?
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Uniqueness
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
validates :email, uniqueness: true
|
|
25
|
+
validates :email, uniqueness: { case_sensitive: false }
|
|
26
|
+
validates :slug, uniqueness: { scope: :organization_id }
|
|
27
|
+
validates :email, uniqueness: { conditions: -> { where(deleted_at: nil) } }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Test:**
|
|
31
|
+
```ruby
|
|
32
|
+
test "requires unique email" do
|
|
33
|
+
existing = users(:one)
|
|
34
|
+
record = User.new(email: existing.email, password: "password123", account: accounts(:one))
|
|
35
|
+
assert_not record.valid?
|
|
36
|
+
assert record.errors[:email].any?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
test "requires unique slug scoped to organization" do
|
|
40
|
+
existing = records(:one)
|
|
41
|
+
record = Record.new(slug: existing.slug, organization: existing.organization)
|
|
42
|
+
assert_not record.valid?
|
|
43
|
+
assert record.errors[:slug].any?
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Length
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
validates :name, length: { maximum: 100 }
|
|
51
|
+
validates :bio, length: { minimum: 10, maximum: 500 }
|
|
52
|
+
validates :pin, length: { is: 4 }
|
|
53
|
+
validates :tags, length: { in: 1..5 }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Test:**
|
|
57
|
+
```ruby
|
|
58
|
+
test "rejects name longer than 100 characters" do
|
|
59
|
+
record = Model.new(valid_attributes.merge(name: "a" * 101))
|
|
60
|
+
assert_not record.valid?
|
|
61
|
+
assert record.errors[:name].any?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
test "accepts name within 100 characters" do
|
|
65
|
+
record = Model.new(valid_attributes.merge(name: "a" * 100))
|
|
66
|
+
assert record.valid?
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Format
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
74
|
+
validates :phone, format: { with: /\A\+?[\d\s-]+\z/ }
|
|
75
|
+
validates :slug, format: { with: /\A[a-z0-9-]+\z/, message: "only allows lowercase letters, numbers, and hyphens" }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Test:**
|
|
79
|
+
```ruby
|
|
80
|
+
test "accepts valid email format" do
|
|
81
|
+
record = Model.new(valid_attributes.merge(email: "test@example.com"))
|
|
82
|
+
assert record.valid?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
test "rejects invalid email format" do
|
|
86
|
+
record = Model.new(valid_attributes.merge(email: "invalid-email"))
|
|
87
|
+
assert_not record.valid?
|
|
88
|
+
assert record.errors[:email].any?
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Numericality
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
validates :age, numericality: { only_integer: true, greater_than: 0 }
|
|
96
|
+
validates :price, numericality: { greater_than_or_equal_to: 0 }
|
|
97
|
+
validates :quantity, numericality: { only_integer: true, in: 1..100 }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Test:**
|
|
101
|
+
```ruby
|
|
102
|
+
test "requires positive integer for age" do
|
|
103
|
+
record = Model.new(valid_attributes.merge(age: -1))
|
|
104
|
+
assert_not record.valid?
|
|
105
|
+
assert record.errors[:age].any?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
test "rejects non-integer age" do
|
|
109
|
+
record = Model.new(valid_attributes.merge(age: 1.5))
|
|
110
|
+
assert_not record.valid?
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Inclusion/Exclusion
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
validates :status, inclusion: { in: %w[draft published archived] }
|
|
118
|
+
validates :role, inclusion: { in: :allowed_roles }
|
|
119
|
+
validates :username, exclusion: { in: %w[admin root system] }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Test:**
|
|
123
|
+
```ruby
|
|
124
|
+
test "accepts valid status values" do
|
|
125
|
+
%w[draft published archived].each do |status|
|
|
126
|
+
record = Model.new(valid_attributes.merge(status: status))
|
|
127
|
+
assert record.valid?, "Expected #{status} to be valid"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
test "rejects invalid status values" do
|
|
132
|
+
record = Model.new(valid_attributes.merge(status: "invalid"))
|
|
133
|
+
assert_not record.valid?
|
|
134
|
+
assert record.errors[:status].any?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
test "rejects reserved usernames" do
|
|
138
|
+
%w[admin root system].each do |username|
|
|
139
|
+
record = Model.new(valid_attributes.merge(username: username))
|
|
140
|
+
assert_not record.valid?, "Expected #{username} to be invalid"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Acceptance
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
validates :terms, acceptance: true
|
|
149
|
+
validates :terms, acceptance: { accept: ['yes', 'true', '1'] }
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Confirmation
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
validates :password, confirmation: true
|
|
156
|
+
# Requires :password_confirmation attribute in form
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Conditional Validations
|
|
160
|
+
|
|
161
|
+
### With If/Unless
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
validates :phone, presence: true, if: :requires_phone?
|
|
165
|
+
validates :company, presence: true, unless: :individual?
|
|
166
|
+
validates :bio, length: { minimum: 50 }, if: -> { featured? }
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Test:**
|
|
170
|
+
```ruby
|
|
171
|
+
test "requires phone when requires_phone? is true" do
|
|
172
|
+
record = Model.new(valid_attributes.except(:phone))
|
|
173
|
+
record.stub(:requires_phone?, true) do
|
|
174
|
+
assert_not record.valid?
|
|
175
|
+
assert record.errors[:phone].any?
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
test "does not require phone when requires_phone? is false" do
|
|
180
|
+
record = Model.new(valid_attributes.except(:phone))
|
|
181
|
+
record.stub(:requires_phone?, false) do
|
|
182
|
+
assert record.valid?
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### With On (Context)
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
validates :password, presence: true, on: :create
|
|
191
|
+
validates :reason, presence: true, on: :archive
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Test:**
|
|
195
|
+
```ruby
|
|
196
|
+
test "requires password on create" do
|
|
197
|
+
record = Model.new(valid_attributes.except(:password))
|
|
198
|
+
assert_not record.valid?
|
|
199
|
+
assert record.errors[:password].any?
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Custom Validations
|
|
204
|
+
|
|
205
|
+
### Custom Method
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
class User < ApplicationRecord
|
|
209
|
+
validate :email_domain_allowed
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
def email_domain_allowed
|
|
214
|
+
return if email.blank?
|
|
215
|
+
|
|
216
|
+
domain = email.split('@').last
|
|
217
|
+
unless allowed_domains.include?(domain)
|
|
218
|
+
errors.add(:email, "domain is not allowed")
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Test:**
|
|
225
|
+
```ruby
|
|
226
|
+
test "accepts allowed email domain" do
|
|
227
|
+
user = User.new(valid_attributes.merge(email: "test@allowed.com"))
|
|
228
|
+
assert user.valid?
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
test "rejects disallowed email domain" do
|
|
232
|
+
user = User.new(valid_attributes.merge(email: "test@blocked.com"))
|
|
233
|
+
assert_not user.valid?
|
|
234
|
+
assert_includes user.errors[:email], "domain is not allowed"
|
|
235
|
+
end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Custom Validator Class
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
# app/validators/email_domain_validator.rb
|
|
242
|
+
class EmailDomainValidator < ActiveModel::EachValidator
|
|
243
|
+
def validate_each(record, attribute, value)
|
|
244
|
+
return if value.blank?
|
|
245
|
+
|
|
246
|
+
domain = value.split('@').last
|
|
247
|
+
unless options[:allowed].include?(domain)
|
|
248
|
+
record.errors.add(attribute, options[:message] || "domain not allowed")
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Usage in model:
|
|
254
|
+
validates :email, email_domain: { allowed: %w[company.com], message: "must be company email" }
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Association Validations
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
validates :organization, presence: true
|
|
261
|
+
validates_associated :profile # Validates the associated record too
|
|
262
|
+
|
|
263
|
+
# With nested attributes
|
|
264
|
+
accepts_nested_attributes_for :addresses, allow_destroy: true
|
|
265
|
+
validates :addresses, length: { minimum: 1, message: "must have at least one address" }
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Database-Level Constraints
|
|
269
|
+
|
|
270
|
+
Always pair validations with database constraints:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
# Migration
|
|
274
|
+
add_column :users, :email, :string, null: false
|
|
275
|
+
add_index :users, :email, unique: true
|
|
276
|
+
add_check_constraint :users, 'age >= 0', name: 'age_non_negative'
|
|
277
|
+
|
|
278
|
+
# Model
|
|
279
|
+
validates :email, presence: true, uniqueness: true
|
|
280
|
+
validates :age, numericality: { greater_than_or_equal_to: 0 }
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Common Email Regex Patterns
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
# Simple (recommended for most cases)
|
|
287
|
+
URI::MailTo::EMAIL_REGEXP
|
|
288
|
+
|
|
289
|
+
# More permissive
|
|
290
|
+
/\A[^@\s]+@[^@\s]+\z/
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Performance Tips
|
|
294
|
+
|
|
295
|
+
1. **Order validations by cost**: Put cheap validations first
|
|
296
|
+
2. **Use `on:` to skip validations**: Don't validate password on every save
|
|
297
|
+
3. **Avoid N+1 in custom validations**: Cache lookups
|
|
298
|
+
4. **Use database constraints**: They're faster than Rails validations
|