tenant_partition 0.1.1 → 0.1.3

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: 6f686ab0abe926917ef8f7b12e142570e6ace6943c725a6ebd93471fcbea4bfe
4
+ data.tar.gz: c88ed9c6d0e322314f4d80e8dec69312dd95e3d362e939d0467b9204f660c34f
5
5
  SHA512:
6
- metadata.gz: 326a4ed3905b92f44a59b4dc22489e7f68b208f2e8e0a1d6195089a965acefbea35ad43763dcb5499c89859358eb0fac7a8dd84f8bdb506418170d09cef9d07b
7
- data.tar.gz: 2eb2a9e5cdf25f74b26d58599bed33fe45d2c5fad0eaf7630298964ffbb014c1dc2c8335c538ca1f20df2a2a0e763c0d3fa0c7840cb5457c9909524b3b3faf4a
6
+ metadata.gz: 5eae8613d5ed6d0d0a8fc13d44fce4b277060b0979e8486e116614c44e508afee05ff66a5302b3eade5d45cbb4d28eb32e9114c70b88dc510e48a9a05b7ea8a7
7
+ data.tar.gz: 4565df6b3b073849a24a3262d43a2cabc82ad268ee0a71ccfedab1ff952dcf9a86aeacb42cf6f4331fdf59b95a3647ccf81c4f6f42e90be03aa7d59fa4af9cf9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
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.
7
+
8
+ ## [0.1.2] - 2026-02-03
9
+ ### Added
10
+ - Nuevo generador `rails g tenant_partition:api_controller` para crear endpoints de aprovisionamiento.
11
+ - Soporte para namespaces dinámicos en el generador (ej: `rails g tenant_partition:api_controller Ops`).
12
+
3
13
  ## [0.1.1] - 2026-01-27
4
14
  - Refactor: Rename internal module to `TenantPartition`.
5
15
  - 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,78 @@
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
+ end
65
+
66
+ # Muestra instrucciones post-generación en la consola.
67
+ #
68
+ # Es vital para advertir al usuario sobre la necesidad de configurar la seguridad (Autenticación),
69
+ # ya que el generador no puede asumir qué sistema de auth usa la aplicación (Devise, JWT, etc).
70
+ #
71
+ # @return [void]
72
+ def show_readme
73
+ # Solo mostramos el README si estamos generando (invoke), no borrando (revoke).
74
+ readme "README" if behavior == :invoke
75
+ end
76
+ end
77
+ end
78
+ 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,25 @@
1
+ module <%= @module_name %>
2
+ class TenantPartitionsController < ApplicationController
3
+ skip_before_action :verify_authenticity_token, raise: false
4
+
5
+ # POST /<%= @module_name.underscores %>/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
+ end
16
+
17
+ # DELETE /<%= @module_name.underscores %>/tenant_partitions/:id
18
+ def destroy
19
+ partition_id = params.require(:id)
20
+
21
+ TenantPartition.destroy!(partition_id)
22
+ head :no_content
23
+ end
24
+ end
25
+ 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
@@ -2,115 +2,61 @@
2
2
 
3
3
  module TenantPartition
4
4
  module Concerns
5
- # Módulo de infraestructura para modelos de negocio (ApplicationRecord).
6
- #
7
- # Este concern es el encargado de adaptar el modelo de Rails para trabajar con
8
- # la estructura de tablas particionadas de PostgreSQL. Su función crítica es
9
- # configurar las **Primary Keys Compuestas (CPK)** requeridas por Rails 7.1+.
10
- #
11
- # ### Estrategia de Resolución de Claves
12
- # El módulo determina qué columna usar como clave de partición siguiendo este orden de prioridad:
13
- #
14
- # 1. **Explícita:** Definida manualmente con `partitioned_by :key` en el modelo.
15
- # 2. **Inferencia (Convención):** Busca si existe una clase de infraestructura asociada
16
- # (ej: para `Conversation` busca `Partition::Conversation`) y utiliza su configuración.
17
- # 3. **Global:** Utiliza la clave definida en `TenantPartition.configure`.
18
- #
19
- # @example Modo Automático (Inferencia)
20
- # # Si Partition::LegacyChat tiene `self.partition_key = :region_id`
21
- # class LegacyChat < ApplicationRecord
22
- # include TenantPartition::Concerns::Partitioned
23
- # # Automáticamente configura PK: [:id, :region_id]
24
- # end
25
- #
26
- # @example Modo Manual (Override)
27
- # class Log < ApplicationRecord
28
- # include TenantPartition::Concerns::Partitioned
29
- # partitioned_by :custom_id
30
- # end
5
+ # Concern para modelos Rails (ApplicationRecord).
6
+ # Configura automáticamente las claves primarias compuestas y scopes.
31
7
  module Partitioned
