tenant_partition 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f686ab0abe926917ef8f7b12e142570e6ace6943c725a6ebd93471fcbea4bfe
4
- data.tar.gz: c88ed9c6d0e322314f4d80e8dec69312dd95e3d362e939d0467b9204f660c34f
3
+ metadata.gz: 4f9ccf1323d98dd3c78356387de4666127584d7539098e6501d326da1d107fa0
4
+ data.tar.gz: 05e849e009ee93921e6de9b8f207a6ae91781e2b177295d2ae23def5df5e07b1
5
5
  SHA512:
6
- metadata.gz: 5eae8613d5ed6d0d0a8fc13d44fce4b277060b0979e8486e116614c44e508afee05ff66a5302b3eade5d45cbb4d28eb32e9114c70b88dc510e48a9a05b7ea8a7
7
- data.tar.gz: 4565df6b3b073849a24a3262d43a2cabc82ad268ee0a71ccfedab1ff952dcf9a86aeacb42cf6f4331fdf59b95a3647ccf81c4f6f42e90be03aa7d59fa4af9cf9
6
+ metadata.gz: 368ec2f0cfc8b312ca5b5ac61eb7ace18da414faae68846e6353e7058cb5079f04777a0c6418fdd03ddd66da90df188f05d23c43dce8292a556a13d30ca48da1
7
+ data.tar.gz: ecab7244f3a6e106c96a27954b2df753fd1ac0143d67423b35b653c7816dfc973b946b538f1b6a6afb6dae7b0d44ff358b607bb6d394b86e17e0b9704ef86034
data/CHANGELOG.md CHANGED
@@ -1,9 +1,20 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.1.3] - 2026-02-03
4
- ### Changed
5
- - Generator: Se simplificó el controlador API generado (se eliminó la acción `show` y el manejo de errores por defecto).
6
- - Fix: Se restringieron las versiones de `activemodel` y `activerecord` a `< 9.0` para evitar advertencias de RubyGems.
3
+ ## [0.2.0] - 2026-02-13
4
+ ### Changed (Breaking Changes)
5
+ - **Arquitectura:** Se eliminó la clase `TenantPartition::Base` y la necesidad de crear modelos de infraestructura en el namespace `Partition::`.
6
+ - **Integración:** Ahora se utiliza un concern `TenantPartition::Concerns::Partitioned` y la macro `partition_table` directamente en los modelos de dominio.
7
+
8
+ ### Added
9
+ - Macro `partition_table` para activar particionamiento de forma declarativa (Opt-In).
10
+ - Soporte para claves primarias compuestas (Composite Primary Keys) nativas de Rails 7.1+.
11
+ - Nuevo sistema de "Registry" interno para el descubrimiento de modelos particionados (reemplaza a `ObjectSpace`).
12
+ - Métodos de clase `create_partition`, `drop_partition` y `partition_table_exists?` inyectados directamente en el modelo.
13
+ - Documentación actualizada con estrategias de inclusión (Global vs Local).
14
+
15
+ ### Fixed
16
+ - Corrección de ofensas de RuboCop en métodos largos de generación SQL.
17
+ - Mejoras en la seguridad de tipos en la orquestación de tareas de mantenimiento.
7
18
 
8
19
  ## [0.1.2] - 2026-02-03
9
20
  ### Added
data/README.md CHANGED
@@ -1,25 +1,19 @@
1
1
  # TenantPartition
2
2
 
3
- **TenantPartition** es un framework de infraestructura para Ruby on Rails (7.1+) diseñado para simplificar y automatizar la gestión de **Particionamiento por Lista (List Partitioning)** nativo de PostgreSQL.
3
+ **TenantPartition** es una solución robusta y "Rails-native" para implementar **Particionamiento Declarativo (List Partitioning)** de PostgreSQL en aplicaciones Ruby on Rails.
4
4
 
5
- Específicamente construido para arquitecturas **Multi-tenant**, este framework resuelve la complejidad de:
6
- 1. **Composite Primary Keys (CPK):** Configuración automática de claves compuestas (`id` + `partition_key`) requeridas por ActiveRecord para soportar particionamiento.
7
- 2. **Orquestación Centralizada:** Una única interfaz para crear y eliminar particiones en **todos** los modelos de la aplicación simultáneamente.
8
- 3. **Mantenimiento Zero-Downtime:** Migración atómica de datos "huérfanos" (que cayeron en la tabla `DEFAULT`) hacia sus particiones correctas sin perder servicio.
5
+ A diferencia de otras soluciones que dependen de esquemas (schemas) o hackeos a la conexión de base de datos, `tenant_partition` utiliza características nativas de PostgreSQL para dividir tablas gigantes en tablas físicas más pequeñas por tenant (Cliente, ISP, Organización, etc.), manteniendo la experiencia de desarrollo de ActiveRecord estándar.
9
6
 
10
7
  ## 🚀 Características Principales
11
8
 
12
- * **Fachada de Servicio:** API unificada (`TenantPartition.create!`, `destroy!`, `audit`, `cleanup!`) para gestionar el ciclo de vida completo de los tenants.
13
- * **Introspección Inteligente:** Los modelos de negocio detectan automáticamente su configuración de infraestructura por convención (ej: `User` -> `Partition::User`).
14
- * **Migration DSL:** Helper `create_partitioned_table` para definir tablas particionadas y sus tablas `DEFAULT` en una sola instrucción.
15
- * **Seguridad Estricta:** Concern para controladores que valida Headers HTTP (`X-Tenant-ID`) para asegurar el contexto del tenant.
16
- * **Observabilidad:** Instrumentación integrada con `ActiveSupport::Notifications`.
9
+ * **API Simple (Opt-in):** Usa `partition_table` en tus modelos para activar la magia.
10
+ * **Soporte Nativo CPK:** Compatible con **Composite Primary Keys** de Rails 7.1+.
11
+ * **Sin Magic Strings:** Usa métodos explícitos (`create_partition`, `drop_partition`).
12
+ * **Gestión de Datos Huérfanos:** Herramientas para mover datos de la tabla "Default" a su partición correcta automáticamente.
13
+ * **Safety Guards:** Protección contra borrados accidentales en Producción.
14
+ * **Tasks de Mantenimiento:** Rake tasks integradas para auditoría y limpieza.
17
15
 
18
- ## 📋 Requisitos
19
-
20
- * **Ruby:** >= 3.2
21
- * **Ruby on Rails:** >= 7.1
22
- * **PostgreSQL:** >= 13.0 (Requerido para `gen_random_uuid()` nativo)
16
+ ---
23
17
 
24
18
  ## 📦 Instalación
25
19
 
