use std::sync::Arc;
use bytes::Bytes;
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use sqlx::{Sqlite, SqliteConnection};
use uuid::Uuid;
use crate::filter::{CompareOp, Filter, Predicate};
use crate::label::Label;
use crate::name::ResourceName;
use crate::object::{Association, Object};
use crate::store::{
AssociationStore, AssociationStoreReader, EdgeEndpoint, EdgeQuery, ObjectStore,
ObjectStoreReader, Precondition, StoreExec, StoreTx, Transactional,
};
use crate::{Error, Result};
use super::sql_common::{InverseResolver, merge, record_err};
const DB_SYSTEM: &str = "sqlite";
pub fn migrator() -> sqlx::migrate::Migrator {
sqlx::migrate!("./migrations/sqlite")
}
pub fn migrator_with(
extra: impl IntoIterator<Item = sqlx::migrate::Migration>,
) -> sqlx::migrate::Migrator {
merge(migrator(), extra)
}
pub async fn migrate(pool: &SqlitePool) -> Result<()> {
migrator()
.run(pool)
.await
.map_err(|e| Error::generic(e.to_string()))
}
#[derive(Clone)]
pub struct SqlStore<L: Label> {
pool: SqlitePool,
inverse: InverseResolver,
_label: std::marker::PhantomData<L>,
}
impl<L: Label> SqlStore<L> {
pub fn connect(pool: SqlitePool) -> Self {
Self {
pool,
inverse: Arc::new(|_| None),
_label: std::marker::PhantomData,
}
}
pub async fn connect_and_migrate(pool: SqlitePool) -> Result<Self> {
migrate(&pool).await?;
Ok(Self::connect(pool))
}
#[must_use]
pub fn with_inverse(
mut self,
resolver: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
) -> Self {
self.inverse = Arc::new(resolver);
self
}
pub async fn in_memory() -> Result<Self> {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await?;
Self::connect_and_migrate(pool).await
}
}
fn build_object<L: Label>(
id: String,
label: String,
name: String,
properties: Option<String>,
version: i64,
created_at: String,
updated_at: Option<String>,
) -> Result<Object<L>> {
Ok(Object {
id: Uuid::parse_str(&id)?,
label: L::from_str(&label).map_err(|_| Error::generic("unknown label in row"))?,
name: name.parse()?,
properties: properties.map(|p| serde_json::from_str(&p)).transpose()?,
version: version as u64,
created_at: parse_ts(&created_at)?,
updated_at: updated_at.as_deref().map(parse_ts).transpose()?,
})
}
fn parse_ts(s: &str) -> Result<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(s)
.map(|dt| dt.with_timezone(&chrono::Utc))
.map_err(|e| Error::generic(format!("bad timestamp {s:?}: {e}")))
}
fn json_str(v: &Option<serde_json::Value>) -> Result<Option<String>> {
v.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(Into::into)
}
async fn op_get<L: Label>(conn: &mut SqliteConnection, id: &Uuid) -> Result<Object<L>> {
let id = id.hyphenated().to_string();
let row = sqlx::query!(
r#"SELECT id AS "id!", label, name, properties, version, created_at, updated_at
FROM objects WHERE id = ?"#,
id
)
.fetch_optional(conn)
.await?
.ok_or(Error::NotFound)?;
build_object(
row.id,
row.label,
row.name,
row.properties,
row.version,
row.created_at,
row.updated_at,
)
}
async fn op_get_by_name<L: Label>(
conn: &mut SqliteConnection,
label: L,
name: &ResourceName,
) -> Result<Object<L>> {
let label_s = label.as_str().to_string();
let name_s = name.to_string();
let row = sqlx::query!(
r#"SELECT id AS "id!", label, name, properties, version, created_at, updated_at
FROM objects WHERE label = ? AND name = ?"#,
label_s,
name_s
)
.fetch_optional(conn)
.await?
.ok_or(Error::NotFound)?;
build_object(
row.id,
row.label,
row.name,
row.properties,
row.version,
row.created_at,
row.updated_at,
)
}
macro_rules! rows_to_objects {
($rows:expr) => {
$rows
.into_iter()
.map(|r| {
build_object(
r.id,
r.label,
r.name,
r.properties,
r.version,
r.created_at,
r.updated_at,
)
})
.collect::<Result<Vec<_>>>()
};
}
async fn op_list_objects<L: Label>(
conn: &mut SqliteConnection,
label: L,
namespace: Option<&ResourceName>,
max_results: Option<usize>,
page_token: Option<String>,
) -> Result<(Vec<Object<L>>, Option<String>)> {
let q = crate::store::object_fingerprint(label, namespace, None);
let cursor = crate::store::decode_cursor(page_token, q)?;
let limit = max_results.unwrap_or(usize::MAX);
let label_s = label.as_str().to_string();
if let Some(ns) = namespace {
let rows = sqlx::query!(
r#"SELECT id AS "id!", label, name, properties, version, created_at, updated_at
FROM objects WHERE label = ? ORDER BY id"#,
label_s
)
.fetch_all(conn)
.await?;
let mut objects: Vec<Object<L>> = rows_to_objects!(rows)?;
objects.retain(|o| o.name.prefix_matches(ns));
if let Some(k) = cursor {
objects.retain(|o| o.id > k);
}
return Ok(crate::store::paginate_keyset(
objects,
max_results,
|o| o.id,
q,
));
}
let fetch = limit.saturating_add(1).min(i64::MAX as usize) as i64;
let objects = match cursor {
Some(k) => {
let k_s = k.hyphenated().to_string();
let rows = sqlx::query!(
r#"SELECT id AS "id!", label, name, properties, version, created_at, updated_at
FROM objects WHERE label = ? AND id > ? ORDER BY id LIMIT ?"#,
label_s,
k_s,
fetch
)
.fetch_all(conn)
.await?;
rows_to_objects!(rows)?
}
None => {
let rows = sqlx::query!(
r#"SELECT id AS "id!", label, name, properties, version, created_at, updated_at
FROM objects WHERE label = ? ORDER BY id LIMIT ?"#,
label_s,
fetch
)
.fetch_all(conn)
.await?;
rows_to_objects!(rows)?
}
};
Ok(crate::store::paginate_keyset(
objects,
max_results,
|o| o.id,
q,
))
}
fn is_pushable(filter: &Filter) -> bool {
match filter {
Filter::And(fs) | Filter::Or(fs) => fs.iter().all(is_pushable),
Filter::Not(f) => is_pushable(f),
Filter::Predicate(Predicate::Exists { path }) => is_pushable_path(path),
Filter::Predicate(Predicate::Compare { path, op, value }) => {
is_pushable_path(path)
&& match op {
CompareOp::Ne | CompareOp::Contains => false,
CompareOp::Eq
| CompareOp::Lt
| CompareOp::Le
| CompareOp::Gt
| CompareOp::Ge => value.is_string() || value.is_number() || value.is_boolean(),
}
}
}
}
fn is_pushable_path(path: &crate::filter::FieldPath) -> bool {
path.segments()
.iter()
.all(|seg| !seg.contains(['.', '[', ']', '"']))
}
fn json_path(path: &crate::filter::FieldPath) -> String {
let mut s = String::from("$");
for seg in path.segments() {
s.push('.');
s.push_str(seg);
}
s
}
fn allowed_types(value: &serde_json::Value) -> &'static [&'static str] {
if value.is_number() {
&["integer", "real"]
} else if value.is_string() {
&["text"]
} else {
&["true", "false"]
}
}
fn sql_op(op: CompareOp) -> &'static str {
match op {
CompareOp::Eq => "=",
CompareOp::Lt => "<",
CompareOp::Le => "<=",
CompareOp::Gt => ">",
CompareOp::Ge => ">=",
CompareOp::Ne | CompareOp::Contains => unreachable!("not pushable"),
}
}
fn build_where(qb: &mut sqlx::QueryBuilder<'_, Sqlite>, filter: &Filter) {
match filter {
Filter::And(fs) if fs.is_empty() => {
qb.push("1");
}
Filter::Or(fs) if fs.is_empty() => {
qb.push("0");
}
Filter::And(fs) | Filter::Or(fs) => {
let sep = if matches!(filter, Filter::And(_)) {
" AND "
} else {
" OR "
};
qb.push("(");
for (i, f) in fs.iter().enumerate() {
if i > 0 {
qb.push(sep);
}
build_where(qb, f);
}
qb.push(")");
}
Filter::Not(f) => {
qb.push("(NOT ");
build_where(qb, f);
qb.push(")");
}
Filter::Predicate(Predicate::Exists { path }) => {
qb.push("(json_type(properties, ");
qb.push_bind(json_path(path));
qb.push(") IS NOT NULL)");
}
Filter::Predicate(Predicate::Compare { path, op, value }) => {
let p = json_path(path);
qb.push("COALESCE((json_type(properties, ");
qb.push_bind(p.clone());
qb.push(") IN (");
for (i, ty) in allowed_types(value).iter().enumerate() {
if i > 0 {
qb.push(", ");
}
qb.push_bind(*ty);
}
qb.push(")) AND (json_extract(properties, ");
qb.push_bind(p);
qb.push(") ");
qb.push(sql_op(*op));
qb.push(" ");
bind_comparand(qb, value);
qb.push("), 0)");
}
}
}
fn bind_comparand(qb: &mut sqlx::QueryBuilder<'_, Sqlite>, value: &serde_json::Value) {
match value {
serde_json::Value::Number(n) => {
qb.push_bind(n.as_f64().unwrap_or(f64::NAN));
}
serde_json::Value::Bool(b) => {
qb.push_bind(if *b { 1_i64 } else { 0_i64 });
}
serde_json::Value::String(s) => {
qb.push_bind(s.clone());
}
_ => unreachable!("non-scalar comparand is not pushable"),
}
}
async fn op_search_objects<L: Label>(
conn: &mut SqliteConnection,
label: L,
namespace: Option<&ResourceName>,
filter: &Filter,
max_results: Option<usize>,
page_token: Option<String>,
) -> Result<(Vec<Object<L>>, Option<String>)> {
let q = crate::store::object_fingerprint(label, namespace, Some(filter));
let cursor = crate::store::decode_cursor(page_token, q)?;
if !is_pushable(filter) {
let (all, _) = op_list_objects(conn, label, namespace, None, None).await?;
let matched: Vec<_> = all
.into_iter()
.filter(|o| filter.matches(crate::store::props_or_null(&o.properties)))
.filter(|o| cursor.is_none_or(|k| o.id > k))
.collect();
return Ok(crate::store::paginate_keyset(
matched,
max_results,
|o| o.id,
q,
));
}
let limit = max_results.unwrap_or(usize::MAX);
let label_s = label.as_str().to_string();
let mut qb = sqlx::QueryBuilder::<Sqlite>::new(
r#"SELECT id, label, name, properties, version, created_at, updated_at
FROM objects WHERE label = "#,
);
qb.push_bind(label_s);
qb.push(" AND ");
build_where(&mut qb, filter);
if namespace.is_none()
&& let Some(k) = cursor
{
qb.push(" AND id > ");
qb.push_bind(k.hyphenated().to_string());
}
qb.push(" ORDER BY id");
if namespace.is_none() {
let fetch = limit.saturating_add(1).min(i64::MAX as usize) as i64;
qb.push(" LIMIT ");
qb.push_bind(fetch);
}
let rows = qb.build().fetch_all(conn).await?;
let mut objects = rows
.into_iter()
.map(object_from_row)
.collect::<Result<Vec<_>>>()?;
if let Some(ns) = namespace {
objects.retain(|o| o.name.prefix_matches(ns));
if let Some(k) = cursor {
objects.retain(|o| o.id > k);
}
}
Ok(crate::store::paginate_keyset(
objects,
max_results,
|o| o.id,
q,
))
}
async fn op_query_edges<L: Label>(
conn: &mut SqliteConnection,
query: EdgeQuery<'_, L>,
) -> Result<(Vec<Association<L>>, Option<String>)> {
let q = crate::store::edge_fingerprint(&query);
let cursor = crate::store::decode_cursor(query.page_token.clone(), q)?;
let (anchor_col, other_col, anchor_id) = match query.endpoint {
EdgeEndpoint::From(id) => ("from_id", "to_id", id),
EdgeEndpoint::Into(id) => ("to_id", "from_id", id),
};
let base = |qb: &mut sqlx::QueryBuilder<'_, Sqlite>| {
qb.push(" WHERE ");
qb.push(anchor_col);
qb.push(" = ");
qb.push_bind(anchor_id.hyphenated().to_string());
qb.push(" AND label = ");
qb.push_bind(query.label.to_string());
if let Some(tl) = query.target_label {
qb.push(" AND to_label = ");
qb.push_bind(tl.as_str().to_string());
}
if let Some(tid) = query.target_id {
qb.push(" AND ");
qb.push(other_col);
qb.push(" = ");
qb.push_bind(tid.hyphenated().to_string());
}
if let Some(since) = query.since {
qb.push(" AND id >= ");
qb.push_bind(crate::store::v7_lower_bound(since).hyphenated().to_string());
}
if let Some(until) = query.until {
qb.push(" AND id < ");
qb.push_bind(crate::store::v7_lower_bound(until).hyphenated().to_string());
}
if let Some(k) = cursor {
qb.push(" AND id < ");
qb.push_bind(k.hyphenated().to_string());
}
};
const SELECT: &str = "SELECT id, from_id, label, to_id, to_label, properties, created_at, updated_at \
FROM associations";
if let Some(filter) = query.filter
&& !is_pushable(filter)
{
let mut qb = sqlx::QueryBuilder::<Sqlite>::new(SELECT);
base(&mut qb);
qb.push(" ORDER BY id DESC");
let rows = qb.build().fetch_all(conn).await?;
let matched: Vec<_> = rows
.into_iter()
.map(edge_from_row)
.collect::<Result<Vec<_>>>()?
.into_iter()
.filter(|a| filter.matches(crate::store::props_or_null(&a.properties)))
.collect();
return Ok(crate::store::paginate_keyset(
matched,
query.max_results,
|e| e.id,
q,
));
}
let limit = query.max_results.unwrap_or(usize::MAX);
let fetch = limit.saturating_add(1).min(i64::MAX as usize) as i64;
let mut qb = sqlx::QueryBuilder::<Sqlite>::new(SELECT);
base(&mut qb);
if let Some(filter) = query.filter {
qb.push(" AND ");
build_where(&mut qb, filter);
}
qb.push(" ORDER BY id DESC LIMIT ");
qb.push_bind(fetch);
let rows = qb.build().fetch_all(conn).await?;
let edges = rows
.into_iter()
.map(edge_from_row)
.collect::<Result<Vec<_>>>()?;
Ok(crate::store::paginate_keyset(
edges,
query.max_results,
|e| e.id,
q,
))
}
async fn op_count_edges<L: Label>(
conn: &mut SqliteConnection,
endpoint: EdgeEndpoint,
label: &str,
target_label: Option<L>,
) -> Result<u64> {
let (anchor_col, anchor_id) = match endpoint {
EdgeEndpoint::From(id) => ("from_id", id),
EdgeEndpoint::Into(id) => ("to_id", id),
};
let mut qb = sqlx::QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM associations WHERE ");
qb.push(anchor_col);
qb.push(" = ");
qb.push_bind(anchor_id.hyphenated().to_string());
qb.push(" AND label = ");
qb.push_bind(label.to_string());
if let Some(tl) = target_label {
qb.push(" AND to_label = ");
qb.push_bind(tl.as_str().to_string());
}
let count: i64 = qb.build_query_scalar().fetch_one(conn).await?;
Ok(count as u64)
}
fn object_from_row<L: Label>(row: sqlx::sqlite::SqliteRow) -> Result<Object<L>> {
use sqlx::Row;
build_object(
row.try_get("id")?,
row.try_get("label")?,
row.try_get("name")?,
row.try_get("properties")?,
row.try_get("version")?,
row.try_get("created_at")?,
row.try_get("updated_at")?,
)
}
fn edge_from_row<L: Label>(row: sqlx::sqlite::SqliteRow) -> Result<Association<L>> {
use sqlx::Row;
let id: String = row.try_get("id")?;
let from_id: String = row.try_get("from_id")?;
let to_id: String = row.try_get("to_id")?;
let to_label: String = row.try_get("to_label")?;
let properties: Option<String> = row.try_get("properties")?;
let created_at: String = row.try_get("created_at")?;
let updated_at: Option<String> = row.try_get("updated_at")?;
Ok(Association {
id: Uuid::parse_str(&id)?,
from_id: Uuid::parse_str(&from_id)?,
label: row.try_get("label")?,
to_id: Uuid::parse_str(&to_id)?,
to_label: L::from_str(&to_label).map_err(|_| Error::generic("unknown label in row"))?,
properties: properties.map(|p| serde_json::from_str(&p)).transpose()?,
created_at: parse_ts(&created_at)?,
updated_at: updated_at.as_deref().map(parse_ts).transpose()?,
})
}
async fn op_create<L: Label>(
conn: &mut SqliteConnection,
label: L,
name: &ResourceName,
properties: Option<serde_json::Value>,
id: Option<Uuid>,
sensitive: Option<Bytes>,
) -> Result<Object<L>> {
let object = Object {
id: id.unwrap_or_else(Uuid::now_v7),
label,
name: name.clone(),
properties,
version: 0,
created_at: chrono::Utc::now(),
updated_at: None,
};
let id_s = object.id.hyphenated().to_string();
let label_s = object.label.as_str().to_string();
let name_s = object.name.to_string();
let props = json_str(&object.properties)?;
let created = object.created_at.to_rfc3339();
let sensitive = sensitive.as_deref();
sqlx::query!(
"INSERT INTO objects (id, label, name, properties, sensitive, version, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, 0, ?, NULL)",
id_s,
label_s,
name_s,
props,
sensitive,
created,
)
.execute(conn)
.await?;
Ok(object)
}
async fn op_get_sensitive(conn: &mut SqliteConnection, id: &Uuid) -> Result<Option<Bytes>> {
let id_s = id.hyphenated().to_string();
let row = sqlx::query!(r#"SELECT sensitive FROM objects WHERE id = ?"#, id_s)
.fetch_optional(conn)
.await?;
Ok(row.and_then(|r| r.sensitive).map(Bytes::from))
}
async fn op_set_sensitive(conn: &mut SqliteConnection, id: &Uuid, blob: &[u8]) -> Result<()> {
let id_s = id.hyphenated().to_string();
let affected = sqlx::query!("UPDATE objects SET sensitive = ? WHERE id = ?", blob, id_s)
.execute(conn)
.await?
.rows_affected();
if affected == 0 {
return Err(Error::NotFound);
}
Ok(())
}
async fn classify_miss<L: Label>(conn: &mut SqliteConnection, id: &Uuid) -> Error {
match op_get::<L>(conn, id).await {
Ok(_) => {
tracing::debug!(id = %id, "CAS precondition conflict (version moved)");
Error::Conflict
}
Err(Error::NotFound) => Error::NotFound,
Err(e) => e,
}
}
async fn op_update<L: Label>(
conn: &mut SqliteConnection,
id: &Uuid,
properties: Option<serde_json::Value>,
precondition: Precondition,
sensitive: Option<Bytes>,
) -> Result<Object<L>> {
let id_s = id.hyphenated().to_string();
let props = json_str(&properties)?;
let now = chrono::Utc::now().to_rfc3339();
let blob = sensitive.as_deref();
let affected = match (precondition, blob) {
(Precondition::Any, None) => sqlx::query!(
"UPDATE objects SET properties = ?, version = version + 1, updated_at = ? \
WHERE id = ?",
props,
now,
id_s
)
.execute(&mut *conn)
.await?
.rows_affected(),
(Precondition::Any, Some(blob)) => sqlx::query!(
"UPDATE objects SET properties = ?, sensitive = ?, version = version + 1, updated_at = ? \
WHERE id = ?",
props,
blob,
now,
id_s
)
.execute(&mut *conn)
.await?
.rows_affected(),
(Precondition::Version(v), None) => {
let v = v as i64;
sqlx::query!(
"UPDATE objects SET properties = ?, version = version + 1, updated_at = ? \
WHERE id = ? AND version = ?",
props,
now,
id_s,
v
)
.execute(&mut *conn)
.await?
.rows_affected()
}
(Precondition::Version(v), Some(blob)) => {
let v = v as i64;
sqlx::query!(
"UPDATE objects SET properties = ?, sensitive = ?, version = version + 1, updated_at = ? \
WHERE id = ? AND version = ?",
props,
blob,
now,
id_s,
v
)
.execute(&mut *conn)
.await?
.rows_affected()
}
};
if affected == 0 {
return Err(classify_miss::<L>(conn, id).await);
}
op_get(conn, id).await
}
async fn op_rename<L: Label>(
conn: &mut SqliteConnection,
id: &Uuid,
new_name: &ResourceName,
precondition: Precondition,
) -> Result<Object<L>> {
let id_s = id.hyphenated().to_string();
let name_s = new_name.to_string();
let now = chrono::Utc::now().to_rfc3339();
let affected = match precondition {
Precondition::Any => sqlx::query!(
"UPDATE objects SET name = ?, version = version + 1, updated_at = ? WHERE id = ?",
name_s,
now,
id_s
)
.execute(&mut *conn)
.await?
.rows_affected(),
Precondition::Version(v) => {
let v = v as i64;
sqlx::query!(
"UPDATE objects SET name = ?, version = version + 1, updated_at = ? \
WHERE id = ? AND version = ?",
name_s,
now,
id_s,
v
)
.execute(&mut *conn)
.await?
.rows_affected()
}
};
if affected == 0 {
return Err(classify_miss::<L>(conn, id).await);
}
op_get(conn, id).await
}
async fn op_delete(conn: &mut SqliteConnection, id: &Uuid) -> Result<()> {
let id_s = id.hyphenated().to_string();
sqlx::query!(
"DELETE FROM associations WHERE from_id = ? OR to_id = ?",
id_s,
id_s
)
.execute(&mut *conn)
.await?;
let affected = sqlx::query!("DELETE FROM objects WHERE id = ?", id_s)
.execute(&mut *conn)
.await?
.rows_affected();
if affected == 0 {
return Err(Error::NotFound);
}
Ok(())
}
async fn op_add_edge<L: Label>(
conn: &mut SqliteConnection,
from_id: Uuid,
to_id: Uuid,
label: &str,
properties: Option<serde_json::Value>,
inverse: &InverseResolver,
) -> Result<()> {
let from: Object<L> = op_get(&mut *conn, &from_id).await?;
let to: Object<L> = op_get(&mut *conn, &to_id).await?;
insert_edge(
&mut *conn,
from_id,
to_id,
label,
to.label,
properties.clone(),
)
.await?;
if let Some(inv) = inverse(label) {
insert_edge(&mut *conn, to_id, from_id, &inv, from.label, properties).await?;
}
Ok(())
}
async fn insert_edge<L: Label>(
conn: &mut SqliteConnection,
from_id: Uuid,
to_id: Uuid,
label: &str,
to_label: L,
properties: Option<serde_json::Value>,
) -> Result<()> {
let id_s = Uuid::now_v7().hyphenated().to_string();
let from_s = from_id.hyphenated().to_string();
let to_s = to_id.hyphenated().to_string();
let to_label_s = to_label.as_str().to_string();
let props = json_str(&properties)?;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query!(
"INSERT INTO associations \
(id, from_id, label, to_id, to_label, properties, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, NULL)",
id_s,
from_s,
label,
to_s,
to_label_s,
props,
now
)
.execute(conn)
.await?;
Ok(())
}
async fn op_remove_edge(
conn: &mut SqliteConnection,
from_id: Uuid,
to_id: Uuid,
label: &str,
inverse: &InverseResolver,
) -> Result<()> {
let from_s = from_id.hyphenated().to_string();
let to_s = to_id.hyphenated().to_string();
let affected = sqlx::query!(
"DELETE FROM associations WHERE from_id = ? AND to_id = ? AND label = ?",
from_s,
to_s,
label
)
.execute(&mut *conn)
.await?
.rows_affected();
if affected == 0 {
return Err(Error::NotFound);
}
if let Some(inv) = inverse(label) {
sqlx::query!(
"DELETE FROM associations WHERE from_id = ? AND to_id = ? AND label = ?",
to_s,
from_s,
inv
)
.execute(&mut *conn)
.await?;
}
Ok(())
}
#[async_trait::async_trait]
impl<L: Label> ObjectStoreReader<L> for SqlStore<L> {
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.get",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "get",
id = %id,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn get(&self, id: &Uuid) -> Result<Object<L>> {
let mut conn = self.pool.acquire().await?;
let out = op_get(&mut conn, id).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.get_by_name",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "get_by_name",
db.collection.name = label.as_str(),
name = %name,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn get_by_name(&self, label: L, name: &ResourceName) -> Result<Object<L>> {
let mut conn = self.pool.acquire().await?;
let out = op_get_by_name(&mut conn, label, name).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.list",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "list",
db.collection.name = label.as_str(),
max_results = ?max_results,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn list(
&self,
label: L,
namespace: Option<&ResourceName>,
max_results: Option<usize>,
page_token: Option<String>,
) -> Result<(Vec<Object<L>>, Option<String>)> {
let mut conn = self.pool.acquire().await?;
let out = op_list_objects(&mut conn, label, namespace, max_results, page_token).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.search",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "search",
db.collection.name = label.as_str(),
max_results = ?max_results,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn search(
&self,
label: L,
namespace: Option<&ResourceName>,
filter: &Filter,
max_results: Option<usize>,
page_token: Option<String>,
) -> Result<(Vec<Object<L>>, Option<String>)> {
let mut conn = self.pool.acquire().await?;
let out =
op_search_objects(&mut conn, label, namespace, filter, max_results, page_token).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.get_sensitive",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "get_sensitive",
id = %id,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn get_sensitive(&self, id: &Uuid) -> Result<Option<Bytes>> {
let mut conn = self.pool.acquire().await?;
let out = op_get_sensitive(&mut conn, id).await;
record_err(&out);
out
}
}
#[async_trait::async_trait]
impl<L: Label> ObjectStore<L> for SqlStore<L> {
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.create",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "create",
db.collection.name = label.as_str(),
name = %name,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn create(
&self,
label: L,
name: &ResourceName,
properties: Option<serde_json::Value>,
id: Option<Uuid>,
sensitive: Option<Bytes>,
) -> Result<Object<L>> {
let mut conn = self.pool.acquire().await?;
let out = op_create(&mut conn, label, name, properties, id, sensitive).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.update",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "update",
id = %id,
precondition = ?precondition,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn update(
&self,
id: &Uuid,
properties: Option<serde_json::Value>,
precondition: Precondition,
sensitive: Option<Bytes>,
) -> Result<Object<L>> {
let out = async {
let mut tx = self.pool.begin().await?;
let out = op_update(&mut tx, id, properties, precondition, sensitive).await?;
tx.commit().await?;
Ok(out)
}
.await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.rename",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "rename",
id = %id,
name = %new_name,
precondition = ?precondition,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn rename(
&self,
id: &Uuid,
new_name: &ResourceName,
precondition: Precondition,
) -> Result<Object<L>> {
let out = async {
let mut tx = self.pool.begin().await?;
let out = op_rename(&mut tx, id, new_name, precondition).await?;
tx.commit().await?;
Ok(out)
}
.await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.delete",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "delete",
id = %id,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn delete(&self, id: &Uuid) -> Result<()> {
let out = async {
let mut tx = self.pool.begin().await?;
op_delete(&mut tx, id).await?;
tx.commit().await?;
Ok(())
}
.await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.set_sensitive",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "set_sensitive",
id = %id,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn set_sensitive(&self, id: &Uuid, sensitive: Bytes) -> Result<()> {
let mut conn = self.pool.acquire().await?;
let out = op_set_sensitive(&mut conn, id, &sensitive).await;
record_err(&out);
out
}
}
#[async_trait::async_trait]
impl<L: Label> AssociationStoreReader<L> for SqlStore<L> {
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.query_edges",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "query_edges",
label = %query.label,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn query_edges(
&self,
query: EdgeQuery<'_, L>,
) -> Result<(Vec<Association<L>>, Option<String>)> {
let mut conn = self.pool.acquire().await?;
let out = op_query_edges(&mut conn, query).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.count_edges",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "count_edges",
label = %label,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn count_edges(
&self,
endpoint: EdgeEndpoint,
label: &str,
target_label: Option<L>,
) -> Result<u64> {
let mut conn = self.pool.acquire().await?;
let out = op_count_edges(&mut conn, endpoint, label, target_label).await;
record_err(&out);
out
}
}
#[async_trait::async_trait]
impl<L: Label> AssociationStore<L> for SqlStore<L> {
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.add_edge",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "add_edge",
label = %label,
from_id = %from_id,
to_id = %to_id,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn add(
&self,
from_id: Uuid,
to_id: Uuid,
label: &str,
properties: Option<serde_json::Value>,
) -> Result<()> {
let out = async {
let mut tx = self.pool.begin().await?;
op_add_edge::<L>(&mut tx, from_id, to_id, label, properties, &self.inverse).await?;
tx.commit().await?;
Ok(())
}
.await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.remove_edge",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "remove_edge",
label = %label,
from_id = %from_id,
to_id = %to_id,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn remove(&self, from_id: Uuid, to_id: Uuid, label: &str) -> Result<()> {
let out = async {
let mut tx = self.pool.begin().await?;
op_remove_edge(&mut tx, from_id, to_id, label, &self.inverse).await?;
tx.commit().await?;
Ok(())
}
.await;
record_err(&out);
out
}
}
pub struct SqlTx<L: Label> {
tx: tokio::sync::Mutex<sqlx::Transaction<'static, Sqlite>>,
inverse: InverseResolver,
_label: std::marker::PhantomData<L>,
}
#[async_trait::async_trait]
impl<L: Label> ObjectStoreReader<L> for SqlTx<L> {
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.get",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "get",
id = %id,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn get(&self, id: &Uuid) -> Result<Object<L>> {
let mut tx = self.tx.lock().await;
let out = op_get(&mut tx, id).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.get_by_name",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "get_by_name",
db.collection.name = label.as_str(),
name = %name,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn get_by_name(&self, label: L, name: &ResourceName) -> Result<Object<L>> {
let mut tx = self.tx.lock().await;
let out = op_get_by_name(&mut tx, label, name).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.list",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "list",
db.collection.name = label.as_str(),
max_results = ?max_results,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn list(
&self,
label: L,
namespace: Option<&ResourceName>,
max_results: Option<usize>,
page_token: Option<String>,
) -> Result<(Vec<Object<L>>, Option<String>)> {
let mut tx = self.tx.lock().await;
let out = op_list_objects(&mut tx, label, namespace, max_results, page_token).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.get_sensitive",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "get_sensitive",
id = %id,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn get_sensitive(&self, id: &Uuid) -> Result<Option<Bytes>> {
let mut tx = self.tx.lock().await;
let out = op_get_sensitive(&mut tx, id).await;
record_err(&out);
out
}
}
#[async_trait::async_trait]
impl<L: Label> ObjectStore<L> for SqlTx<L> {
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.create",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "create",
db.collection.name = label.as_str(),
name = %name,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn create(
&self,
label: L,
name: &ResourceName,
properties: Option<serde_json::Value>,
id: Option<Uuid>,
sensitive: Option<Bytes>,
) -> Result<Object<L>> {
let mut tx = self.tx.lock().await;
let out = op_create(&mut tx, label, name, properties, id, sensitive).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.update",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "update",
id = %id,
precondition = ?precondition,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn update(
&self,
id: &Uuid,
properties: Option<serde_json::Value>,
precondition: Precondition,
sensitive: Option<Bytes>,
) -> Result<Object<L>> {
let mut tx = self.tx.lock().await;
let out = op_update(&mut tx, id, properties, precondition, sensitive).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.rename",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "rename",
id = %id,
name = %new_name,
precondition = ?precondition,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn rename(
&self,
id: &Uuid,
new_name: &ResourceName,
precondition: Precondition,
) -> Result<Object<L>> {
let mut tx = self.tx.lock().await;
let out = op_rename(&mut tx, id, new_name, precondition).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.delete",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "delete",
id = %id,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn delete(&self, id: &Uuid) -> Result<()> {
let mut tx = self.tx.lock().await;
let out = op_delete(&mut tx, id).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.set_sensitive",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "set_sensitive",
id = %id,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn set_sensitive(&self, id: &Uuid, sensitive: Bytes) -> Result<()> {
let mut tx = self.tx.lock().await;
let out = op_set_sensitive(&mut tx, id, &sensitive).await;
record_err(&out);
out
}
}
#[async_trait::async_trait]
impl<L: Label> AssociationStoreReader<L> for SqlTx<L> {
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.query_edges",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "query_edges",
label = %query.label,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn query_edges(
&self,
query: EdgeQuery<'_, L>,
) -> Result<(Vec<Association<L>>, Option<String>)> {
let mut tx = self.tx.lock().await;
let out = op_query_edges(&mut tx, query).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.count_edges",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "count_edges",
label = %label,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn count_edges(
&self,
endpoint: EdgeEndpoint,
label: &str,
target_label: Option<L>,
) -> Result<u64> {
let mut tx = self.tx.lock().await;
let out = op_count_edges(&mut tx, endpoint, label, target_label).await;
record_err(&out);
out
}
}
#[async_trait::async_trait]
impl<L: Label> AssociationStore<L> for SqlTx<L> {
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.add_edge",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "add_edge",
label = %label,
from_id = %from_id,
to_id = %to_id,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn add(
&self,
from_id: Uuid,
to_id: Uuid,
label: &str,
properties: Option<serde_json::Value>,
) -> Result<()> {
let inverse = self.inverse.clone();
let mut tx = self.tx.lock().await;
let out = op_add_edge::<L>(&mut tx, from_id, to_id, label, properties, &inverse).await;
record_err(&out);
out
}
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.remove_edge",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "remove_edge",
label = %label,
from_id = %from_id,
to_id = %to_id,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn remove(&self, from_id: Uuid, to_id: Uuid, label: &str) -> Result<()> {
let inverse = self.inverse.clone();
let mut tx = self.tx.lock().await;
let out = op_remove_edge(&mut tx, from_id, to_id, label, &inverse).await;
record_err(&out);
out
}
}
#[async_trait::async_trait]
impl<L: Label> StoreTx<L> for SqlTx<L> {
#[tracing::instrument(
skip_all,
fields(otel.kind = "client", db.system = DB_SYSTEM, db.operation.name = "commit")
)]
async fn commit(self: Box<Self>) -> Result<()> {
self.tx.into_inner().commit().await?;
Ok(())
}
#[tracing::instrument(
skip_all,
fields(otel.kind = "client", db.system = DB_SYSTEM, db.operation.name = "rollback")
)]
async fn rollback(self: Box<Self>) -> Result<()> {
self.tx.into_inner().rollback().await?;
Ok(())
}
}
#[async_trait::async_trait]
impl<L: Label> Transactional<L> for SqlStore<L> {
#[tracing::instrument(
skip_all,
fields(
otel.name = "olai_store.transaction",
otel.kind = "client",
db.system = DB_SYSTEM,
db.operation.name = "transaction",
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn transaction<'a, T>(
&'a self,
f: Box<
dyn for<'t> FnOnce(&'t dyn StoreExec<L>) -> futures::future::BoxFuture<'t, Result<T>>
+ Send
+ 'a,
>,
) -> Result<T>
where
T: Send + 'a,
{
let tx = self.pool.begin().await?;
let handle = SqlTx::<L> {
tx: tokio::sync::Mutex::new(tx),
inverse: self.inverse.clone(),
_label: std::marker::PhantomData,
};
match f(&handle).await {
Ok(value) => {
handle.tx.into_inner().commit().await?;
Ok(value)
}
Err(e) => {
let span = tracing::Span::current();
span.record("otel.status_code", "ERROR");
span.record("error.type", e.kind_str());
if let Err(rb) = handle.tx.into_inner().rollback().await {
tracing::warn!(error = %rb, "transaction rollback failed after operation error");
}
Err(e)
}
}
}
#[tracing::instrument(
skip_all,
fields(otel.kind = "client", db.system = DB_SYSTEM, db.operation.name = "begin")
)]
async fn begin(&self) -> Result<Box<dyn StoreTx<L>>> {
let tx = self.pool.begin().await?;
Ok(Box::new(SqlTx::<L> {
tx: tokio::sync::Mutex::new(tx),
inverse: self.inverse.clone(),
_label: std::marker::PhantomData,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conformance::{self, ConformanceLabel};
async fn fresh() -> SqlStore<ConformanceLabel> {
SqlStore::in_memory().await.unwrap()
}
#[tokio::test]
async fn sql_store_passes_conformance() {
conformance::cas_update(&fresh().await).await;
conformance::rename_semantics(&fresh().await).await;
conformance::transaction_atomicity(&fresh().await).await;
conformance::transaction_commit(&fresh().await).await;
conformance::sensitive_blob_roundtrip(&fresh().await).await;
conformance::search_object_predicates(&fresh().await).await;
conformance::search_object_pagination_filters_completely(&fresh().await).await;
conformance::search_namespace_and_filter(&fresh().await).await;
conformance::edge_filter_predicates(&fresh().await).await;
conformance::edge_filter_pagination_completes(&fresh().await).await;
conformance::search_fallback_predicates_agree(&fresh().await).await;
conformance::edge_listing_is_recency_ordered(&fresh().await).await;
conformance::edge_time_window_selects_range(&fresh().await).await;
conformance::edge_target_label_pages_completely(&fresh().await).await;
conformance::incoming_edges_listed(&fresh().await).await;
conformance::edge_target_id_restriction(&fresh().await).await;
conformance::count_edges_matches_list(&fresh().await).await;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
let inv = SqlStore::<ConformanceLabel>::connect_and_migrate(pool)
.await
.unwrap()
.with_inverse(conformance::parent_child_inverse);
conformance::inverse_edges(&inv).await;
}
#[tokio::test]
#[tracing_test::traced_test]
async fn cas_conflict_emits_debug() {
use crate::name::ResourceName;
let store = fresh().await;
let name = ResourceName::from_naive_str_split("a");
let obj = store
.create(ConformanceLabel::Node, &name, None, None, None)
.await
.unwrap();
store
.update(&obj.id, None, Precondition::Version(0), None)
.await
.unwrap();
let err = store
.update(&obj.id, None, Precondition::Version(0), None)
.await
.unwrap_err();
assert!(matches!(err, Error::Conflict));
assert!(logs_contain("CAS precondition conflict"));
}
#[tokio::test]
async fn namespace_filtered_listing_pages_completely() {
use crate::name::ResourceName;
let store = fresh().await;
for i in 0..6 {
let ns_name = ResourceName::from_naive_str_split(format!("ns.item{i}"));
let other = ResourceName::from_naive_str_split(format!("other.item{i}"));
store
.create(ConformanceLabel::Node, &ns_name, None, None, None)
.await
.unwrap();
store
.create(ConformanceLabel::Node, &other, None, None, None)
.await
.unwrap();
}
let ns = ResourceName::from_naive_str_split("ns");
let mut seen = Vec::new();
let mut token = None;
loop {
let (page, next) =
ObjectStoreReader::list(&store, ConformanceLabel::Node, Some(&ns), Some(2), token)
.await
.unwrap();
assert!(page.iter().all(|o| o.name.prefix_matches(&ns)));
seen.extend(page.into_iter().map(|o| o.id));
match next {
Some(t) => token = Some(t),
None => break,
}
}
assert_eq!(seen.len(), 6, "every namespaced object must be paged");
seen.sort();
seen.dedup();
assert_eq!(seen.len(), 6, "no duplicates across pages");
}
#[tokio::test]
async fn public_migrate_api_is_reusable_and_idempotent() {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
migrate(&pool).await.unwrap();
migrate(&pool).await.unwrap();
migrator().run(&pool).await.unwrap();
let store = SqlStore::<ConformanceLabel>::connect(pool);
let obj = store
.create(
ConformanceLabel::Node,
&"m".parse().unwrap(),
None,
None,
None,
)
.await
.unwrap();
assert!(store.get(&obj.id).await.is_ok());
}
}