/
nv-lang
/
nova
Обзор
Документация
Войти
/
nv-lang
/
nova
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
std/src/data/sql.nv
344 строки
16 KB
Evgeniy Golovin
docs(endocs): identifiers/data/path — translate /// docs to English
01 авг 2026, 02:29
01 авг 2026, 02:29
fe8c31b
Код
Авторство
О чём код?
// stdlib_sql.nv — `SqlValue`, `Sql` тип и `sql\`...\``-tag. // // Цель — показать **безопасный SQL без injection** через // [tagged template literals](../spec/decisions/03-syntax.md#d48). // Tag-функция получает `parts` и `args` **раздельно** (D48), template // рендерится с `?`-плейсхолдерами, args подставляются параметром БД. // // Когда-нибудь это переедет в `std.sql` (Q-stdlib-data-types). Здесь — // эталонная реализация на актуальной спеке. module data.sql import std.text // []str @join(sep) — используется в sql() tag fn // ────────────────────────────────────────────────────────────────────────── // SqlValue — closed sum для SQL-аргументов // ────────────────────────────────────────────────────────────────────────── // Соответствует канонической схеме «SQL primitives»: целые, дробные, // строки, логические, бинарные, NULL. Этого достаточно для 95% // backend-задач. Для остального — ручной cast в одном из этих // вариантов. // // D55 literal coercion: `[42, "alice", true]` в позиции `[]SqlValue` // раскрывается в `[I(42), S("alice"), B(true)]` автоматически. /// SQL value variants: int, float, string, bool, bytes, null. D55 coercion: /// `[42, "alice"]` → `[I(42), S("alice")]` automatically in a `[]SqlValue` position. #stable(since = "0.1") // Plan 221.1 (E_DEBUG_PRINTABLE_NOT_IMPLEMENTED audit): `@expect_*` methods // below interpolate the receiver bare via `${@:?}` (Debug) in their error // messages — that requires `#impl(Debug)` same as `#impl(Display)` would // (D229 §4), else it's a compile error now instead of a silent heap-address // garbage print. #impl(Debug) export type SqlValue enum | I(i64) | F(f64) | S(str) | B(bool) | Bytes([]u8) | Null /// SQL fragment: template + args. Parameters are passed to the driver separately /// from the template — **SQL injection is impossible**. /// /// # Examples /// ```nova /// let q = sql`SELECT * FROM users WHERE id = ${user_id}` /// // q.template = "SELECT * FROM users WHERE id = ?" /// ``` #stable(since = "0.1") export type Sql { ro template str ro args []SqlValue } // ────────────────────────────────────────────────────────────────────────── // Sql composition — concat и with_filter // ────────────────────────────────────────────────────────────────────────── // concat — соединить два Sql фрагмента: template'ы конкатенируются с // разделителем-пробелом, args сцепляются по порядку. // // Используется query-builder'ом для динамического WHERE/ORDER BY: каждый // предикат — отдельный Sql, в конце всё сшивается в один. /// Concat two Sql fragments (space-separated). Args are chained in order. #stable(since = "0.1") export fn Sql @concat(other Sql) -> Sql => { template: "${@template} ${other.template}", args: [...@args, ...other.args], } /// Concat many Sql fragments with a separator. AND-chains: `concat_many(preds, " AND ")`. #stable(since = "0.1") export fn Sql.concat_many(parts []Sql, sep str) -> Sql { if parts.len() == 0 { return { template: "", args: [] } } consume sb = StringBuilder.new().append(parts[0].template) mut args []SqlValue = [...parts[0].args] mut i = 1 while i < parts.len() { sb.append(sep).append(parts[i].template) args.extend(parts[i].args) i += 1 } { template: sb.into_str(), args } } // with_filter — добавить дополнительное `AND <expr>` к WHERE-секции. // Используется handler-декораторами (soft-delete, multi-tenancy) для // прозрачной фильтрации без вмешательства в бизнес-код. // // Реализация — простая: append " AND <expr>". Это работает для запросов // с уже существующим WHERE. Реальная stdlib-версия делала бы parsing, // чтобы корректно вставлять в любые позиции (включая UNION, подзапросы); // здесь — упрощённо ради читаемости. /// Append `AND (<predicate>)` to the WHERE clause. Used by handler decorators /// (soft-delete, multi-tenancy) for transparent filtering. #stable(since = "0.1") export fn Sql @with_filter(predicate Sql) -> Sql => { template: "${@template} AND (${predicate.template})", args: [...@args, ...predicate.args], } // ────────────────────────────────────────────────────────────────────────── // `sql\`...\``-tag функция // ────────────────────────────────────────────────────────────────────────── // Сигнатура tag-функции для D48: получает `parts` (статические сегменты // между `${...}`) и `args` (значения интерполяций). Длина `parts` всегда // `args.len + 1`. // // Template получается соединением `parts` через `?`-плейсхолдер, args // проходят как параметры запроса. /// D48 tag function for the `sql` template literal. The `parts` segments are joined /// with `?` placeholders. Args are passed as query parameters. #stable(since = "0.1") export fn sql(parts []str, args []SqlValue) -> Sql => { template: parts.join("?"), args } // ────────────────────────────────────────────────────────────────────────── // Db effect — стандартный эффект для запросов // ────────────────────────────────────────────────────────────────────────── // `Db` принимает уже подготовленный `Sql`. Это упрощает API: вызывающий // конструирует Sql через тег (безопасно), Db исполняет. // // В тестах handler заменяется на in-memory; никаких mock-библиотек. /// `Db` effect — the standard handler for database operations. In tests /// it is replaced with in-memory; no mock libraries. #stable(since = "0.1") export type Db effect { query(q Sql) Fail[DbError] -> []DbRow exec(q Sql) Fail[DbError] -> int in_transaction[T, E](body fn() Db Fail[E] -> T) Db Fail[E] -> T } /// Errors when running DB operations. #stable(since = "0.1") export type DbError enum Connection | Constraint(str) | Timeout | Other(str) // ────────────────────────────────────────────────────────────────────────── // DbRow — результат запроса // ────────────────────────────────────────────────────────────────────────── // Минимальное представление: позиционный доступ + named-доступ к // колонкам. Реальный stdlib даст больше методов (typed get'ы и т.п.), // здесь — каркас для demo. /// Database row — columns + values, name-addressable. #stable(since = "0.1") export type DbRow { ro columns []str ro values []SqlValue } /// Get column value by name. `None` if the column is missing. #stable(since = "0.1") export fn DbRow @get(name str) -> Option[SqlValue] { mut i = 0 for col in @columns { if col == name { return Some(@values[i]) } i += 1 } None } /// Get column value by positional index. #stable(since = "0.1") export fn DbRow @at(i int) -> Option[SqlValue] => @values.get(i) // ────────────────────────────────────────────────────────────────────────── // SqlValue extractors — typed accessors для парсинга строк // ────────────────────────────────────────────────────────────────────────── // `expect_int` / `expect_str` / etc. — извлечь конкретный тип или `Err`. // Используется from_row-парсерами: `let id = row.get_int("id")?` (D325). // // Возврат `Result[_, DbError]`, чтобы ошибки парсинга поднимались как // DbError (Constraint), а не молча возвращали None. /// Extract i64 or `Err(Constraint)`. Used by from-row parsers. #stable(since = "0.1") export fn SqlValue @expect_int() -> Result[i64, DbError] => match @ { I(n) => Ok(n) // Plan 221.1 (E_INTERP_NO_DISPLAY audit): `SqlValue` has no // `#impl(Display)` — bare `${@}` used to silently reach emit_c's // numeric-cast fallback (print the enum's heap address as an int). // `${@:?}` uses the always-available Debug auto-derive instead — // shows the actual variant/value, which is strictly more useful in // an error message anyway. _ => Err(DbError.Constraint("expected I, got ${@:?}")) } /// Extract str or `Err(Constraint)`. #stable(since = "0.1") export fn SqlValue @expect_str() -> Result[str, DbError] => match @ { S(s) => Ok(s) _ => Err(DbError.Constraint("expected S, got ${@:?}")) } /// Extract bool or `Err(Constraint)`. #stable(since = "0.1") export fn SqlValue @expect_bool() -> Result[bool, DbError] => match @ { B(b) => Ok(b) _ => Err(DbError.Constraint("expected B, got ${@:?}")) } /// Extract f64 or `Err(Constraint)`. INT→FLOAT promotion is allowed. #stable(since = "0.1") export fn SqlValue @expect_f64() -> Result[f64, DbError] => match @ { F(x) => Ok(x) I(n) => Ok(n as f64) _ => Err(DbError.Constraint("expected F/I, got ${@:?}")) } /// Extract i64 into an `Option` (NULL → None) — for NULL-able columns. #stable(since = "0.1") export fn SqlValue @as_int() -> Option[i64] => // [M-d410-as-to-migration] match @ { I(n) => Some(n) _ => None } /// Extract str into an `Option` (NULL → None). #stable(since = "0.1") export fn SqlValue @str() -> Option[str] => match @ { S(s) => Some(s) _ => None } /// Get column by name + expect i64. `Err(Constraint)` if column is missing/wrong type. #stable(since = "0.1") export fn DbRow @get_int(name str) -> Result[i64, DbError] => match @get(name) { Some(v) => v.expect_int() None => Err(DbError.Constraint("missing column: ${name}")) } /// Get column by name + expect str. #stable(since = "0.1") export fn DbRow @get_str(name str) -> Result[str, DbError] => match @get(name) { Some(v) => v.expect_str() None => Err(DbError.Constraint("missing column: ${name}")) } /// Get column by name + expect bool. #stable(since = "0.1") export fn DbRow @get_bool(name str) -> Result[bool, DbError] => match @get(name) { Some(v) => v.expect_bool() None => Err(DbError.Constraint("missing column: ${name}")) } // ────────────────────────────────────────────────────────────────────────── // SqlBuilder — динамический WHERE через method chain // ────────────────────────────────────────────────────────────────────────── // SqlBuilder накапливает предикаты и в конце собирает Sql одним вызовом. // Альтернатива конкатенации строк "WHERE 1=1 AND a=? AND b=?" с ручными // параметрами — здесь параметры идут вместе с фрагментом. // // Все методы — chainable: возвращают `SqlBuilder`. Финальный `.build()` // собирает Sql. /// Dynamic SQL builder via method chain. An alternative to string concatenation. /// /// # Examples /// ```nova /// let q = Sql.builder(sql`SELECT * FROM users WHERE 1=1`) /// .where_eq("active", B(true)) /// .where_in("role", [S("admin"), S("editor")]) /// .build() /// ``` #stable(since = "0.1") export type SqlBuilder { base Sql // SELECT ... FROM ... WHERE 1=1 predicates []Sql // AND-extensions suffix Option[Sql] // ORDER BY / LIMIT / OFFSET } /// Init SqlBuilder with a base SELECT fragment. #stable(since = "0.1") export fn Sql.builder(base Sql) -> SqlBuilder => { base, predicates: [], suffix: None } /// Add `AND col = ?` predicate. Chainable. #stable(since = "0.1") export fn SqlBuilder mut @where_eq(col str, value SqlValue) -> SqlBuilder { @predicates.push({ template: "${col} = ?", args: [value] }) @ } /// Add `AND col IN (?, ?, ...)` predicate. Empty `values` → `AND FALSE` (0 rows). #stable(since = "0.1") export fn SqlBuilder mut @where_in(col str, values []SqlValue) -> SqlBuilder { if values.len() == 0 { // IN () — пустой набор. Чтобы запрос вернул 0 строк без // синтаксической ошибки, добавляем AND FALSE. @predicates.push({ template: "FALSE", args: [] }) return @ } ro rest = ", ?".repeat(values.len() - 1) ro placeholders = "?${rest}" @predicates.push({ template: "${col} IN (${placeholders})", args: values }) @ } /// Escape hatch: insert an arbitrary Sql fragment as a predicate. Chainable. #stable(since = "0.1") export fn SqlBuilder mut @where_raw(predicate Sql) -> SqlBuilder { @predicates.push(predicate) @ } /// Set suffix — ORDER BY / LIMIT / OFFSET in one block. Chainable. #stable(since = "0.1") export fn SqlBuilder mut @set_suffix(s Sql) -> SqlBuilder { @suffix = Some(s) @ } /// Build the final Sql. Joins base + AND-predicates + suffix. #stable(since = "0.1") export fn SqlBuilder @build() -> Sql { ro body = if @predicates.len() == 0 { @base } else { ro preds = Sql.concat_many(@predicates, " AND ") @base.concat({ template: "AND", args: [] }).concat(preds) } match @suffix { Some(s) => body.concat(s) None => body } } // Тесты — см. peer-файл sql_test.nv (module data.sql_test).