@@ -29,183 +23,177 @@ Agrega esto a tu `Gemfile`:
29
23
  gem 'tenant_partition'
30
24
  ```
31
25
 
32
- Luego ejecuta:
26
+ Y ejecuta:
33
27
 
34
28
  ```bash
35
29
  bundle install
36
30
  ```
37
31
 
38
- ## ⚙️ Configuración
32
+ ---
33
+
34
+ ## ⚙️ Configuración Inicial
39
35
 
40
- Crea un inicializador en `config/initializers/tenant_partition.rb`. Es **obligatorio** definir la clave de partición.
36
+ Crea un inicializador para definir tu clave de partición global (por ejemplo, `:isp_id`, `:account_id`, `:tenant_id`).
41
37
 
42
38
  ```ruby
39
+ # config/initializers/tenant_partition.rb
40
+
43
41
  TenantPartition.configure do |config|
44
- # 1. La columna que discrimina los tenants (ej: :isp_id, :account_id, :tenant_id)
42
+ # Esta es la columna que actuará como discriminador global
45
43
  config.partition_key = :isp_id
46
-
47
- # 2. El Header HTTP para la seguridad en controladores API
48
- config.header_name = 'X-Tenant-ID'
49
44
  end
50
45
  ```
51
46
 
52
- ## 🛠 Guía de Uso
47
+ ---
53
48
 
54
- ### 1. Migraciones (Crear las Tablas)
49
+ ## 🏗 Estrategias de Uso
55
50
 
56
- Usa el helper `create_partitioned_table`. Este método deshabilita el ID automático simple y configura una **Primary Key Compuesta** (`[:id, :partition_key]`) necesaria para que PostgreSQL permita el particionamiento.
51
+ La gema utiliza un patrón "Opt-In". Incluir el módulo no altera tus modelos hasta que lo activas explícitamente. Puedes elegir la estrategia que mejor se adapte a tu proyecto:
52
+
53
+ ### Opción A: Global (Recomendado)
54
+ Incluye el concern en `ApplicationRecord`. Esto **NO** particiona tus tablas, solo habilita la posibilidad de usar la macro `partition_table` en el futuro. Es ideal para mantener el código limpio.
57
55
 
58
56
  ```ruby
59
- class CreateConversations < ActiveRecord::Migration[7.1]
60
- def change
61
- create_partitioned_table :conversations do |t|
62
- # No definas t.primary_key. La gema crea (id, isp_id) automáticamente.
63
- t.string :topic
64
- t.timestamps
65
- end
66
- end
57
+ # app/models/application_record.rb
58
+ class ApplicationRecord < ActiveRecord::Base
59
+ primary_abstract_class
60
+
61
+ # Habilita la herramienta, pero permanece inactiva por defecto.
62
+ include TenantPartition::Concerns::Partitioned
67
63
  end
68
64
  ```
69
65
 
70
- ### 2. Capa de Infraestructura (Modelos Partition)
71
-
72
- Define modelos que hereden de `TenantPartition::Base`. Estos modelos son responsables de las operaciones DDL (Create/Drop tables). Por convención, se recomienda usar el namespace `Partition::`.
66
+ ### Opción B: Local (A la carta)
67
+ Si prefieres no tocar `ApplicationRecord` o estás en un sistema legacy, puedes incluir el concern solo en los modelos específicos.
73
68
 
74
69
  ```ruby
75
- # app/models/partition/conversation.rb
76
- module Partition
77
- class Conversation < TenantPartition::Base
78
- # Hereda la configuración global (:isp_id) automáticamente.
79
- end
70
+ # app/models/conversation.rb
71
+ class Conversation < ApplicationRecord
72
+ include TenantPartition::Concerns::Partitioned
73
+ partition_table # Activación inmediata
80
74
  end
81
75
  ```
82
76
 
83
- ### 3. Capa de Negocio (Modelos Rails)
77
+ ---
84
78
 
85
- En tus modelos estándar (`ApplicationRecord`), incluye el concern `Partitioned`.
79
+ ## 📖 Referencia de Macros y Métodos
86
80
 
87
- **Magia de Introspección:**
88
- Al incluir el concern, la gema busca automáticamente si existe un modelo de infraestructura asociado (ej: `Partition::Conversation`) y hereda su configuración.
81
+ Una vez que incluyes `TenantPartition::Concerns::Partitioned` en tu clase, obtienes acceso a las siguientes herramientas:
82
+
83
+ ### 1. La Macro de Activación: `partition_table`
84
+
85
+ Es el interruptor de encendido. Debe llamarse al inicio de la definición del modelo.
89
86
 
90
87
  ```ruby
91
- # app/models/conversation.rb
92
88
  class Conversation < ApplicationRecord
93
- include TenantPartition::Concerns::Partitioned
89
+ # Uso estándar (usa la key configurada globalmente, ej: :isp_id)
90
+ partition_table
94
91
 
95
- # ¡Listo! Rails ahora sabe que la Primary Key es [:id, :isp_id]
96
- # y aplica scopes automáticos.
92
+ # Uso personalizado (para modelos con keys únicas, ej: :year)
93
+ # partition_table key: :year
97
94
  end
98
95
  ```
99
96
 
100
- ### 4. Orquestación (Ciclo de Vida del Tenant)
97
+ **¿Qué hace esta macro internamente?**
98
+ 1. Configura la **Primary Key Compuesta** (`[:id, :partition_key]`).
99
+ 2. Registra el modelo en el sistema de mantenimiento de la gema.
100
+ 3. Inyecta los métodos de gestión de infraestructura (ver abajo).
101
+ 4. Agrega el scope `for_partition(value)`.
101
102
 
102
- Ya no necesitas crear particiones tabla por tabla. Usa la **Fachada** `TenantPartition` para gestionar la infraestructura de un tenant en **todos** los modelos registrados simultáneamente.
103
+ ### 2. Métodos de Gestión de Infraestructura (Class Methods)
103
104
 
104
- **Crear un nuevo Tenant (Provisioning):**
105
- Ideal para usar en tu `RegistrationService` o `AfterCommit` de la creación del tenant.
105
+ Estos métodos se inyectan en tu modelo **solo después** de llamar a `partition_table`. Úsalos para gestionar el ciclo de vida de las tablas físicas.
106
106
 
107
- ```ruby
108
- # En tu Service Object o Controller de registro
109
- def create_tenant
110
- isp = Isp.create!(params)
107
+ | Método | Descripción | Ejemplo |
108
+ | :--- | :--- | :--- |
109
+ | `create_partition(val)` | Crea la tabla física en Postgres (`CREATE TABLE ... PARTITION OF ...`). | `Conversation.create_partition(100)` |
110
+ | `drop_partition(val)` | Elimina la tabla física y sus datos (`DROP TABLE ...`). | `Conversation.drop_partition(100)` |
111
+ | `partition_table_exists?(val)` | Devuelve `true` si la tabla física existe en la BD. | `Conversation.partition_table_exists?(100)` |
112
+ | `partition_table_name(val)` | Devuelve el nombre real de la tabla hija. | `Conversation.partition_table_name(100)` <br> *=> "conversations_isp_100"* |
111
113
 
