// Tablas de Ramaflex: las tres operaciones (GET listar, PUT guardar, DELETE borrar) sobre las tablas // del catalogo. Implementa ramaflex/pseudocodigo.txt; si algo difiere, gana el pseudocodigo. // Especificacion: https://docurm.random.cl/integracion-erp.html#tablas // // No depende del framework HTTP: el servidor solo autentica y llama a AtenderAsync. // Las tablas NO estan escritas aqui: salen del CATALOGO, que el script de las tablas guarda en la base // (XESQUEMA.CATALOGO, en la fila de la ultima version). Este codigo lo lee de ahi y lo vuelve a leer cada // minuto: cuando se ejecuta un script nuevo, las tablas y columnas nuevas se aceptan solas, sin reiniciar // y sin cambiar este codigo. // // Compilado y probado con .NET 8 contra SQL Server, PostgreSQL y MySQL (ver LEEME.txt). using System.Data; using System.Data.Common; using System.Globalization; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; namespace Ramaflex; public sealed class TablasRamaflex { sealed record Columna(string Nombre, string Tipo, bool Nulo, int? Largo, bool TieneDefecto, int? Precision, int? Escala); sealed record Tabla(string Nombre, List Columnas, Dictionary PorNombre, string[] Clave, string? Autonumerico, bool SoloLectura); /// Error esperado: se responde con su estado y su codigo. sealed class Falla : Exception { public int Estado { get; } public string Codigo { get; } public Falla(int estado, string codigo, string mensaje) : base(mensaje) { Estado = estado; Codigo = codigo; } } static Falla Invalido(string mensaje) => new(400, "DATO_INVALIDO", mensaje); static Falla Malo(Columna c) => Invalido($"{c.Nombre}: valor invalido para el tipo {c.Tipo}."); static readonly string[] Reservados = { "orden", "pagina", "limite" }; // [0-9] y no \d: en .NET \d acepta digitos de cualquier alfabeto. static readonly Regex RxEntero = new("^-?[0-9]+$"); static readonly Regex RxDecimal = new(@"^-?[0-9]+(\.[0-9]+)?$"); static readonly Regex RxFecha = new(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\.[0-9]{1,7})?)?(Z|[+-][0-9]{2}:[0-9]{2})$"); // Las respuestas son application/json (no HTML): se pueden dejar la ñ y las tildes sin escapar. static readonly JsonSerializerOptions OpcionesJson = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; /// El catalogo leido y cuando se leyo (Environment.TickCount64). Inmutable: se reemplaza entero. sealed record Catalogo(Dictionary Tablas, long LeidoEn); readonly Dialecto _motor; readonly Func _conectar; readonly Action _registrarError; readonly long _refrescoMs; // volatile: una llamada que no relee ve siempre el ultimo catalogo publicado, completo. volatile Catalogo? _catalogo; // Una sola lectura a la vez: las llamadas que llegan mientras tanto esperan y usan lo leido. readonly SemaphoreSlim _leyendo = new(1, 1); /// "sqlserver", "postgresql" o "mysql". /// Devuelve una conexion nueva SIN abrir (el pool del driver la reutiliza). /// Donde va el detalle de los errores inesperados (nunca a la respuesta). /// Cada cuanto se vuelve a leer el catalogo de la base (por defecto, 60 segundos). public TablasRamaflex(string motor, Func conectar, Action? registrarError = null, TimeSpan? refresco = null) { _motor = Dialecto.De(motor); _conectar = conectar; _registrarError = registrarError ?? (e => Console.Error.WriteLine(e)); _refrescoMs = (long)(refresco ?? TimeSpan.FromSeconds(60)).TotalMilliseconds; } // ------------------------------------------------------------------------------------------ CATALOGO /// Las tablas del catalogo; lo lee de XESQUEMA si nunca se leyo o si ya paso el periodo de refresco. async Task> Definiciones() { var actual = _catalogo; if (actual is not null && Environment.TickCount64 - actual.LeidoEn < _refrescoMs) return actual.Tablas; await _leyendo.WaitAsync(); try { var anterior = _catalogo; // otra llamada pudo leerlo mientras se esperaba if (anterior is not null && Environment.TickCount64 - anterior.LeidoEn < _refrescoMs) return anterior.Tablas; Catalogo nuevo; try { nuevo = new Catalogo(await LeerCatalogo(), Environment.TickCount64); } catch (Exception e) { if (anterior is null) throw; // nunca se pudo leer: la llamada responde 500 y el detalle va al log // Ya habia uno: se sigue con el y se reintenta en el proximo periodo. _registrarError(new InvalidOperationException("No se pudo releer el catalogo; se sigue con el anterior.", e)); nuevo = anterior with { LeidoEn = Environment.TickCount64 }; } _catalogo = nuevo; return nuevo.Tablas; } finally { _leyendo.Release(); } } async Task> LeerCatalogo() { string? texto; try { await using var con = _conectar(); await using var cmd = con.CreateCommand(); cmd.CommandText = "SELECT CATALOGO FROM XESQUEMA WHERE VERSION = (SELECT MAX(VERSION) FROM XESQUEMA)"; await con.OpenAsync(); texto = await cmd.ExecuteScalarAsync() as string; // sin filas: null; CATALOGO NULL: DBNull -> null } catch (Exception e) { throw new InvalidOperationException( "No se pudo leer XESQUEMA.CATALOGO (¿falta ejecutar el script actual de las tablas?): " + e.Message, e); } if (string.IsNullOrEmpty(texto)) throw new InvalidOperationException("XESQUEMA no tiene el catálogo: falta ejecutar el script actual de las tablas."); using var doc = JsonDocument.Parse(texto); return Tablas(doc.RootElement); } static Dictionary Tablas(JsonElement catalogo) { var tablas = new Dictionary(StringComparer.Ordinal); foreach (var t in catalogo.GetProperty("tablas").EnumerateObject()) { var columnas = t.Value.GetProperty("columnas").EnumerateObject().Select(c => new Columna( c.Name, c.Value.GetProperty("tipo").GetString()!, c.Value.GetProperty("nulo").GetBoolean(), Entero(c.Value, "largo"), c.Value.TryGetProperty("defecto", out _), Entero(c.Value, "precision"), Entero(c.Value, "escala"))).ToList(); tablas[t.Name] = new Tabla( t.Name, columnas, columnas.ToDictionary(c => c.Nombre, StringComparer.Ordinal), t.Value.GetProperty("clave").EnumerateArray().Select(k => k.GetString()!).ToArray(), t.Value.TryGetProperty("autonumerico", out var a) ? a.GetString() : null, t.Value.TryGetProperty("soloLectura", out var s) && s.GetBoolean()); } return tablas; } static int? Entero(JsonElement columna, string propiedad) => columna.TryGetProperty(propiedad, out var v) && v.ValueKind == JsonValueKind.Number ? (int?)v.GetInt32() : null; /// /// Unica entrada. parametros = la query en orden y con repetidos. Devuelve el estado HTTP y el cuerpo JSON. /// La autenticacion la hace el servidor ANTES de llamar aqui. /// public async Task<(int Estado, string Cuerpo)> AtenderAsync(string metodo, string tabla, IReadOnlyList> parametros, string? cuerpo) { try { // Comparacion exacta: "xmovildoc" no es "XMOVILDOC". if (!(await Definiciones()).TryGetValue(tabla, out var def)) throw new Falla(404, "TABLA_DESCONOCIDA", "Tabla desconocida: " + tabla); JsonObject respuesta = metodo switch { "GET" => await Listar(def, parametros), "PUT" => await Guardar(SoloEscritura(def), cuerpo), "DELETE" => await Borrar(SoloEscritura(def), parametros), _ => throw new Falla(405, "METODO_NO_PERMITIDO", "Solo GET, PUT y DELETE.") }; return (200, respuesta.ToJsonString(OpcionesJson)); } catch (Falla f) { return Error(f.Estado, f.Codigo, f.Message); } catch (Exception e) when (_motor.EsConflicto(e)) { return Error(409, "CONFLICTO", "Choco con otra escritura simultanea; se puede reintentar."); } catch (Exception e) { _registrarError(e); // el detalle (SQL, datos) va al log, nunca a la respuesta return Error(500, "ERROR_INTERNO", "Error interno."); } } /// Cuerpo de error { ok: false, codigo, mensaje }. Publico para que el servidor responda el 401. public static (int Estado, string Cuerpo) Error(int estado, string codigo, string mensaje) => (estado, new JsonObject { ["ok"] = false, ["codigo"] = codigo, ["mensaje"] = mensaje }.ToJsonString(OpcionesJson)); static Tabla SoloEscritura(Tabla def) => def.SoloLectura ? throw new Falla(405, "SOLO_LECTURA", def.Nombre + " es de solo lectura.") : def; static Columna BuscarColumna(Tabla def, string nombre) => def.PorNombre.TryGetValue(nombre, out var c) ? c : throw new Falla(400, "COLUMNA_DESCONOCIDA", "Columna desconocida: " + nombre); // ------------------------------------------------------------------------------------------ LISTAR async Task Listar(Tabla def, IReadOnlyList> parametros) { await using var con = _conectar(); await using var cmd = con.CreateCommand(); var donde = Filtros(cmd, def, parametros); var orden = new List(); var yaOrdenadas = new HashSet(StringComparer.Ordinal); foreach (var o in Valores(parametros, "orden").SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries))) { var desc = o.StartsWith('-'); var col = BuscarColumna(def, desc ? o[1..] : o); if (yaOrdenadas.Add(col.Nombre)) orden.Add(col.Nombre + (desc ? " DESC" : "")); // SQL Server rechaza una columna repetida } // Sin un orden total, las paginas se pisan: se completa con la clave. foreach (var k in def.Clave) if (yaOrdenadas.Add(k)) orden.Add(k); int pagina = Numero(parametros, "pagina", 1), limite = Numero(parametros, "limite", 1000); if (pagina < 1) throw Invalido("pagina debe ser 1 o mayor."); if (limite < 1 || limite > 1000) throw Invalido("limite debe estar entre 1 y 1000."); cmd.CommandText = $"SELECT {ListaColumnas(def)} FROM {def.Nombre}" + (donde == "" ? "" : " WHERE " + donde) + " ORDER BY " + string.Join(", ", orden) + " " + _motor.Paginar(Param(cmd, null, (long)(pagina - 1) * limite), Param(cmd, null, (long)limite)); await con.OpenAsync(); var filas = new JsonArray(); await LeerFilas(cmd, def, filas); return new JsonObject { ["filas"] = filas }; } static IEnumerable Valores(IReadOnlyList> parametros, string nombre) => parametros.Where(p => p.Key == nombre).Select(p => p.Value); static int Numero(IReadOnlyList> parametros, string nombre, int porDefecto) { var v = Valores(parametros, nombre).FirstOrDefault(); if (v is null) return porDefecto; if (RxEntero.IsMatch(v) && int.TryParse(v, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var n)) return n; throw Invalido(nombre + " debe ser un numero entero."); } // ------------------------------------------------------------------------------------------ GUARDAR async Task Guardar(Tabla def, string? cuerpo) { using var doc = LeerCuerpo(cuerpo); var raiz = doc.RootElement; if (raiz.ValueKind != JsonValueKind.Object || !raiz.TryGetProperty("filas", out var filas) || filas.ValueKind != JsonValueKind.Array || filas.GetArrayLength() > 500) throw Invalido("Se espera { \"filas\": [...] } con hasta 500 filas."); // 1) Validar TODO antes de tocar la base. var lote = new List>(); foreach (var fila in filas.EnumerateArray()) { if (fila.ValueKind != JsonValueKind.Object) throw Invalido("Cada fila debe ser un objeto."); foreach (var p in fila.EnumerateObject()) BuscarColumna(def, p.Name); foreach (var k in def.Clave) if (!fila.TryGetProperty(k, out var x) || x.ValueKind == JsonValueKind.Null) throw new Falla(400, "CLAVE_INCOMPLETA", "Falta la clave: " + string.Join(" + ", def.Clave)); var v = new Dictionary(StringComparer.Ordinal); foreach (var p in fila.EnumerateObject()) if (p.Name != def.Autonumerico) // el autonumerico lo asigna la base y nunca se actualiza v[p.Name] = Convertir(def.PorNombre[p.Name], p.Value); lote.Add(v); } // 2) Escribir, todo o nada. Si algo lanza antes del Commit, el Dispose de la transaccion la deshace. await using var con = _conectar(); await con.OpenAsync(); await using var tx = await con.BeginTransactionAsync(); var resultado = new JsonArray(); foreach (var v in lote) { if (await Existe(con, tx, def, v)) { var resto = v.Keys.Where(k => !def.Clave.Contains(k)).ToList(); if (resto.Count > 0) // UPDATE solo de lo enviado { await using var upd = Comando(con, tx); var set = string.Join(", ", resto.Select(k => $"{k} = {Param(upd, def.PorNombre[k], v[k])}")); upd.CommandText = $"UPDATE {def.Nombre} SET {set} WHERE {WhereClave(upd, def, v)}"; await upd.ExecuteNonQueryAsync(); } } else { var falta = def.Columnas.FirstOrDefault(c => !c.Nulo && !c.TieneDefecto && c.Nombre != def.Autonumerico && !v.ContainsKey(c.Nombre)); if (falta is not null) throw Invalido("Falta " + falta.Nombre + "."); // Las columnas que no vienen toman su DEFAULT o null: lo pone la base. await using var ins = Comando(con, tx); var marcadores = string.Join(", ", v.Select(kv => Param(ins, def.PorNombre[kv.Key], kv.Value))); ins.CommandText = $"INSERT INTO {def.Nombre} ({string.Join(", ", v.Keys)}) VALUES ({marcadores})"; await ins.ExecuteNonQueryAsync(); } await using var sel = Comando(con, tx); sel.CommandText = $"SELECT {ListaColumnas(def)} FROM {def.Nombre} WHERE {WhereClave(sel, def, v)}"; await LeerFilas(sel, def, resultado); } await tx.CommitAsync(); return new JsonObject { ["filas"] = resultado }; } static JsonDocument LeerCuerpo(string? cuerpo) { try { return JsonDocument.Parse(cuerpo ?? ""); } catch (JsonException) { throw Invalido("El cuerpo no es JSON valido."); } } async Task Existe(DbConnection con, DbTransaction tx, Tabla def, Dictionary v) { await using var cmd = Comando(con, tx); cmd.CommandText = _motor.SqlExiste(def.Nombre, WhereClave(cmd, def, v)); return await cmd.ExecuteScalarAsync() is not null; } string WhereClave(DbCommand cmd, Tabla def, Dictionary v) => string.Join(" AND ", def.Clave.Select(k => $"{k} = {Param(cmd, def.PorNombre[k], v[k])}")); static DbCommand Comando(DbConnection con, DbTransaction tx) { var cmd = con.CreateCommand(); cmd.Transaction = tx; return cmd; } // ------------------------------------------------------------------------------------------ BORRAR async Task Borrar(Tabla def, IReadOnlyList> parametros) { await using var con = _conectar(); await using var cmd = con.CreateCommand(); var donde = Filtros(cmd, def, parametros); // Un filtro olvidado no vacia una tabla. if (donde == "") throw new Falla(400, "FILTRO_REQUERIDO", "Borrar exige al menos un filtro."); cmd.CommandText = $"DELETE FROM {def.Nombre} WHERE {donde}"; await con.OpenAsync(); return new JsonObject { ["borradas"] = await cmd.ExecuteNonQueryAsync() }; } // ------------------------------------------------------------------------------------------ FILTROS // COL=v -> COL = @p; COL=v&COL=w -> COL IN (@p, @q); COL.desde=a -> COL >= @p; COL.hasta=b -> COL <= @p. // Devuelve las condiciones unidas con AND, o "" si no hay filtros. string Filtros(DbCommand cmd, Tabla def, IReadOnlyList> parametros) { var condiciones = new List(); foreach (var nombre in parametros.Select(p => p.Key).Distinct().Where(n => !Reservados.Contains(n))) { var valores = Valores(parametros, nombre).ToList(); var rango = nombre.EndsWith(".desde", StringComparison.Ordinal) ? ">=" : nombre.EndsWith(".hasta", StringComparison.Ordinal) ? "<=" : null; var col = BuscarColumna(def, rango is null ? nombre : nombre[..^6]); // ".desde" y ".hasta" miden 6 if (rango is not null) // un solo valor: si se repite, el primero condiciones.Add($"{col.Nombre} {rango} {Param(cmd, col, Convertir(col, valores[0]))}"); else if (valores.Count == 1) condiciones.Add($"{col.Nombre} = {Param(cmd, col, Convertir(col, valores[0]))}"); else condiciones.Add($"{col.Nombre} IN ({string.Join(", ", valores.Select(x => Param(cmd, col, Convertir(col, x))))})"); } return string.Join(" AND ", condiciones); } // ------------------------------------------------------------------------------------------ VALORES /// Agrega el valor como parametro y devuelve su marcador. Los valores NUNCA van en el texto del SQL. string Param(DbCommand cmd, Columna? col, object valor) { var p = cmd.CreateParameter(); p.ParameterName = "@p" + cmd.Parameters.Count; p.DbType = col?.Tipo switch { null => DbType.Int64, // paginacion "texto" => DbType.String, // Unicode: en SQL Server viaja como NVARCHAR (ñ y tildes) "entero" => DbType.Int32, "decimal" => DbType.Decimal, "logico" => DbType.Boolean, "fecha" => _motor.TipoFecha, var t => throw new InvalidOperationException("Tipo desconocido en el catalogo: " + t) }; p.Value = valor; cmd.Parameters.Add(p); return p.ParameterName; } /// /// Valor recibido -> valor para el driver (string, long, decimal, bool, DateTime o DBNull.Value). /// valor es un string (de la URL) o un JsonElement (del cuerpo). Lanza DATO_INVALIDO. /// static object Convertir(Columna c, object valor) { string? texto; // el valor como texto, si lo es (en JSON: un string o un numero) bool numeroJson = false; bool? logicoJson = null; if (valor is JsonElement j) { if (j.ValueKind == JsonValueKind.Null) { if (!c.Nulo) throw Invalido(c.Nombre + " no acepta null."); return DBNull.Value; } numeroJson = j.ValueKind == JsonValueKind.Number; texto = j.ValueKind == JsonValueKind.String ? j.GetString() : numeroJson ? j.GetRawText() : null; if (j.ValueKind is JsonValueKind.True or JsonValueKind.False) logicoJson = j.GetBoolean(); } else texto = (string)valor; // de la URL: el texto "null" no es especial switch (c.Tipo) { case "texto": if (texto is null) throw Malo(c); // un booleano, un objeto o una lista if (c.Largo is int largo && texto.Length > largo) throw Invalido($"{c.Nombre}: mas de {largo} caracteres."); return texto; case "entero": // INT de 32 bits: fuera de rango, int.TryParse falla if (texto is not null && RxEntero.IsMatch(texto) && int.TryParse(texto, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var n)) return n; throw Malo(c); case "decimal": if (texto is not null && (numeroJson || RxDecimal.IsMatch(texto)) && decimal.TryParse(texto, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) && CabeEnLaColumna(c, d)) return d; // los decimales de mas los redondea la base a "escala" throw Malo(c); case "logico": if (logicoJson is bool b) return b; if (valor is string s) // solo en la URL { if (s is "true" or "1") return true; if (s is "false" or "0") return false; } throw Malo(c); case "fecha": // Exige la zona (Z u offset): sin ella el instante es ambiguo. if (!numeroJson && texto is not null && RxFecha.IsMatch(texto) && DateTimeOffset.TryParse(texto, CultureInfo.InvariantCulture, DateTimeStyles.None, out var f)) { // Instante en UTC, sin zona (Kind Unspecified: Npgsql rechaza Kind Utc en un TIMESTAMP sin zona) // y truncado a milisegundos, como lo guarda la columna. var utc = f.UtcDateTime; return DateTime.SpecifyKind(utc.AddTicks(-(utc.Ticks % TimeSpan.TicksPerMillisecond)), DateTimeKind.Unspecified); } throw Malo(c); default: throw new InvalidOperationException("Tipo desconocido en el catalogo: " + c.Tipo); } } // DECIMAL(p, s) admite a lo mas p - s digitos enteros, es decir |parte entera| < 10^(p - s). static bool CabeEnLaColumna(Columna c, decimal d) { if (c.Precision is not int p) return true; decimal tope = 1; for (int i = 0; i < p - (c.Escala ?? 0) && i < 28; i++) tope *= 10; // 10^28 es lo mas que cabe en decimal return Math.Abs(Math.Truncate(d)) < tope; } static string ListaColumnas(Tabla def) => string.Join(", ", def.Columnas.Select(c => c.Nombre)); static async Task LeerFilas(DbCommand cmd, Tabla def, JsonArray destino) { await using var r = await cmd.ExecuteReaderAsync(); while (await r.ReadAsync()) { // Por posicion: el SELECT lista las columnas del catalogo en su orden, y la respuesta usa el // nombre del catalogo (PostgreSQL los devuelve en minusculas). Todas, tambien las nulas. var fila = new JsonObject(); for (int i = 0; i < def.Columnas.Count; i++) fila[def.Columnas[i].Nombre] = HaciaJson(def.Columnas[i], r.GetValue(i)); destino.Add((JsonNode)fila); } } static JsonNode? HaciaJson(Columna c, object v) => v is DBNull ? null : c.Tipo switch { "texto" => JsonValue.Create(Convert.ToString(v, CultureInfo.InvariantCulture)!.TrimEnd(' ')), // los CHAR vienen rellenos "entero" => JsonValue.Create(Convert.ToInt64(v, CultureInfo.InvariantCulture)), "decimal" => JsonValue.Create(Convert.ToDecimal(v, CultureInfo.InvariantCulture)), "logico" => JsonValue.Create(Convert.ToBoolean(v, CultureInfo.InvariantCulture)), // MySQL puede devolver 0/1 "fecha" => JsonValue.Create(TextoFecha(v)), _ => throw new InvalidOperationException("Tipo desconocido en el catalogo: " + c.Tipo) }; // El valor guardado ES UTC y llega sin zona (Kind Unspecified): se formatea tal cual, sin pasar por la // zona del servidor ni la del proceso. static string TextoFecha(object v) => (v is DateTimeOffset o ? o.UtcDateTime : (DateTime)v).ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); }