claude-agent-framework 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.
Files changed (111) hide show
  1. package/README.md +128 -0
  2. package/bin/claude-framework +3 -0
  3. package/framework/agents/design-lead.md +240 -0
  4. package/framework/agents/product-owner.md +179 -0
  5. package/framework/agents/tech-lead.md +226 -0
  6. package/framework/commands/ayuda.md +127 -0
  7. package/framework/commands/a/303/261adir.md +98 -0
  8. package/framework/commands/backup.md +397 -0
  9. package/framework/commands/cambiar.md +110 -0
  10. package/framework/commands/cloud.md +457 -0
  11. package/framework/commands/code.md +142 -0
  12. package/framework/commands/debug.md +334 -0
  13. package/framework/commands/deploy.md +383 -0
  14. package/framework/commands/deshacer.md +120 -0
  15. package/framework/commands/estado.md +218 -0
  16. package/framework/commands/explica.md +227 -0
  17. package/framework/commands/feature.md +120 -0
  18. package/framework/commands/git.md +427 -0
  19. package/framework/commands/historial.md +202 -0
  20. package/framework/commands/learn.md +408 -0
  21. package/framework/commands/movil.md +245 -0
  22. package/framework/commands/nuevo.md +118 -0
  23. package/framework/commands/plan.md +134 -0
  24. package/framework/commands/prd.md +113 -0
  25. package/framework/commands/probar.md +148 -0
  26. package/framework/commands/revisar.md +208 -0
  27. package/framework/commands/seeds.md +230 -0
  28. package/framework/commands/seguridad.md +226 -0
  29. package/framework/commands/tasks.md +157 -0
  30. package/framework/skills/architecture/algorithms.md +970 -0
  31. package/framework/skills/architecture/clean-code.md +1080 -0
  32. package/framework/skills/architecture/design-patterns.md +1984 -0
  33. package/framework/skills/architecture/functional-programming.md +972 -0
  34. package/framework/skills/architecture/solid.md +991 -0
  35. package/framework/skills/cloud/cloud-aws.md +848 -0
  36. package/framework/skills/cloud/cloud-azure.md +931 -0
  37. package/framework/skills/cloud/cloud-gcp.md +848 -0
  38. package/framework/skills/cloud/message-queues.md +1229 -0
  39. package/framework/skills/core/accessibility.md +401 -0
  40. package/framework/skills/core/api.md +474 -0
  41. package/framework/skills/core/authentication.md +306 -0
  42. package/framework/skills/core/authorization.md +388 -0
  43. package/framework/skills/core/background-jobs.md +341 -0
  44. package/framework/skills/core/caching.md +473 -0
  45. package/framework/skills/core/code-review.md +341 -0
  46. package/framework/skills/core/controllers.md +290 -0
  47. package/framework/skills/core/cua.md +285 -0
  48. package/framework/skills/core/documentation.md +472 -0
  49. package/framework/skills/core/file-uploads.md +351 -0
  50. package/framework/skills/core/hotwire-native.md +296 -0
  51. package/framework/skills/core/hotwire.md +278 -0
  52. package/framework/skills/core/i18n.md +334 -0
  53. package/framework/skills/core/imports-exports.md +750 -0
  54. package/framework/skills/core/infrastructure.md +337 -0
  55. package/framework/skills/core/models.md +228 -0
  56. package/framework/skills/core/notifications.md +672 -0
  57. package/framework/skills/core/payments.md +581 -0
  58. package/framework/skills/core/performance.md +361 -0
  59. package/framework/skills/core/rails-scaffold.md +131 -0
  60. package/framework/skills/core/search.md +518 -0
  61. package/framework/skills/core/security.md +565 -0
  62. package/framework/skills/core/seeds.md +307 -0
  63. package/framework/skills/core/seo.md +542 -0
  64. package/framework/skills/core/testing.md +393 -0
  65. package/framework/skills/core/views.md +260 -0
  66. package/framework/skills/core/websockets.md +564 -0
  67. package/framework/skills/data/advanced-sql.md +1204 -0
  68. package/framework/skills/data/nosql.md +1141 -0
  69. package/framework/skills/devops/containers-advanced.md +1237 -0
  70. package/framework/skills/devops/debugging.md +834 -0
  71. package/framework/skills/devops/git-workflow.md +752 -0
  72. package/framework/skills/devops/networking.md +932 -0
  73. package/framework/skills/devops/shell-scripting.md +1132 -0
  74. package/framework/sub-agents/architecture-patterns-agent.md +1450 -0
  75. package/framework/sub-agents/cloud-agent.md +677 -0
  76. package/framework/sub-agents/data.md +504 -0
  77. package/framework/sub-agents/debugging-agent.md +554 -0
  78. package/framework/sub-agents/devops.md +483 -0
  79. package/framework/sub-agents/docs.md +176 -0
  80. package/framework/sub-agents/frontend-dev.md +349 -0
  81. package/framework/sub-agents/git-workflow-agent.md +697 -0
  82. package/framework/sub-agents/integrations.md +630 -0
  83. package/framework/sub-agents/native-dev.md +434 -0
  84. package/framework/sub-agents/qa.md +138 -0
  85. package/framework/sub-agents/rails-dev.md +375 -0
  86. package/framework/sub-agents/security.md +526 -0
  87. package/framework/sub-agents/ui.md +437 -0
  88. package/framework/sub-agents/ux.md +284 -0
  89. package/framework/templates/api-spec.md +500 -0
  90. package/framework/templates/component-spec.md +248 -0
  91. package/framework/templates/feature.json +13 -0
  92. package/framework/templates/model-spec.md +318 -0
  93. package/framework/templates/prd-template.md +80 -0
  94. package/framework/templates/task-plan.md +122 -0
  95. package/framework/templates/task-user-story.md +52 -0
  96. package/framework/templates/technical-spec.md +260 -0
  97. package/framework/templates/user-story.md +95 -0
  98. package/package.json +42 -0
  99. package/project-templates/CLAUDE.md +42 -0
  100. package/project-templates/contexts/architecture.md +25 -0
  101. package/project-templates/contexts/conventions.md +46 -0
  102. package/project-templates/contexts/design-system.md +47 -0
  103. package/project-templates/contexts/requirements.md +38 -0
  104. package/project-templates/contexts/stack.md +30 -0
  105. package/project-templates/history/active/models.md +11 -0
  106. package/project-templates/history/changelog.md +15 -0
  107. package/project-templates/workspace/.gitkeep +0 -0
  108. package/src/cli.js +52 -0
  109. package/src/init.js +104 -0
  110. package/src/status.js +75 -0
  111. package/src/update.js +88 -0
