use crate::model::project;
use anyhow::{Result, anyhow, bail};
use rusqlite::{Connection, OpenFlags, Row, types::ValueRef};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
collections::HashSet,
path::Path,
time::{Duration, SystemTime, UNIX_EPOCH},
};
mod ddl;
mod rows;
mod sql;
pub use rows::{Edit, Page, Record};
const FILE: &str = "data.db";
const BUSY: Duration = Duration::from_secs(5);
const DDL: &str = "CREATE TABLE IF NOT EXISTS _tables (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER,
archived INTEGER,
author TEXT
)";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ColType {
Text,
Number,
Date,
Check,
}
impl ColType {
pub const ALL: [Self; 4] = [Self::Text, Self::Number, Self::Date, Self::Check];
pub fn name(self) -> &'static str {
match self {
Self::Text => "text",
Self::Number => "number",
Self::Date => "date",
Self::Check => "check",
}
}
fn sql(self) -> &'static str {
match self {
Self::Text => "TEXT",
Self::Number => "NUMERIC",
Self::Date => "DATE",
Self::Check => "BOOLEAN",
}
}
}
impl From<&str> for ColType {
fn from(declared: &str) -> Self {
match declared.to_ascii_uppercase().as_str() {
"NUMERIC" => Self::Number,
"DATE" => Self::Date,
"BOOLEAN" => Self::Check,
_ => Self::Text,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Column {
pub name: String,
#[serde(rename = "type")]
pub kind: ColType,
}
#[derive(Debug, Clone, Serialize)]
pub struct Table {
pub key: String,
pub name: String,
pub columns: Vec<Column>,
pub rows: i64,
pub created_at: i64,
pub updated_at: Option<i64>,
pub archived: bool,
pub author: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct Rows {
pub columns: Vec<String>,
pub rows: Vec<Vec<Value>>,
}
pub struct Data {
writer: Connection,
reader: Connection,
}
impl Data {
pub fn open(project: &Path) -> Result<Self> {
let path = project::init(project)?.join(FILE);
let writer = Connection::open(&path)?;
writer.busy_timeout(BUSY)?;
writer.execute_batch("PRAGMA journal_mode = WAL")?;
writer.execute_batch(DDL)?;
let held = columns_of(&writer, "_tables")?;
for column in ["updated_at", "archived"] {
if !held.iter().any(|col| col.name == column) {
writer
.execute_batch(&format!("ALTER TABLE _tables ADD COLUMN {column} INTEGER"))?;
}
}
let reader = Connection::open_with_flags(
&path,
OpenFlags::SQLITE_OPEN_READ_ONLY
| OpenFlags::SQLITE_OPEN_URI
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
reader.busy_timeout(BUSY)?;
Ok(Self { writer, reader })
}
pub fn attach(project: &Path) -> Option<Self> {
match project::dir(project).join(FILE).exists() {
true => Self::open(project).ok(),
false => None,
}
}
pub fn list(&self) -> Result<Vec<Table>> {
self.keys()?.iter().map(|key| self.table(key)).collect()
}
pub fn archive(&mut self, key: &str, archived: bool) -> Result<()> {
self.writer.execute(
"UPDATE _tables SET archived = ?2 WHERE key = ?1",
rusqlite::params![key, archived],
)?;
Ok(())
}
pub fn touch(&mut self, key: &str) -> Result<()> {
self.writer.execute(
"UPDATE _tables SET updated_at = ?2 WHERE key = ?1",
rusqlite::params![key, now()],
)?;
Ok(())
}
fn table(&self, key: &str) -> Result<Table> {
let meta = self
.reader
.query_row(
"SELECT name, created_at, updated_at, archived, author FROM _tables \
WHERE key = ?1",
[key],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get::<_, Option<bool>>(3)?,
row.get(4)?,
))
},
)
.ok();
let (name, created_at, updated_at, archived, author) = meta.unwrap_or_else(|| {
(
key.to_owned(),
0i64,
None::<i64>,
None::<bool>,
None::<String>,
)
});
Ok(Table {
columns: columns_of(&self.reader, key)?,
rows: self.reader.query_row(
&format!("SELECT count(*) FROM {}", quote(key)),
[],
|row| row.get(0),
)?,
key: key.to_owned(),
name,
created_at,
updated_at,
archived: archived.unwrap_or_default(),
author,
})
}
fn keys(&self) -> Result<Vec<String>> {
let mut stmt = self.reader.prepare(
"SELECT m.name FROM sqlite_master m \
LEFT JOIN _tables t ON t.key = m.name \
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND m.name <> '_tables' \
ORDER BY COALESCE(t.updated_at, t.created_at, 0) DESC, m.name",
)?;
let keys = stmt
.query_map([], |row| row.get(0))?
.collect::<rusqlite::Result<Vec<String>>>()?;
Ok(keys)
}
pub fn create(
&mut self,
name: &str,
key: Option<&str>,
columns: &[Column],
author: Option<&str>,
) -> Result<Table> {
let name = name.trim();
if name.is_empty() {
bail!("a table needs a name");
}
if columns.is_empty() {
bail!("a table needs at least one column");
}
let mut seen = HashSet::new();
for col in columns {
let column = col.name.trim();
if column.is_empty() {
bail!("a column needs a name");
}
if !seen.insert(column.to_ascii_lowercase()) {
bail!("two columns named {column}");
}
}
let key = self.mint_key(key.filter(|k| !k.trim().is_empty()).unwrap_or(name))?;
let defs = columns
.iter()
.map(|col| format!("{} {}", quote(col.name.trim()), col.kind.sql()))
.collect::<Vec<_>>()
.join(", ");
let tx = self.writer.transaction()?;
tx.execute_batch(&format!("CREATE TABLE {} ({defs})", quote(&key)))?;
tx.execute(
"INSERT INTO _tables (key, name, created_at, author) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![&key, name, now(), author],
)?;
tx.commit()?;
self.table(&key)
}
pub fn update(&mut self, table: &str, name: Option<&str>, key: Option<&str>) -> Result<Table> {
let mut current = self.resolve(table)?;
if let Some(wanted) = key.map(str::trim).filter(|key| !key.is_empty()) {
let next = slug(wanted);
if next != current {
if self.exists(&next)? {
bail!("{next} is taken");
}
let tx = self.writer.transaction()?;
tx.execute_batch(&format!(
"ALTER TABLE {} RENAME TO {}",
quote(¤t),
quote(&next)
))?;
tx.execute(
"UPDATE _tables SET key = ?2 WHERE key = ?1",
[¤t, &next],
)?;
tx.commit()?;
current = next;
}
}
if let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) {
self.writer.execute(
"INSERT INTO _tables (key, name, created_at, author) VALUES (?1, ?2, ?3, NULL) \
ON CONFLICT(key) DO UPDATE SET name = ?2",
rusqlite::params![¤t, name, now()],
)?;
}
self.table(¤t)
}
pub fn remove(&mut self, table: &str) -> Result<()> {
let key = self.resolve(table)?;
let tx = self.writer.transaction()?;
tx.execute_batch(&format!("DROP TABLE {}", quote(&key)))?;
tx.execute("DELETE FROM _tables WHERE key = ?1", [&key])?;
Ok(tx.commit()?)
}
fn resolve(&self, ident: &str) -> Result<String> {
let ident = ident.trim();
self.reader
.query_row(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1 \
UNION ALL SELECT key FROM _tables WHERE name = ?1 COLLATE NOCASE",
[ident],
|row| row.get(0),
)
.map_err(|_| anyhow!("no table {ident}"))
}
fn exists(&self, key: &str) -> Result<bool> {
Ok(self
.reader
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
[key],
|row| row.get::<_, i64>(0),
)
.is_ok())
}
fn mint_key(&self, name: &str) -> Result<String> {
let base = slug(name);
if !self.exists(&base)? {
return Ok(base);
}
for n in 2.. {
let key = format!("{base}_{n}");
if !self.exists(&key)? {
return Ok(key);
}
}
unreachable!("an unbounded range holds a free suffix")
}
}
pub(crate) fn columns_of(conn: &Connection, key: &str) -> Result<Vec<Column>> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", quote(key)))?;
let columns = stmt
.query_map([], |row| {
Ok(Column {
name: row.get("name")?,
kind: ColType::from(row.get::<_, String>("type").unwrap_or_default().as_str()),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(columns)
}
pub(crate) fn value(row: &Row, i: usize) -> Value {
match row.get_ref(i) {
Ok(ValueRef::Integer(n)) => Value::from(n),
Ok(ValueRef::Real(f)) => Value::from(f),
Ok(ValueRef::Text(t)) => Value::from(String::from_utf8_lossy(t).into_owned()),
_ => Value::Null,
}
}
pub(crate) fn bind(v: &Value) -> rusqlite::types::Value {
use rusqlite::types::Value as Sql;
match v {
Value::Bool(b) => Sql::Integer(i64::from(*b)),
Value::Number(n) => n
.as_i64()
.map_or_else(|| n.as_f64().map_or(Sql::Null, Sql::Real), Sql::Integer),
Value::String(s) => Sql::Text(s.clone()),
_ => Sql::Null,
}
}
pub(crate) fn quote(ident: &str) -> String {
format!("\"{}\"", ident.replace('"', "\"\""))
}
fn slug(name: &str) -> String {
let mut out = String::new();
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else if !out.ends_with('_') && !out.is_empty() {
out.push('_');
}
}
let out = out.trim_end_matches('_');
if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) {
format!("t_{out}")
} else {
out.to_owned()
}
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64)
}