lariv-rs 0.1.0

Compile-time plugin web application framework built on Axum, SeaORM, Maud, and HTMX
Documentation
//! minijinja globals/filters go`.

use chrono::{DateTime, Utc};
use minijinja::{Environment, Error, ErrorKind, Value};
use pulldown_cmark::{Options, Parser, html};
use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement};
use serde_json::{Map as JsonMap, Value as JsonValue, json};

fn format_time_val(val: &Value, fmt_layout: &str) -> String {
    if val.is_undefined() || val.is_none() {
        return String::new();
    }
    if let Some(s) = val.as_str() {
        let s = s.trim();
        if s.is_empty() {
            return String::new();
        }
        if let Ok(t) = DateTime::parse_from_rfc3339(s) {
            return t.with_timezone(&Utc).format(fmt_layout).to_string();
        }
        if let Some(t) = crate::datetime::parse_naive_datetime(s) {
            return t.format(fmt_layout).to_string();
        }
        if let Some(t) = crate::datetime::parse_date(s) {
            return t.format(fmt_layout).to_string();
        }
        return s.to_string();
    }
    val.to_string()
}

fn markdown_to_html(s: &str) -> String {
    let mut opts = Options::empty();
    opts.insert(Options::ENABLE_TABLES);
    opts.insert(Options::ENABLE_STRIKETHROUGH);
    let parser = Parser::new_ext(s, opts);
    let mut out = String::new();
    html::push_html(&mut out, parser);
    out
}

fn block_on_db<F, T>(fut: F) -> Result<T, Error>
where
    F: std::future::Future<Output = Result<T, sea_orm::DbErr>>,
{
    let handle = tokio::runtime::Handle::try_current().map_err(|_| {
        Error::new(
            ErrorKind::InvalidOperation,
            "no tokio runtime for template query",
        )
    })?;
    tokio::task::block_in_place(|| handle.block_on(fut))
        .map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))
}

fn sql_bind(v: &Value) -> String {
    if let Some(s) = v.as_str() {
        format!("'{}'", s.replace('\'', "''"))
    } else if let Some(i) = v.as_i64() {
        i.to_string()
    } else {
        format!("'{}'", v.to_string().replace('\'', "''"))
    }
}

fn fk_col(table: &str) -> String {
    let base = table.strip_suffix('s').unwrap_or(table);
    format!("{base}_id")
}

fn rows_to_maps(
    backend: DatabaseBackend,
    db: &DatabaseConnection,
    table: &str,
    rows: Vec<sea_orm::QueryResult>,
) -> Result<Value, Error> {
    if rows.is_empty() {
        return Ok(Value::from_serialize(Vec::<JsonValue>::new()));
    }
    let cols = column_names(backend, db, table)?;
    let mut out = Vec::new();
    for row in rows {
        let mut map = JsonMap::new();
        for col in &cols {
            if let Ok(v) = row.try_get::<Option<String>>("", col.as_str()) {
                map.insert(col.clone(), json!(v));
            } else if let Ok(v) = row.try_get::<Option<i64>>("", col.as_str()) {
                map.insert(col.clone(), json!(v));
            } else if let Ok(v) = row.try_get::<Option<bool>>("", col.as_str()) {
                map.insert(col.clone(), json!(v));
            } else {
                map.insert(col.clone(), JsonValue::Null);
            }
        }
        out.push(JsonValue::Object(map));
    }
    Ok(Value::from_serialize(out))
}

fn column_names(
    backend: DatabaseBackend,
    db: &DatabaseConnection,
    table: &str,
) -> Result<Vec<String>, Error> {
    let sql = match backend {
        DatabaseBackend::Postgres => format!(
            "SELECT column_name FROM information_schema.columns WHERE table_name = '{table}' ORDER BY ordinal_position"
        ),
        DatabaseBackend::Sqlite => format!("PRAGMA table_info('{table}')"),
        _ => return Ok(vec![]),
    };
    let db2 = db.clone();
    let rows =
        block_on_db(async move { db2.query_all(Statement::from_string(backend, sql)).await })?;
    let mut cols = Vec::new();
    for row in rows {
        let name = match backend {
            DatabaseBackend::Postgres => row
                .try_get::<String>("", "column_name")
                .map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))?,
            DatabaseBackend::Sqlite => row
                .try_get::<String>("", "name")
                .map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))?,
            _ => continue,
        };
        cols.push(name);
    }
    Ok(cols)
}