32
8
  extend ActiveSupport::Concern
33
9
 
34
10
  included do
35
- # Punto de entrada para inclusión directa.
36
- # 'self' es la clase que incluye el módulo.
37
11
  TenantPartition::Concerns::Partitioned.configure_model(self)
38
12
  end
39
13
 
40
14
  class_methods do
41
- # Hook de Ruby disparado al heredar.
42
- # Permite incluir el módulo en ApplicationRecord y que la configuración
43
- # se aplique automáticamente a cada subclase en el momento de su definición.
44
- #
45
- # @param subclass [Class] La clase que está heredando.
15
+ # Intercepta la herencia para autoconfigurar subclases.
46
16
  def inherited(subclass)
47
17
  super
48
18
  TenantPartition::Concerns::Partitioned.configure_model(subclass)
49
19
  end
50
20
 
51
- # Define manualmente la clave de partición, ignorando la inferencia y la config global.
52
- #
53
- # @param key [Symbol] Nombre de la columna de partición.
21
+ # Permite definir manualmente la clave de partición para este modelo.
22
+ # @param key [Symbol] Nombre de la columna.
54
23
  def partitioned_by(key)
55
24
  TenantPartition::Concerns::Partitioned.apply_configuration(self, key)
56
25
  end
57
26
  end
58
27
 
59
- # --- Métodos de Utilería (Privados de la Gema) ---
60
-
61
- # Orquesta la configuración del modelo.
28
+ # Configura el modelo detectando la clave de partición adecuada.
62
29
  # @api private
30
+ # @param klass [Class] El modelo a configurar.
63
31
  def self.configure_model(klass)
64
- # Ignoramos clases abstractas para evitar errores de tabla inexistente.
65
32
  return if klass.respond_to?(:abstract_class?) && klass.abstract_class?
66
33
 
67
- # Resolvemos la clave correcta según la prioridad establecida.
68
34
  key_to_use = resolve_partition_key(klass)
69
-
70
35
  apply_configuration(klass, key_to_use) if key_to_use.present?
71
36
  end
72
37
 
73
- # Determina la clave de partición inspeccionando la infraestructura y la configuración.
74
- #
75
- # @param klass [Class] El modelo de negocio a inspeccionar.
76
- # @return [Symbol, nil] La clave de partición encontrada o nil.
38
+ # Resuelve la clave de partición mediante introspección o configuración global.
77
39
  # @api private
40
+ # @param klass [Class] El modelo a inspeccionar.
41
+ # @return [Symbol, nil] La clave encontrada.
78
42
  def self.resolve_partition_key(klass)
79
- # 1. Inferencia por Convención:
80
- # Buscamos la clase "Partition::NombreDelModelo".
81
- # Ej: Conversation -> Partition::Conversation
82
43
  infra_class_name = "Partition::#{klass.name}"
83
-
84
- # safe_constantize (ActiveSupport) devuelve la clase si existe, nil si no.
85
- # No lanza excepciones si la constante no está definida.
86
44
  infra_class = infra_class_name.safe_constantize
87
45
 
88
- if infra_class && infra_class.respond_to?(:partition_key)
89
- # Si existe el modelo de infraestructura, confiamos en su configuración.
90
- return infra_class.partition_key
91
- end
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)
92
48
 
93
- # 2. Configuración Global (Fallback):
94
49
  TenantPartition.configuration&.partition_key
95
50
  end
96
51
 
97
- # Aplica la configuración de Primary Key y Scopes al modelo.
98
- #
99
- # @param klass [Class] El modelo a configurar.
100
- # @param key [Symbol] La clave de partición.
52
+ # Aplica la configuración de CPK y scopes al modelo.
101
53
  # @api private
54
+ # @param klass [Class] El modelo.
55
+ # @param key [Symbol] La clave de partición.
102
56
  def self.apply_configuration(klass, key)
103
- # Idempotencia:
104
- # Si la clase ya tiene la PK compuesta correcta, no hacemos nada.
105
- # Esto previene conflictos si se llama múltiples veces.
106
57
  return if klass.primary_key.is_a?(Array) && klass.primary_key.include?(key.to_s)
107
58
 
108
- # A. Configuración de CPK (Composite Primary Keys) - Rails 7.1+
109
- # Le dice a Rails que la identidad única es (id + partition_key).
110
59
  klass.primary_key = [:id, key]
111
-
112
- # B. Scope de Conveniencia
113
- # Permite usar Model.for_partition("uuid")
114
60
  klass.scope :for_partition, ->(value) { where(key => value) }
115
61
  end
116
62
  end