@@ -0,0 +1,1204 @@
1
+ # Skill: SQL Avanzado para Rails
2
+
3
+ ## Purpose
4
+
5
+ Dominar tecnicas avanzadas de SQL para optimizar consultas, crear reportes complejos y manejar grandes volumenes de datos en aplicaciones Rails.
6
+
7
+ ## CTEs (Common Table Expressions)
8
+
9
+ ### Basico
10
+
11
+ ```ruby
12
+ # SQL con WITH clause
13
+ class Report < ApplicationRecord
14
+ def self.sales_by_category
15
+ sql = <<-SQL
16
+ WITH category_sales AS (
17
+ SELECT
18
+ categories.name AS category_name,
19
+ SUM(order_items.quantity * order_items.unit_price) AS total_sales,
20
+ COUNT(DISTINCT orders.id) AS order_count
21
+ FROM order_items
22
+ JOIN products ON products.id = order_items.product_id
23
+ JOIN categories ON categories.id = products.category_id
24
+ JOIN orders ON orders.id = order_items.order_id
25
+ WHERE orders.created_at >= :start_date
26
+ GROUP BY categories.id, categories.name
27
+ )
28
+ SELECT
29
+ category_name,
30
+ total_sales,
31
+ order_count,
32
+ total_sales / order_count AS avg_order_value
33
+ FROM category_sales
34
+ ORDER BY total_sales DESC
35
+ SQL
36
+
37
+ find_by_sql([sql, { start_date: 30.days.ago }])
38
+ end
39
+ end
40
+ ```
41
+
42
+ ### CTEs multiples
43
+
44
+ ```ruby
45
+ def self.customer_lifetime_analysis
46
+ sql = <<-SQL
47
+ WITH first_orders AS (
48
+ SELECT
49
+ user_id,
50
+ MIN(created_at) AS first_order_date
51
+ FROM orders
52
+ GROUP BY user_id
53
+ ),
54
+ customer_totals AS (
55
+ SELECT
56
+ user_id,
57
+ COUNT(*) AS total_orders,
58
+ SUM(total_amount) AS lifetime_value,
59
+ MAX(created_at) AS last_order_date
60
+ FROM orders
61
+ GROUP BY user_id
62
+ ),
63
+ customer_metrics AS (
64
+ SELECT
65
+ ct.user_id,
66
+ ct.total_orders,
67
+ ct.lifetime_value,
68
+ fo.first_order_date,
69
+ ct.last_order_date,
70
+ EXTRACT(EPOCH FROM (ct.last_order_date - fo.first_order_date)) / 86400 AS customer_age_days
71
+ FROM customer_totals ct
72
+ JOIN first_orders fo ON fo.user_id = ct.user_id
73
+ )
74
+ SELECT
75
+ u.id,
76
+ u.email,
77
+ cm.total_orders,
78
+ cm.lifetime_value,
79
+ cm.first_order_date,
80
+ cm.last_order_date,
81
+ cm.customer_age_days,
82
+ CASE
83
+ WHEN cm.total_orders >= 10 THEN 'vip'
84
+ WHEN cm.total_orders >= 5 THEN 'loyal'
85
+ WHEN cm.total_orders >= 2 THEN 'returning'
86
+ ELSE 'new'
87
+ END AS customer_tier
88
+ FROM users u
89
+ JOIN customer_metrics cm ON cm.user_id = u.id
90
+ ORDER BY cm.lifetime_value DESC
91
+ SQL
92
+
93
+ find_by_sql(sql)
94
+ end
95
+ ```
96
+
97
+ ### CTEs recursivos
98
+
99
+ ```ruby
100
+ # Arbol de categorias
101
+ def self.category_tree(root_id = nil)
102
+ sql = <<-SQL
103
+ WITH RECURSIVE category_tree AS (
104
+ -- Caso base: categorias raiz
105
+ SELECT
106
+ id,
107
+ name,
108
+ parent_id,
109
+ 1 AS level,
110
+ ARRAY[id] AS path,
111
+ name AS full_path
112
+ FROM categories
113
+ WHERE parent_id #{root_id ? '= :root_id' : 'IS NULL'}
114
+
115
+ UNION ALL
116
+
117
+ -- Caso recursivo: hijos
118
+ SELECT
119
+ c.id,
120
+ c.name,
121
+ c.parent_id,
122
+ ct.level + 1,
123
+ ct.path || c.id,
124
+ ct.full_path || ' > ' || c.name
125
+ FROM categories c
126
+ JOIN category_tree ct ON c.parent_id = ct.id
127
+ WHERE ct.level < 10 -- Limite de profundidad
128
+ )
129
+ SELECT * FROM category_tree
130
+ ORDER BY path
131
+ SQL
132
+
133
+ find_by_sql([sql, { root_id: root_id }])
134
+ end
135
+
136
+ # Estructura organizacional
137
+ def self.org_chart(manager_id)
138
+ sql = <<-SQL
139
+ WITH RECURSIVE subordinates AS (
140
+ SELECT
141
+ id,
142
+ name,
143
+ manager_id,
144
+ 1 AS depth,
145
+ ARRAY[name] AS reporting_chain
146
+ FROM employees
147
+ WHERE manager_id = :manager_id
148
+
149
+ UNION ALL
150
+
151
+ SELECT
152
+ e.id,
153
+ e.name,
154
+ e.manager_id,
155
+ s.depth + 1,
156
+ s.reporting_chain || e.name
157
+ FROM employees e
158
+ JOIN subordinates s ON e.manager_id = s.id
159
+ WHERE s.depth < 20
160
+ )
161
+ SELECT
162
+ id,
163
+ name,
164
+ depth,
165
+ array_to_string(reporting_chain, ' -> ') AS chain
166
+ FROM subordinates
167
+ ORDER BY depth, name
168
+ SQL
169
+
170
+ find_by_sql([sql, { manager_id: manager_id }])
171
+ end
172
+ ```
173
+
174
+ ## Window Functions
175
+
176
+ ### ROW_NUMBER, RANK, DENSE_RANK
177
+
178
+ ```ruby
179
+ # Ranking de productos por ventas
180
+ def self.products_ranked_by_sales
181
+ sql = <<-SQL
182
+ SELECT
183
+ p.id,
184
+ p.name,
185
+ c.name AS category,
186
+ SUM(oi.quantity) AS units_sold,
187
+ SUM(oi.quantity * oi.unit_price) AS revenue,
188
+ ROW_NUMBER() OVER (ORDER BY SUM(oi.quantity * oi.unit_price) DESC) AS overall_rank,
189
+ RANK() OVER (PARTITION BY c.id ORDER BY SUM(oi.quantity * oi.unit_price) DESC) AS category_rank,
190
+ DENSE_RANK() OVER (ORDER BY SUM(oi.quantity) DESC) AS units_rank
191
+ FROM products p
192
+ JOIN categories c ON c.id = p.category_id
193
+ JOIN order_items oi ON oi.product_id = p.id
194
+ JOIN orders o ON o.id = oi.order_id
195
+ WHERE o.created_at >= :start_date
196
+ GROUP BY p.id, p.name, c.id, c.name
197
+ ORDER BY revenue DESC
198
+ SQL
199
+
200
+ find_by_sql([sql, { start_date: 30.days.ago }])
201
+ end
202
+ ```
203
+
204
+ ### LAG y LEAD
205
+
206
+ ```ruby
207
+ # Comparacion mes a mes
208
+ def self.monthly_comparison
209
+ sql = <<-SQL
210
+ WITH monthly_sales AS (
211
+ SELECT
212
+ DATE_TRUNC('month', created_at) AS month,
213
+ SUM(total_amount) AS revenue,
214
+ COUNT(*) AS order_count
215
+ FROM orders
216
+ WHERE created_at >= :start_date
217
+ GROUP BY DATE_TRUNC('month', created_at)
218
+ )
219
+ SELECT
220
+ month,
221
+ revenue,
222
+ order_count,
223
+ LAG(revenue) OVER (ORDER BY month) AS prev_month_revenue,
224
+ LEAD(revenue) OVER (ORDER BY month) AS next_month_revenue,
225
+ revenue - LAG(revenue) OVER (ORDER BY month) AS revenue_change,
226
+ ROUND(
227
+ (revenue - LAG(revenue) OVER (ORDER BY month))::numeric /
228
+ NULLIF(LAG(revenue) OVER (ORDER BY month), 0) * 100,
229
+ 2
230
+ ) AS revenue_change_pct
231
+ FROM monthly_sales
232
+ ORDER BY month
233
+ SQL
234
+
235
+ find_by_sql([sql, { start_date: 1.year.ago }])
236
+ end
237
+ ```
238
+
239
+ ### SUM OVER (Running totals)
240
+
241
+ ```ruby
242
+ # Totales acumulados
243
+ def self.cumulative_sales
244
+ sql = <<-SQL
245
+ SELECT
246
+ DATE(created_at) AS sale_date,
247
+ SUM(total_amount) AS daily_revenue,
248
+ SUM(SUM(total_amount)) OVER (
249
+ ORDER BY DATE(created_at)
250
+ ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
251
+ ) AS cumulative_revenue,
252
+ AVG(SUM(total_amount)) OVER (
253
+ ORDER BY DATE(created_at)
254
+ ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
255
+ ) AS rolling_7day_avg
256
+ FROM orders
257
+ WHERE created_at >= :start_date
258
+ GROUP BY DATE(created_at)
259
+ ORDER BY sale_date
260
+ SQL
261
+
262
+ find_by_sql([sql, { start_date: 30.days.ago }])
263
+ end
264
+ ```
265
+
266
+ ### NTILE y percentiles
267
+
268
+ ```ruby
269
+ # Segmentacion de clientes por valor
270
+ def self.customer_segments
271
+ sql = <<-SQL
272
+ WITH customer_values AS (
273
+ SELECT
274
+ user_id,
275
+ SUM(total_amount) AS total_spent,
276
+ COUNT(*) AS order_count
277
+ FROM orders
278
+ WHERE created_at >= :start_date
279
+ GROUP BY user_id
280
+ )
281
+ SELECT
282
+ u.id,
283
+ u.email,
284
+ cv.total_spent,
285
+ cv.order_count,
286
+ NTILE(4) OVER (ORDER BY cv.total_spent) AS spending_quartile,
287
+ PERCENT_RANK() OVER (ORDER BY cv.total_spent) AS percentile_rank,
288
+ CASE NTILE(4) OVER (ORDER BY cv.total_spent)
289
+ WHEN 4 THEN 'Premium'
290
+ WHEN 3 THEN 'High Value'
291
+ WHEN 2 THEN 'Medium Value'
292
+ ELSE 'Low Value'
293
+ END AS segment
294
+ FROM users u
295
+ JOIN customer_values cv ON cv.user_id = u.id
296
+ ORDER BY cv.total_spent DESC
297
+ SQL
298
+
299
+ find_by_sql([sql, { start_date: 1.year.ago }])
300
+ end
301
+ ```
302
+
303
+ ## Vistas
304
+
305
+ ### Crear vista en migracion
306
+
307
+ ```ruby
308
+ # db/migrate/xxx_create_order_summary_view.rb
309
+ class CreateOrderSummaryView < ActiveRecord::Migration[8.0]
310
+ def up
311
+ execute <<-SQL
312
+ CREATE VIEW order_summaries AS
313
+ SELECT
314
+ o.id AS order_id,
315
+ o.user_id,
316
+ u.email AS user_email,
317
+ o.created_at AS order_date,
318
+ o.status,
319
+ COUNT(oi.id) AS item_count,
320
+ SUM(oi.quantity) AS total_quantity,
321
+ SUM(oi.quantity * oi.unit_price) AS subtotal,
322
+ o.shipping_cost,
323
+ o.tax_amount,
324
+ o.total_amount
325
+ FROM orders o
326
+ JOIN users u ON u.id = o.user_id
327
+ LEFT JOIN order_items oi ON oi.order_id = o.id
328
+ GROUP BY o.id, u.id
329
+ SQL
330
+ end
331
+
332
+ def down
333
+ execute "DROP VIEW IF EXISTS order_summaries"
334
+ end
335
+ end
336
+
337
+ # app/models/order_summary.rb
338
+ class OrderSummary < ApplicationRecord
339
+ self.primary_key = :order_id
340
+
341
+ # Solo lectura
342
+ def readonly?
343
+ true
344
+ end
345
+
346
+ belongs_to :user
347
+ belongs_to :order, foreign_key: :order_id
348
+
349
+ scope :recent, -> { where("order_date >= ?", 30.days.ago) }
350
+ scope :by_status, ->(status) { where(status: status) }
351
+ end
352
+ ```
353
+
354
+ ## Vistas Materializadas
355
+
356
+ ### Con Scenic gem
357
+
358
+ ```ruby
359
+ # Gemfile
360
+ gem "scenic"
361
+
362
+ # Generar vista materializada
363
+ # rails generate scenic:view product_sales_stats --materialized
364
+
365
+ # db/views/product_sales_stats_v01.sql
366
+ SELECT
367
+ p.id AS product_id,
368
+ p.name AS product_name,
369
+ p.sku,
370
+ c.name AS category_name,
371
+ COUNT(DISTINCT o.id) AS order_count,
372
+ SUM(oi.quantity) AS units_sold,
373
+ SUM(oi.quantity * oi.unit_price) AS total_revenue,
374
+ AVG(oi.unit_price) AS avg_selling_price,
375
+ MIN(o.created_at) AS first_sale_date,
376
+ MAX(o.created_at) AS last_sale_date
377
+ FROM products p
378
+ JOIN categories c ON c.id = p.category_id
379
+ LEFT JOIN order_items oi ON oi.product_id = p.id
380
+ LEFT JOIN orders o ON o.id = oi.order_id AND o.status = 'completed'
381
+ GROUP BY p.id, p.name, p.sku, c.name
382
+
383
+ # db/migrate/xxx_create_product_sales_stats.rb
384
+ class CreateProductSalesStats < ActiveRecord::Migration[8.0]
385
+ def change
386
+ create_view :product_sales_stats, materialized: true
387
+ end
388
+ end
389
+ ```
390
+
391
+ ### Indice para vista materializada
392
+
393
+ ```ruby
394
+ # db/migrate/xxx_add_index_to_product_sales_stats.rb
395
+ class AddIndexToProductSalesStats < ActiveRecord::Migration[8.0]
396
+ def change
397
+ # Indice unico requerido para REFRESH CONCURRENTLY
398
+ add_index :product_sales_stats, :product_id, unique: true
399
+
400
+ # Indices adicionales para queries comunes
401
+ add_index :product_sales_stats, :total_revenue
402
+ add_index :product_sales_stats, :units_sold
403
+ add_index :product_sales_stats, :category_name
404
+ end
405
+ end
406
+ ```
407
+
408
+ ### Refrescar vista materializada
409
+
410
+ ```ruby
411
+ # app/models/product_sales_stat.rb
412
+ class ProductSalesStat < ApplicationRecord
413
+ self.primary_key = :product_id
414
+
415
+ def readonly?
416
+ true
417
+ end
418
+
419
+ def self.refresh
420
+ Scenic.database.refresh_materialized_view(
421
+ table_name,
422
+ concurrently: true,
423
+ cascade: false
424
+ )
425
+ end
426
+ end
427
+
428
+ # app/jobs/refresh_materialized_views_job.rb
429
+ class RefreshMaterializedViewsJob < ApplicationJob
430
+ queue_as :low
431
+
432
+ def perform
433
+ ProductSalesStat.refresh
434
+ CustomerMetric.refresh
435
+ # Otras vistas...
436
+ end
437
+ end
438
+
439
+ # Programar en recurring.yml
440
+ # refresh_views:
441
+ # class: RefreshMaterializedViewsJob
442
+ # schedule: every 1 hour
443
+ ```
444
+
445
+ ## Funciones y Procedimientos
446
+
447
+ ### Crear funcion
448
+
449
+ ```ruby
450
+ # db/migrate/xxx_create_calculate_discount_function.rb
451
+ class CreateCalculateDiscountFunction < ActiveRecord::Migration[8.0]
452
+ def up
453
+ execute <<-SQL
454
+ CREATE OR REPLACE FUNCTION calculate_discount(
455
+ subtotal NUMERIC,
456
+ customer_tier VARCHAR,
457
+ promo_code VARCHAR DEFAULT NULL
458
+ )
459
+ RETURNS NUMERIC AS $$
460
+ DECLARE
461
+ discount NUMERIC := 0;
462
+ tier_discount NUMERIC := 0;
463
+ promo_discount NUMERIC := 0;
464
+ BEGIN
465
+ -- Descuento por tier
466
+ tier_discount := CASE customer_tier
467
+ WHEN 'vip' THEN subtotal * 0.15
468
+ WHEN 'premium' THEN subtotal * 0.10
469
+ WHEN 'loyal' THEN subtotal * 0.05
470
+ ELSE 0
471
+ END;
472
+
473
+ -- Descuento por promocion
474
+ IF promo_code IS NOT NULL THEN
475
+ SELECT COALESCE(
476
+ CASE p.discount_type
477
+ WHEN 'percentage' THEN subtotal * p.discount_value / 100
478
+ WHEN 'fixed' THEN p.discount_value
479
+ END,
480
+ 0
481
+ )
482
+ INTO promo_discount
483
+ FROM promotions p
484
+ WHERE p.code = promo_code
485
+ AND p.active = true
486
+ AND p.valid_until >= CURRENT_DATE;
487
+ END IF;
488
+
489
+ -- Usar el mayor descuento
490
+ discount := GREATEST(tier_discount, promo_discount);
491
+
492
+ -- Maximo 50% de descuento
493
+ RETURN LEAST(discount, subtotal * 0.5);
494
+ END;
495
+ $$ LANGUAGE plpgsql STABLE;
496
+ SQL
497
+ end
498
+
499
+ def down
500
+ execute "DROP FUNCTION IF EXISTS calculate_discount"
501
+ end
502
+ end
503
+
504
+ # Uso en Rails
505
+ Order.select(
506
+ "*, calculate_discount(subtotal, customer_tier, promo_code) AS discount"
507
+ )
508
+ ```
509
+
510
+ ### Triggers
511
+
512
+ ```ruby
513
+ # db/migrate/xxx_create_audit_trigger.rb
514
+ class CreateAuditTrigger < ActiveRecord::Migration[8.0]
515
+ def up
516
+ # Tabla de auditoria
517
+ create_table :audit_logs do |t|
518
+ t.string :table_name, null: false
519
+ t.bigint :record_id, null: false
520
+ t.string :action, null: false
521
+ t.jsonb :old_data
522
+ t.jsonb :new_data
523
+ t.bigint :user_id
524
+ t.timestamps
525
+ end
526
+
527
+ add_index :audit_logs, [:table_name, :record_id]
528
+ add_index :audit_logs, :user_id
529
+
530
+ # Funcion de auditoria
531
+ execute <<-SQL
532
+ CREATE OR REPLACE FUNCTION audit_trigger_function()
533
+ RETURNS TRIGGER AS $$
534
+ BEGIN
535
+ IF TG_OP = 'INSERT' THEN
536
+ INSERT INTO audit_logs (table_name, record_id, action, new_data, created_at, updated_at)
537
+ VALUES (TG_TABLE_NAME, NEW.id, 'INSERT', to_jsonb(NEW), NOW(), NOW());
538
+ RETURN NEW;
539
+ ELSIF TG_OP = 'UPDATE' THEN
540
+ INSERT INTO audit_logs (table_name, record_id, action, old_data, new_data, created_at, updated_at)
541
+ VALUES (TG_TABLE_NAME, NEW.id, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), NOW(), NOW());
542
+ RETURN NEW;
543
+ ELSIF TG_OP = 'DELETE' THEN
544
+ INSERT INTO audit_logs (table_name, record_id, action, old_data, created_at, updated_at)
545
+ VALUES (TG_TABLE_NAME, OLD.id, 'DELETE', to_jsonb(OLD), NOW(), NOW());
546
+ RETURN OLD;
547
+ END IF;
548
+ RETURN NULL;
549
+ END;
550
+ $$ LANGUAGE plpgsql;
551
+ SQL
552
+
553
+ # Aplicar trigger a tablas sensibles
554
+ %w[orders payments users].each do |table|
555
+ execute <<-SQL
556
+ CREATE TRIGGER #{table}_audit_trigger
557
+ AFTER INSERT OR UPDATE OR DELETE ON #{table}
558
+ FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();
559
+ SQL
560
+ end
561
+ end
562
+
563
+ def down
564
+ %w[orders payments users].each do |table|
565
+ execute "DROP TRIGGER IF EXISTS #{table}_audit_trigger ON #{table}"
566
+ end
567
+ execute "DROP FUNCTION IF EXISTS audit_trigger_function"
568
+ drop_table :audit_logs
569
+ end
570
+ end
571
+ ```
572
+
573
+ ## Indices Avanzados
574
+
575
+ ### Indices parciales
576
+
577
+ ```ruby
578
+ # db/migrate/xxx_add_partial_indexes.rb
579
+ class AddPartialIndexes < ActiveRecord::Migration[8.0]
580
+ def change
581
+ # Solo indexar pedidos pendientes (los mas consultados)
582
+ add_index :orders, :created_at,
583
+ where: "status = 'pending'",
584
+ name: "index_orders_on_created_at_pending"
585
+
586
+ # Solo indexar usuarios activos
587
+ add_index :users, :email,
588
+ where: "deleted_at IS NULL",
589
+ name: "index_users_on_email_active"
590
+
591
+ # Solo productos en stock
592
+ add_index :products, [:category_id, :price],
593
+ where: "stock_quantity > 0",
594
+ name: "index_products_available"
595
+ end
596
+ end
597
+ ```
598
+
599
+ ### Indices de expresion
600
+
601
+ ```ruby
602
+ # db/migrate/xxx_add_expression_indexes.rb
603
+ class AddExpressionIndexes < ActiveRecord::Migration[8.0]
604
+ def change
605
+ # Busqueda case-insensitive
606
+ add_index :users, "LOWER(email)",
607
+ name: "index_users_on_lower_email",
608
+ unique: true
609
+
610
+ # Extraer ano de fecha
611
+ add_index :orders, "DATE_TRUNC('month', created_at)",
612
+ name: "index_orders_on_month"
613
+
614
+ # Campo JSONB
615
+ add_index :products, "(metadata->>'brand')",
616
+ name: "index_products_on_brand"
617
+ end
618
+ end
619
+ ```
620
+
621
+ ### Indices GIN y GiST
622
+
623
+ ```ruby
624
+ # db/migrate/xxx_add_gin_gist_indexes.rb
625
+ class AddGinGistIndexes < ActiveRecord::Migration[8.0]
626
+ def change
627
+ # GIN para JSONB
628
+ add_index :products, :metadata, using: :gin
629
+
630
+ # GIN para arrays
631
+ add_index :articles, :tags, using: :gin
632
+
633
+ # GIN para full-text search
634
+ execute <<-SQL
635
+ CREATE INDEX articles_search_idx ON articles
636
+ USING gin(to_tsvector('spanish', title || ' ' || body))
637
+ SQL
638
+
639
+ # GiST para rangos y geometria
640
+ add_index :events, :duration, using: :gist
641
+
642
+ # GiST para busqueda por proximidad
643
+ execute <<-SQL
644
+ CREATE INDEX locations_coordinates_idx ON locations
645
+ USING gist(ll_to_earth(latitude, longitude))
646
+ SQL
647
+ end
648
+ end
649
+ ```
650
+
651
+ ## EXPLAIN ANALYZE
652
+
653
+ ### Analizar queries
654
+
655
+ ```ruby
656
+ # app/models/concerns/queryable.rb
657
+ module Queryable
658
+ extend ActiveSupport::Concern
659
+
660
+ class_methods do
661
+ def explain_query(relation = all)
662
+ result = connection.execute("EXPLAIN ANALYZE #{relation.to_sql}")
663
+ result.values.map(&:first).join("\n")
664
+ end
665
+
666
+ def analyze_slow_query(relation = all)
667
+ plan = connection.execute(
668
+ "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) #{relation.to_sql}"
669
+ )
670
+
671
+ JSON.parse(plan.first["QUERY PLAN"])
672
+ end
673
+ end
674
+ end
675
+
676
+ # Uso
677
+ puts Order.where(status: "pending").explain_query
678
+
679
+ # Resultado:
680
+ # Seq Scan on orders (cost=0.00..1234.00 rows=50 width=100) (actual time=0.015..2.345 rows=48 loops=1)
681
+ # Filter: (status = 'pending')
682
+ # Rows Removed by Filter: 9952
683
+ # Planning Time: 0.123 ms
684
+ # Execution Time: 2.456 ms
685
+ ```
686
+
687
+ ### Interpretar planes
688
+
689
+ ```ruby
690
+ # app/services/query_analyzer.rb
691
+ class QueryAnalyzer
692
+ WARNINGS = {
693
+ seq_scan: "Sequential scan detected - consider adding an index",
694
+ high_rows_removed: "Many rows removed by filter - index may help",
695
+ nested_loop: "Nested loop with high cost - consider JOIN optimization",
696
+ sort: "Sort operation - consider adding index for ORDER BY"
697
+ }.freeze
698
+
699
+ def initialize(plan)
700
+ @plan = plan.is_a?(String) ? JSON.parse(plan) : plan
701
+ end
702
+
703
+ def analyze
704
+ warnings = []
705
+ @plan.each do |node|
706
+ plan_node = node["Plan"]
707
+ warnings.concat(analyze_node(plan_node))
708
+ end
709
+ warnings
710
+ end
711
+
712
+ private
713
+
714
+ def analyze_node(node, depth = 0)
715
+ warnings = []
716
+
717
+ # Detectar problemas comunes
718
+ if node["Node Type"] == "Seq Scan" && node["Actual Rows"].to_i > 1000
719
+ warnings << { type: :seq_scan, node: node["Relation Name"], rows: node["Actual Rows"] }
720
+ end
721
+
722
+ if node["Rows Removed by Filter"].to_i > node["Actual Rows"].to_i * 10
723
+ warnings << { type: :high_rows_removed, removed: node["Rows Removed by Filter"] }
724
+ end
725
+
726
+ # Analizar nodos hijos
727
+ (node["Plans"] || []).each do |child|
728
+ warnings.concat(analyze_node(child, depth + 1))
729
+ end
730
+
731
+ warnings
732
+ end
733
+ end
734
+ ```
735
+
736
+ ## Batch Operations
737
+
738
+ ### INSERT...SELECT
739
+
740
+ ```ruby
741
+ # Copiar datos entre tablas
742
+ def self.archive_old_orders
743
+ execute <<-SQL
744
+ INSERT INTO archived_orders (
745
+ id, user_id, total_amount, status, created_at, archived_at
746
+ )
747
+ SELECT
748
+ id, user_id, total_amount, status, created_at, NOW()
749
+ FROM orders
750
+ WHERE created_at < :cutoff_date
751
+ AND status IN ('completed', 'cancelled')
752
+ ON CONFLICT (id) DO NOTHING
753
+ SQL
754
+ end
755
+ ```
756
+
757
+ ### UPDATE...FROM
758
+
759
+ ```ruby
760
+ # Actualizar basado en otra tabla
761
+ def self.update_product_stats
762
+ execute <<-SQL
763
+ UPDATE products
764
+ SET
765
+ total_sold = stats.units_sold,
766
+ last_sale_at = stats.last_sale,
767
+ updated_at = NOW()
768
+ FROM (
769
+ SELECT
770
+ product_id,
771
+ SUM(quantity) AS units_sold,
772
+ MAX(orders.created_at) AS last_sale
773
+ FROM order_items
774
+ JOIN orders ON orders.id = order_items.order_id
775
+ WHERE orders.status = 'completed'
776
+ GROUP BY product_id
777
+ ) AS stats
778
+ WHERE products.id = stats.product_id
779
+ SQL
780
+ end
781
+ ```
782
+
783
+ ### UPSERT (INSERT ON CONFLICT)
784
+
785
+ ```ruby
786
+ # db/migrate/xxx_add_unique_constraint_for_upsert.rb
787
+ class AddUniqueConstraintForUpsert < ActiveRecord::Migration[8.0]
788
+ def change
789
+ add_index :daily_stats, [:date, :product_id], unique: true
790
+ end
791
+ end
792
+
793
+ # Upsert en Rails
794
+ def self.update_daily_stats(date, product_id, sales_data)
795
+ execute <<-SQL
796
+ INSERT INTO daily_stats (date, product_id, units_sold, revenue, created_at, updated_at)
797
+ VALUES (:date, :product_id, :units_sold, :revenue, NOW(), NOW())
798
+ ON CONFLICT (date, product_id)
799
+ DO UPDATE SET
800
+ units_sold = daily_stats.units_sold + EXCLUDED.units_sold,
801
+ revenue = daily_stats.revenue + EXCLUDED.revenue,
802
+ updated_at = NOW()
803
+ SQL
804
+ end
805
+
806
+ # Con ActiveRecord (Rails 7+)
807
+ DailyStat.upsert(
808
+ { date: Date.current, product_id: 1, units_sold: 10, revenue: 100 },
809
+ unique_by: [:date, :product_id],
810
+ on_duplicate: Arel.sql("units_sold = daily_stats.units_sold + EXCLUDED.units_sold")
811
+ )
812
+
813
+ # Upsert multiple
814
+ DailyStat.upsert_all(
815
+ stats_array,
816
+ unique_by: [:date, :product_id]
817
+ )
818
+ ```
819
+
820
+ ## Transacciones
821
+
822
+ ### Isolation Levels
823
+
824
+ ```ruby
825
+ # app/services/inventory_service.rb
826
+ class InventoryService
827
+ def reserve_stock(order)
828
+ # SERIALIZABLE para prevenir race conditions
829
+ Order.transaction(isolation: :serializable) do
830
+ order.line_items.each do |item|
831
+ product = Product.lock.find(item.product_id)
832
+
833
+ if product.stock_quantity < item.quantity
834
+ raise InsufficientStockError, "Not enough stock for #{product.name}"
835
+ end
836
+
837
+ product.decrement!(:stock_quantity, item.quantity)
838
+ end
839
+
840
+ order.update!(status: "reserved")
841
+ end
842
+ rescue ActiveRecord::SerializationFailure
843
+ # Retry on serialization failure
844
+ retry
845
+ end
846
+
847
+ def process_concurrent_updates
848
+ # READ COMMITTED (default) - cada query ve datos commiteados
849
+ Order.transaction(isolation: :read_committed) do
850
+ # ...
851
+ end
852
+
853
+ # REPEATABLE READ - snapshot al inicio de transaccion
854
+ Order.transaction(isolation: :repeatable_read) do
855
+ # ...
856
+ end
857
+
858
+ # SERIALIZABLE - transacciones parecen secuenciales
859
+ Order.transaction(isolation: :serializable) do
860
+ # ...
861
+ end
862
+ end
863
+ end
864
+ ```
865
+
866
+ ### Locks
867
+
868
+ ```ruby
869
+ # Pessimistic locking
870
+ def update_balance(user_id, amount)
871
+ User.transaction do
872
+ user = User.lock("FOR UPDATE").find(user_id)
873
+ user.update!(balance: user.balance + amount)
874
+ end
875
+ end
876
+
877
+ # Skip locked (para job queues)
878
+ def claim_next_job
879
+ Job.transaction do
880
+ job = Job.lock("FOR UPDATE SKIP LOCKED")
881
+ .where(status: "pending")
882
+ .order(:created_at)
883
+ .first
884
+
885
+ return nil unless job
886
+
887
+ job.update!(status: "processing", worker_id: worker_id)
888
+ job
889
+ end
890
+ end
891
+
892
+ # Advisory locks
893
+ def with_named_lock(name, timeout: 5)
894
+ lock_id = Zlib.crc32(name)
895
+
896
+ result = ActiveRecord::Base.connection.execute(
897
+ "SELECT pg_try_advisory_lock(#{lock_id})"
898
+ ).first["pg_try_advisory_lock"]
899
+
900
+ unless result
901
+ raise LockNotAcquiredError, "Could not acquire lock: #{name}"
902
+ end
903
+
904
+ yield
905
+ ensure
906
+ ActiveRecord::Base.connection.execute(
907
+ "SELECT pg_advisory_unlock(#{lock_id})"
908
+ )
909
+ end
910
+ ```
911
+
912
+ ### Deadlock Prevention
913
+
914
+ ```ruby
915
+ # Siempre adquirir locks en orden consistente
916
+ def transfer_funds(from_account_id, to_account_id, amount)
917
+ # Ordenar IDs para evitar deadlock
918
+ ids = [from_account_id, to_account_id].sort
919
+
920
+ Account.transaction do
921
+ accounts = Account.lock.where(id: ids).order(:id).to_a
922
+ from_account = accounts.find { |a| a.id == from_account_id }
923
+ to_account = accounts.find { |a| a.id == to_account_id }
924
+
925
+ from_account.decrement!(:balance, amount)
926
+ to_account.increment!(:balance, amount)
927
+ end
928
+ end
929
+ ```
930
+
931
+ ## JSON/JSONB
932
+
933
+ ### Queries
934
+
935
+ ```ruby
936
+ # Buscar en campo JSONB
937
+ Product.where("metadata @> ?", { color: "red" }.to_json)
938
+ Product.where("metadata->>'brand' = ?", "Apple")
939
+ Product.where("metadata->'specs'->>'size' = ?", "large")
940
+
941
+ # Verificar si key existe
942
+ Product.where("metadata ? 'warranty'")
943
+ Product.where("metadata ?& ARRAY['color', 'size']") # tiene todas
944
+ Product.where("metadata ?| ARRAY['color', 'size']") # tiene alguna
945
+
946
+ # Buscar en array dentro de JSONB
947
+ Product.where("metadata->'tags' @> ?", ["electronics"].to_json)
948
+
949
+ # Actualizar campo JSONB
950
+ Product.where(id: 1).update_all(
951
+ "metadata = jsonb_set(metadata, '{specs,weight}', '\"2kg\"')"
952
+ )
953
+
954
+ # Concatenar JSONB
955
+ Product.where(id: 1).update_all(
956
+ "metadata = metadata || '{\"new_field\": \"value\"}'::jsonb"
957
+ )
958
+
959
+ # Remover key
960
+ Product.where(id: 1).update_all(
961
+ "metadata = metadata - 'old_field'"
962
+ )
963
+ ```
964
+
965
+ ### Indices para JSONB
966
+
967
+ ```ruby
968
+ # db/migrate/xxx_add_jsonb_indexes.rb
969
+ class AddJsonbIndexes < ActiveRecord::Migration[8.0]
970
+ def change
971
+ # Indice general GIN (para @>, ?, ?&, ?|)
972
+ add_index :products, :metadata, using: :gin
973
+
974
+ # Indice para path especifico
975
+ add_index :products, "(metadata->>'brand')",
976
+ name: "index_products_on_metadata_brand"
977
+
978
+ # Indice GIN para path especifico (para @> en ese path)
979
+ execute <<-SQL
980
+ CREATE INDEX index_products_on_metadata_tags
981
+ ON products USING gin ((metadata->'tags'))
982
+ SQL
983
+ end
984
+ end
985
+ ```
986
+
987
+ ## Full-Text Search
988
+
989
+ ### Configuracion basica
990
+
991
+ ```ruby
992
+ # db/migrate/xxx_add_full_text_search.rb
993
+ class AddFullTextSearch < ActiveRecord::Migration[8.0]
994
+ def up
995
+ # Agregar columna tsvector
996
+ add_column :articles, :search_vector, :tsvector
997
+
998
+ # Indice GIN
999
+ add_index :articles, :search_vector, using: :gin
1000
+
1001
+ # Trigger para mantener actualizado
1002
+ execute <<-SQL
1003
+ CREATE OR REPLACE FUNCTION articles_search_trigger()
1004
+ RETURNS trigger AS $$
1005
+ BEGIN
1006
+ NEW.search_vector :=
1007
+ setweight(to_tsvector('spanish', COALESCE(NEW.title, '')), 'A') ||
1008
+ setweight(to_tsvector('spanish', COALESCE(NEW.excerpt, '')), 'B') ||
1009
+ setweight(to_tsvector('spanish', COALESCE(NEW.body, '')), 'C');
1010
+ RETURN NEW;
1011
+ END
1012
+ $$ LANGUAGE plpgsql;
1013
+
1014
+ CREATE TRIGGER articles_search_update
1015
+ BEFORE INSERT OR UPDATE ON articles
1016
+ FOR EACH ROW EXECUTE FUNCTION articles_search_trigger();
1017
+ SQL
1018
+
1019
+ # Actualizar registros existentes
1020
+ execute "UPDATE articles SET search_vector = search_vector"
1021
+ end
1022
+
1023
+ def down
1024
+ execute "DROP TRIGGER IF EXISTS articles_search_update ON articles"
1025
+ execute "DROP FUNCTION IF EXISTS articles_search_trigger"
1026
+ remove_column :articles, :search_vector
1027
+ end
1028
+ end
1029
+
1030
+ # app/models/article.rb
1031
+ class Article < ApplicationRecord
1032
+ scope :search, ->(query) {
1033
+ where("search_vector @@ plainto_tsquery('spanish', ?)", query)
1034
+ .order(Arel.sql("ts_rank(search_vector, plainto_tsquery('spanish', '#{sanitize_sql_like(query)}')) DESC"))
1035
+ }
1036
+
1037
+ scope :search_with_highlights, ->(query) {
1038
+ select(
1039
+ "articles.*",
1040
+ "ts_headline('spanish', title, plainto_tsquery('spanish', #{connection.quote(query)})) AS title_highlight",
1041
+ "ts_headline('spanish', body, plainto_tsquery('spanish', #{connection.quote(query)}), 'MaxWords=50') AS body_highlight"
1042
+ ).search(query)
1043
+ }
1044
+ end
1045
+ ```
1046
+
1047
+ ### Con pg_search gem
1048
+
1049
+ ```ruby
1050
+ # Gemfile
1051
+ gem "pg_search"
1052
+
1053
+ # app/models/article.rb
1054
+ class Article < ApplicationRecord
1055
+ include PgSearch::Model
1056
+
1057
+ pg_search_scope :search,
1058
+ against: {
1059
+ title: 'A',
1060
+ excerpt: 'B',
1061
+ body: 'C'
1062
+ },
1063
+ using: {
1064
+ tsearch: {
1065
+ dictionary: "spanish",
1066
+ tsvector_column: "search_vector",
1067
+ prefix: true
1068
+ },
1069
+ trigram: {
1070
+ threshold: 0.3
1071
+ }
1072
+ }
1073
+
1074
+ pg_search_scope :search_by_category,
1075
+ against: :title,
1076
+ associated_against: {
1077
+ category: :name,
1078
+ tags: :name
1079
+ }
1080
+ end
1081
+ ```
1082
+
1083
+ ## Partitioning
1084
+
1085
+ ### Range Partitioning
1086
+
1087
+ ```ruby
1088
+ # db/migrate/xxx_create_partitioned_orders.rb
1089
+ class CreatePartitionedOrders < ActiveRecord::Migration[8.0]
1090
+ def up
1091
+ execute <<-SQL
1092
+ CREATE TABLE orders (
1093
+ id BIGSERIAL,
1094
+ user_id BIGINT NOT NULL,
1095
+ total_amount DECIMAL(10,2),
1096
+ status VARCHAR(20),
1097
+ created_at TIMESTAMP NOT NULL,
1098
+ PRIMARY KEY (id, created_at)
1099
+ ) PARTITION BY RANGE (created_at);
1100
+ SQL
1101
+
1102
+ # Crear particiones por mes
1103
+ 12.times do |i|
1104
+ month_start = Date.current.beginning_of_year + i.months
1105
+ month_end = month_start + 1.month
1106
+
1107
+ execute <<-SQL
1108
+ CREATE TABLE orders_#{month_start.strftime('%Y_%m')}
1109
+ PARTITION OF orders
1110
+ FOR VALUES FROM ('#{month_start}') TO ('#{month_end}');
1111
+ SQL
1112
+ end
1113
+
1114
+ # Particion default para datos futuros
1115
+ execute <<-SQL
1116
+ CREATE TABLE orders_default PARTITION OF orders DEFAULT;
1117
+ SQL
1118
+ end
1119
+
1120
+ def down
1121
+ execute "DROP TABLE orders CASCADE"
1122
+ end
1123
+ end
1124
+ ```
1125
+
1126
+ ### Mantenimiento de particiones
1127
+
1128
+ ```ruby
1129
+ # app/jobs/partition_maintenance_job.rb
1130
+ class PartitionMaintenanceJob < ApplicationJob
1131
+ queue_as :low
1132
+
1133
+ def perform
1134
+ create_future_partitions
1135
+ archive_old_partitions
1136
+ end
1137
+
1138
+ private
1139
+
1140
+ def create_future_partitions
1141
+ # Crear particiones para los proximos 3 meses
1142
+ 3.times do |i|
1143
+ month = Date.current.beginning_of_month + (i + 1).months
1144
+ partition_name = "orders_#{month.strftime('%Y_%m')}"
1145
+
1146
+ unless partition_exists?(partition_name)
1147
+ ActiveRecord::Base.connection.execute <<-SQL
1148
+ CREATE TABLE IF NOT EXISTS #{partition_name}
1149
+ PARTITION OF orders
1150
+ FOR VALUES FROM ('#{month}') TO ('#{month + 1.month}');
1151
+ SQL
1152
+ end
1153
+ end
1154
+ end
1155
+
1156
+ def archive_old_partitions
1157
+ # Detach particiones mayores a 2 anos
1158
+ cutoff = Date.current - 2.years
1159
+
1160
+ old_partitions = ActiveRecord::Base.connection.execute(<<-SQL
1161
+ SELECT tablename FROM pg_tables
1162
+ WHERE tablename LIKE 'orders_%'
1163
+ AND tablename ~ 'orders_[0-9]{4}_[0-9]{2}'
1164
+ SQL
1165
+ ).map { |r| r["tablename"] }
1166
+
1167
+ old_partitions.each do |partition|
1168
+ match = partition.match(/orders_(\d{4})_(\d{2})/)
1169
+ next unless match
1170
+
1171
+ partition_date = Date.new(match[1].to_i, match[2].to_i, 1)
1172
+
1173
+ if partition_date < cutoff
1174
+ # Mover a tabla de archivo
1175
+ ActiveRecord::Base.connection.execute <<-SQL
1176
+ ALTER TABLE orders DETACH PARTITION #{partition};
1177
+ ALTER TABLE #{partition} RENAME TO archived_#{partition};
1178
+ SQL
1179
+ end
1180
+ end
1181
+ end
1182
+
1183
+ def partition_exists?(name)
1184
+ ActiveRecord::Base.connection.execute(<<-SQL
1185
+ SELECT 1 FROM pg_tables WHERE tablename = '#{name}'
1186
+ SQL
1187
+ ).any?
1188
+ end
1189
+ end
1190
+ ```
1191
+
1192
+ ## Checklist
1193
+
1194
+ - [ ] CTEs para queries complejos y legibles
1195
+ - [ ] Window functions para rankings y comparaciones
1196
+ - [ ] Vistas para abstraer queries frecuentes
1197
+ - [ ] Vistas materializadas para reportes pesados
1198
+ - [ ] Indices parciales para filtros comunes
1199
+ - [ ] Indices GIN para JSONB y full-text
1200
+ - [ ] EXPLAIN ANALYZE para optimizar queries lentos
1201
+ - [ ] Batch operations para actualizaciones masivas
1202
+ - [ ] Isolation levels apropiados para transacciones
1203
+ - [ ] Full-text search configurado
1204
+ - [ ] Partitioning para tablas grandes