/// Register request-scoped globals and DB helpers on a minijinja environment.
pub fn register_funcs(
    env: &mut Environment<'static>,
    db: DatabaseConnection,
    path: String,
    query: Vec<(String, String)>,
) {
    let path_fn = path.clone();
    env.add_function("path", move || path_fn.clone());

    let slug_path = path;
    env.add_function("slug", move || {
        let trimmed = slug_path.trim_end_matches('/');
        trimmed.rsplit('/').next().unwrap_or("").to_string()
    });

    env.add_function("param", move |name: String| {
        query
            .iter()
            .find(|(k, _)| k == &name)
            .map(|(_, v)| v.clone())
            .unwrap_or_default()
    });

    env.add_function("first", |slice: Value| {
        slice
            .get_item_by_index(0)
            .unwrap_or_else(|_| Value::from(()))
    });

    env.add_filter("format_datetime", |val: Value, _layout: Option<String>| {
        format_time_val(&val, crate::datetime::DATETIME_SECONDS_FMT)
    });

    env.add_filter("format_date", |val: Value, _layout: Option<String>| {
        format_time_val(&val, crate::datetime::DATE_FMT)
    });

    env.add_filter("markdown", |val: Value| {
        let s = val
            .as_str()
            .map(|s| s.to_string())
            .unwrap_or_else(|| val.to_string());
        Value::from_safe_string(markdown_to_html(&s))
    });

    env.add_filter("markdown_unsafe", |val: Value| {
        let s = val
            .as_str()
            .map(|s| s.to_string())
            .unwrap_or_else(|| val.to_string());
        Value::from_safe_string(markdown_to_html(&s))
    });

    let db_q = db.clone();
    env.add_function(
        "query",
        move |table: String, limit: Option<i64>, offset: Option<i64>| -> Result<Value, Error> {
            let limit = limit.filter(|l| *l > 0).unwrap_or(10);
            let offset = offset.unwrap_or(0);
            let backend = db_q.get_database_backend();
            let sql = format!("SELECT * FROM \"{table}\" LIMIT {limit} OFFSET {offset}");
            let db = db_q.clone();
            let rows =
                block_on_db(
                    async move { db.query_all(Statement::from_string(backend, sql)).await },
                )?;
            rows_to_maps(backend, &db_q, &table, rows)
        },
    );

    let db_qw = db.clone();
    env.add_function(
        "query_where",
        move |table: String, where_clause: String, bind: Option<Value>| -> Result<Value, Error> {
            let where_sql = if let Some(v) = bind.filter(|v| !v.is_undefined() && !v.is_none()) {
                where_clause.replacen('?', &sql_bind(&v), 1)
            } else {
                where_clause
            };
            let backend = db_qw.get_database_backend();
            let sql = format!("SELECT * FROM \"{table}\" WHERE {where_sql}");
            let db = db_qw.clone();
            let rows =
                block_on_db(
                    async move { db.query_all(Statement::from_string(backend, sql)).await },
                )?;
            rows_to_maps(backend, &db_qw, &table, rows)
        },
    );

    let db_m2m = db.clone();
    env.add_function(
        "m2m_list",
        move |left_table: String,
              m2m_table: String,
              right_table: String,
              id: Value|
              -> Result<Value, Error> {
            let left_col = fk_col(&left_table);
            let right_col = fk_col(&right_table);
            let backend = db_m2m.get_database_backend();
            let sql = format!(
                "SELECT {right_table}.* FROM \"{right_table}\" \
                 JOIN \"{m2m_table}\" ON \"{m2m_table}\".\"{right_col}\" = \"{right_table}\".id \
                 WHERE \"{m2m_table}\".\"{left_col}\" = {}",
                sql_bind(&id)
            );
            let db = db_m2m.clone();
            let rows =
                block_on_db(
                    async move { db.query_all(Statement::from_string(backend, sql)).await },
                )?;
            rows_to_maps(backend, &db_m2m, &right_table, rows)
        },
    );

    let db_m2o = db.clone();
    env.add_function(
        "m2o",
        move |left_table: String, right_table: String, id: Value| -> Result<Value, Error> {
            let backend = db_m2o.get_database_backend();
            let left_sql = format!(
                "SELECT * FROM \"{left_table}\" WHERE id = {} LIMIT 1",
                sql_bind(&id)
            );
            let db = db_m2o.clone();
            let left_rows = block_on_db(async move {
                db.query_all(Statement::from_string(backend, left_sql))
                    .await
            })?;
            let left_vals = rows_to_maps(backend, &db_m2o, &left_table, left_rows)?;
            let left_row = left_vals
                .get_item_by_index(0)
                .map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))?;
            if left_row.is_undefined() || left_row.is_none() {
                return Ok(Value::from(()));
            }
            let fk = fk_col(&right_table);
            let fk_val = left_row
                .get_attr(&fk)
                .ok()
                .filter(|v| !v.is_undefined() && !v.is_none())
                .or_else(|| {
                    left_row
                        .get_attr("created_by_id")
                        .ok()
                        .filter(|v| !v.is_undefined() && !v.is_none())
                });
            let Some(fk_val) = fk_val else {
                return Ok(Value::from(()));
            };
            let right_sql = format!(
                "SELECT * FROM \"{right_table}\" WHERE id = {} LIMIT 1",
                sql_bind(&fk_val)
            );
            let db = db_m2o.clone();
            let right_rows = block_on_db(async move {
                db.query_all(Statement::from_string(backend, right_sql))
                    .await
            })?;
            let vals = rows_to_maps(backend, &db_m2o, &right_table, right_rows)?;
            Ok(vals
                .get_item_by_index(0)
                .unwrap_or_else(|_| Value::from(())))
        },
    );

    let db_get = db;
    env.add_function(
        "get",
        move |table: String, id: Value| -> Result<Value, Error> {
            let backend = db_get.get_database_backend();
            let id_sql = id.to_string();
            let sql = format!("SELECT * FROM \"{table}\" WHERE id = {id_sql} LIMIT 1");
            let db = db_get.clone();
            let rows =
                block_on_db(
                    async move { db.query_all(Statement::from_string(backend, sql)).await },
                )?;
            let vals = rows_to_maps(backend, &db_get, &table, rows)?;
            Ok(vals
                .get_item_by_index(0)
                .unwrap_or_else(|_| Value::from(())))
        },
    );
}