gss-bi-udfs 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl

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.
@@ -0,0 +1,11 @@
1
+ from . import io
2
+ from . import merges
3
+ from . import transforms
4
+ from . import utils
5
+
6
+ __all__ = [
7
+ "io",
8
+ "merges",
9
+ "transforms",
10
+ "utils",
11
+ ]
gss_bi_udfs/io.py ADDED
@@ -0,0 +1,420 @@
1
+ from pathlib import Path
2
+ from datetime import datetime
3
+ from .utils import get_env, get_table_info
4
+
5
+
6
+ class _LocalFileInfo:
7
+ # univamentemente para uso de la libreria en entornos locales
8
+ def __init__(self, path: str):
9
+ self.path = path
10
+ p = Path(path)
11
+ self.name = p.name
12
+ self.size = p.stat().st_size if p.exists() else 0
13
+ self.modificationTime = int(p.stat().st_mtime * 1000) if p.exists() else 0
14
+
15
+ def isFile(self) -> bool:
16
+ return Path(self.path).is_file()
17
+
18
+ def __repr__(self) -> str:
19
+ return (
20
+ "FileInfo("
21
+ f"path='{self.path}', "
22
+ f"name='{self.name}', "
23
+ f"size={self.size}, "
24
+ f"modificationTime={self.modificationTime}"
25
+ ")"
26
+ )
27
+
28
+ def _normalize_path(path):
29
+ if path.startswith("dbfs:"):
30
+ return path.replace("dbfs:", "", 1)
31
+ return path
32
+
33
+
34
+ def _ls_path(base_path):
35
+ try:
36
+ # Databricks runtime provides dbutils in globals.
37
+ files = dbutils.fs.ls(base_path) # type: ignore
38
+ return files
39
+ except Exception:
40
+ local_path = _normalize_path(base_path)
41
+ p = Path(local_path)
42
+ if not p.exists():
43
+ return []
44
+ return [_LocalFileInfo(str(child)) for child in p.iterdir()]
45
+
46
+ # def load_latest_file_bronze(spark, data_base, schema, table, env=None):
47
+ def load_latest_parquet(spark, data_base, schema, table, env=None):
48
+ """
49
+ Carga el último archivo Parquet para la tabla especificada y retorna un DataFrame.
50
+
51
+ Parámetros:
52
+ spark (SparkSession): Sesión activa de Spark.
53
+ data_base (str): Nombre de la base de datos.
54
+ schema (str): Nombre del esquema.
55
+ table (str): Nombre de la tabla.
56
+ env (str, opcional): Entorno de ejecución (por ejemplo: 'dev', 'qa', 'prod').
57
+ Si no se proporciona, se obtiene usando get_env().
58
+
59
+ Retorna:
60
+ DataFrame de Spark cargado desde el archivo Parquet más reciente.
61
+ """
62
+ env = env or get_env()
63
+ base_path = f"/Volumes/bronze/{data_base}_{schema}/{env}/{table}/"
64
+ print("Ruta base:", base_path)
65
+
66
+ try:
67
+ files = _ls_path(base_path)
68
+
69
+ parquet_files = [f for f in files if table in f.name]
70
+
71
+ if not parquet_files:
72
+ return None
73
+
74
+ latest_file = sorted(parquet_files, key=lambda f: f.name, reverse=True)[0]
75
+ df = spark.read.parquet(latest_file.path)
76
+ return df
77
+
78
+ except Exception as e:
79
+ print("Error al procesar los archivos:", e)
80
+ return None
81
+
82
+
83
+ def return_parquets_and_register_temp_views(spark, tables_load, verbose=False, env=None):
84
+ """
85
+ Carga dataframes a partir de un diccionario de definición de tablas y materializa vistas temporales.
86
+ Retorna un diccionario con los dataframes.
87
+
88
+ Parámetros:
89
+ spark (SparkSession): Sesión activa de Spark.
90
+ tables_load (dict): Diccionario que define las tablas a cargar y sus vistas temporales.
91
+ verbose (bool): Si es True, imprime mensajes de estado durante la carga.
92
+ env (str, opcional): Entorno de ejecución (por ejemplo: 'dev', 'qa', 'prod').
93
+ Si no se proporciona, se obtiene usando get_env().
94
+
95
+ Retorna:
96
+ dict: Diccionario donde las claves son nombres completos de tablas y los valores son DataFrames.
97
+ """
98
+ dataframes = {}
99
+
100
+ for data_base, schemas in tables_load.items():
101
+ for schema, tables in schemas.items():
102
+ for t in tables:
103
+ table = t['table']
104
+ view = t['view']
105
+
106
+ # Cargar el dataframe usando la función que proveés
107
+ df = load_latest_parquet(spark, data_base, schema, table, env)
108
+
109
+ # Guardar en el diccionario
110
+ key = f"{data_base}.{schema}.{table}"
111
+ dataframes[key] = df
112
+
113
+ # Materializar la vista (esto no necesita asignación en Python)
114
+ try:
115
+ df.createOrReplaceTempView(view)
116
+ if verbose:
117
+ print(f'Tabla "{key}" cargada y vista "{view}" materializada')
118
+ except Exception as e:
119
+ print(f"Error al materializar la vista '{view} tabla {key}': {e}")
120
+
121
+ return dataframes
122
+
123
+
124
+ def parquets_register_temp_views(spark, tables_load, verbose=False, env=None):
125
+ """
126
+ Lee los últimos parquets y materializa vistas temporales en Spark. Sin retorna nada.
127
+ Parámetros:
128
+ spark (SparkSession): Sesión activa de Spark.
129
+ tables_load (dict): Diccionario que define las tablas a cargar y sus vistas temporales.
130
+ La estructura esperada del parámetro `tables_load` es un diccionario
131
+ anidado con el siguiente formato:
132
+
133
+ {
134
+ "<base_de_datos>": {
135
+ "<schema>": [
136
+ {
137
+ "table": "<nombre_tabla>",
138
+ "view": "<nombre_vista_temporal>"
139
+ },
140
+ ...
141
+ ]
142
+ },
143
+ ...
144
+ }
145
+ Ejemplo:
146
+ tables_load = {
147
+ "bup": {
148
+ "bup": [
149
+ {"table": "naturalpersons", "view": "vw_naturalpersons"},
150
+ {"table": "maritalstatus", "view": "vw_maritalstatus"},
151
+ {"table": "genders", "view": "vw_genders"},
152
+ {"table": "legalpersons", "view": "vw_legalpersons"},
153
+ {"table": "phones", "view": "vw_phones"},
154
+ {"table": "emails", "view": "vw_emails"},
155
+ {"table": "addresses", "view": "vw_addresses"},
156
+ {"table": "persons", "view": "vw_persons"},
157
+ {"table": "administrativefreezeperiods", "view": "vw_administrativefreezeperiods"},
158
+ {"table": "fraudrisklevels", "view": "vw_fraudrisklevels"},
159
+ ],
160
+ },
161
+ "oraculo": {
162
+ "dbo": [
163
+ {"table": "ml_segmentacion", "view": "vw_segmentacion"},
164
+ ],
165
+ },
166
+ "timepro": {
167
+ "insudb": [
168
+ {"table": "logauth0user", "view": "vw_logauth0user"},
169
+ {"table": "benefitprogramadhesion", "view": "vw_benefitprogramadhesion"},
170
+ ],
171
+ },
172
+ "dwgssprotmp": {
173
+ "dwo": [
174
+ {"table": "int_dim_clientactive_bds", "view": "vw_int_dim_cliente_active"},
175
+ ],
176
+ },
177
+ "odscommon": {
178
+ "employee": [
179
+ {"table": "vw_active_employee", "view": "vw_active_employee"},
180
+ ],
181
+ },
182
+ }
183
+ verbose (bool): Si es True, imprime mensajes de estado durante la carga.
184
+ env (str, opcional): Entorno de ejecución (por ejemplo: 'dev', 'qa', 'prod').
185
+ Si no se proporciona, se obtiene usando get_env().
186
+ Retorna:
187
+ None
188
+ """
189
+ for data_base, schemas in tables_load.items():
190
+ for schema, tables in schemas.items():
191
+ for t in tables:
192
+ table = t['table']
193
+ view = t['view']
194
+ try:
195
+ df = load_latest_parquet(spark, data_base, schema, table, env)
196
+ df.createOrReplaceTempView(view)
197
+ if verbose:
198
+ print(f'Vista "{view}" materializada desde {data_base}.{schema}.{table}')
199
+ except Exception as e:
200
+ print(f"Error al materializar la vista '{view}' desde {data_base}.{schema}.{table}: {e}")
201
+
202
+
203
+ def load_latest_excel(spark, source_file, env=None):
204
+
205
+ """
206
+ Carga el último archivo de Excel (aunque no tenga extensión visible) para la carpeta especificada
207
+ y retorna un DataFrame.
208
+
209
+ Parámetros:
210
+ spark (SparkSession): Sesión activa de Spark.
211
+ source_file (str): Nombre de la carpeta.
212
+ env (str, opcional): Entorno de ejecución (por ejemplo: 'dev', 'qa', 'prod').
213
+ Si no se proporciona, se obtiene usando get_env().
214
+ Retorna:
215
+ DataFrame de Spark cargado desde el archivo Excel más reciente (en formato xls).
216
+ """
217
+
218
+ import pandas as pd
219
+
220
+ env = env or get_env()
221
+ base_path = f"/Volumes/bronze/excel/{env}/{source_file}/"
222
+ print("Ruta base:", base_path)
223
+
224
+ try:
225
+ files = _ls_path(base_path)
226
+ print("Archivos encontrados:", [f.name for f in files])
227
+ excel_candidates = [f for f in files if f.isFile()]
228
+
229
+ if not excel_candidates:
230
+ print(f"No se encontraron archivos en la carpeta: {source_file}")
231
+ return None
232
+
233
+ latest_file = sorted(excel_candidates, key=lambda f: f.name, reverse=True)[0]
234
+
235
+ file_path = latest_file.path.replace("dbfs:", "")
236
+
237
+ pdf = pd.read_excel(file_path, header=0, engine='xlrd')
238
+
239
+ return spark.createDataFrame(pdf)
240
+
241
+ except Exception as e:
242
+ return None
243
+
244
+
245
+ def return_excels_and_register_temp_views(spark, files_load, verbose=False, env=None):
246
+ """
247
+ Carga dataframes a partir de un diccionario de definición de excels y materializa vistas temporales.
248
+ Retorna un diccionario con los dataframes.
249
+
250
+ Parámetros:
251
+ spark (SparkSession): Sesión activa de Spark.
252
+ files_load (dict): Diccionario que define las tablas a cargar y sus vistas temporales.
253
+ La estructura esperada del parámetro `files_load` es un diccionario
254
+ anidado con el siguiente formato:
255
+
256
+ {
257
+ "<Dominio>": {
258
+ "<SubDominio>": [
259
+ {
260
+ "file": "<nombre_archivo>",
261
+ "view": "<nombre_vista_temporal>"
262
+ },
263
+ ...
264
+ ]
265
+ },
266
+ ...
267
+ }
268
+ Ejemplo:
269
+ verbose (bool): Si es True, imprime mensajes de estado durante la carga.
270
+ env (str, opcional): Entorno de ejecución (por ejemplo: 'dev', 'qa', 'prod').
271
+ Si no se proporciona, se obtiene usando get_env().
272
+
273
+ Retorna:
274
+ dict: Diccionario donde las claves son nombres completos de tablas y los valores son DataFrames.
275
+ Quedando las vistas materializadas en el entorno Spark.
276
+ """
277
+ dataframes = {}
278
+
279
+ for domain, subdomain in files_load.items():
280
+ for subdomain, tables in subdomain.items():
281
+ for t in tables:
282
+ file = t['file']
283
+ view = t['view']
284
+
285
+ # Cargar el dataframe usando la función que proveés
286
+ source_file = f"{domain}/{subdomain}/{file}"
287
+ df = load_latest_excel(spark, source_file, env)
288
+
289
+ # Guardar en el diccionario
290
+ key = f"{domain}.{subdomain}.{file}"
291
+ dataframes[key] = df
292
+
293
+ # Materializar la vista (esto no necesita asignación en Python)
294
+ try:
295
+ df.createOrReplaceTempView(view)
296
+ if verbose:
297
+ print(f'El archivo "{key}" cargado y vista "{view}" materializada')
298
+ except Exception as e:
299
+ print(f"Error al materializar la vista '{view} tabla {key}': {e}")
300
+
301
+ return dataframes
302
+
303
+
304
+ def excels_register_temp_views(spark, files_load, verbose=False, env=None):
305
+ """
306
+ Lee los últimos archivos excels y materializa vistas temporales en Spark. Sin retorna nada.
307
+
308
+ Parámetros:
309
+ spark (SparkSession): Sesión activa de Spark.
310
+ files_load (dict): Diccionario que define las tablas a cargar y sus vistas temporales.
311
+ La estructura esperada del parámetro `files_load` es un diccionario
312
+ anidado con el siguiente formato:
313
+
314
+ {
315
+ "<Dominio>": {
316
+ "<SubDominio>": [
317
+ {
318
+ "file": "<nombre_archivo>",
319
+ "view": "<nombre_vista_temporal>"
320
+ },
321
+ ...
322
+ ]
323
+ },
324
+ ...
325
+ }
326
+ Ejemplo:
327
+ verbose (bool): Si es True, imprime mensajes de estado durante la carga.
328
+ env (str, opcional): Entorno de ejecución (por ejemplo: 'dev', 'qa', 'prod').
329
+ Si no se proporciona, se obtiene usando get_env().
330
+
331
+ Retorna:
332
+ Nada
333
+ """
334
+
335
+ for domain, subdomain in files_load.items():
336
+ for subdomain, tables in subdomain.items():
337
+ for t in tables:
338
+ file = t['file']
339
+ view = t['view']
340
+
341
+ # Cargar el dataframe usando la función que proveés
342
+ source_file = f"{domain}/{subdomain}/{file}"
343
+ df = load_latest_excel(spark, source_file, env)
344
+
345
+ # Guardar en el diccionario
346
+ key = f"{domain}.{subdomain}.{file}"
347
+
348
+ # Materializar la vista (esto no necesita asignación en Python)
349
+ try:
350
+ df.createOrReplaceTempView(view)
351
+ if verbose:
352
+ print(f'El archivo "{key}" leido y vista "{view}" materializada')
353
+ except Exception as e:
354
+ print(f"Error al materializar la vista '{view} tabla {key}': {e}")
355
+
356
+
357
+ def load_and_materialize_views(action, **kwargs):
358
+ actions_load_bronze = {
359
+ # Todas las acciones aqui declaradas deberan devolver un diccionario de DataFrames
360
+ # 'load_notebook': load_notebook,
361
+ 'return_parquets_and_register_temp_views': return_parquets_and_register_temp_views,
362
+ 'parquets_register_temp_views': parquets_register_temp_views,
363
+ 'return_excels_and_register_temp_views': return_excels_and_register_temp_views,
364
+ 'excels_register_temp_views': excels_register_temp_views,
365
+ # ir agregando más acciones acá
366
+ }
367
+ results = {}
368
+ func = actions_load_bronze.get(action)
369
+ if func:
370
+ results = func(**kwargs)
371
+ # results[action] = result
372
+ else:
373
+ print(f"Acción '{action}' no está implementada.")
374
+ return results
375
+
376
+
377
+ def save_table_to_delta(df, catalog, schema, table_name):
378
+ """
379
+ Guarda un DataFrame en formato Delta en la ubicación y tabla especificadas,
380
+ sobrescribiendo los datos existentes y el esquema si es necesario.
381
+
382
+ Parámetros:
383
+ df (DataFrame): DataFrame de Spark que se desea guardar.
384
+ db_name (str): Nombre del catálogo o base de datos destino.
385
+ schema (str): Nombre del esquema, capa o entorno destino (ejemplo: 'silver', 'gold').
386
+ table_name (str): Nombre de la tabla destino.
387
+
388
+ Retorna:
389
+ None
390
+
391
+ Lógica:
392
+ - Utiliza la función auxiliar 'get_table_info' para obtener el path
393
+ de almacenamiento y el nombre completo de la tabla.
394
+ - Escribe el DataFrame en formato Delta en la ruta especificada,
395
+ sobrescribiendo cualquier dato y adaptando el esquema si es necesario.
396
+ - Registra la tabla como tabla administrada en el metastore con el nombre completo.
397
+
398
+ Notas:
399
+ - El modo 'overwrite' reemplaza todos los datos existentes en la tabla.
400
+ - La opción 'overwriteSchema' asegura que el esquema de la tabla se actualice si cambió.
401
+ - Es necesario que la ruta y la tabla existan o sean accesibles en el entorno Spark.
402
+ - Las opciones
403
+ `.option("delta.columnMapping.mode", "nameMapping")` y `.option("delta.columnMapping.mode", "name")`
404
+ permiten especificar el modo de mapeo de columnas para Delta Lake:
405
+ - **"nameMapping"**: usa un mapeo explícito de columnas por nombre, útil para cambios de nombre o reordenamiento de columnas sin perder datos.
406
+ - **"name"**: usa el nombre de columna directamente para el mapeo, opción recomendada cuando no se necesita trazabilidad de cambios en los nombres de columna, permite utilizar los acentos en el nombre de las columnas.
407
+ - Si ambas opciones se usan al mismo tiempo, solo una tendrá efecto (se aplicará la última indicada).
408
+
409
+ """
410
+ dim_destino = get_table_info(catalog=catalog, schema=schema, table=table_name)
411
+ (
412
+ df.write
413
+ .format("delta")
414
+ .option("path", dim_destino["path"])
415
+ .mode("overwrite")
416
+ .option("overwriteSchema", "true")
417
+ .option("delta.columnMapping.mode", "nameMapping") \
418
+ .option("delta.columnMapping.mode", "name") \
419
+ .saveAsTable(dim_destino["full_table_name"])
420
+ )
gss_bi_udfs/merges.py ADDED
@@ -0,0 +1,191 @@
1
+ from pyspark.sql import functions as F
2
+ from .io import save_table_to_delta
3
+ from .utils import get_table_info
4
+ from .transforms import add_hashid
5
+
6
+ def merge_scd2(
7
+ spark,
8
+ df_dim_src,
9
+ table_name,
10
+ business_keys,
11
+ surrogate_key,
12
+ eow_date="9999-12-31"
13
+ ):
14
+ """
15
+ Aplica Slowly Changing Dimension Tipo 2 (SCD2) sobre una tabla Delta.
16
+
17
+ El DataFrame de entrada (`df_dim_src`) debe representar el estado actual de la dimensión
18
+ a nivel de negocio, con el MISMO esquema lógico que la tabla destino,
19
+ excluyendo únicamente la PK física (surrogate key) del Data Warehouse.
20
+ `df_dim_src` NO debe incluir la clave primaria física (surrogate key).
21
+ Esta se genera internamente como un hash de (business_keys + valid_from).
22
+
23
+ La lógica implementa versionado histórico de registros utilizando fechas de vigencia
24
+ (valid_from, valid_to), manteniendo una única versión activa por entidad de negocio.
25
+
26
+ CONCEPTOS CLAVE Y SUPUESTOS DEL MODELO
27
+ -------------------------------------
28
+ 1. Clave de negocio (Business Keys):
29
+ - El parámetro `business_keys` DEBE representar la clave de negocio que identifica
30
+ unívocamente a la entidad (por ejemplo: nbranch, nproduct).
31
+ - Esta clave NO debe cambiar en el tiempo.
32
+ - El merge se realiza exclusivamente contra esta clave de negocio
33
+ y contra el registro activo (valid_to = eow_date).
34
+
35
+ 2. Clave primaria física del Data Warehouse:
36
+ - La dimensión DEBE tener una clave primaria física (surrogate key) propia del DW
37
+ para identificar cada versión histórica del registro.
38
+ - Esta PK física NO participa de la lógica de comparación ni del merge funcional,
39
+ y su generación queda fuera del alcance de esta función.
40
+
41
+ 3. Columnas comparadas (detección de cambios):
42
+ - La función compara TODAS las columnas del DataFrame de entrada
43
+ EXCEPTO:
44
+ - la clave de negocio (`business_keys`)
45
+ - las columnas de vigencia (`valid_from`, `valid_to`)
46
+ - Si cualquiera de las columnas comparadas cambia, se genera una nueva versión
47
+ del registro (SCD Tipo 2).
48
+ - Esto implica que cualquier nueva columna agregada al esquema será
49
+ automáticamente considerada para versionado.
50
+
51
+ IMPORTANTE:
52
+ - Por este motivo, se recomienda que el DataFrame NO incluya columnas técnicas
53
+ o volátiles (timestamps de carga, ids de proceso, metadatos, etc.),
54
+ ya que provocarían versionado innecesario.
55
+
56
+ - El DataFrame de entrada PUEDE incluir las columnas `valid_from` y `valid_to`;
57
+ en caso de existir, serán ignoradas para la detección de cambios y
58
+ recalculadas internamente por la función.
59
+
60
+ 4. Manejo de fechas de vigencia:
61
+ - valid_from: DATE inclusiva
62
+ - valid_to: DATE inclusiva
63
+ - El registro activo siempre tiene valid_to = eow_date (por defecto 9999-12-31).
64
+ - Ante un cambio:
65
+ * la versión anterior se cierra con valid_to = current_date() - 1
66
+ * la nueva versión se inserta con valid_from = current_date()
67
+ - Esto garantiza que no existan solapamientos de vigencia.
68
+
69
+ - Si el DataFrame de entrada no contiene `valid_from` y `valid_to`,
70
+ la función las generará automáticamente durante la carga inicial
71
+ o incremental.
72
+
73
+ 5. Carga inicial:
74
+ - Si la tabla destino no existe, se realiza un full load inicial,
75
+ asignando valid_from = '1900-01-01' y valid_to = eow_date
76
+ a todos los registros.
77
+
78
+ PARÁMETROS
79
+ ----------
80
+ spark : SparkSession
81
+ SparkSession activo.
82
+ df_dim_src : DataFrame
83
+ DataFrame de Spark que contiene los datos a mergear.
84
+ Debe incluir TODAS las columnas de negocio de la dimensión
85
+ (clave(s) de negocio + atributos versionables),
86
+ con el mismo esquema lógico que la tabla destino.
87
+ NO debe incluir la clave primaria física (surrogate key).
88
+ table_name : str
89
+ Nombre completo (catalogo.esquema.tabla) de la tabla Delta destino.
90
+ business_keys : str or list[str]
91
+ Nombre(s) de la(s) columna(s) que representa(n) la clave de negocio.
92
+ Puede ser una clave simple (str) o compuesta (lista de str).
93
+ surrogate_key : str
94
+ Nombre de la clave primaria física (surrogate key) de la dimensión.
95
+ Se genera internamente como un hash de (business_keys + valid_from).
96
+ eow_date : str, opcional
97
+ Fecha de fin de vigencia para registros activos.
98
+ Por defecto: '9999-12-31'.
99
+
100
+ RETORNO
101
+ -------
102
+ None
103
+ La función ejecuta el merge directamente sobre la tabla Delta.
104
+ """
105
+
106
+ # Normalize business_keys to a list (support single column or composite key)
107
+ if isinstance(business_keys, str):
108
+ business_keys = [business_keys]
109
+
110
+ missing_bk = [c for c in business_keys if c not in df_dim_src.columns]
111
+ if missing_bk:
112
+ raise ValueError(f"Columnas de business key inexistentes en df_dim_src: {missing_bk}")
113
+
114
+ exclude_cols = set(business_keys) | {"valid_from", "valid_to"}
115
+ compare_cols = [c for c in df_dim_src.columns if c not in exclude_cols]
116
+
117
+ info_table = get_table_info(spark,full_table_name=table_name)
118
+
119
+ if not spark.catalog.tableExists(table_name):
120
+ df_nov = (
121
+ df_dim_src
122
+ .withColumn("valid_from", F.to_date(F.lit("1900-01-01")))
123
+ .withColumn("valid_to", F.to_date(F.lit(eow_date)))
124
+ )
125
+ pk_cols = business_keys + ["valid_from"]
126
+ df_nov = add_hashid(df_nov, pk_cols, surrogate_key)
127
+
128
+ save_table_to_delta(spark, df_nov, info_table["catalog"], info_table["schema"], info_table["table"])
129
+ df_nov.write \
130
+ .format("delta") \
131
+ .mode("overwrite") \
132
+ .option("overwriteSchema", "true") \
133
+ .saveAsTable(table_name)
134
+ print(f"[FULL LOAD] {table_name} creado con SCD-2")
135
+ return
136
+
137
+ df_nov = (
138
+ df_dim_src
139
+ .withColumn("valid_from", F.current_date().cast("date"))
140
+ .withColumn("valid_to", F.to_date(F.lit(eow_date)))
141
+ )
142
+ pk_cols = business_keys + ["valid_from"]
143
+ df_nov = add_hashid(df_nov, pk_cols, surrogate_key)
144
+
145
+ delta_tgt = info_table["full_table_name"]
146
+ bk_cond = " AND ".join([f"t.{k} = s.{k}" for k in business_keys])
147
+ merge_cond = f"{bk_cond} AND t.valid_to = date('{eow_date}')"
148
+ t_hash = "xxhash64(concat_ws('', " + ", ".join(f"t.{c}" for c in compare_cols) + "))"
149
+ s_hash = "xxhash64(concat_ws('', " + ", ".join(f"s.{c}" for c in compare_cols) + "))"
150
+ diff_cond = f"{t_hash} <> {s_hash}"
151
+
152
+ (delta_tgt.alias("t")
153
+ .merge(df_nov.alias("s"), merge_cond)
154
+ .whenMatchedUpdate(
155
+ condition=diff_cond,
156
+ set={"valid_to": "date_sub(current_date(), 1)"}
157
+ )
158
+ .whenNotMatchedInsertAll()
159
+ .whenNotMatchedBySourceUpdate(
160
+ condition=f"t.valid_to = date('{eow_date}')",
161
+ set={"valid_to": "date_sub(current_date(), 1)"}
162
+ )
163
+ .execute()
164
+ )
165
+
166
+ closed_count = delta_tgt.toDF() \
167
+ .filter(F.col("valid_to") == F.date_sub(F.current_date(), 1)) \
168
+ .count()
169
+ if closed_count > 0:
170
+ print(f"Se cerraron {closed_count} versiones en {table_name}")
171
+
172
+ t_active = (
173
+ delta_tgt.toDF()
174
+ .filter(F.col("valid_to") == F.to_date(F.lit(eow_date)))
175
+ )
176
+ join_conds = [F.col(f"s.{k}") == F.col(f"t.{k}") for k in business_keys] + [
177
+ F.col(f"s.{c}") == F.col(f"t.{c}") for c in compare_cols
178
+ ]
179
+ df_to_app = df_nov.alias("s").join(
180
+ t_active.alias("t"), on=join_conds, how="left_anti"
181
+ )
182
+
183
+ new_count = df_to_app.limit(1).count()
184
+ if new_count > 0:
185
+ df_to_app.write \
186
+ .format("delta") \
187
+ .mode("append") \
188
+ .saveAsTable(table_name)
189
+ print(f"Se insertaron nuevas versiones en {table_name}")
190
+ else:
191
+ print(f"No hay nuevas versiones para {table_name}")
@@ -0,0 +1,56 @@
1
+ from pyspark.sql import DataFrame
2
+ from pyspark.sql.functions import xxhash64
3
+ from pyspark.sql.functions import concat_ws, col
4
+
5
+
6
+ from .utils import get_default_value_by_type
7
+
8
+ def add_hashid(
9
+ df: DataFrame,
10
+ columns: list[str],
11
+ new_col_name: str = "hashid"
12
+ ) -> DataFrame:
13
+ """
14
+ Agrega una columna hash (PK) a partir de la concatenación de columnas
15
+ y reordena el DataFrame dejando el hash como primera columna.
16
+
17
+ Parametros:
18
+ df: DataFrame de Spark de entrada
19
+ columns: Lista de columnas a concatenar
20
+ new_col_name: Nombre de la columna hash (default: hashid)
21
+
22
+ Retorna:
23
+ DataFrame con hash agregado
24
+ """
25
+
26
+ if not columns:
27
+ raise ValueError("La lista de columnas no puede estar vacía")
28
+
29
+ # Concatenación segura
30
+ concatenated = concat_ws("|", *[col(c).cast("string") for c in columns])
31
+
32
+ # Hash rápido y determinístico (ideal para PK técnica)
33
+ df_with_hash = df.withColumn(new_col_name, xxhash64(concatenated))
34
+
35
+ # Reordenar columnas
36
+ original_cols = [c for c in df.columns if c != new_col_name]
37
+ new_cols = [new_col_name] + original_cols
38
+
39
+ return df_with_hash.select(*new_cols)
40
+
41
+
42
+ def get_default_record(spark, df: DataFrame) -> DataFrame:
43
+ """
44
+ Crea un DataFrame con un único registro de valores por defecto según el esquema de df:
45
+ Parámetros:
46
+ spark: SparkSession activo.
47
+ df (DataFrame): DataFrame de Spark del cual se tomará el esquema.
48
+
49
+ Retorna:
50
+ DataFrame: DataFrame con un único registro con valores por defecto.
51
+ """
52
+ defaults = {}
53
+ for field in df.schema.fields:
54
+ defaults[field.name] = get_default_value_by_type(field.dataType)
55
+
56
+ return spark.createDataFrame([defaults], schema=df.schema)