tenant_partition 0.1.1 → 0.1.2
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/CHANGELOG.md +5 -0
- data/README.md +13 -1
- data/lib/generators/tenant_partition/api_controller_generator.rb +79 -0
- data/lib/generators/tenant_partition/templates/README +13 -0
- data/lib/generators/tenant_partition/templates/tenant_partitions_controller.rb.erb +36 -0
- data/lib/tenant_partition/base.rb +38 -67
- data/lib/tenant_partition/concerns/controller.rb +17 -41
- data/lib/tenant_partition/concerns/data_mover.rb +93 -0
- data/lib/tenant_partition/concerns/partitioned.rb +15 -69
- data/lib/tenant_partition/configuration.rb +13 -5
- data/lib/tenant_partition/maintenance.rb +82 -0
- data/lib/tenant_partition/safety_guard.rb +29 -30
- data/lib/tenant_partition/schema/statements.rb +40 -40
- data/lib/tenant_partition/version.rb +1 -1
- data/lib/tenant_partition.rb +50 -148
- metadata +21 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 45c660ca7844d37613ad78edf986ebaf37d3ddb041d1d12f26fb6b0e59a22be3
|
|
4
|
+
data.tar.gz: 63a8b5b216e53866408ca4476dc2c0f5e17b818c72da540de2176d6582636d0d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: af6c4fee560cdad57d5c430f07739a41d99fb1bb60e7e87ab04026c7d05a2ec98798e6f6730da4eba44cdd01e2133d6ec82e3f9aafd7fb182656a57ffd2d1ef5
|
|
7
|
+
data.tar.gz: a89e0a33ced5ab698892f6f48785a492ac2c3f467b92fe8f67d9e155479f3feb6745cc481cc975027d45569b880d6a5aef39c84be96afd07b7347291c7741a44
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.1.2] - 2026-02-03
|
|
4
|
+
### Added
|
|
5
|
+
- Nuevo generador `rails g tenant_partition:api_controller` para crear endpoints de aprovisionamiento.
|
|
6
|
+
- Soporte para namespaces dinámicos en el generador (ej: `rails g tenant_partition:api_controller Ops`).
|
|
7
|
+
|
|
3
8
|
## [0.1.1] - 2026-01-27
|
|
4
9
|
- Refactor: Rename internal module to `TenantPartition`.
|
|
5
10
|
- Fix: Update Rake tasks and Notifications to use `tenant_partition` namespace.
|
data/README.md
CHANGED
|
@@ -123,7 +123,19 @@ end
|
|
|
123
123
|
TenantPartition.destroy!(old_isp.id)
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
-
### 5.
|
|
126
|
+
### 5. Generador de API (Provisioning)
|
|
127
|
+
|
|
128
|
+
Para facilitar la integración con sistemas externos, la gema incluye un generador que crea un controlador base con las acciones `create`, `destroy` y `show` para tus tenants.
|
|
129
|
+
|
|
130
|
+
**Uso Básico (Namespace por defecto `TenantPartition`):**
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
bundle exec rails g tenant_partition:api_controller
|
|
134
|
+
# Crea: app/controllers/tenant_partition/tenant_partitions_controller.rb
|
|
135
|
+
# Ruta: POST /tenant_partition/tenant_partitions
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### 6. Seguridad en Controladores
|
|
127
139
|
|
|
128
140
|
Protege tus API endpoints asegurando que siempre reciban el ID del tenant.
|
|
129
141
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module TenantPartition
|
|
6
|
+
module Generators
|
|
7
|
+
# Generador encargado de crear el controlador API para la orquestación de tenants.
|
|
8
|
+
#
|
|
9
|
+
# Este generador facilita la creación de un punto de entrada (Endpoint) para que sistemas externos
|
|
10
|
+
# o administradores puedan gestionar el ciclo de vida de las particiones (Crear/Borrar/Consultar).
|
|
11
|
+
#
|
|
12
|
+
# Soporta la especificación de un **Namespace** opcional para organizar el controlador
|
|
13
|
+
# dentro de una carpeta específica (ej: `Ops`, `System`, `Admin`).
|
|
14
|
+
#
|
|
15
|
+
# @example Uso básico (Namespace por defecto: tenant_partition)
|
|
16
|
+
# rails g tenant_partition:api_controller
|
|
17
|
+
#
|
|
18
|
+
# @example Uso con Namespace personalizado (ej: Ops)
|
|
19
|
+
# rails g tenant_partition:api_controller Ops
|
|
20
|
+
#
|
|
21
|
+
class ApiControllerGenerator < Rails::Generators::Base
|
|
22
|
+
source_root File.expand_path("templates", __dir__)
|
|
23
|
+
|
|
24
|
+
# Define el argumento posicional para el namespace.
|
|
25
|
+
# Si el usuario no lo provee, se utiliza "system" por defecto.
|
|
26
|
+
#
|
|
27
|
+
# @!attribute [r] namespace_name
|
|
28
|
+
# @return [String] El nombre del módulo/carpeta donde se alojará el controlador.
|
|
29
|
+
argument :namespace_name, type: :string, default: "system", banner: "namespace"
|
|
30
|
+
|
|
31
|
+
desc "Crea un controlador API profesional para gestionar el ciclo de vida de los Tenants, " \
|
|
32
|
+
"incluyendo rutas y boilerplate de seguridad."
|
|
33
|
+
|
|
34
|
+
# Genera el archivo del controlador utilizando la plantilla ERB.
|
|
35
|
+
#
|
|
36
|
+
# Calcula dinámicamente el nombre de la carpeta y del módulo basándose en el
|
|
37
|
+
# argumento `namespace_name` para asegurar que la estructura de archivos
|
|
38
|
+
# coincida con la nomenclatura de Ruby (CamelCase vs snake_case).
|
|
39
|
+
#
|
|
40
|
+
# @return [void]
|
|
41
|
+
def create_controller_file
|
|
42
|
+
# @module_name se usa dentro del template .erb para definir "module Ops"
|
|
43
|
+
@module_name = namespace_name.camelize
|
|
44
|
+
|
|
45
|
+
# folder_name define la ruta física: "app/controllers/ops/..."
|
|
46
|
+
folder_name = namespace_name.underscore
|
|
47
|
+
|
|
48
|
+
template "tenant_partitions_controller.rb.erb",
|
|
49
|
+
"app/controllers/#{folder_name}/tenant_partitions_controller.rb"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Inyecta las rutas necesarias en el archivo `config/routes.rb` de la aplicación.
|
|
53
|
+
#
|
|
54
|
+
# Utiliza rutas directas en lugar de `namespace :xyz` para mantener la configuración
|
|
55
|
+
# de rutas lo más limpia y aislada posible, evitando bloques anidados innecesarios.
|
|
56
|
+
#
|
|
57
|
+
# @return [void]
|
|
58
|
+
def add_routes
|
|
59
|
+
folder_name = namespace_name.underscore
|
|
60
|
+
|
|
61
|
+
# Definición de rutas explícitas apuntando al controlador generado
|
|
62
|
+
route "post '#{folder_name}/tenant_partitions', to: '#{folder_name}/tenant_partitions#create'"
|
|
63
|
+
route "delete '#{folder_name}/tenant_partitions/:id', to: '#{folder_name}/tenant_partitions#destroy'"
|
|
64
|
+
route "get '#{folder_name}/tenant_partitions/:id', to: '#{folder_name}/tenant_partitions#show'"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Muestra instrucciones post-generación en la consola.
|
|
68
|
+
#
|
|
69
|
+
# Es vital para advertir al usuario sobre la necesidad de configurar la seguridad (Autenticación),
|
|
70
|
+
# ya que el generador no puede asumir qué sistema de auth usa la aplicación (Devise, JWT, etc).
|
|
71
|
+
#
|
|
72
|
+
# @return [void]
|
|
73
|
+
def show_readme
|
|
74
|
+
# Solo mostramos el README si estamos generando (invoke), no borrando (revoke).
|
|
75
|
+
readme "README" if behavior == :invoke
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
===============================================================================
|
|
2
|
+
👋 ¡Controlador de TenantPartition generado!
|
|
3
|
+
|
|
4
|
+
Se ha creado: tenant_partitions_controller.rb
|
|
5
|
+
Se han agregado rutas en: config/routes.rb
|
|
6
|
+
|
|
7
|
+
⚠️ IMPORTANTE - SEGURIDAD ⚠️
|
|
8
|
+
Por defecto, este controlador NO TIENE AUTENTICACIÓN.
|
|
9
|
+
Cualquiera podría crear o borrar particiones si no lo proteges.
|
|
10
|
+
|
|
11
|
+
1. Abre tenant_partitions_controller.rb
|
|
12
|
+
2. Descomenta o agrega tu `before_action` de autenticación (ej: Devise).
|
|
13
|
+
===============================================================================
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module <%= @module_name %>
|
|
2
|
+
class TenantPartitionsController < ApplicationController
|
|
3
|
+
skip_before_action :verify_authenticity_token, raise: false
|
|
4
|
+
|
|
5
|
+
# POST /system/tenant_partitions
|
|
6
|
+
def create
|
|
7
|
+
partition_id = params.require(:id)
|
|
8
|
+
|
|
9
|
+
if TenantPartition.exists?(partition_id)
|
|
10
|
+
render json: { message: "Tenant already exists" }, status: :ok
|
|
11
|
+
else
|
|
12
|
+
TenantPartition.create!(partition_id)
|
|
13
|
+
render json: { message: "Tenant provisioned successfully" }, status: :created
|
|
14
|
+
end
|
|
15
|
+
rescue => e
|
|
16
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# DELETE /system/tenant_partitions/:id
|
|
20
|
+
def destroy
|
|
21
|
+
partition_id = params.require(:id)
|
|
22
|
+
|
|
23
|
+
TenantPartition.destroy!(partition_id)
|
|
24
|
+
head :no_content
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# GET /system/tenant_partitions/:id
|
|
28
|
+
def show
|
|
29
|
+
if TenantPartition.exists?(params[:id])
|
|
30
|
+
render json: { active: true, id: params[:id] }, status: :ok
|
|
31
|
+
else
|
|
32
|
+
render json: { active: false }, status: :not_found
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "concerns/data_mover"
|
|
4
|
+
|
|
3
5
|
module TenantPartition
|
|
4
|
-
# Clase base para
|
|
5
|
-
#
|
|
6
|
-
# Proporciona una interfaz para manejar el ciclo de vida de las tablas físicas (Particiones).
|
|
6
|
+
# Clase base abstracta para definir modelos de infraestructura de particionamiento.
|
|
7
|
+
# Hereda de esta clase para habilitar operaciones DDL (Create/Drop) sobre tus tablas particionadas.
|
|
7
8
|
#
|
|
8
|
-
# @abstract
|
|
9
|
-
# @example
|
|
10
|
-
# class Partition::Chat < TenantPartition::Base
|
|
11
|
-
# # Opcional: Sobrescribir la clave global
|
|
12
|
-
# self.partition_key = :region_code
|
|
13
|
-
# end
|
|
9
|
+
# @abstract
|
|
14
10
|
class Base
|
|
15
11
|
include ActiveModel::Model
|
|
16
12
|
include ActiveModel::Attributes
|
|
13
|
+
include TenantPartition::Concerns::DataMover
|
|
17
14
|
|
|
18
|
-
#
|
|
15
|
+
# Hook de herencia para definir automáticamente el atributo de partición en las subclases.
|
|
16
|
+
# @param subclass [Class] La clase que hereda.
|
|
19
17
|
def self.inherited(subclass)
|
|
20
18
|
super
|
|
21
|
-
# Intentamos definir el atributo por defecto si existe configuración global
|
|
22
19
|
key = TenantPartition.configuration&.partition_key
|
|
23
20
|
subclass.attribute key if key
|
|
24
21
|
end
|
|
@@ -26,39 +23,44 @@ module TenantPartition
|
|
|
26
23
|
class << self
|
|
27
24
|
attr_writer :parent_table, :prefix, :default_table
|
|
28
25
|
|
|
29
|
-
#
|
|
30
|
-
attr_writer :partition_key
|
|
31
|
-
|
|
26
|
+
# @return [String] Nombre de la tabla padre (ej: 'conversations').
|
|
32
27
|
def parent_table
|
|
33
28
|
@parent_table ||= name.demodulize.underscore.pluralize
|
|
34
29
|
end
|
|
35
30
|
|
|
36
|
-
#
|
|
31
|
+
# @return [Symbol] Clave de partición configurada (ej: :isp_id).
|
|
32
|
+
# @raise [TenantPartition::Error] Si no hay configuración global ni local.
|
|
37
33
|
def partition_key
|
|
38
34
|
@partition_key ||= TenantPartition.configuration&.partition_key ||
|
|
39
|
-
|
|
35
|
+
raise(TenantPartition::Error, "Clave de partición no configurada.")
|
|
40
36
|
end
|
|
41
37
|
|
|
38
|
+
# Define manualmente la clave de partición para esta clase, sobrescribiendo la global.
|
|
39
|
+
# @param value [Symbol] Nombre de la columna.
|
|
42
40
|
def partition_key=(value)
|
|
43
41
|
@partition_key = value
|
|
44
|
-
attribute value
|
|
42
|
+
attribute value
|
|
45
43
|
end
|
|
46
44
|
|
|
45
|
+
# @return [String] Prefijo para las tablas particionadas (ej: 'conversations_isp').
|
|
47
46
|
def prefix
|
|
48
|
-
@prefix ||= "#{parent_table}_#{partition_key.to_s.gsub(
|
|
47
|
+
@prefix ||= "#{parent_table}_#{partition_key.to_s.gsub("_id", "")}"
|
|
49
48
|
end
|
|
50
49
|
|
|
50
|
+
# @return [String] Nombre de la tabla DEFAULT.
|
|
51
51
|
def default_table
|
|
52
52
|
@default_table ||= "#{parent_table}_default"
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# @return [ActiveRecord::ConnectionAdapters::PostgreSQLAdapter] Conexión activa a la DB.
|
|
55
56
|
def connection
|
|
56
57
|
ActiveRecord::Base.connection
|
|
57
58
|
end
|
|
58
59
|
|
|
59
|
-
# Crea
|
|
60
|
+
# Crea una nueva partición física en la base de datos.
|
|
61
|
+
# @param value [String, Integer] El valor discriminador del tenant (ej: UUID).
|
|
62
|
+
# @return [TenantPartition::Base] Una instancia representando la nueva partición.
|
|
60
63
|
def create(value)
|
|
61
|
-
# Importante: Usamos el método partition_key (no la variable) para respetar overrides
|
|
62
64
|
payload = { partition_key: partition_key, value: value, table: parent_table }
|
|
63
65
|
|
|
64
66
|
ActiveSupport::Notifications.instrument("create.tenant_partition", payload) do
|
|
@@ -69,14 +71,20 @@ module TenantPartition
|
|
|
69
71
|
end
|
|
70
72
|
end
|
|
71
73
|
|
|
74
|
+
# Genera el nombre físico de la tabla particionada, sanitizando el valor.
|
|
75
|
+
# @param value [Object] Valor del tenant.
|
|
76
|
+
# @return [String] Nombre de la tabla (ej: 'conversations_isp_123').
|
|
72
77
|
def partition_name(value)
|
|
73
|
-
sanitized = value.to_s.gsub(
|
|
78
|
+
sanitized = value.to_s.gsub("-", "_")
|
|
74
79
|
"#{prefix}_#{sanitized}"
|
|
75
80
|
end
|
|
76
81
|
|
|
82
|
+
# Verifica si la tabla particionada existe físicamente en el catálogo de PostgreSQL.
|
|
83
|
+
# @param value [Object] Valor del tenant.
|
|
84
|
+
# @return [Boolean] true si la tabla existe.
|
|
77
85
|
def exists?(value)
|
|
78
86
|
name = partition_name(value)
|
|
79
|
-
sql =
|
|
87
|
+
sql = <<~SQL.squish
|
|
80
88
|
SELECT 1 FROM pg_class c
|
|
81
89
|
JOIN pg_inherits i ON c.oid = i.inhrelid
|
|
82
90
|
JOIN pg_class p ON i.inhparent = p.oid
|
|
@@ -85,6 +93,9 @@ module TenantPartition
|
|
|
85
93
|
connection.execute(sql).any?
|
|
86
94
|
end
|
|
87
95
|
|
|
96
|
+
# Busca una partición existente.
|
|
97
|
+
# @param value [Object] Valor del tenant.
|
|
98
|
+
# @return [TenantPartition::Base, nil] Instancia si existe, nil si no.
|
|
88
99
|
def find(value)
|
|
89
100
|
new(partition_key => value) if exists?(value)
|
|
90
101
|
end
|
|
@@ -92,63 +103,23 @@ module TenantPartition
|
|
|
92
103
|
|
|
93
104
|
# --- Métodos de Instancia ---
|
|
94
105
|
|
|
106
|
+
# @return [Object] Valor del ID de partición de esta instancia.
|
|
95
107
|
def partition_id
|
|
96
|
-
# CORRECCIÓN: Usamos public_send porque ActiveModel no tiene read_attribute
|
|
97
|
-
# Esto invoca al getter generado dinámicamente (ej: .isp_id)
|
|
98
108
|
public_send(self.class.partition_key)
|
|
99
109
|
end
|
|
100
110
|
|
|
111
|
+
# @return [String] Nombre de la tabla física correspondiente a esta instancia.
|
|
101
112
|
def partition_table_name
|
|
102
113
|
self.class.partition_name(partition_id)
|
|
103
114
|
end
|
|
104
115
|
|
|
116
|
+
# @return [Boolean] Si la partición está persistida en base de datos.
|
|
105
117
|
def persisted?
|
|
106
118
|
self.class.exists?(partition_id)
|
|
107
119
|
end
|
|
108
120
|
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
return 0 unless persisted?
|
|
112
|
-
|
|
113
|
-
parent = self.class.parent_table
|
|
114
|
-
default = self.class.default_table
|
|
115
|
-
key = self.class.partition_key
|
|
116
|
-
val = partition_id
|
|
117
|
-
|
|
118
|
-
payload = { partition_key: key, value: val, parent_table: parent }
|
|
119
|
-
|
|
120
|
-
ActiveSupport::Notifications.instrument("populate.tenant_partition", payload) do |notification_payload|
|
|
121
|
-
total_moved = 0
|
|
122
|
-
|
|
123
|
-
loop do
|
|
124
|
-
batch_count = 0
|
|
125
|
-
self.class.connection.transaction do
|
|
126
|
-
# Usamos el ID para paginar el borrado/insertado
|
|
127
|
-
move_sql = <<-SQL.squish
|
|
128
|
-
WITH moved_rows AS (
|
|
129
|
-
DELETE FROM #{default}
|
|
130
|
-
WHERE #{key} = '#{val}'
|
|
131
|
-
AND id IN (
|
|
132
|
-
SELECT id FROM #{default} WHERE #{key} = '#{val}' LIMIT #{batch_size}
|
|
133
|
-
)
|
|
134
|
-
RETURNING *
|
|
135
|
-
)
|
|
136
|
-
INSERT INTO #{parent} SELECT * FROM moved_rows;
|
|
137
|
-
SQL
|
|
138
|
-
|
|
139
|
-
result = self.class.connection.execute(move_sql)
|
|
140
|
-
batch_count = result.cmd_tuples
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
total_moved += batch_count
|
|
144
|
-
break if batch_count < batch_size
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
notification_payload[:count] = total_moved
|
|
148
|
-
total_moved
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
|
|
121
|
+
# Elimina la partición física de la base de datos (DETACH + DROP).
|
|
122
|
+
# @return [Boolean] true si la eliminación fue exitosa.
|
|
152
123
|
def destroy
|
|
153
124
|
return false unless persisted?
|
|
154
125
|
|
|
@@ -2,70 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
module TenantPartition
|
|
4
4
|
module Concerns
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# Este módulo implementa una estrategia de extracción estricta:
|
|
8
|
-
# Solo acepta el ID de partición si viene en el Header HTTP configurado explícitamente.
|
|
9
|
-
#
|
|
10
|
-
# @example Configuración requerida
|
|
11
|
-
# # config/initializers/tenant_partition.rb
|
|
12
|
-
# TenantPartition.configure do |config|
|
|
13
|
-
# config.partition_key = :isp_id
|
|
14
|
-
# config.header_name = 'X-Tenant-ID' # <--- Obligatorio
|
|
15
|
-
# end
|
|
16
|
-
#
|
|
17
|
-
# @example Uso en el controlador
|
|
18
|
-
# class ApiController < ActionController::API
|
|
19
|
-
# include TenantPartition::Concerns::Controller
|
|
20
|
-
# before_action :require_partition_key!
|
|
21
|
-
# end
|
|
5
|
+
# Concern para controladores que valida la presencia del Header de partición.
|
|
22
6
|
module Controller
|
|
23
7
|
extend ActiveSupport::Concern
|
|
24
8
|
|
|
25
9
|
included do
|
|
26
|
-
# Expone el método a las vistas de Rails (erb, jbuilder, etc.)
|
|
27
10
|
helper_method :current_partition_id if respond_to?(:helper_method)
|
|
28
11
|
end
|
|
29
12
|
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
# Utiliza únicamente el nombre de header definido en +TenantPartition.configuration.header_name+.
|
|
33
|
-
# No realiza inferencias ni busca en parámetros de la URL.
|
|
34
|
-
#
|
|
35
|
-
# @return [String, nil] El valor del header o nil si no está presente o configurado.
|
|
13
|
+
# Obtiene el ID de partición desde los headers de la petición.
|
|
14
|
+
# @return [String, nil] El valor del header configurado.
|
|
36
15
|
def current_partition_id
|
|
37
16
|
return @current_partition_id if defined?(@current_partition_id)
|
|
38
17
|
|
|
39
18
|
header_key = TenantPartition.configuration.header_name
|
|
40
|
-
|
|
41
|
-
# Si no se configuró un nombre de header, no podemos buscar nada.
|
|
42
19
|
return @current_partition_id = nil unless header_key.present?
|
|
43
20
|
|
|
44
21
|
@current_partition_id = request.headers[header_key]
|
|
45
22
|
end
|
|
46
23
|
|
|
47
|
-
# Filtro
|
|
48
|
-
#
|
|
49
|
-
# Retorna un error 400 Bad Request si el header falta.
|
|
50
|
-
#
|
|
51
|
-
# @return [void]
|
|
24
|
+
# Filtro before_action para forzar la presencia del tenant.
|
|
25
|
+
# Renderiza un error 400 Bad Request si falta.
|
|
52
26
|
def require_partition_key!
|
|
53
27
|
return if current_partition_id.present?
|
|
54
28
|
|
|
55
|
-
header_key = TenantPartition.configuration.header_name
|
|
56
|
-
|
|
57
|
-
# Mensaje de error detallado dependiendo de si es un error de configuración o de petición
|
|
58
|
-
error_message = if header_key.blank?
|
|
59
|
-
"Server Configuration Error: 'header_name' is not configured in TenantPartition."
|
|
60
|
-
else
|
|
61
|
-
"Missing required header: '#{header_key}'"
|
|
62
|
-
end
|
|
63
|
-
|
|
64
29
|
render json: {
|
|
65
30
|
error: "Partitioning Error",
|
|
66
|
-
message:
|
|
31
|
+
message: missing_header_message
|
|
67
32
|
}, status: :bad_request
|
|
68
33
|
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def missing_header_message
|
|
38
|
+
header_key = TenantPartition.configuration.header_name
|
|
39
|
+
if header_key.blank?
|
|
40
|
+
"Server Configuration Error: 'header_name' is not configured in TenantPartition."
|
|
41
|
+
else
|
|
42
|
+
"Missing required header: '#{header_key}'"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
69
45
|
end
|
|
70
46
|
end
|
|
71
47
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TenantPartition
|
|
4
|
+
module Concerns
|
|
5
|
+
# Módulo encargado de la migración de datos (Backfilling) entre tablas.
|
|
6
|
+
# Se extrajo de {TenantPartition::Base} para desacoplar la lógica de movimiento de datos.
|
|
7
|
+
module DataMover
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
# Query SQL parametrizada para el movimiento atómico (DELETE + INSERT).
|
|
11
|
+
# Se define como constante para evitar la ofensa Metrics/MethodLength de RuboCop
|
|
12
|
+
# y mejorar la performance evitando la re-asignación de memoria en cada llamada.
|
|
13
|
+
# @api private
|
|
14
|
+
MOVE_SQL = <<~SQL.squish.freeze
|
|
15
|
+
WITH moved_rows AS (
|
|
16
|
+
DELETE FROM %<default>s
|
|
17
|
+
WHERE %<key>s = '%<val>s'
|
|
18
|
+
AND id IN (
|
|
19
|
+
SELECT id FROM %<default>s WHERE %<key>s = '%<val>s' LIMIT %<batch_size>d
|
|
20
|
+
)
|
|
21
|
+
RETURNING *
|
|
22
|
+
)
|
|
23
|
+
INSERT INTO %<parent>s SELECT * FROM moved_rows;
|
|
24
|
+
SQL
|
|
25
|
+
private_constant :MOVE_SQL
|
|
26
|
+
|
|
27
|
+
# Mueve registros desde la tabla DEFAULT hacia la partición actual.
|
|
28
|
+
# Utiliza transacciones por lotes para evitar bloqueos prolongados en la base de datos.
|
|
29
|
+
#
|
|
30
|
+
# @param batch_size [Integer] Cantidad de registros por transacción (Default: 5000).
|
|
31
|
+
# @return [Integer] La cantidad total de registros movidos exitosamente.
|
|
32
|
+
def populate_from_default(batch_size: 5000)
|
|
33
|
+
return 0 unless persisted?
|
|
34
|
+
|
|
35
|
+
ActiveSupport::Notifications.instrument("populate.tenant_partition", instrumentation_payload) do |evt|
|
|
36
|
+
total = perform_batch_move(batch_size)
|
|
37
|
+
evt[:count] = total
|
|
38
|
+
total
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Construye el payload de datos para la instrumentación de ActiveSupport.
|
|
45
|
+
# @return [Hash] Datos del contexto de la migración.
|
|
46
|
+
def instrumentation_payload
|
|
47
|
+
{
|
|
48
|
+
partition_key: self.class.partition_key,
|
|
49
|
+
value: partition_id,
|
|
50
|
+
parent_table: self.class.parent_table
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Ejecuta el bucle de movimiento hasta que no queden registros pendientes.
|
|
55
|
+
# @param batch_size [Integer] Tamaño del lote.
|
|
56
|
+
# @return [Integer] Total acumulado de registros movidos.
|
|
57
|
+
def perform_batch_move(batch_size)
|
|
58
|
+
total_moved = 0
|
|
59
|
+
loop do
|
|
60
|
+
batch_count = move_single_batch(batch_size)
|
|
61
|
+
total_moved += batch_count
|
|
62
|
+
break if batch_count < batch_size
|
|
63
|
+
end
|
|
64
|
+
total_moved
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Ejecuta una transacción atómica para mover un solo lote de registros.
|
|
68
|
+
# @param batch_size [Integer] Tamaño del lote.
|
|
69
|
+
# @return [Integer] Cantidad de filas afectadas (cmd_tuples).
|
|
70
|
+
def move_single_batch(batch_size)
|
|
71
|
+
self.class.connection.transaction do
|
|
72
|
+
# Kernel#format es más rápido y seguro que la interpolación directa para templates
|
|
73
|
+
sql = format(MOVE_SQL, move_query_params(batch_size))
|
|
74
|
+
self.class.connection.execute(sql).cmd_tuples
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Prepara los parámetros para inyectar en la plantilla SQL.
|
|
79
|
+
# Se extrajo a un método separado para reducir la longitud de `move_single_batch`.
|
|
80
|
+
# @param batch_size [Integer] Tamaño del lote.
|
|
81
|
+
# @return [Hash] Parámetros formateados para Kernel#format.
|
|
82
|
+
def move_query_params(batch_size)
|
|
83
|
+
{
|
|
84
|
+
default: self.class.default_table,
|
|
85
|
+
parent: self.class.parent_table,
|
|
86
|
+
key: self.class.partition_key,
|
|
87
|
+
val: partition_id,
|
|
88
|
+
batch_size: batch_size
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|