675 lines
25 KiB
Rust
675 lines
25 KiB
Rust
//! In-memory CRUD engine for the compiled schema.
|
|
//!
|
|
//! Storage model: `HashMap<type_name, BTreeMap<id, row>>`. Rows are
|
|
//! `serde_json::Value::Object`. `Id` columns are auto-populated on insert.
|
|
//! All queries are plain iteration — fine for Stage 2.
|
|
|
|
use crate::ast::{DefaultExpr, FieldTy};
|
|
use crate::compile::{Catalog, CompiledType};
|
|
|
|
use anyhow::Result;
|
|
use serde_json::{json, Map, Value};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
pub type Row = Map<String, Value>;
|
|
|
|
/// Comparable encoding of an indexed column value — the key space of the
|
|
/// secondary indexes (`@table(index: [...])`). Ordering: Null < Bool < Int
|
|
/// < Str, then natural order within each. Residual filters always re-check
|
|
/// with real JSON equality, so encoding collisions cannot produce wrong
|
|
/// results — only wasted candidates.
|
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
enum IndexKey {
|
|
Null,
|
|
Bool(bool),
|
|
Int(i64),
|
|
Str(String),
|
|
}
|
|
|
|
impl IndexKey {
|
|
fn from_value(v: Option<&Value>) -> IndexKey {
|
|
match v {
|
|
None | Some(Value::Null) => IndexKey::Null,
|
|
Some(Value::Bool(b)) => IndexKey::Bool(*b),
|
|
Some(other) => match other.as_i64() {
|
|
Some(n) => IndexKey::Int(n),
|
|
None => match other {
|
|
Value::String(s) => IndexKey::Str(s.clone()),
|
|
v => IndexKey::Str(v.to_string()),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One composite secondary index: ordered key tuples → row ids. Per-shard,
|
|
/// in RAM, maintained incrementally by [`Engine::row_insert`]/[`row_remove`].
|
|
#[derive(Debug)]
|
|
struct Index {
|
|
cols: Vec<String>,
|
|
map: BTreeMap<Vec<IndexKey>, BTreeSet<i64>>,
|
|
}
|
|
|
|
impl Index {
|
|
fn key_for(&self, row: &Row) -> Vec<IndexKey> {
|
|
self.cols.iter().map(|c| IndexKey::from_value(row.get(c))).collect()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct Engine {
|
|
catalog: Catalog,
|
|
/// type_name → { id → row }
|
|
tables: std::collections::HashMap<String, BTreeMap<i64, Row>>,
|
|
/// type_name → its secondary indexes (`@table(index: [...])`). Only
|
|
/// mutated by `row_insert`/`row_remove` — every table mutation path
|
|
/// (CRUD, replay, txn undo) goes through those two helpers.
|
|
indexes: std::collections::HashMap<String, Vec<Index>>,
|
|
/// per-type id allocator
|
|
next_id: std::collections::HashMap<String, i64>,
|
|
/// id stride — 1 for a standalone engine, `n_shards` for a 09b shard so
|
|
/// ids interleave (shard t mints t+1, t+1+n, …) and the owner of any id
|
|
/// is recoverable as `(id-1) % n` with zero coordination.
|
|
id_step: i64,
|
|
/// Per-shard write-ahead log (plan 09c). `PerCommit` fsyncs inside the
|
|
/// mutating call (simple, used by tests); `Group` stages frames and
|
|
/// parks acks for the worker's per-tick io_uring flush — the C
|
|
/// prototype's phase-D group commit.
|
|
wal: Option<WalBackend>,
|
|
/// Set when the last mutation staged a group-commit frame — the caller
|
|
/// (handler or shard job) must park its ack. Cleared by `take_staged`.
|
|
staged: bool,
|
|
/// Active method transaction (plan 13b): mutations defer their WAL
|
|
/// records here and journal undo entries; `commit_txn` emits one
|
|
/// [`WalRec::Txn`] frame, `abort_txn` reverts RAM in reverse order.
|
|
txn: Option<TxnState>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct TxnState {
|
|
wal: Vec<crate::wal::WalRec>,
|
|
undo: Vec<Undo>,
|
|
}
|
|
|
|
/// Inverse of one applied mutation — enough to restore the pre-txn RAM state.
|
|
#[derive(Debug)]
|
|
enum Undo {
|
|
Created { ty: String, id: i64 },
|
|
Updated { ty: String, id: i64, prev: Row },
|
|
Deleted { ty: String, id: i64, row: Row },
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum WalBackend {
|
|
PerCommit(crate::wal::Wal),
|
|
Group(crate::wal::WalGroup),
|
|
}
|
|
|
|
impl Engine {
|
|
pub fn new(catalog: Catalog) -> Self {
|
|
Self::for_shard(catalog, 0, 1)
|
|
}
|
|
|
|
/// One shard of a thread-per-core deployment (plan 09b): same engine,
|
|
/// interleaved id minting.
|
|
pub fn for_shard(catalog: Catalog, shard: usize, n_shards: usize) -> Self {
|
|
let mut tables = std::collections::HashMap::new();
|
|
let mut next_id = std::collections::HashMap::new();
|
|
let mut indexes = std::collections::HashMap::new();
|
|
for name in catalog.order.iter() {
|
|
tables.insert(name.clone(), BTreeMap::new());
|
|
next_id.insert(name.clone(), shard as i64 + 1);
|
|
let t = catalog.get(name).expect("type present");
|
|
if !t.indexes.is_empty() {
|
|
indexes.insert(name.clone(), t.indexes.iter().map(|cols| Index {
|
|
cols: cols.clone(),
|
|
map: BTreeMap::new(),
|
|
}).collect());
|
|
}
|
|
}
|
|
Self { catalog, tables, indexes, next_id, id_step: n_shards.max(1) as i64,
|
|
wal: None, staged: false, txn: None }
|
|
}
|
|
|
|
/// Attach a per-commit WAL (fsync inside each mutation). Must happen
|
|
/// AFTER replay — replayed mutations must not be re-logged.
|
|
pub fn attach_wal(&mut self, wal: crate::wal::Wal) {
|
|
self.wal = Some(WalBackend::PerCommit(wal));
|
|
}
|
|
|
|
/// Attach a group-commit WAL (io_uring): mutations stage frames; the
|
|
/// worker flushes once per tick and releases parked acks on the CQE.
|
|
pub fn attach_wal_group(&mut self, wal: crate::wal::WalGroup) {
|
|
self.wal = Some(WalBackend::Group(wal));
|
|
}
|
|
|
|
/// Did the last mutation stage a group-commit frame? (Cleared on read.)
|
|
/// The caller must park its ack on the batch when this is true.
|
|
pub fn take_staged(&mut self) -> bool {
|
|
std::mem::take(&mut self.staged)
|
|
}
|
|
|
|
/// Park a cross-shard reply on the active batch — sent on fsync.
|
|
pub fn park_reply(&mut self, cb: Box<dyn FnOnce() + Send>) {
|
|
match self.wal.as_mut() {
|
|
Some(WalBackend::Group(g)) => g.park(crate::wal::Parked::Reply(cb)),
|
|
_ => cb(), // no group WAL: durability already settled (or off)
|
|
}
|
|
}
|
|
|
|
/// Park a local connection's response on the active batch.
|
|
pub fn park_conn(&mut self, fd: std::os::unix::io::RawFd, gen: u64) {
|
|
if let Some(WalBackend::Group(g)) = self.wal.as_mut() {
|
|
g.park(crate::wal::Parked::Conn { fd, gen });
|
|
}
|
|
}
|
|
|
|
/// Worker hooks — flush at tick end; reap on ring-fd readable.
|
|
pub fn wal_flush(&mut self) {
|
|
if let Some(WalBackend::Group(g)) = self.wal.as_mut() {
|
|
if let Err(e) = g.flush() { eprintln!("[wo] wal flush: {e}"); }
|
|
}
|
|
}
|
|
|
|
pub fn wal_complete(&mut self) -> Option<(bool, Vec<crate::wal::Parked>)> {
|
|
match self.wal.as_mut() {
|
|
Some(WalBackend::Group(g)) => g.complete(),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn wal_ring_fd(&self) -> Option<std::os::unix::io::RawFd> {
|
|
match self.wal.as_ref() {
|
|
Some(WalBackend::Group(g)) => Some(g.ring_fd()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Apply one replayed WAL record. Bypasses default-seeding and id
|
|
/// minting — the log carries exact state — but advances the id
|
|
/// high-water mark so post-recovery mints never collide.
|
|
pub fn replay(&mut self, rec: &crate::wal::WalRec) {
|
|
use crate::wal::WalRec;
|
|
match rec {
|
|
WalRec::Create { ty, row } => {
|
|
let Some(id) = row.get("id").and_then(|v| v.as_i64()) else { return };
|
|
if self.tables.contains_key(ty) {
|
|
self.row_insert(ty, id, row.clone());
|
|
let step = self.id_step;
|
|
let counter = self.next_id.entry(ty.clone()).or_insert(1);
|
|
while *counter <= id { *counter += step; }
|
|
}
|
|
}
|
|
WalRec::Update { ty, id, body } => {
|
|
// Remove-then-insert keeps the secondary indexes in step.
|
|
let Some(mut row) = self.row_remove(ty, *id) else { return };
|
|
if let Value::Object(input) = body {
|
|
for (k, v) in input {
|
|
if k != "id" { row.insert(k.clone(), v.clone()); }
|
|
}
|
|
}
|
|
self.row_insert(ty, *id, row);
|
|
}
|
|
WalRec::Delete { ty, id } => {
|
|
self.row_remove(ty, *id);
|
|
}
|
|
// A method's mutations — the frame validated whole, apply all.
|
|
WalRec::Txn { recs } => {
|
|
for r in recs { self.replay(r); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- method transactions (plan 13b) ---
|
|
|
|
/// Enter method-transaction mode: subsequent mutations journal undo
|
|
/// entries and defer their WAL records until [`commit_txn`].
|
|
pub fn begin_txn(&mut self) -> Result<()> {
|
|
if self.txn.is_some() {
|
|
anyhow::bail!("nested method transactions are not supported");
|
|
}
|
|
self.txn = Some(TxnState::default());
|
|
Ok(())
|
|
}
|
|
|
|
/// Commit the active transaction: every deferred record leaves as ONE
|
|
/// `WalRec::Txn` frame (atomic on replay). `Err` means the WAL rejected
|
|
/// the frame — RAM has been rolled back and the caller must not ack.
|
|
pub fn commit_txn(&mut self) -> Result<()> {
|
|
let Some(t) = self.txn.take() else {
|
|
anyhow::bail!("commit_txn without begin_txn");
|
|
};
|
|
if t.wal.is_empty() { return Ok(()); } // read-only method — nothing to log
|
|
if let Err(e) = self.wal_log(crate::wal::WalRec::Txn { recs: t.wal }) {
|
|
self.apply_undo(t.undo); // never ack non-durable
|
|
return Err(e);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Abort the active transaction: revert RAM in reverse order, log nothing.
|
|
pub fn abort_txn(&mut self) {
|
|
if let Some(t) = self.txn.take() {
|
|
self.apply_undo(t.undo);
|
|
}
|
|
}
|
|
|
|
fn apply_undo(&mut self, undo: Vec<Undo>) {
|
|
for u in undo.into_iter().rev() {
|
|
match u {
|
|
Undo::Created { ty, id } => {
|
|
self.row_remove(&ty, id);
|
|
}
|
|
Undo::Updated { ty, id, prev } | Undo::Deleted { ty, id, row: prev } => {
|
|
// Clear the current version's index keys (if any row is
|
|
// present) before restoring the previous one.
|
|
self.row_remove(&ty, id);
|
|
self.row_insert(&ty, id, prev);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Make a mutation durable (per-commit) or stage it (group). `Err`
|
|
/// means the caller must undo the RAM apply. Inside a method
|
|
/// transaction the record is deferred instead — durability happens
|
|
/// once, at [`commit_txn`], as a single atomic frame.
|
|
fn wal_log(&mut self, rec: crate::wal::WalRec) -> Result<()> {
|
|
if let Some(t) = self.txn.as_mut() {
|
|
t.wal.push(rec);
|
|
return Ok(());
|
|
}
|
|
match self.wal.as_mut() {
|
|
Some(WalBackend::PerCommit(w)) => {
|
|
w.append(&rec).map_err(|e| anyhow::anyhow!("wal append: {e}"))?;
|
|
}
|
|
Some(WalBackend::Group(g)) => {
|
|
g.stage(&rec).map_err(|e| anyhow::anyhow!("wal stage: {e}"))?;
|
|
self.staged = true;
|
|
}
|
|
None => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn catalog(&self) -> &Catalog { &self.catalog }
|
|
|
|
/// List every row of `ty` in insertion (id) order.
|
|
pub fn list(&self, ty: &str) -> Result<Vec<Row>> {
|
|
self.table(ty).map(|t| t.values().cloned().collect())
|
|
}
|
|
|
|
pub fn get(&self, ty: &str, id: i64) -> Result<Option<Row>> {
|
|
Ok(self.table(ty)?.get(&id).cloned())
|
|
}
|
|
|
|
/// Create a row. `body` is the JSON object from the request; missing columns
|
|
/// fill in from defaults. Returns the finalized row (including the auto-id).
|
|
pub fn create(&mut self, ty: &str, body: Value) -> Result<Row> {
|
|
let t = self.compiled(ty)?.clone();
|
|
let mut row = self.seed_defaults(&t);
|
|
|
|
if let Value::Object(input) = body {
|
|
for (k, v) in input {
|
|
row.insert(k, v);
|
|
}
|
|
}
|
|
|
|
// Assign id if the type declares one and the caller didn't provide.
|
|
if t.has_id && !row.contains_key("id") {
|
|
let id = self.mint_id(ty);
|
|
row.insert("id".into(), json!(id));
|
|
}
|
|
|
|
let id = row.get("id")
|
|
.and_then(|v| v.as_i64())
|
|
.unwrap_or_else(|| self.mint_id(ty));
|
|
row.insert("id".into(), json!(id));
|
|
|
|
self.row_insert(ty, id, row.clone());
|
|
if let Some(t) = self.txn.as_mut() {
|
|
t.undo.push(Undo::Created { ty: ty.into(), id });
|
|
}
|
|
// Dual-write order: RAM applied above, durable now, ack after return.
|
|
if let Err(e) = self.wal_log(crate::wal::WalRec::Create { ty: ty.into(), row: row.clone() }) {
|
|
self.row_remove(ty, id); // never ack non-durable
|
|
return Err(e);
|
|
}
|
|
Ok(row)
|
|
}
|
|
|
|
/// Merge-update a row. Remove-then-insert so the secondary indexes see
|
|
/// both the old and the new key tuples.
|
|
pub fn update(&mut self, ty: &str, id: i64, body: Value) -> Result<Option<Row>> {
|
|
let Some(prev) = self.table(ty)?.get(&id).cloned() else { return Ok(None); };
|
|
let mut row = prev.clone();
|
|
if let Value::Object(input) = &body {
|
|
for (k, v) in input {
|
|
if k == "id" { continue; } // don't let the client mutate the primary key
|
|
row.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
let updated = row.clone();
|
|
self.row_remove(ty, id);
|
|
self.row_insert(ty, id, row);
|
|
if let Some(t) = self.txn.as_mut() {
|
|
t.undo.push(Undo::Updated { ty: ty.into(), id, prev: prev.clone() });
|
|
}
|
|
if let Err(e) = self.wal_log(crate::wal::WalRec::Update { ty: ty.into(), id, body }) {
|
|
self.row_remove(ty, id); // undo: never ack non-durable
|
|
self.row_insert(ty, id, prev);
|
|
return Err(e);
|
|
}
|
|
Ok(Some(updated))
|
|
}
|
|
|
|
pub fn delete(&mut self, ty: &str, id: i64) -> Result<bool> {
|
|
self.table(ty)?; // surface unknown-type as an error, not a silent false
|
|
let Some(removed) = self.row_remove(ty, id) else { return Ok(false) };
|
|
if let Some(t) = self.txn.as_mut() {
|
|
t.undo.push(Undo::Deleted { ty: ty.into(), id, row: removed.clone() });
|
|
}
|
|
if let Err(e) = self.wal_log(crate::wal::WalRec::Delete { ty: ty.into(), id }) {
|
|
self.row_insert(ty, id, removed); // undo
|
|
return Err(e);
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
/// Equality lookup, index-accelerated. Picks the index whose leading
|
|
/// columns form the longest prefix of the queried fields (prefix range
|
|
/// scan on its BTreeMap); remaining predicates filter the candidates;
|
|
/// no matching index → full scan. Results in id order. `eq` empty =
|
|
/// plain `list`.
|
|
pub fn find_by(&self, ty: &str, eq: &[(String, Value)]) -> Result<Vec<Row>> {
|
|
let table = self.table(ty)?;
|
|
if eq.is_empty() {
|
|
return Ok(table.values().cloned().collect());
|
|
}
|
|
// Real-equality re-check over ALL queried fields — the index only
|
|
// narrows candidates, it never decides membership.
|
|
let matches = |row: &Row| eq.iter().all(|(f, v)| {
|
|
match row.get(f) {
|
|
Some(rv) => rv == v,
|
|
None => v.is_null(),
|
|
}
|
|
});
|
|
|
|
let mut best: Option<(&Index, usize)> = None;
|
|
if let Some(idxs) = self.indexes.get(ty) {
|
|
for idx in idxs {
|
|
let mut k = 0;
|
|
for col in &idx.cols {
|
|
if eq.iter().any(|(f, _)| f == col) { k += 1; } else { break; }
|
|
}
|
|
if k > 0 && best.map_or(true, |(_, bk)| k > bk) {
|
|
best = Some((idx, k));
|
|
}
|
|
}
|
|
}
|
|
|
|
let Some((idx, k)) = best else {
|
|
return Ok(table.values().filter(|r| matches(r)).cloned().collect());
|
|
};
|
|
let prefix: Vec<IndexKey> = idx.cols[..k].iter()
|
|
.map(|c| IndexKey::from_value(eq.iter().find(|(f, _)| f == c).map(|(_, v)| v)))
|
|
.collect();
|
|
let mut ids: Vec<i64> = Vec::new();
|
|
// A shorter Vec sorts before any longer Vec sharing its prefix, so
|
|
// range(prefix..) starts exactly at the first candidate key.
|
|
for (key, set) in idx.map.range(prefix.clone()..) {
|
|
if key.len() < k || key[..k] != prefix[..] { break; }
|
|
ids.extend(set.iter().copied());
|
|
}
|
|
ids.sort_unstable();
|
|
Ok(ids.into_iter()
|
|
.filter_map(|id| table.get(&id))
|
|
.filter(|r| matches(r))
|
|
.cloned()
|
|
.collect())
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
fn table(&self, ty: &str) -> Result<&BTreeMap<i64, Row>> {
|
|
self.tables.get(ty).ok_or_else(|| anyhow::anyhow!("no such type: {ty}"))
|
|
}
|
|
|
|
/// THE two table-mutation primitives — every path that changes a row
|
|
/// (CRUD, WAL replay, txn undo) goes through these so the secondary
|
|
/// indexes can never drift from the tables.
|
|
fn row_insert(&mut self, ty: &str, id: i64, row: Row) {
|
|
if let Some(idxs) = self.indexes.get_mut(ty) {
|
|
for idx in idxs {
|
|
let key = idx.key_for(&row);
|
|
idx.map.entry(key).or_default().insert(id);
|
|
}
|
|
}
|
|
if let Some(t) = self.tables.get_mut(ty) {
|
|
t.insert(id, row);
|
|
}
|
|
}
|
|
|
|
fn row_remove(&mut self, ty: &str, id: i64) -> Option<Row> {
|
|
let row = self.tables.get_mut(ty)?.remove(&id)?;
|
|
if let Some(idxs) = self.indexes.get_mut(ty) {
|
|
for idx in idxs {
|
|
let key = idx.key_for(&row);
|
|
if let Some(set) = idx.map.get_mut(&key) {
|
|
set.remove(&id);
|
|
if set.is_empty() { idx.map.remove(&key); }
|
|
}
|
|
}
|
|
}
|
|
Some(row)
|
|
}
|
|
|
|
fn compiled(&self, ty: &str) -> Result<&CompiledType> {
|
|
self.catalog.get(ty).ok_or_else(|| anyhow::anyhow!("no such type: {ty}"))
|
|
}
|
|
|
|
fn mint_id(&mut self, ty: &str) -> i64 {
|
|
let counter = self.next_id.entry(ty.to_string()).or_insert(1);
|
|
let id = *counter;
|
|
*counter += self.id_step;
|
|
id
|
|
}
|
|
|
|
/// Produce the initial row object — default values for every non-relation
|
|
/// field that declared one.
|
|
fn seed_defaults(&self, t: &CompiledType) -> Row {
|
|
let mut row = Map::new();
|
|
for f in &t.fields {
|
|
if f.is_relation { continue; }
|
|
if let Some(def) = &f.default {
|
|
if let Some(v) = Self::eval_default(def, &f.ty) {
|
|
row.insert(f.name.clone(), v);
|
|
}
|
|
} else if matches!(f.ty, FieldTy::Array(_)) && !f.nullable {
|
|
row.insert(f.name.clone(), Value::Array(Vec::new()));
|
|
} else if let FieldTy::Struct(inner) = &f.ty {
|
|
let mut nested = Map::new();
|
|
for g in inner {
|
|
if let Some(d) = &g.default {
|
|
if let Some(v) = Self::eval_default(d, &g.ty) {
|
|
nested.insert(g.name.clone(), v);
|
|
}
|
|
}
|
|
}
|
|
if !nested.is_empty() {
|
|
row.insert(f.name.clone(), Value::Object(nested));
|
|
}
|
|
}
|
|
}
|
|
row
|
|
}
|
|
|
|
fn eval_default(def: &DefaultExpr, _ty: &FieldTy) -> Option<Value> {
|
|
Some(match def {
|
|
DefaultExpr::Str(s) => Value::String(s.clone()),
|
|
DefaultExpr::Int(n) => json!(n),
|
|
DefaultExpr::Bool(b) => json!(b),
|
|
DefaultExpr::Null => Value::Null,
|
|
DefaultExpr::Now => Value::String(now_iso8601()),
|
|
DefaultExpr::Enum(s) => Value::String(s.clone()),
|
|
// Opaque expressions (computed fields like `total = sum(...)`) are
|
|
// Stage 2-unevaluated. Omit rather than echo parser-debug tokens.
|
|
DefaultExpr::Opaque(_) => return None,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub(crate) fn now_iso8601() -> String {
|
|
let d = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default();
|
|
let secs = d.as_secs() as i64;
|
|
let nanos = d.subsec_nanos();
|
|
// Minimal ISO-8601 UTC formatter — good enough for Stage 2.
|
|
// (`chrono` would be nicer but we're keeping deps small.)
|
|
let (yr, mo, da, hr, mi, se) = ymdhms(secs);
|
|
format!("{yr:04}-{mo:02}-{da:02}T{hr:02}:{mi:02}:{se:02}.{:03}Z", nanos / 1_000_000)
|
|
}
|
|
|
|
/// Pure-Rust epoch → (year, month, day, hour, minute, second) for UTC.
|
|
fn ymdhms(mut secs: i64) -> (i32, u32, u32, u32, u32, u32) {
|
|
const SECS_PER_DAY: i64 = 86_400;
|
|
let se = (secs.rem_euclid(60)) as u32;
|
|
secs = secs.div_euclid(60);
|
|
let mi = (secs.rem_euclid(60)) as u32;
|
|
secs = secs.div_euclid(60);
|
|
let hr = (secs.rem_euclid(24)) as u32;
|
|
let mut days = secs.div_euclid(24);
|
|
|
|
// Days → calendar. Epoch 1970-01-01 is a Thursday but we don't need weekday.
|
|
let mut year: i32 = 1970;
|
|
loop {
|
|
let dy = if is_leap(year) { 366 } else { 365 };
|
|
if days >= dy {
|
|
days -= dy;
|
|
year += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let months = if is_leap(year) {
|
|
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
} else {
|
|
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
};
|
|
let mut mo = 1u32;
|
|
for (i, d) in months.iter().enumerate() {
|
|
if days < *d as i64 {
|
|
mo = i as u32 + 1;
|
|
break;
|
|
}
|
|
days -= *d as i64;
|
|
}
|
|
let _ = SECS_PER_DAY;
|
|
let da = days as u32 + 1;
|
|
(year, mo, da, hr, mi, se)
|
|
}
|
|
|
|
fn is_leap(y: i32) -> bool {
|
|
(y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::parser::parse;
|
|
|
|
fn engine_from(src: &str) -> Engine {
|
|
let cat = Catalog::from_schemas(vec![parse(src).unwrap()]).unwrap();
|
|
Engine::new(cat)
|
|
}
|
|
|
|
const INDEXED: &str = r#"
|
|
@table(index: [owner, at])
|
|
type Item { id: Id
|
|
owner: Int
|
|
at: Text
|
|
service rest "/api/items" expose list }
|
|
"#;
|
|
|
|
#[test]
|
|
fn secondary_index_tracks_create_update_delete() {
|
|
let mut eng = engine_from(INDEXED);
|
|
for i in 0..3 {
|
|
eng.create("Item", json!({"owner": 1, "at": format!("t{i}")})).unwrap();
|
|
}
|
|
eng.create("Item", json!({"owner": 2, "at": "t9"})).unwrap();
|
|
|
|
// prefix match (owner) and full composite (owner, at)
|
|
let one = eng.find_by("Item", &[("owner".into(), json!(1))]).unwrap();
|
|
assert_eq!(one.len(), 3);
|
|
let exact = eng.find_by("Item",
|
|
&[("owner".into(), json!(1)), ("at".into(), json!("t1"))]).unwrap();
|
|
assert_eq!(exact.len(), 1);
|
|
|
|
// update moves the row between index keys
|
|
let id = exact[0]["id"].as_i64().unwrap();
|
|
eng.update("Item", id, json!({"owner": 2})).unwrap();
|
|
assert_eq!(eng.find_by("Item", &[("owner".into(), json!(1))]).unwrap().len(), 2);
|
|
assert_eq!(eng.find_by("Item", &[("owner".into(), json!(2))]).unwrap().len(), 2);
|
|
|
|
// delete clears its entries
|
|
eng.delete("Item", id).unwrap();
|
|
assert_eq!(eng.find_by("Item", &[("owner".into(), json!(2))]).unwrap().len(), 1);
|
|
|
|
// non-indexed field → scan fallback, same semantics
|
|
assert_eq!(eng.find_by("Item", &[("at".into(), json!("t0"))]).unwrap().len(), 1);
|
|
|
|
// index answers equal scan answers (ground truth)
|
|
let scan: Vec<_> = eng.list("Item").unwrap().into_iter()
|
|
.filter(|r| r["owner"] == json!(1)).collect();
|
|
assert_eq!(eng.find_by("Item", &[("owner".into(), json!(1))]).unwrap(), scan);
|
|
}
|
|
|
|
#[test]
|
|
fn txn_abort_restores_index_state() {
|
|
let mut eng = engine_from(INDEXED);
|
|
eng.create("Item", json!({"owner": 1, "at": "a"})).unwrap(); // id 1
|
|
|
|
eng.begin_txn().unwrap();
|
|
eng.create("Item", json!({"owner": 1, "at": "b"})).unwrap();
|
|
eng.update("Item", 1, json!({"owner": 5})).unwrap();
|
|
eng.abort_txn();
|
|
|
|
assert_eq!(eng.find_by("Item", &[("owner".into(), json!(1))]).unwrap().len(), 1);
|
|
assert!(eng.find_by("Item", &[("owner".into(), json!(5))]).unwrap().is_empty());
|
|
assert_eq!(eng.list("Item").unwrap().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn crud_roundtrip_auto_id() {
|
|
let mut eng = engine_from(r#"
|
|
type Article { id: Id
|
|
title: Text
|
|
published: Bool = false
|
|
service rest "/api/articles" expose list, get, create, update, delete }
|
|
"#);
|
|
let a = eng.create("Article", json!({"title": "Hello"})).unwrap();
|
|
assert_eq!(a.get("id").unwrap().as_i64().unwrap(), 1);
|
|
assert_eq!(a.get("title").unwrap().as_str().unwrap(), "Hello");
|
|
assert_eq!(a.get("published").unwrap(), &json!(false));
|
|
|
|
let b = eng.create("Article", json!({"title": "World", "published": true})).unwrap();
|
|
assert_eq!(b.get("id").unwrap().as_i64().unwrap(), 2);
|
|
|
|
let list = eng.list("Article").unwrap();
|
|
assert_eq!(list.len(), 2);
|
|
|
|
let got = eng.get("Article", 1).unwrap().unwrap();
|
|
assert_eq!(got.get("title").unwrap().as_str().unwrap(), "Hello");
|
|
|
|
let upd = eng.update("Article", 1, json!({"title": "Hi"})).unwrap().unwrap();
|
|
assert_eq!(upd.get("title").unwrap().as_str().unwrap(), "Hi");
|
|
|
|
assert!(eng.delete("Article", 1).unwrap());
|
|
assert!(!eng.delete("Article", 1).unwrap());
|
|
assert_eq!(eng.list("Article").unwrap().len(), 1);
|
|
}
|
|
}
|