103 / Networking

A model written once

Writing a model twice — once as a struct and once as the SQL that stores it — is how a schema starts lying: renaming a field still compiles, and the description keeps describing something that no longer exists. #[derive(Fields)] removes the second copy. It generates three functions from the fields themselves, at compile time and with no runtime reflection: Contacto_campos() returns "name:type" per field, Contacto_valores(c) returns the same fields as strings in the same order, and Contacto_desde_fila(row) rebuilds the struct from a database row. This recipe drives them against std/postgres, the native PostgreSQL client — wire protocol v3 spoken in Nyx, with no libpq and no external dependency.

103-orm-sin-mapeo.nxSource →
// #[derive(Fields)]: el struct describe su propio esquema — ORM sin mapeo escrito a mano
//
// #[derive(Fields)] emite tres funciones a partir de los campos del struct,
// TODO en compilación (nada de reflexión en runtime):
//   Contacto_campos()         -> Array de "nombre:tipo", en el orden de declaración
//   Contacto_valores(c)       -> Array de String, alineado por índice con campos()
//   Contacto_desde_fila(fila) -> Contacto, la vuelta desde una fila de la base
//
// El derive solo sabe convertir int, bool, float y String — un campo Array,
// Map o struct anidado aborta la compilación con NYX2013 a propósito: ver
// docs/gotchas/derive-fields-solo-primitivos.md. Inventar un valor ahí
// escribiría un dato falso en una columna de la base.
//
// Hace falta un servidor PostgreSQL para correr esta receta (ver
// examples/by-example/104-postgres.nx para montar uno local). Sin servidor
// el programa lo dice por stdout y sale con código 1 — no hay panic ni
// segfault por no tener con quién hablar.

import "std/postgres"

#[derive(Fields)]
struct Contacto {
    nombre: String,
    rif: String,
    activo: bool,
    saldo: int
}

// bool -> texto: no hay conversión automática (mismo gotcha que en
// 07-strings.nx), así que se escribe explícita. "true"/"false" y no "si"/"no"
// porque es lo que este programa también manda a la base.
fn bool_texto(b: bool) -> String {
    if b { return "true" }
    return "false"
}

// Mapea el tipo Nyx (tal como aparece en "nombre:tipo") al tipo de columna
// SQL. Un solo lugar: si el derive soporta otro primitivo el día de mañana,
// el mapeo se extiende acá y no en cada CREATE TABLE escrito a mano.
fn tipo_sql(nyx_tipo: String) -> String {
    if nyx_tipo == "int" { return "integer" }
    if nyx_tipo == "bool" { return "boolean" }
    if nyx_tipo == "float" { return "double precision" }
    return "text"  // String, y cualquier otro primitivo cae en texto
}

// CREATE TABLE armado ENTERAMENTE desde campos(): el struct de arriba es la
// única fuente de verdad del esquema. Agregar o renombrar un campo en
// Contacto cambia esta sentencia sola, sin tocar una sola línea acá.
fn crear_tabla_sql(tabla: String, campos: Array) -> String {
    var columnas: String = ""
    var i: int = 0
    while i < campos.length() {
        let campo: String = campos[i]
        let partes: Array = campo.split(":")
        let nombre: String = partes[0]
        let tipo: String = partes[1]
        if i > 0 { columnas = columnas + ", " }
        columnas = columnas + nombre + " " + tipo_sql(tipo)
        i = i + 1
    }
    return "CREATE TABLE IF NOT EXISTS " + tabla + " (" + columnas + ")"
}

// "nombre, rif, activo, saldo" — lista de columnas en el mismo orden que
// campos() y, por lo tanto, que valores(): el INSERT no necesita mantener
// esa lista a mano.
fn nombres_columnas(campos: Array) -> String {
    var out: String = ""
    var i: int = 0
    while i < campos.length() {
        let partes: Array = campos[i].split(":")
        if i > 0 { out = out + ", " }
        out = out + partes[0]
        i = i + 1
    }
    return out
}

// "$1, $2, $3, $4" — placeholders posicionales para try_pg_exec_params, uno
// por cada campo del struct.
fn placeholders(n: int) -> String {
    var out: String = ""
    var i: int = 0
    while i < n {
        if i > 0 { out = out + ", " }
        out = out + "$" + int_to_string(i + 1)
        i = i + 1
    }
    return out
}