112
- # Busca TODOS los modelos particionados y crea las tablas físicas para este ID.
113
- # Es idempotente: si alguna ya existe, la salta sin error.
114
- TenantPartition.create!(isp.id)
115
- end
116
- ```
114
+ ---
117
115
 
118
- **Eliminar un Tenant (Deprovisioning):**
116
+ ## 🛠 Guía de Implementación
119
117
 
120
- ```ruby
121
- # Esta operación realiza DETACH + DROP de las tablas físicas.
122
- # ¡Es destructiva e irreversible!
123
- TenantPartition.destroy!(old_isp.id)
124
- ```
118
+ ### 1. Migración de Base de Datos
125
119
 
126
- ### 5. Generador de API (Provisioning)
120
+ PostgreSQL necesita que la tabla padre se cree con la opción `PARTITION BY LIST`.
127
121
 
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.
122
+ ```ruby
123
+ class CreateConversations < ActiveRecord::Migration[7.1]
124
+ def up
125
+ # 1. Crear la tabla padre particionada (id: false es importante)
126
+ create_table :conversations, id: false, options: "PARTITION BY LIST (isp_id)" do |t|
127
+ t.bigserial :id, null: false
128
+ t.integer :isp_id, null: false # Tu partition key
129
+
130
+ t.string :subject
131
+ t.timestamps
132
+ end
129
133
 
130
- **Uso Básico (Namespace por defecto `TenantPartition`):**
134
+ # 2. Definir la Primary Key Compuesta (Requerido por Postgres)
135
+ execute "ALTER TABLE conversations ADD PRIMARY KEY (id, isp_id);"
131
136
 
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
137
+ # 3. Crear tabla DEFAULT (Recomendado para evitar errores de inserción)
138
+ execute "CREATE TABLE conversations_default PARTITION OF conversations DEFAULT;"
139
+ end
140
+ # ...
136
141
  ```
137
142
 
138
- ### 6. Seguridad en Controladores
143
+ ### 2. Callbacks de Aprovisionamiento
139
144
 
140
- Protege tus API endpoints asegurando que siempre reciban el ID del tenant.
145
+ Es común automatizar la creación de particiones cuando se crea un nuevo Tenant (ej. un nuevo ISP o Cliente).
141
146
 
142
147
  ```ruby
143
- class ApiController < ActionController::API
144
- include TenantPartition::Concerns::Controller
145
-
146
- # Valida que el request traiga el header 'X-Tenant-ID'.
147
- # Devuelve 400 Bad Request si falta.
148
- before_action :require_partition_key!
148
+ # app/models/isp.rb
149
+ class Isp < ApplicationRecord
150
+ after_create :provision_infrastructure
149
151
 
150
- def index
151
- # current_partition_id contiene el valor seguro del Header
152
- @chats = Conversation.for_partition(current_partition_id).all
153
- render json: @chats
152
+ def provision_infrastructure
153
+ # Método helper que crea las particiones en TODOS los modelos registrados
154
+ TenantPartition.create!(self.id)
154
155
  end
155
156
  end
156
157
  ```
157
158
 
158
- ## 🛡 Mantenimiento y Recuperación
159
-
160
- TenantPartition maneja el escenario de "Race Condition" donde llegan datos *antes* de que la partición exista. Esos datos caen automáticamente en la tabla `_default`.
159
+ ---
161
160
 
162
- ### Auditoría
161
+ ## 🧹 Mantenimiento y Datos Huérfanos
163
162
 
164
- Verifica si tienes datos "fugados" en las tablas default:
163
+ Si insertas datos con un `isp_id` para el cual no has creado una partición (y tienes una tabla `DEFAULT`), los datos caerán ahí.
165
164
 
166
- ```ruby
167
- # Desde consola Rails
168
- report = TenantPartition.audit
169
- # => { "Partition::Conversation" => 14, "Partition::Message" => 0 }
170
- ```
165
+ ### Auditoría
166
+ Verifica si tienes datos en las tablas default:
171
167
 
172
- O vía Rake task:
173
168
  ```bash
174
- bundle exec rails tenant_partition:audit
169
+ bundle exec rake tenant_partition:audit
170
+ # [TenantPartition] [AUDIT] Iniciando auditoría...
171
+ # [TenantPartition] [ALERTA] Conversation: 450 registros huérfanos encontrados.
175
172
  ```
176
173
 
177
174
  ### Limpieza (Cleanup)
175
+ Crea las particiones faltantes y mueve los datos automáticamente:
178
176
 
179
- Mueve los datos huérfanos a sus particiones correspondientes de forma atómica y segura.
180
-
181
- ```ruby
182
- # Ruby API (Ideal para Jobs nocturnos)
183
- TenantPartition.cleanup!
184
- ```
185
-
186
- O vía Rake task:
187
177
  ```bash
188
- bundle exec rails tenant_partition:cleanup
178
+ bundle exec rake tenant_partition:cleanup
179
+ # [TenantPartition] [FIX] Conversation: Procesando 2 tenants con datos huérfanos.
180
+ # [TenantPartition] [MOVE] -> ID 101: 450 registros recuperados.
189
181
  ```
190
182
 
191
- ## 📊 Observabilidad
183
+ ---
192
184
 
193
- Puedes suscribirte a los eventos para enviar métricas a tu sistema de monitoreo (Datadog, Prometheus, NewRelic).
185
+ ## 🛡️ Producción y Seguridad
194
186
 
195
- ```ruby
196
- # config/initializers/notifications.rb
197
- ActiveSupport::Notifications.subscribe(/tenant_partition/) do |name, start, finish, id, payload|
198
- duration = (finish - start) * 1000
199
-
200
- case name
201
- when "create.tenant_partition"
202
- Rails.logger.info "📦 Partición creada: #{payload[:table]} para #{payload[:value]}"
203
- when "populate.tenant_partition"
204
- Rails.logger.info "🧹 Limpieza: #{payload[:count]} registros movidos en #{duration.round(2)}ms"
205
- end
206
- end
187
+ La gema incluye un `SafetyGuard` que impide ejecutar comandos destructivos (`drop_partition`, `destroy!`) en entorno de producción a menos que se fuerce explícitamente.
188
+
189
+ Para ejecutar tareas destructivas en producción, debes setear la variable de entorno:
190
+
191
+ ```bash
192
+ DISABLE_TENANT_PARTITION_GUARD=true bundle exec rake tenant_partition:destroy_tenant[123]
207
193
  ```
