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.
- package/README.md +128 -0
- package/bin/claude-framework +3 -0
- package/framework/agents/design-lead.md +240 -0
- package/framework/agents/product-owner.md +179 -0
- package/framework/agents/tech-lead.md +226 -0
- package/framework/commands/ayuda.md +127 -0
- package/framework/commands/a/303/261adir.md +98 -0
- package/framework/commands/backup.md +397 -0
- package/framework/commands/cambiar.md +110 -0
- package/framework/commands/cloud.md +457 -0
- package/framework/commands/code.md +142 -0
- package/framework/commands/debug.md +334 -0
- package/framework/commands/deploy.md +383 -0
- package/framework/commands/deshacer.md +120 -0
- package/framework/commands/estado.md +218 -0
- package/framework/commands/explica.md +227 -0
- package/framework/commands/feature.md +120 -0
- package/framework/commands/git.md +427 -0
- package/framework/commands/historial.md +202 -0
- package/framework/commands/learn.md +408 -0
- package/framework/commands/movil.md +245 -0
- package/framework/commands/nuevo.md +118 -0
- package/framework/commands/plan.md +134 -0
- package/framework/commands/prd.md +113 -0
- package/framework/commands/probar.md +148 -0
- package/framework/commands/revisar.md +208 -0
- package/framework/commands/seeds.md +230 -0
- package/framework/commands/seguridad.md +226 -0
- package/framework/commands/tasks.md +157 -0
- package/framework/skills/architecture/algorithms.md +970 -0
- package/framework/skills/architecture/clean-code.md +1080 -0
- package/framework/skills/architecture/design-patterns.md +1984 -0
- package/framework/skills/architecture/functional-programming.md +972 -0
- package/framework/skills/architecture/solid.md +991 -0
- package/framework/skills/cloud/cloud-aws.md +848 -0
- package/framework/skills/cloud/cloud-azure.md +931 -0
- package/framework/skills/cloud/cloud-gcp.md +848 -0
- package/framework/skills/cloud/message-queues.md +1229 -0
- package/framework/skills/core/accessibility.md +401 -0
- package/framework/skills/core/api.md +474 -0
- package/framework/skills/core/authentication.md +306 -0
- package/framework/skills/core/authorization.md +388 -0
- package/framework/skills/core/background-jobs.md +341 -0
- package/framework/skills/core/caching.md +473 -0
- package/framework/skills/core/code-review.md +341 -0
- package/framework/skills/core/controllers.md +290 -0
- package/framework/skills/core/cua.md +285 -0
- package/framework/skills/core/documentation.md +472 -0
- package/framework/skills/core/file-uploads.md +351 -0
- package/framework/skills/core/hotwire-native.md +296 -0
- package/framework/skills/core/hotwire.md +278 -0
- package/framework/skills/core/i18n.md +334 -0
- package/framework/skills/core/imports-exports.md +750 -0
- package/framework/skills/core/infrastructure.md +337 -0
- package/framework/skills/core/models.md +228 -0
- package/framework/skills/core/notifications.md +672 -0
- package/framework/skills/core/payments.md +581 -0
- package/framework/skills/core/performance.md +361 -0
- package/framework/skills/core/rails-scaffold.md +131 -0
- package/framework/skills/core/search.md +518 -0
- package/framework/skills/core/security.md +565 -0
- package/framework/skills/core/seeds.md +307 -0
- package/framework/skills/core/seo.md +542 -0
- package/framework/skills/core/testing.md +393 -0
- package/framework/skills/core/views.md +260 -0
- package/framework/skills/core/websockets.md +564 -0
- package/framework/skills/data/advanced-sql.md +1204 -0
- package/framework/skills/data/nosql.md +1141 -0
- package/framework/skills/devops/containers-advanced.md +1237 -0
- package/framework/skills/devops/debugging.md +834 -0
- package/framework/skills/devops/git-workflow.md +752 -0
- package/framework/skills/devops/networking.md +932 -0
- package/framework/skills/devops/shell-scripting.md +1132 -0
- package/framework/sub-agents/architecture-patterns-agent.md +1450 -0
- package/framework/sub-agents/cloud-agent.md +677 -0
- package/framework/sub-agents/data.md +504 -0
- package/framework/sub-agents/debugging-agent.md +554 -0
- package/framework/sub-agents/devops.md +483 -0
- package/framework/sub-agents/docs.md +176 -0
- package/framework/sub-agents/frontend-dev.md +349 -0
- package/framework/sub-agents/git-workflow-agent.md +697 -0
- package/framework/sub-agents/integrations.md +630 -0
- package/framework/sub-agents/native-dev.md +434 -0
- package/framework/sub-agents/qa.md +138 -0
- package/framework/sub-agents/rails-dev.md +375 -0
- package/framework/sub-agents/security.md +526 -0
- package/framework/sub-agents/ui.md +437 -0
- package/framework/sub-agents/ux.md +284 -0
- package/framework/templates/api-spec.md +500 -0
- package/framework/templates/component-spec.md +248 -0
- package/framework/templates/feature.json +13 -0
- package/framework/templates/model-spec.md +318 -0
- package/framework/templates/prd-template.md +80 -0
- package/framework/templates/task-plan.md +122 -0
- package/framework/templates/task-user-story.md +52 -0
- package/framework/templates/technical-spec.md +260 -0
- package/framework/templates/user-story.md +95 -0
- package/package.json +42 -0
- package/project-templates/CLAUDE.md +42 -0
- package/project-templates/contexts/architecture.md +25 -0
- package/project-templates/contexts/conventions.md +46 -0
- package/project-templates/contexts/design-system.md +47 -0
- package/project-templates/contexts/requirements.md +38 -0
- package/project-templates/contexts/stack.md +30 -0
- package/project-templates/history/active/models.md +11 -0
- package/project-templates/history/changelog.md +15 -0
- package/project-templates/workspace/.gitkeep +0 -0
- package/src/cli.js +52 -0
- package/src/init.js +104 -0
- package/src/status.js +75 -0
- 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
|