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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4375402293baef4a0b28e50a004e57d25e2eff598f68d02d43056c7d5cdf51dc
4
- data.tar.gz: c84e2003e7b8d812fec7edc2970fc6244a770d7bbfb81f64f9db0859663e8ae0
3
+ metadata.gz: 45c660ca7844d37613ad78edf986ebaf37d3ddb041d1d12f26fb6b0e59a22be3
4
+ data.tar.gz: 63a8b5b216e53866408ca4476dc2c0f5e17b818c72da540de2176d6582636d0d
5
5
  SHA512:
6
- metadata.gz: 326a4ed3905b92f44a59b4dc22489e7f68b208f2e8e0a1d6195089a965acefbea35ad43763dcb5499c89859358eb0fac7a8dd84f8bdb506418170d09cef9d07b
7
- data.tar.gz: 2eb2a9e5cdf25f74b26d58599bed33fe45d2c5fad0eaf7630298964ffbb014c1dc2c8335c538ca1f20df2a2a0e763c0d3fa0c7840cb5457c9909524b3b3faf4a
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. Seguridad en Controladores
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 la gestión de infraestructura de particionamiento en PostgreSQL.
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 Hereda de esta clase para definir un recurso de partición.
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
- # Registra el atributo de partición en la subclase en el momento de la herencia.
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
- # Permite inyectar una clave de partición personalizada por clase
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
- # Prioridad: 1. Clave de la clase, 2. Clave global
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
- raise(TenantPartition::Error, "Clave de partición no configurada.")
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 # Define el atributo en ActiveModel automáticamente
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('_id', '')}"
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 físicamente una partición en PostgreSQL.
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 = <<-SQL.squish
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
- # Mueve registros desde la tabla por defecto hacia la partición atómicamente.
110
- def populate_from_default(batch_size: 5000)
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
- # Helpers y validaciones para controladores en arquitecturas Multi-tenant.
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
- # Devuelve el valor del ID de partición actual extraído exclusivamente de los Headers.
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 (before_action) para detener la ejecución si el ID de partición no está presente.
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: error_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