fn main() -> int {
    let conninfo: String = "host=127.0.0.1 port=5432 dbname=nyx_test user=nyx_test password=nyx_test_pw"

    match try_pg_connect(conninfo) {
        Result.Err(e) => {
            // Degradar limpio: sin servidor no hay ORM que mostrar, pero
            // tampoco hay por qué reventar el proceso.
            print("no se pudo conectar a PostgreSQL: " + e.msg)
            print("esta receta necesita un servidor local — ver examples/by-example/104-postgres.nx")
            return 1
        }
        Result.Ok(conn) => {
            // 1. El struct de arriba es la ÚNICA fuente de verdad: nombre,
            //    rif, activo y saldo se escriben acá y en ningún otro lugar
            //    de este archivo.
            let campos: Array = Contacto_campos()

            // 2. El esquema sale de campos(), no de un CREATE TABLE tipeado
            //    a mano que se puede desincronizar del struct.
            let _d = try_pg_exec(conn, "DROP TABLE IF EXISTS contactos")
            match try_pg_exec(conn, crear_tabla_sql("contactos", campos)) {
                Result.Ok(_) => { print("tabla creada desde Contacto_campos()") }
                Result.Err(e) => { print("crear tabla fallo: " + e.msg) }
            }

            // 3. Insertar: valores() emite la fila en el mismo orden que
            //    campos(), así que la lista de columnas y los placeholders
            //    calzan solos, sin escribir "nombre, rif, activo, saldo" una
            //    segunda vez a mano.
            let c: Contacto = Contacto { nombre: "El Tornillo", rif: "J-29643190-6", activo: true, saldo: 1500 }
            let sql_insert: String = "INSERT INTO contactos (" + nombres_columnas(campos) + ") VALUES (" + placeholders(campos.length()) + ")"
            match try_pg_exec_params(conn, sql_insert, Contacto_valores(c)) {
                Result.Ok(n) => { print("insertadas: " + int_to_string(n)) }
                Result.Err(e) => { print("insert fallo: " + e.msg) }
            }

            // 4. Leer y reconstruir con desde_fila(), directo sobre la fila
            //    que devuelve el servidor. El booleano no necesita
            //    normalizarse a mano: PostgreSQL manda "t"/"f" en formato
            //    text, y desde_fila entiende esa forma además de la que
            //    escribe valores() ("true"/"false"). Un texto que no sea
            //    ninguna de las dos ABORTA nombrando el valor, en vez de
            //    asumir false — ver docs/gotchas/derive-fields-pg-bool-text.md.
            match try_pg_query(conn, "SELECT " + nombres_columnas(campos) + " FROM contactos ORDER BY nombre") {
                Result.Ok(filas) => {
                    var i: int = 0
                    while i < filas.length() {
                        let fila: Array = filas[i]
                        let reconstruido: Contacto = Contacto_desde_fila(fila)
                        let linea: String = reconstruido.nombre + " · " + reconstruido.rif + " · activo=" + bool_texto(reconstruido.activo) + " · saldo=" + int_to_string(reconstruido.saldo)
                        print(linea)
                        i = i + 1
                    }
                }
                Result.Err(e) => { print("select fallo: " + e.msg) }
            }

            let _c = try_pg_close(conn)

            // 5. El modelo se escribió UNA sola vez: el struct Contacto del
            //    principio. campos(), valores() y desde_fila() salieron
            //    todos de ahí — en ningún otro lugar de este archivo aparece
            //    "nombre text, rif text, activo boolean, saldo integer"
            //    tipeado a mano.
            return 0
        }
    }
}
Illustrative outputstdout
tabla creada desde Contacto_campos()
insertadas: 1
El Tornillo · J-29643190-6 · activo=true · saldo=1500

How it works

Declaration order is the contract between the three generated functions: valores()[i] is the value of the field described by campos()[i], and desde_fila reads that same position. That is what lets crear_tabla_sql, nombres_columnas and placeholders all be derived from campos() — the CREATE TABLE, the column list and the $1, $2, … placeholders are never typed by hand, so adding or renaming a field in Contacto changes the SQL by itself.

The values travel through try_pg_exec_params, which puts them in the protocol's Bind message rather than interpolating them into the SQL string. A name like O'Brien is a name, not a quote that ends the literal; the same mechanism is what makes the parameterized form the safe one by default.

Rows come from two producers that disagree about booleans: valores() writes "true"/"false", while PostgreSQL's text format for boolean is t/f. desde_fila understands both — plus 1/0 — and aborts naming the value on anything else, rather than assuming false: a boolean nobody can read is not false, and assuming it would write a wrong decision into a system that bills people. The derive itself only converts int, bool, float and String: a field holding an array, a map or a nested struct stops the compilation instead of writing an approximation into a real column.