208
194
 
209
- ## 📄 Licencia
195
+ ---
196
+
197
+ ## License
210
198
 
211
- Este proyecto está disponible como código abierto bajo los términos de la [Licencia MIT](https://opensource.org/licenses/MIT).
199
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -61,6 +61,7 @@ module TenantPartition
61
61
  # Definición de rutas explícitas apuntando al controlador generado
62
62
  route "post '#{folder_name}/tenant_partitions', to: '#{folder_name}/tenant_partitions#create'"
63
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'"
64
65
  end
65
66
 
66
67
  # Muestra instrucciones post-generación en la consola.
@@ -2,7 +2,7 @@ module <%= @module_name %>
2
2
  class TenantPartitionsController < ApplicationController
3
3
  skip_before_action :verify_authenticity_token, raise: false
4
4
 
5
- # POST /<%= @module_name.underscores %>/tenant_partitions
5
+ # POST /system/tenant_partitions
6
6
  def create
7
7
  partition_id = params.require(:id)
8
8
 
@@ -12,14 +12,25 @@ module <%= @module_name %>
12
12
  TenantPartition.create!(partition_id)
13
13
  render json: { message: "Tenant provisioned successfully" }, status: :created
14
14
  end
15
+ rescue => e
16
+ render json: { error: e.message }, status: :unprocessable_entity
15
17
  end
16
18
 
17
- # DELETE /<%= @module_name.underscores %>/tenant_partitions/:id
19
+ # DELETE /system/tenant_partitions/:id
18
20
  def destroy
19
21
  partition_id = params.require(:id)
20
22
 
21
23
  TenantPartition.destroy!(partition_id)
22
24
  head :no_content
23
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
24
35
  end
25
36
  end
@@ -2,15 +2,12 @@
2
2
 
3
3
  module TenantPartition
4
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.
5
+ # Funcionalidad para mover registros desde la tabla DEFAULT hacia su partición correspondiente.
6
+ # Utilizado principalmente en tareas de mantenimiento y recuperación de datos.
7
7
  module DataMover
8
8
  extend ActiveSupport::Concern
9
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
10
+ # SQL optimizado para mover datos en masa (CTE + DELETE/INSERT).
14
11
  MOVE_SQL = <<~SQL.squish.freeze
15
12
  WITH moved_rows AS (
16
13
  DELETE FROM %<default>s
@@ -24,14 +21,12 @@ module TenantPartition
24
21
  SQL
25
22
  private_constant :MOVE_SQL
26
23
 
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.
24
+ # Mueve registros pertenecientes a este tenant desde la tabla Default a la partición.
25
+ # Se ejecuta en lotes para no bloquear la base de datos.
29
26
  #
30
- # @param batch_size [Integer] Cantidad de registros por transacción (Default: 5000).
31
- # @return [Integer] La cantidad total de registros movidos exitosamente.
27
+ # @param batch_size [Integer] Tamaño del lote (default: 5000).
28
+ # @return [Integer] Cantidad total de registros movidos.
32
29
  def populate_from_default(batch_size: 5000)
33
- return 0 unless persisted?
34
-
35
30
  ActiveSupport::Notifications.instrument("populate.tenant_partition", instrumentation_payload) do |evt|
36
31
  total = perform_batch_move(batch_size)
37
32
  evt[:count] = total
@@ -41,19 +36,19 @@ module TenantPartition
41
36
 
42
37
  private
43
38
 
44
- # Construye el payload de datos para la instrumentación de ActiveSupport.
45
- # @return [Hash] Datos del contexto de la migración.
46
39
  def instrumentation_payload
47
40
  {
48
- partition_key: self.class.partition_key,
41
+ partition_key: self.class.partition_key_column,
49
42
  value: partition_id,
50
- parent_table: self.class.parent_table
43
+ parent_table: self.class.table_name
51
44
  }
52
45
  end
53
46
 
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.
47
+ # Obtiene el valor del ID de partición de la instancia actual.
48
+ def partition_id
49
+ public_send(self.class.partition_key_column)
50
+ end
51
+
57
52
  def perform_batch_move(batch_size)
58
53
  total_moved = 0
59
54
  loop do
@@ -64,26 +59,18 @@ module TenantPartition
64
59
  total_moved
65
60
  end
66
61
 
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
62
  def move_single_batch(batch_size)
71
63
  self.class.connection.transaction do
72
- # Kernel#format es más rápido y seguro que la interpolación directa para templates
73
64
  sql = format(MOVE_SQL, move_query_params(batch_size))
74
65
  self.class.connection.execute(sql).cmd_tuples
75
66
  end
76
67
  end
77
68
 
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
69
  def move_query_params(batch_size)
83
70
  {
84
- default: self.class.default_table,
85
- parent: self.class.parent_table,
86
- key: self.class.partition_key,
71
+ default: self.class.default_partition_table_name,
72
+ parent: self.class.table_name,
73
+ key: self.class.partition_key_column,
87
74
  val: partition_id,
88
75
  batch_size: batch_size
89
76
  }
@@ -1,63 +1,138 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "data_mover"
4
+
3
5
  module TenantPartition
4
6
  module Concerns
5
- # Concern para modelos Rails (ApplicationRecord).
6
- # Configura automáticamente las claves primarias compuestas y scopes.
7
+ # Concern principal para dotar a un modelo ActiveRecord de capacidades de particionamiento.
8
+ #
9
+ # Al incluir este concern en ApplicationRecord, los modelos obtienen acceso a la macro
10
+ # {.partition_table}, la cual activa la lógica de partición, configura la clave primaria
11
+ # compuesta y añade métodos de gestión de tablas (DDL).
7
12
  module Partitioned
8
13
  extend ActiveSupport::Concern
9
14
 
10
- included do
11
- TenantPartition::Concerns::Partitioned.configure_model(self)
12
- end
13
-
14
15
  class_methods do
15
- # Intercepta la herencia para autoconfigurar subclases.
16
- def inherited(subclass)
17
- super
18
- TenantPartition::Concerns::Partitioned.configure_model(subclass)
16
+ # Macro para activar el particionamiento en el modelo actual.
17
+ #
18
+ # @example Activar partición por ISP
19
+ # class Conversation < ApplicationRecord
20
+ # partition_table key: :isp_id
21
+ # end
22
+ #
23
+ # @param key [Symbol, nil] La columna clave de partición. Si es nil, usa la global.
24
+ # @return [void]
25
+ def partition_table(key: nil)
26
+ resolved_key = key || TenantPartition.configuration.partition_key
27
+
28
+ # Guardamos la key en una variable de instancia de clase para acceso rápido
29
+ @partition_key_column = resolved_key
30
+
31
+ # Registrar este modelo en el sistema
32
+ TenantPartition.register_model(self)
33
+
34
+ # Configurar Primary Key Compuesta (Soporte Rails 7.1+)
35
+ self.primary_key = [:id, resolved_key]
36
+
37
+ # Inyectar Scopes Automáticos
38
+ scope :for_partition, ->(val) { where(resolved_key => val) }
39
+
40
+ # Inyectar lógica de infraestructura y movimiento de datos
41
+ extend ManagementMethods
42
+ include TenantPartition::Concerns::DataMover
19
43
  end
20
44
 
21
- # Permite definir manualmente la clave de partición para este modelo.
22
- # @param key [Symbol] Nombre de la columna.
23
- def partitioned_by(key)
24
- TenantPartition::Concerns::Partitioned.apply_configuration(self, key)
45
+ # Devuelve el nombre de la columna usada para particionar este modelo.
46
+ # @return [Symbol]
47
+ def partition_key_column
48
+ @partition_key_column
25
49
  end
26
50
  end
27
51
 
28
- # Configura el modelo detectando la clave de partición adecuada.
29
- # @api private
30
- # @param klass [Class] El modelo a configurar.
31
- def self.configure_model(klass)
32
- return if klass.respond_to?(:abstract_class?) && klass.abstract_class?
52
+ # Métodos de gestión de infraestructura (DDL) inyectados como métodos de clase.
53
+ module ManagementMethods
54
+ # Crea físicamente la partición en la base de datos para un valor dado.
55
+ #
56
+ # @param value [String, Integer] El valor del tenant (ej: ID del ISP).
57
+ # @return [void]
58
+ # @raise [ActiveRecord::StatementInvalid] Si falla la ejecución SQL.
59
+ def create_partition(value)
60
+ payload = create_partition_payload(value)
33
61
 
34
- key_to_use = resolve_partition_key(klass)
35
- apply_configuration(klass, key_to_use) if key_to_use.present?
36
- end
62
+ ActiveSupport::Notifications.instrument("create.tenant_partition", payload) do
63
+ execute_create_partition_sql(value)
64
+ end
65
+ end
37
66
 
38
- # Resuelve la clave de partición mediante introspección o configuración global.
39
- # @api private
40
- # @param klass [Class] El modelo a inspeccionar.
41
- # @return [Symbol, nil] La clave encontrada.
42
- def self.resolve_partition_key(klass)
43
- infra_class_name = "Partition::#{klass.name}"
44
- infra_class = infra_class_name.safe_constantize
67
+ # Elimina (DROP) la partición asociada al valor dado.
68
+ # Realiza un DETACH primero para seguridad y luego DROP.
69
+ #
70
+ # @param value [String, Integer] El valor del tenant.
71
+ # @return [void]
72
+ def drop_partition(value)
73
+ partition_name = partition_table_name(value)
45
74
 
46
- # CORRECCIÓN: respond_to? es seguro en nil, no requiere safe navigation (&.)
47
- return infra_class.partition_key if infra_class.respond_to?(:partition_key)
75
+ return unless partition_table_exists?(value)
48
76
 
49
- TenantPartition.configuration&.partition_key
50
- end
77
+ connection.transaction do
78
+ connection.execute("ALTER TABLE #{table_name} DETACH PARTITION #{partition_name};")
79
+ connection.execute("DROP TABLE IF EXISTS #{partition_name};")
80
+ end
81
+ end
51
82
 
52
- # Aplica la configuración de CPK y scopes al modelo.
53
- # @api private
54
- # @param klass [Class] El modelo.
55
- # @param key [Symbol] La clave de partición.
56
- def self.apply_configuration(klass, key)
57
- return if klass.primary_key.is_a?(Array) && klass.primary_key.include?(key.to_s)
83
+ # Genera el nombre de la tabla física para una partición específica.
84
+ #
85
+ # @param value [Object] El valor del tenant.
86
+ # @return [String] Nombre de la tabla (ej: 'conversations_isp_1').
87
+ def partition_table_name(value)
88
+ sanitized_value = value.to_s.gsub("-", "_")
89
+ suffix = partition_key_column.to_s.gsub("_id", "")
58
90
 
59
- klass.primary_key = [:id, key]
60
- klass.scope :for_partition, ->(value) { where(key => value) }
91
+ # Formato: nombre_tabla_sufijo_valor
92
+ "#{table_name}_#{suffix}_#{sanitized_value}"
93
+ end
94
+
95
+ # Verifica si la tabla de la partición existe en el catálogo de PostgreSQL.
96
+ #
97
+ # @param value [Object] El valor del tenant.
98
+ # @return [Boolean]
99
+ def partition_table_exists?(value)
100
+ child_table = partition_table_name(value)
101
+
102
+ sql = <<~SQL.squish
103
+ SELECT 1 FROM pg_class c
104
+ JOIN pg_inherits i ON c.oid = i.inhrelid
105
+ JOIN pg_class p ON i.inhparent = p.oid
106
+ WHERE p.relname = '#{table_name}' AND c.relname = '#{child_table}';
107
+ SQL
108
+
109
+ connection.execute(sql).any?
110
+ end
111
+
112
+ # Nombre de la tabla DEFAULT (para valores que no caen en ninguna partición).
113
+ # @return [String]
114
+ def default_partition_table_name
115
+ "#{table_name}_default"
116
+ end
117
+
118
+ private
119
+
120
+ def create_partition_payload(value)
121
+ {
122
+ partition_key: partition_key_column,
123
+ value: value,
124
+ parent_table: table_name
125
+ }
126
+ end
127
+
128
+ def execute_create_partition_sql(value)
129
+ table_name_for_partition = partition_table_name(value)
130
+ sql = <<~SQL.squish
131
+ CREATE TABLE IF NOT EXISTS #{table_name_for_partition}
132
+ PARTITION OF #{table_name} FOR VALUES IN ('#{value}');
133
+ SQL
134
+ connection.execute(sql)
135
+ end
61
136
  end
62
137
  end
63
138
  end
@@ -1,79 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TenantPartition
4
- # Módulo Mixin que agrega capacidades de mantenimiento (Auditoría y Limpieza)
5
- # a la fachada principal.
4
+ # Módulo de utilidades para el mantenimiento de la salud de las particiones.
5
+ # Permite auditar tablas DEFAULT en busca de registros huérfanos y moverlos.
6
6
  module Maintenance
7
- # Audita todas las tablas DEFAULT del sistema buscando registros huérfanos.
8
- # Un registro huérfano es aquel que cayó en la tabla default porque su partición no existía.
7
+ # Audita todas las tablas particionadas y cuenta registros en la tabla DEFAULT.
9
8
  #
10
- # @return [Hash{String => Integer}] Reporte con nombre del modelo y cantidad de huérfanos.
9
+ # @return [Hash{String => Integer}] Reporte con el conteo de huérfanos por modelo.
11
10
  def audit
12
- ensure_models_loaded!
13
- log_info "AUDIT", "Iniciando auditoría de tablas DEFAULT..."
11
+ TenantPartition.send(:ensure_models_loaded!)
12
+ TenantPartition.log_info "AUDIT", "Iniciando auditoría de tablas DEFAULT..."
14
13
 
15
- partitionable_models.each_with_object({}) do |model, report|
14
+ TenantPartition.partitionable_models.each_with_object({}) do |model, report|
16
15
  count = count_default_rows(model)
17
16
  report[model.name] = count
18
17
  log_audit_result(model, count)
19
18
  end
20
19
  end
21
20
 
22
- # Ejecuta el proceso de limpieza global.
23
- # Identifica registros huérfanos y los mueve a sus particiones correspondientes.
21
+ # Ejecuta el proceso de limpieza: busca huérfanos y los mueve a su partición correspondiente.
22
+ # Si la partición no existe, lanza un error en el log.
24
23
  #
25
24
  # @return [void]
26
25
  def cleanup!
27
- ensure_models_loaded!
28
- key = configuration.partition_key
29
- log_info "CLEANUP", "Iniciando proceso de limpieza global..."
26
+ TenantPartition.log_info "CLEANUP", "Iniciando proceso de limpieza global..."
30
27
 
31
- partitionable_models.each do |model|
32
- process_cleanup_for_model(model, key)
28
+ TenantPartition.partitionable_models.each do |model|
29
+ # Usamos la key específica del modelo por si fue configurada localmente
30
+ model_key = model.partition_key_column
31
+ process_cleanup_for_model(model, model_key)
33
32
  end
34
33
  end
35
34
 
36
35
  private
37
36
 
38
- # Registra el resultado de una auditoría en el log.
39
37
  def log_audit_result(model, count)
40
38
  if count.positive?
41
- log_warn "ALERTA", "#{model.name}: #{count} registros huérfanos encontrados."
39
+ TenantPartition.log_warn "ALERTA", "#{model.name}: #{count} registros huérfanos encontrados."
42
40
  else
43
- log_info "OK", "#{model.name}: Tabla default limpia."
41
+ TenantPartition.log_info "OK", "#{model.name}: Tabla default limpia."
44
42
  end
45
43
  end
46
44
 
47
- # Orquesta la limpieza para un modelo específico.
48
45
  def process_cleanup_for_model(model, key)
49
46
  orphan_ids = fetch_orphan_ids(model, key)
50
- return log_info("OK", "#{model.name}: Sin datos huérfanos.") if orphan_ids.empty?
47
+ return TenantPartition.log_info("OK", "#{model.name}: Sin datos huérfanos.") if orphan_ids.empty?
51
48
 
52
- log_warn "FIX", "#{model.name}: Procesando #{orphan_ids.count} tenants con datos huérfanos."
49
+ TenantPartition.log_warn "FIX", "#{model.name}: Procesando #{orphan_ids.count} tenants con datos huérfanos."
53
50
  orphan_ids.each { |id| move_orphans(model, id) }
54
51
  end
55
52
 
56
- # Mueve los huérfanos de un tenant específico.
57
53
  def move_orphans(model, id)
58
- partition = model.find(id)
59
- if partition
60
- moved = partition.populate_from_default
61
- log_info "MOVE", " -> ID #{id}: #{moved} registros recuperados."
54
+ # Verificamos si la tabla destino existe antes de intentar mover
55
+ if model.partition_table_exists?(id)
56
+ # Instanciamos el modelo solo para usar el DataMover
57
+ instance = model.new(model.partition_key_column => id)
58
+
59
+ moved = instance.populate_from_default
60
+ TenantPartition.log_info "MOVE", " -> ID #{id}: #{moved} registros recuperados."
62
61
  else
63
- log_error "ERROR", " -> ID #{id}: La partición física no existe. Cree el tenant primero."
62
+ TenantPartition.log_error "ERROR", " -> ID #{id}: La partición física no existe. Cree el tenant primero."
64
63
  end
65
64
  end
66
65
 
67
- # Cuenta las filas en la tabla default de un modelo.
68
66
  def count_default_rows(model)
69
- model.connection.select_value("SELECT count(*) FROM #{model.default_table}").to_i
67
+ model.connection.select_value("SELECT count(*) FROM #{model.default_partition_table_name}").to_i
70
68
  rescue ActiveRecord::StatementInvalid
71
69
  0
72
70
  end
73
71
 
74
- # Obtiene los IDs únicos de los registros atrapados en la tabla default.
75
72
  def fetch_orphan_ids(model, key)
76
- sql = "SELECT DISTINCT #{key} FROM #{model.default_table} WHERE #{key} IS NOT NULL"
73
+ sql = "SELECT DISTINCT #{key} FROM #{model.default_partition_table_name} WHERE #{key} IS NOT NULL"
77
74
  model.connection.execute(sql).map { |r| r[key.to_s] }
78
75
  rescue ActiveRecord::StatementInvalid
79
76
  []
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TenantPartition
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -7,7 +7,6 @@ require "active_support/all"
7
7
  require_relative "tenant_partition/version"
8
8
  require_relative "tenant_partition/configuration"
9
9
  require_relative "tenant_partition/safety_guard"
10
- require_relative "tenant_partition/base"
11
10
  require_relative "tenant_partition/maintenance"
12
11
  require_relative "tenant_partition/concerns/partitioned"
13
12
  require_relative "tenant_partition/concerns/controller"
@@ -15,29 +14,62 @@ require_relative "tenant_partition/concerns/controller"
15
14
  require_relative "tenant_partition/railtie" if defined?(Rails)
16
15
 
17
16
  # Fachada principal para la gestión y orquestación de particionamiento en PostgreSQL.
18
- # Centraliza la configuración, creación de particiones y mantenimiento.
17
+ # Permite configurar la gema y ejecutar comandos globales de creación/eliminación de tenants.
19
18
  module TenantPartition
20
19
  class Error < StandardError; end
21
20
 
22
- # Incorpora las funcionalidades de Auditoría y Limpieza.
23
21
  extend Maintenance
24
22
 
25
23
  class << self
26
- # @return [TenantPartition::Configuration] Objeto de configuración global.
24
+ # @return [TenantPartition::Configuration] la configuración actual.
27
25
  attr_accessor :configuration
28
26
 
29
- # Bloque de configuración e inicialización.
30
- # @yieldparam [TenantPartition::Configuration] config
27
+ # Array para almacenar los modelos que han activado el particionamiento.
28
+ # @return [Array<Class>] Lista de clases ActiveRecord registradas.
29
+ def registered_models
30
+ @registered_models ||= []
31
+ end
32
+
33
+ # Registra un modelo como particionado.
34
+ # Método llamado automáticamente por el concern {TenantPartition::Concerns::Partitioned}.
35
+ #
36
+ # @api private
37
+ # @param model [Class] La clase del modelo a registrar.
38
+ # @return [Array<Class>] La lista actualizada de modelos.
39
+ def register_model(model)
40
+ registered_models << model
41
+ registered_models.uniq!
42
+ end
43
+
44
+ # Devuelve la lista de modelos particionados activos en la aplicación.
45
+ # @return [Array<Class>] Lista de modelos.
46
+ def partitionable_models
47
+ registered_models
48
+ end
49
+
50
+ # Bloque de configuración global para la gema.
51
+ #
52
+ # @example Configurar el ISP ID como clave
53
+ # TenantPartition.configure do |config|
54
+ # config.partition_key = :isp_id
55
+ # end
56
+ #
57
+ # @yield [configuration] Objeto de configuración.
58
+ # @return [void]
31
59
  def configure
32
60
  self.configuration ||= Configuration.new
33
61
  yield(configuration)
34
62
  SafetyGuard.validate!
35
63
  end
36
64
 
37
- # Crea las particiones físicas para un tenant en todos los modelos registrados.
38
- # @param partition_id [String, Integer] Identificador del tenant.
65
+ # Crea la infraestructura de particiones (tablas) para un tenant específico.
66
+ # Itera sobre todos los modelos registrados y crea su tabla particionada correspondiente.
67
+ #
68
+ # @param partition_id [Integer, String] El identificador del tenant (ej. ISP ID).
69
+ # @return [void]
39
70
  def create!(partition_id)
40
71
  ensure_models_loaded!
72
+
41
73
  log_info "CREATE", "Iniciando aprovisionamiento para ID: #{partition_id}"
42
74
 
43
75
  partitionable_models.each do |model|
@@ -45,10 +77,14 @@ module TenantPartition
45
77
  end
46
78
  end
47
79
 
48
- # Elimina irreversiblemente las particiones y datos de un tenant.
49
- # @param partition_id [String, Integer] Identificador del tenant.
80
+ # Destruye la infraestructura de particiones para un tenant específico.
81
+ # ¡CUIDADO! Esto elimina físicamente las tablas y sus datos.
82
+ #
83
+ # @param partition_id [Integer, String] El identificador del tenant.
84
+ # @return [void]
50
85
  def destroy!(partition_id)
51
86
  ensure_models_loaded!
87
+
52
88
  log_info "DESTROY", "Eliminando infraestructura para ID: #{partition_id}"
53
89
 
54
90
  partitionable_models.each do |model|
@@ -57,57 +93,44 @@ module TenantPartition
57
93
  end
58
94
 
59
95
  # Verifica si existe infraestructura creada para un tenant.
60
- # @param partition_id [String, Integer] Identificador del tenant.
61
- # @return [Boolean] true si existe al menos una tabla particionada.
96
+ #
97
+ # @param partition_id [Integer, String] El identificador del tenant.
98
+ # @return [Boolean] true si al menos un modelo tiene la tabla creada.
62
99
  def exists?(partition_id)
63
- ensure_models_loaded!
64
- partitionable_models.any? { |model| model.exists?(partition_id) }
100
+ partitionable_models.any? { |model| model.partition_table_exists?(partition_id) }
65
101
  end
66
102
 
67
- # --- Shared Helpers (Accesibles por Maintenance) ---
68
-
69
- # Fuerza la carga de los modelos de partición en entornos con Lazy Loading (Dev).
70
- def ensure_models_loaded!
71
- return unless defined?(Rails)
72
-
73
- partition_dir = Rails.root.join("app/models/partition")
74
- return unless Dir.exist?(partition_dir)
75
-
76
- Dir[partition_dir.join("**/*.rb")].each { |file| require_dependency file }
77
- end
78
-
79
- # Retorna todas las subclases de TenantPartition::Base cargadas en memoria.
80
- # @return [Array<Class>] Lista de clases de modelos.
81
- def partitionable_models
82
- ObjectSpace.each_object(Class).select { |klass| klass < TenantPartition::Base }
83
- end
84
-
85
- # --- Logging ---
86
-
103
+ # @api private
87
104
  def log_info(tag, msg) = logger&.info(format_log(tag, msg))
105
+ # @api private
88
106
  def log_warn(tag, msg) = logger&.warn(format_log(tag, msg))
107
+ # @api private
89
108
  def log_error(tag, msg) = logger&.error(format_log(tag, msg))
109
+
110
+ private
111
+
90
112
  def format_log(tag, msg) = "[TenantPartition] [#{tag}] #{msg}"
91
113
  def logger = (defined?(Rails) ? Rails.logger : Logger.new($stdout))
92
114
 
93
- private
115
+ def ensure_models_loaded!
116
+ return unless defined?(Rails) && !Rails.configuration.eager_load
117
+
118
+ Rails.application.eager_load!
119
+ end
94
120
 
95
121
  def process_creation(model, partition_id)
96
- if model.exists?(partition_id)
122
+ if model.partition_table_exists?(partition_id)
97
123
  log_info "SKIP", "#{model.name}: La partición ya existe."
98
124
  else
99
- model.create(partition_id)
125
+ model.create_partition(partition_id)
100
126
  log_info "OK", "#{model.name}: Partición creada exitosamente."
101
127
  end
102
128
  end
103
129
 
104
130
  def process_destruction(model, partition_id)
105
- if model.exists?(partition_id)
106
- if model.find(partition_id).destroy
107
- log_info "DROP", "#{model.name}: Partición eliminada."
108
- else
109
- log_error "FAIL", "#{model.name}: No se pudo eliminar la partición."
110
- end
131
+ if model.partition_table_exists?(partition_id)
132
+ model.drop_partition(partition_id)
133
+ log_info "DROP", "#{model.name}: Partición eliminada."
111
134
  else
112
135
  log_info "SKIP", "#{model.name}: No existe partición para borrar."
113
136
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tenant_partition
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - gabriel
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-03 00:00:00.000000000 Z
11
+ date: 2026-02-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -17,9 +17,6 @@ dependencies:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '7.1'
20
- - - "<"
21
- - !ruby/object:Gem::Version
22
- version: '9.0'
23
20
  type: :runtime
24
21
  prerelease: false
25
22
  version_requirements: !ruby/object:Gem::Requirement
@@ -27,9 +24,6 @@ dependencies:
27
24
  - - ">="
28
25
  - !ruby/object:Gem::Version
29
26
  version: '7.1'
30
- - - "<"
31
- - !ruby/object:Gem::Version
32
- version: '9.0'
33
27
  - !ruby/object:Gem::Dependency
34
28
  name: activerecord
35
29
  requirement: !ruby/object:Gem::Requirement
@@ -37,9 +31,6 @@ dependencies:
37
31
  - - ">="
38
32
  - !ruby/object:Gem::Version
39
33
  version: '7.1'
40
- - - "<"
41
- - !ruby/object:Gem::Version
42
- version: '9.0'
43
34
  type: :runtime
44
35
  prerelease: false
45
36
  version_requirements: !ruby/object:Gem::Requirement
@@ -47,9 +38,6 @@ dependencies:
47
38
  - - ">="
48
39
  - !ruby/object:Gem::Version
49
40
  version: '7.1'
50
- - - "<"
51
- - !ruby/object:Gem::Version
52
- version: '9.0'
53
41
  description: Framework de infraestructura para Rails 7.1+ que automatiza el particionamiento
54
42
  nativo (List Partitioning). Incluye soporte para Composite Primary Keys, orquestación
55
43
  de tenants y migraciones zero-downtime.
@@ -67,7 +55,6 @@ files:
67
55
  - lib/generators/tenant_partition/templates/README
68
56
  - lib/generators/tenant_partition/templates/tenant_partitions_controller.rb.erb
69
57
  - lib/tenant_partition.rb
70
- - lib/tenant_partition/base.rb
71
58
  - lib/tenant_partition/concerns/controller.rb
72
59
  - lib/tenant_partition/concerns/data_mover.rb
73
60
  - lib/tenant_partition/concerns/partitioned.rb
@@ -1,138 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "concerns/data_mover"
4
-
5
- module TenantPartition
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.
8
- #
9
- # @abstract
10
- class Base
11
- include ActiveModel::Model
12
- include ActiveModel::Attributes
13
- include TenantPartition::Concerns::DataMover
14
-
15
- # Hook de herencia para definir automáticamente el atributo de partición en las subclases.
16
- # @param subclass [Class] La clase que hereda.
17
- def self.inherited(subclass)
18
- super
19
- key = TenantPartition.configuration&.partition_key
20
- subclass.attribute key if key
21
- end
22
-
23
- class << self
24
- attr_writer :parent_table, :prefix, :default_table
25
-
26
- # @return [String] Nombre de la tabla padre (ej: 'conversations').
27
- def parent_table
28
- @parent_table ||= name.demodulize.underscore.pluralize
29
- end
30
-
31
- # @return [Symbol] Clave de partición configurada (ej: :isp_id).
32
- # @raise [TenantPartition::Error] Si no hay configuración global ni local.
33
- def partition_key
34
- @partition_key ||= TenantPartition.configuration&.partition_key ||
35
- raise(TenantPartition::Error, "Clave de partición no configurada.")
36
- end
37
-
38
- # Define manualmente la clave de partición para esta clase, sobrescribiendo la global.
39
- # @param value [Symbol] Nombre de la columna.
40
- def partition_key=(value)
41
- @partition_key = value
42
- attribute value
43
- end
44
-
45
- # @return [String] Prefijo para las tablas particionadas (ej: 'conversations_isp').
46
- def prefix
47
- @prefix ||= "#{parent_table}_#{partition_key.to_s.gsub("_id", "")}"
48
- end
49
-
50
- # @return [String] Nombre de la tabla DEFAULT.
51
- def default_table
52
- @default_table ||= "#{parent_table}_default"
53
- end
54
-
55
- # @return [ActiveRecord::ConnectionAdapters::PostgreSQLAdapter] Conexión activa a la DB.
56
- def connection
57
- ActiveRecord::Base.connection
58
- end
59
-
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.
63
- def create(value)
64
- payload = { partition_key: partition_key, value: value, table: parent_table }
65
-
66
- ActiveSupport::Notifications.instrument("create.tenant_partition", payload) do
67
- name = partition_name(value)
68
- sql = "CREATE TABLE IF NOT EXISTS #{name} PARTITION OF #{parent_table} FOR VALUES IN ('#{value}');"
69
- connection.execute(sql)
70
- new(partition_key => value)
71
- end
72
- end
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').
77
- def partition_name(value)
78
- sanitized = value.to_s.gsub("-", "_")
79
- "#{prefix}_#{sanitized}"
80
- end
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.
85
- def exists?(value)
86
- name = partition_name(value)
87
- sql = <<~SQL.squish
88
- SELECT 1 FROM pg_class c
89
- JOIN pg_inherits i ON c.oid = i.inhrelid
90
- JOIN pg_class p ON i.inhparent = p.oid
91
- WHERE p.relname = '#{parent_table}' AND c.relname = '#{name}';
92
- SQL
93
- connection.execute(sql).any?
94
- end
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.
99
- def find(value)
100
- new(partition_key => value) if exists?(value)
101
- end
102
- end
103
-
104
- # --- Métodos de Instancia ---
105
-
106
- # @return [Object] Valor del ID de partición de esta instancia.
107
- def partition_id
108
- public_send(self.class.partition_key)
109
- end
110
-
111
- # @return [String] Nombre de la tabla física correspondiente a esta instancia.
112
- def partition_table_name
113
- self.class.partition_name(partition_id)
114
- end
115
-
116
- # @return [Boolean] Si la partición está persistida en base de datos.
117
- def persisted?
118
- self.class.exists?(partition_id)
119
- end
120
-
121
- # Elimina la partición física de la base de datos (DETACH + DROP).
122
- # @return [Boolean] true si la eliminación fue exitosa.
123
- def destroy
124
- return false unless persisted?
125
-
126
- p_name = partition_table_name
127
- p_table = self.class.parent_table
128
-
129
- self.class.connection.transaction do
130
- self.class.connection.execute("ALTER TABLE #{p_table} DETACH PARTITION #{p_name};")
131
- self.class.connection.execute("DROP TABLE IF EXISTS #{p_name};")
132
- end
133
- true
134
- rescue ActiveRecord::StatementInvalid
135
- false
136
- end
137
- end
138
- end