Biblioteca estándar
SQL, Mongo y Redis
Todas las bases abren con db_open y están gateadas por require db(scope). Tres familias: SQL (SQLite/Postgres/MySQL), documentos (MongoDB, mongo_), key-value (Redis, redis_). Todos los drivers son puro-Rust (un solo binario estático).
-- Doc example: SQL over SQLite in-memory (real, no server). Same API for Postgres/MySQL.
intent: "doc example: SQL"
require db(":memory:")
db_open(":memory:", "memory")
sql_exec("CREATE TABLE demo (n TEXT)")
sql_exec("INSERT INTO demo VALUES (?)", ["it works"])
print("sql demo → " + (sql("SELECT n FROM demo")[0])["n"])
db_close()
-- The message of an error, or nothing when the call did not fail.
task error_of(f)
try
f()
recover e
give e
give nothing
test "create, parameterized insert, query"
db_open(":memory:", "memory")
sql_exec("CREATE TABLE items (name TEXT, price REAL)")
sql_exec("INSERT INTO items VALUES (?, ?)", ["Laptop", 999])
sql_exec("INSERT INTO items VALUES (?, ?)", ["Mouse", 25])
let rows be sql("SELECT name, price FROM items WHERE price > ? ORDER BY price DESC", [50])
assert_eq(length(rows), 1)
assert_eq(name of (rows[0]), "Laptop")
db_close()
test "sql_exec reports rows_affected and last_id; sql_batch runs one statement per row"
db_open(":memory:", "memory")
sql_exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
assert_eq(sql_exec("INSERT INTO users (name) VALUES (?)", ["Ada"]), {"rows_affected": 1, "last_id": 1})
assert_eq(sql_batch("INSERT INTO users (name) VALUES (?)", [["Bo"], ["Cy"]]), {"rows_affected": 2})
assert_eq(sql_tables(), ["users"])
db_close()
test "RETURNING goes through sql(); on SQLite sql_exec refuses it before running"
db_open(":memory:", "memory")
sql_exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
let e be error_of(() => sql_exec("INSERT INTO users (name) VALUES (?) RETURNING id", ["Ada"]))
assert(contains(e, "nothing was executed"))
assert_eq(length(sql("SELECT * FROM users")), 0)
assert_eq(sql("INSERT INTO users (name) VALUES (?) RETURNING id", ["Ada"]), [{"id": 1}])
db_close()
test "every ? needs exactly one value"
db_open(":memory:", "memory")
let e be error_of(() => sql("SELECT ? AS v"))
assert(contains(e, "the statement has 1 parameter(s) but 0 value(s) were passed"))
db_close()
test "maps, lists and integers beyond 64 bits are encoded on purpose"
db_open(":memory:", "memory")
sql_exec("CREATE TABLE docs (body TEXT, big TEXT)")
let doc be {"id": 2 ** 70, "tags": ["a", "b"]}
sql_exec("INSERT INTO docs VALUES (?, ?)", [json_encode(doc), text(2 ** 70)])
let row be sql("SELECT body, big FROM docs")[0]
assert_eq(json_decode(row.body), doc) -- json_decode keeps the big integer exact
assert_eq(int(row.big), 2 ** 70)
db_close()
test "bytes round-trip to a BLOB byte-exactly"
db_open(":memory:", "memory")
sql_exec("CREATE TABLE files (data BLOB)")
sql_exec("INSERT INTO files VALUES (?)", [bytes([0, 255, 7])])
assert_eq(sql("SELECT data FROM files")[0].data, bytes([0, 255, 7]))
db_close()
test "readonly refuses writes; db_close(target) closes that connection"
db_open(":memory:", "readonly")
assert(contains(error_of(() => sql_exec("CREATE TABLE x (a)")), "readonly"))
db_close(":memory:")
assert(contains(error_of(() => sql("SELECT 1")), "No database connection"))
test "SQLite cannot open another file from SQL: ATTACH a file and VACUUM INTO are refused (engine v0.6.45+)"
db_open(":memory:", "memory")
assert(contains(error_of(() => sql_exec("ATTACH DATABASE 'other.db' AS o")), "not authorized"))
assert(contains(error_of(() => sql_exec("ATTACH ? AS o", ["other.db"])), "not authorized"))
-- v0.6.46+: the message says why and what to use instead
assert(contains(error_of(() => sql_exec("ATTACH DATABASE 'other.db' AS o")), "open the other database with db_open(<path>), which needs require db(<path>)"))
assert(contains(error_of(() => sql_exec("VACUUM INTO 'copy.db'")), "authorization denied"))
sql_exec("ATTACH ':memory:' AS scratch") -- an in-memory database still attaches
sql_exec("VACUUM") -- a plain VACUUM still runs
db_close()
test "db_open refuses a file: URI; open the database by its path"
let e be error_of(() => db_open("file:/etc/x.db"))
assert(contains(e, "is not a database path"))SQL (SQLite / Postgres / MySQL)§
require db("./store.db")
db_open("./store.db") -- file → SQLite. ":memory:" with mode "memory". postgres:// / mysql://
sql_exec("INSERT INTO users (name) VALUES (?)", ["Ada"]) -- parameterized (safe)
let rows be sql("SELECT * FROM users WHERE id = ?", [1])
give paged("SELECT * FROM users ORDER BY id") -- pagination for a route handler
Los placeholders son siempre ? (Postgres los reescribe a $n internamente; MySQL usa ? nativo). Las columnas bytes round-trippean a BLOB/BYTEA byte-exacto.
Todas (los tests del ejemplo corren cada una sobre SQLite, salvo paged, que necesita una ruta):
db_open(target, mode?)— un path de archivo es SQLite;modees"memory"(con":memory:") o"readonly"(las escrituras fallan conattempt to write a readonly database).postgres://…ymysql://…abren esos servidores.- Otra base se abre con
db_open, nunca desde SQL (motor v0.6.45+).db_openes el paso que gatearequire db(...), así que SQLite rechaza lo que abriría o crearía un archivo sin él:ATTACH DATABASE '<archivo>'(not authorized) yVACUUM INTO '<archivo>'(authorization denied), con el nombre escrito o calculado (ATTACH ? AS x,'a' || '.db'), y los pragmastemp_store_directoryydata_store_directory. Desde el motor v0.6.46 el mensaje dice por qué y qué hacer en su lugar:not authorized: Synsema refuses ATTACH of a file (VACUUM INTO uses it) and the pragmas data_store_directory and temp_store_directory, so a query cannot open a database the program did not declare; open the other database with db_open(<path>), which needs require db(<path>)(authorization denied: …paraVACUUM INTO, con la misma explicación). UnVACUUMpelado yATTACH ':memory:'siguen andando.db_openrechaza una URIfile:(file:/etc/x.db, también escrita./file:…) y un path con://que no es un servidor conocido (`… is not a database path (afile:URI or a://URL that is not a known server); open the database by its path): nombran otro lugar que el que ve el scope. Las conexiones corren en el modo defensivo de SQLite:PRAGMA writable_schema = ONyPRAGMA journal_mode = OFFno tienen efecto, y escribir a mano el esquema o las tablas internas de FTS5 se rechaza (table sqlite_master may not be modified); las vistas de FTS5, los triggers yrebuild` siguen andando. - Las conexiones a
postgres,mysql,mongoyredisse habilitan condb, no connet: la regla de v0.6.46 de que unnetcon comodín sólo llega a direcciones públicas (Cliente HTTP) no se les aplica — una base en127.0.0.1o10.xse alcanza con surequire db(...). sql(statement, params?)→ una lista de maps, uno por fila.sql_exec(statement, params?)→{rows_affected, last_id}(last_ides el rowid en SQLite,last_insert_id()en MySQL,0en Postgres).sql_batch(statement, rows)corre la sentencia una vez por cada lista de valores →{rows_affected}.sql_tables()→ los nombres de las tablas.paged(statement)es para un handler de ruta — ver Servidor HTTP.- Una sentencia que devuelve filas va por
sql.INSERT … RETURNING idessql(...); en SQLite,sql_execysql_batchla rechazan antes de correrla (… returns rows (RETURNING) — run it with sql(statement, params) … (nothing was executed)), desde engine v0.6.29. Postgres y MySQL la corren como antes y devuelven la cuenta. - Un valor por cada
?. Si falta uno esthe statement has 1 parameter(s) but 0 value(s) were passed, y un argumento de más en cualquiera de estos builtins es un error (sql() takes at most 2 arguments, got 3). - Pasá texto, números, bytes o
nothing(NULL). Un map o una lista no se convierten a JSON solos — hacéjson_encode, yjson_decodeal leerlo (los enteros de cualquier tamaño quedan exactos). Un entero de más de 64 bits llega a SQLite como float, así que guardalo como texto (text(n)para escribir,int(t)para leer). - Una columna
NUMERICde Postgres oDECIMALde MySQL se lee comodecimal; desde engine v0.6.29 unnumericde Postgres de cualquier cantidad de dígitos vuelve exacto.
MongoDB (documentos)§
require db("mongodb://localhost/appdb")
db_open("mongodb://host:27017/appdb")
let id be mongo_insert("users", {"name": "Ana", "age": 30})
let adults be mongo_find("users", {"age": {"$gte": 18}}, {"sort": {"age": -1}})
Todos: mongo_find(coll, filtro?, opts?) (opts {limit, skip, sort: {f: 1/-1}, fields: {f: 1}}), mongo_find_one(coll, filtro?) → doc o nothing, mongo_insert(coll, doc) → _id, mongo_insert_many(coll, docs) → ids, mongo_update(coll, filtro, update) → {matched, modified} (update usa operadores, {"$set": …}), mongo_delete(coll, filtro) → {deleted}, mongo_count(coll, filtro?), mongo_aggregate(coll, pipeline), mongo_collections(). Documentos/filtros son mapas ↔ BSON; _id se lee como texto hex y un string de 24 hex bajo _id en un filtro se convierte a ObjectId.
Redis (key-value / cache / estructuras)§
require db("redis://localhost")
db_open("redis://localhost:6379")
redis_set("session:42", token, 3600) -- with TTL
let v be redis_get("session:42")
redis_set("cfg", json_encode({"theme": "dark"})) -- structured data is explicit
Los valores son byte-strings (text si es UTF-8, si no bytes; enteros → number); los argumentos aceptan text/bytes/number (cualquier otra cosa: json_encode). Todos:
- strings / claves:
redis_get(key)→ valor onothing,redis_set(key, val, ttl_secs?),redis_del(key…),redis_exists(key…),redis_mget(keys),redis_mset(mapa),redis_keys(patrón)(O(N)),redis_type(key). - contadores:
redis_incr,redis_decr,redis_incrby(key, n)(atómicos). - TTL:
redis_expire(key, secs),redis_ttl(key)(-1sin TTL,-2ausente),redis_persist(key). - hashes:
redis_hget(key, campo),redis_hset(key, mapa)→ campos nuevos,redis_hdel(key, campo…),redis_hgetall(key)→ mapa,redis_hincrby(key, campo, n). - listas:
redis_lpush/redis_rpush(key, val…)→ largo,redis_lpop/redis_rpop(key),redis_lrange(key, inicio, fin),redis_llen(key). - sets:
redis_sadd/redis_srem(key, miembro…),redis_smembers(key),redis_sismember(key, miembro). - lock distribuido:
redis_lock(key, ttl_ms?)→ token onothingsi está tomado (SET NX PX, 30000 ms por defecto),redis_unlock(key, token)→ bool (atómico, sólo libera quien tiene el token).
db_close(path?) cierra una conexión de cualquier familia.
Gotcha del db-index de Redis:redis://host:6379→ scoperedis://host(sin/0);…/0es un scope distinto.