Merge fix/b5-textpair — EdgeKeyKind + archive verb
This commit is contained in:
commit
70867a7fd0
11 changed files with 451 additions and 36 deletions
|
|
@ -7,7 +7,7 @@
|
|||
//! connection + schema-aware DDL.
|
||||
|
||||
use crate::error::VerbError;
|
||||
use crate::schema::{EntitySchema, FieldDef, FieldKind};
|
||||
use crate::schema::{EdgeKeyKind, EntitySchema, FieldDef, FieldKind};
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
|
|
@ -68,7 +68,7 @@ pub fn run_migrations(conn: &Connection, schema: &EntitySchema) -> Result<(), Ve
|
|||
conn.execute_batch(&ddl_fts_table(schema.table, cols))?;
|
||||
}
|
||||
if let Some(edge) = schema.edge_table {
|
||||
conn.execute_batch(&ddl_edge_table(edge))?;
|
||||
conn.execute_batch(&ddl_edge_table_for(edge, schema.edge_key_kind))?;
|
||||
}
|
||||
for stmt in schema.custom_migrations {
|
||||
conn.execute_batch(stmt)?;
|
||||
|
|
@ -153,3 +153,25 @@ fn ddl_edge_table(edge: &str) -> String {
|
|||
CREATE INDEX IF NOT EXISTS idx_{edge}_to ON {edge}(to_id);"
|
||||
)
|
||||
}
|
||||
|
||||
/// Dispatcher — picks edge-table DDL for a given `EdgeKeyKind`. Added
|
||||
/// for kei-sage migration; `IntegerPair` branch preserves legacy body.
|
||||
fn ddl_edge_table_for(edge: &str, kind: EdgeKeyKind) -> String {
|
||||
match kind {
|
||||
EdgeKeyKind::IntegerPair => ddl_edge_table(edge),
|
||||
EdgeKeyKind::TextPair => ddl_edge_table_text(edge),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text-keyed edge DDL: `(src_path TEXT, dst_path TEXT, edge_type TEXT)`.
|
||||
fn ddl_edge_table_text(edge: &str) -> String {
|
||||
format!(
|
||||
"CREATE TABLE IF NOT EXISTS {edge} (\n \
|
||||
src_path TEXT NOT NULL,\n \
|
||||
dst_path TEXT NOT NULL,\n \
|
||||
edge_type TEXT NOT NULL DEFAULT 'links',\n \
|
||||
PRIMARY KEY(src_path, dst_path, edge_type)\n\
|
||||
);\n\
|
||||
CREATE INDEX IF NOT EXISTS idx_{edge}_dst ON {edge}(dst_path);"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,4 +21,4 @@ pub mod verbs;
|
|||
|
||||
pub use engine::Store;
|
||||
pub use error::VerbError;
|
||||
pub use schema::{EntitySchema, FieldDef, FieldKind};
|
||||
pub use schema::{EdgeKeyKind, EntitySchema, FieldDef, FieldKind};
|
||||
|
|
|
|||
|
|
@ -73,6 +73,22 @@ impl FieldDef {
|
|||
}
|
||||
}
|
||||
|
||||
/// Edge-key storage strategy for the schema's `edge_table`.
|
||||
///
|
||||
/// - `IntegerPair` (default) — legacy `(from_id INTEGER, to_id INTEGER,
|
||||
/// edge_type TEXT)` — matches kei-task byte-for-byte.
|
||||
/// - `TextPair` — `(src_path TEXT, dst_path TEXT, edge_type TEXT)` —
|
||||
/// required by kei-sage (composite text keys, no integer ids).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EdgeKeyKind {
|
||||
IntegerPair,
|
||||
TextPair,
|
||||
}
|
||||
|
||||
impl Default for EdgeKeyKind {
|
||||
fn default() -> Self { Self::IntegerPair }
|
||||
}
|
||||
|
||||
/// Declarative schema for one entity.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct EntitySchema {
|
||||
|
|
@ -88,9 +104,19 @@ pub struct EntitySchema {
|
|||
/// with the listed non-id columns and keeps it in sync on create
|
||||
/// + update. `search` verb uses it.
|
||||
pub fts_columns: Option<&'static [&'static str]>,
|
||||
/// If `Some`, engine creates `<edge_table>(from_id, to_id, edge_type)`
|
||||
/// for the `link` verb. `rank` verb runs PageRank over it.
|
||||
/// If `Some`, engine creates `<edge_table>` for the `link` verb.
|
||||
/// Column layout depends on `edge_key_kind`. `rank` verb runs
|
||||
/// PageRank over it.
|
||||
pub edge_table: Option<&'static str>,
|
||||
/// Edge-table key layout. Default `IntegerPair` preserves legacy
|
||||
/// `(from_id, to_id)` schema; `TextPair` switches to
|
||||
/// `(src_path, dst_path)` for path-keyed graphs (kei-sage).
|
||||
pub edge_key_kind: EdgeKeyKind,
|
||||
/// If `Some`, enables the `archive` verb. Names the INTEGER column
|
||||
/// used as the soft-delete flag (0 / 1). The `archive` verb flips
|
||||
/// it to 1 and stamps a sibling `<field>_at` timestamp if such a
|
||||
/// column exists in `fields`.
|
||||
pub archived_field: Option<&'static str>,
|
||||
/// Arbitrary DDL statements run after the primary table + FTS +
|
||||
/// edge table have been created. Used for secondary tables
|
||||
/// (milestones, task_deps) that piggy-back on the same DB but are
|
||||
|
|
|
|||
64
_primitives/_rust/kei-entity-store/src/verbs/archive.rs
Normal file
64
_primitives/_rust/kei-entity-store/src/verbs/archive.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
//! `archive` verb — soft-delete. Flips `<archived_field>` to 1, stamps
|
||||
//! a sibling `<archived_field>_at` column with current Unix timestamp
|
||||
//! if such a column exists in the schema.
|
||||
//!
|
||||
//! Required schema configuration: `archived_field: Some("<col>")`.
|
||||
//! Without it the verb errors with `InvalidInput` — the engine does NOT
|
||||
//! fall back to legacy `archived` heuristics (those remain in
|
||||
//! `delete.rs` soft-path only).
|
||||
//!
|
||||
//! Input: `{ id: i64 }`.
|
||||
//! Output: `{ id, archived_at }` — `archived_at` is the stamped
|
||||
//! timestamp when a `<field>_at` column exists, else `null`.
|
||||
|
||||
use crate::error::VerbError;
|
||||
use crate::schema::EntitySchema;
|
||||
use rusqlite::Connection;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub fn run(
|
||||
conn: &Connection,
|
||||
schema: &EntitySchema,
|
||||
input: Value,
|
||||
) -> Result<Value, VerbError> {
|
||||
if !schema.verb_enabled("archive") {
|
||||
return Err(VerbError::VerbDisabled {
|
||||
verb: "archive".into(),
|
||||
schema: schema.name.into(),
|
||||
});
|
||||
}
|
||||
let field = schema.archived_field.ok_or_else(|| {
|
||||
VerbError::InvalidInput(format!(
|
||||
"archive: schema {} has no archived_field configured",
|
||||
schema.name
|
||||
))
|
||||
})?;
|
||||
let id = input
|
||||
.get("id")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| VerbError::InvalidInput("archive: missing `id` integer".into()))?;
|
||||
|
||||
let ts_col = format!("{field}_at");
|
||||
let has_ts = schema.fields.iter().any(|f| f.name == ts_col);
|
||||
let now: i64 = chrono::Utc::now().timestamp();
|
||||
|
||||
let rows = if has_ts {
|
||||
conn.execute(
|
||||
&format!(
|
||||
"UPDATE {t} SET {field} = 1, {ts_col} = ?1 WHERE id = ?2",
|
||||
t = schema.table
|
||||
),
|
||||
rusqlite::params![now, id],
|
||||
)?
|
||||
} else {
|
||||
conn.execute(
|
||||
&format!("UPDATE {t} SET {field} = 1 WHERE id = ?1", t = schema.table),
|
||||
rusqlite::params![id],
|
||||
)?
|
||||
};
|
||||
if rows == 0 {
|
||||
return Err(VerbError::NotFound { entity: schema.name.into(), id });
|
||||
}
|
||||
let stamped = if has_ts { json!(now) } else { Value::Null };
|
||||
Ok(json!({ "id": id, "archived_at": stamped }))
|
||||
}
|
||||
|
|
@ -2,9 +2,13 @@
|
|||
//! INSERT OR IGNORE). Caller is responsible for higher-level semantic
|
||||
//! checks (cycle detection, self-loop) — those live in the sibling
|
||||
//! crate (e.g. kei-task::deps).
|
||||
//!
|
||||
//! Dispatches on `schema.edge_key_kind`:
|
||||
//! - `IntegerPair` — input `{from: i64, to: i64, edge_type?}`
|
||||
//! - `TextPair` — input `{from: str, to: str, edge_type?}`
|
||||
|
||||
use crate::error::VerbError;
|
||||
use crate::schema::EntitySchema;
|
||||
use crate::schema::{EdgeKeyKind, EntitySchema};
|
||||
use rusqlite::Connection;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
|
|
@ -25,6 +29,24 @@ pub fn run(
|
|||
schema.name
|
||||
))
|
||||
})?;
|
||||
let edge_type = input
|
||||
.get("edge_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("links")
|
||||
.to_string();
|
||||
|
||||
match schema.edge_key_kind {
|
||||
EdgeKeyKind::IntegerPair => insert_integer(conn, edge, &input, &edge_type),
|
||||
EdgeKeyKind::TextPair => insert_text(conn, edge, &input, &edge_type),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_integer(
|
||||
conn: &Connection,
|
||||
edge: &str,
|
||||
input: &Value,
|
||||
edge_type: &str,
|
||||
) -> Result<Value, VerbError> {
|
||||
let from = input
|
||||
.get("from")
|
||||
.and_then(|v| v.as_i64())
|
||||
|
|
@ -33,12 +55,6 @@ pub fn run(
|
|||
.get("to")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| VerbError::InvalidInput("link: missing `to` integer".into()))?;
|
||||
let edge_type = input
|
||||
.get("edge_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("links")
|
||||
.to_string();
|
||||
|
||||
conn.execute(
|
||||
&format!(
|
||||
"INSERT OR IGNORE INTO {edge} (from_id, to_id, edge_type) VALUES (?1, ?2, ?3)"
|
||||
|
|
@ -47,3 +63,26 @@ pub fn run(
|
|||
)?;
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
fn insert_text(
|
||||
conn: &Connection,
|
||||
edge: &str,
|
||||
input: &Value,
|
||||
edge_type: &str,
|
||||
) -> Result<Value, VerbError> {
|
||||
let from = input
|
||||
.get("from")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| VerbError::InvalidInput("link: missing `from` string".into()))?;
|
||||
let to = input
|
||||
.get("to")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| VerbError::InvalidInput("link: missing `to` string".into()))?;
|
||||
conn.execute(
|
||||
&format!(
|
||||
"INSERT OR IGNORE INTO {edge} (src_path, dst_path, edge_type) VALUES (?1, ?2, ?3)"
|
||||
),
|
||||
rusqlite::params![from, to, edge_type],
|
||||
)?;
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
//! only copy declared schema fields into SQL (defence against
|
||||
//! unexpected keys).
|
||||
|
||||
pub mod archive;
|
||||
pub mod create;
|
||||
pub mod delete;
|
||||
pub mod get;
|
||||
|
|
@ -22,5 +23,5 @@ pub mod validate;
|
|||
/// Full list of supported verbs — SSoT for documentation + schema
|
||||
/// validation. `EntitySchema.enabled_verbs` entries MUST appear here.
|
||||
pub const ALL_VERBS: &[&str] = &[
|
||||
"create", "get", "list", "search", "update", "delete", "link", "rank",
|
||||
"create", "get", "list", "search", "update", "delete", "link", "rank", "archive",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@
|
|||
//! schema's `edge_table`. Returns `{ results: [{id, score}, ...] }`
|
||||
//! sorted by score descending.
|
||||
//!
|
||||
//! Ported from `kei-sage/src/pagerank.rs` but generalised to operate on
|
||||
//! the integer `(from_id, to_id)` edge table this engine provisions.
|
||||
//! Dispatches on `schema.edge_key_kind`: `IntegerPair` emits
|
||||
//! `{id: i64, score: f64}` rows; `TextPair` emits `{id: String, score}`
|
||||
//! where `id` carries the text node key (src_path / dst_path).
|
||||
|
||||
use crate::error::VerbError;
|
||||
use crate::schema::EntitySchema;
|
||||
use crate::schema::{EdgeKeyKind, EntitySchema};
|
||||
use rusqlite::Connection;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::Hash;
|
||||
|
||||
const DAMPING: f64 = 0.85;
|
||||
const ITERATIONS: usize = 50;
|
||||
|
|
@ -31,18 +33,15 @@ pub fn run(
|
|||
schema.name
|
||||
))
|
||||
})?;
|
||||
match schema.edge_key_kind {
|
||||
EdgeKeyKind::IntegerPair => rank_integer(conn, edge),
|
||||
EdgeKeyKind::TextPair => rank_text(conn, edge),
|
||||
}
|
||||
}
|
||||
|
||||
let (nodes, out_edges) = collect_graph(conn, edge)?;
|
||||
if nodes.is_empty() {
|
||||
return Ok(json!({ "results": [] }));
|
||||
}
|
||||
let mut rank: HashMap<i64, f64> = nodes
|
||||
.iter()
|
||||
.map(|n| (*n, 1.0 / nodes.len() as f64))
|
||||
.collect();
|
||||
for _ in 0..ITERATIONS {
|
||||
rank = one_iteration(&nodes, &out_edges, &rank);
|
||||
}
|
||||
fn rank_integer(conn: &Connection, edge: &str) -> Result<Value, VerbError> {
|
||||
let (nodes, out_edges) = collect_integer(conn, edge)?;
|
||||
let rank = pagerank(&nodes, &out_edges);
|
||||
let mut out: Vec<(i64, f64)> = rank.into_iter().collect();
|
||||
out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let results: Vec<Value> =
|
||||
|
|
@ -50,7 +49,17 @@ pub fn run(
|
|||
Ok(json!({ "results": results }))
|
||||
}
|
||||
|
||||
fn collect_graph(
|
||||
fn rank_text(conn: &Connection, edge: &str) -> Result<Value, VerbError> {
|
||||
let (nodes, out_edges) = collect_text(conn, edge)?;
|
||||
let rank = pagerank(&nodes, &out_edges);
|
||||
let mut out: Vec<(String, f64)> = rank.into_iter().collect();
|
||||
out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let results: Vec<Value> =
|
||||
out.into_iter().map(|(id, score)| json!({ "id": id, "score": score })).collect();
|
||||
Ok(json!({ "results": results }))
|
||||
}
|
||||
|
||||
fn collect_integer(
|
||||
conn: &Connection,
|
||||
edge: &str,
|
||||
) -> Result<(Vec<i64>, HashMap<i64, Vec<i64>>), VerbError> {
|
||||
|
|
@ -68,14 +77,49 @@ fn collect_graph(
|
|||
Ok((nodes.into_iter().collect(), out_edges))
|
||||
}
|
||||
|
||||
fn one_iteration(
|
||||
nodes: &[i64],
|
||||
out_edges: &HashMap<i64, Vec<i64>>,
|
||||
prev: &HashMap<i64, f64>,
|
||||
) -> HashMap<i64, f64> {
|
||||
fn collect_text(
|
||||
conn: &Connection,
|
||||
edge: &str,
|
||||
) -> Result<(Vec<String>, HashMap<String, Vec<String>>), VerbError> {
|
||||
let sql = format!("SELECT src_path, dst_path FROM {edge}");
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows =
|
||||
stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
|
||||
let mut nodes: HashSet<String> = HashSet::new();
|
||||
let mut out_edges: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for row in rows {
|
||||
let (src, dst) = row?;
|
||||
nodes.insert(src.clone());
|
||||
nodes.insert(dst.clone());
|
||||
out_edges.entry(src).or_default().push(dst);
|
||||
}
|
||||
Ok((nodes.into_iter().collect(), out_edges))
|
||||
}
|
||||
|
||||
/// Generic PageRank — works on any hashable node type.
|
||||
fn pagerank<K: Eq + Hash + Clone>(
|
||||
nodes: &[K],
|
||||
out_edges: &HashMap<K, Vec<K>>,
|
||||
) -> HashMap<K, f64> {
|
||||
if nodes.is_empty() {
|
||||
return HashMap::new();
|
||||
}
|
||||
let init = 1.0 / nodes.len() as f64;
|
||||
let mut rank: HashMap<K, f64> = nodes.iter().map(|n| (n.clone(), init)).collect();
|
||||
for _ in 0..ITERATIONS {
|
||||
rank = one_iteration(nodes, out_edges, &rank);
|
||||
}
|
||||
rank
|
||||
}
|
||||
|
||||
fn one_iteration<K: Eq + Hash + Clone>(
|
||||
nodes: &[K],
|
||||
out_edges: &HashMap<K, Vec<K>>,
|
||||
prev: &HashMap<K, f64>,
|
||||
) -> HashMap<K, f64> {
|
||||
let n = nodes.len() as f64;
|
||||
let base = (1.0 - DAMPING) / n;
|
||||
let mut next: HashMap<i64, f64> = nodes.iter().map(|k| (*k, base)).collect();
|
||||
let mut next: HashMap<K, f64> = nodes.iter().map(|k| (k.clone(), base)).collect();
|
||||
for (src, dsts) in out_edges {
|
||||
if dsts.is_empty() { continue; }
|
||||
let share = DAMPING * prev.get(src).copied().unwrap_or(0.0) / dsts.len() as f64;
|
||||
|
|
|
|||
99
_primitives/_rust/kei-entity-store/tests/archive_smoke.rs
Normal file
99
_primitives/_rust/kei-entity-store/tests/archive_smoke.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
//! Archive-verb smoke tests.
|
||||
//!
|
||||
//! Covers kei-chat-store migration: schemas opt-in to soft-delete via
|
||||
//! `archived_field: Some("archived")`. The verb flips the column + an
|
||||
//! optional `<field>_at` sibling timestamp.
|
||||
|
||||
use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef};
|
||||
use kei_entity_store::verbs::{archive, create, get};
|
||||
use kei_entity_store::Store;
|
||||
use serde_json::json;
|
||||
|
||||
static ARCHIVABLE_FIELDS: &[FieldDef] = &[
|
||||
FieldDef::pk("id"),
|
||||
FieldDef::text_nn("title"),
|
||||
FieldDef::integer("archived"),
|
||||
FieldDef::integer("archived_at"),
|
||||
FieldDef::created_at(),
|
||||
];
|
||||
|
||||
static ARCHIVABLE: EntitySchema = EntitySchema {
|
||||
name: "msg",
|
||||
table: "msgs",
|
||||
fields: ARCHIVABLE_FIELDS,
|
||||
enabled_verbs: &["create", "get", "archive"],
|
||||
fts_columns: None,
|
||||
edge_table: None,
|
||||
edge_key_kind: EdgeKeyKind::IntegerPair,
|
||||
archived_field: Some("archived"),
|
||||
custom_migrations: &[],
|
||||
};
|
||||
|
||||
static NO_ARCHIVE_FIELDS: &[FieldDef] = &[
|
||||
FieldDef::pk("id"),
|
||||
FieldDef::text_nn("title"),
|
||||
FieldDef::created_at(),
|
||||
];
|
||||
|
||||
static WITHOUT_FIELD: EntitySchema = EntitySchema {
|
||||
name: "msg",
|
||||
table: "msgs_plain",
|
||||
fields: NO_ARCHIVE_FIELDS,
|
||||
enabled_verbs: &["create", "archive"],
|
||||
fts_columns: None,
|
||||
edge_table: None,
|
||||
edge_key_kind: EdgeKeyKind::IntegerPair,
|
||||
archived_field: None,
|
||||
custom_migrations: &[],
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn archive_sets_flag_and_stamps_timestamp() {
|
||||
let s = Store::open_memory(&ARCHIVABLE).unwrap();
|
||||
let v = create::run(s.conn(), &ARCHIVABLE, json!({ "title": "hi" })).unwrap();
|
||||
let id = v["id"].as_i64().unwrap();
|
||||
|
||||
let before: i64 = chrono::Utc::now().timestamp();
|
||||
let out = archive::run(s.conn(), &ARCHIVABLE, json!({ "id": id })).unwrap();
|
||||
assert_eq!(out["id"].as_i64().unwrap(), id);
|
||||
let stamped = out["archived_at"].as_i64().unwrap();
|
||||
assert!(stamped >= before);
|
||||
|
||||
let row = get::run(s.conn(), &ARCHIVABLE, json!({ "id": id })).unwrap();
|
||||
assert_eq!(row["archived"].as_i64().unwrap(), 1);
|
||||
assert_eq!(row["archived_at"].as_i64().unwrap(), stamped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_preserves_id_and_other_fields() {
|
||||
let s = Store::open_memory(&ARCHIVABLE).unwrap();
|
||||
let v = create::run(s.conn(), &ARCHIVABLE, json!({ "title": "keep" })).unwrap();
|
||||
let id = v["id"].as_i64().unwrap();
|
||||
archive::run(s.conn(), &ARCHIVABLE, json!({ "id": id })).unwrap();
|
||||
let row = get::run(s.conn(), &ARCHIVABLE, json!({ "id": id })).unwrap();
|
||||
assert_eq!(row["title"], "keep");
|
||||
assert_eq!(row["id"].as_i64().unwrap(), id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_errors_when_archived_field_missing() {
|
||||
let s = Store::open_memory(&WITHOUT_FIELD).unwrap();
|
||||
let v = create::run(s.conn(), &WITHOUT_FIELD, json!({ "title": "x" })).unwrap();
|
||||
let id = v["id"].as_i64().unwrap();
|
||||
let err = archive::run(s.conn(), &WITHOUT_FIELD, json!({ "id": id })).unwrap_err();
|
||||
assert_eq!(err.exit_code(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_not_found_errors() {
|
||||
let s = Store::open_memory(&ARCHIVABLE).unwrap();
|
||||
let err = archive::run(s.conn(), &ARCHIVABLE, json!({ "id": 9999 })).unwrap_err();
|
||||
assert_eq!(err.exit_code(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_rejects_missing_id() {
|
||||
let s = Store::open_memory(&ARCHIVABLE).unwrap();
|
||||
let err = archive::run(s.conn(), &ARCHIVABLE, json!({})).unwrap_err();
|
||||
assert_eq!(err.exit_code(), 2);
|
||||
}
|
||||
114
_primitives/_rust/kei-entity-store/tests/text_pair_smoke.rs
Normal file
114
_primitives/_rust/kei-entity-store/tests/text_pair_smoke.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! TextPair edge-key regression tests for link + rank verbs.
|
||||
//!
|
||||
//! Covers kei-sage migration target: `(src_path, dst_path)` TEXT
|
||||
//! composite edge keys. Also keeps one IntegerPair regression case to
|
||||
//! prove we did not disturb the default behaviour.
|
||||
|
||||
use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef};
|
||||
use kei_entity_store::verbs::{link, rank};
|
||||
use kei_entity_store::Store;
|
||||
use serde_json::json;
|
||||
|
||||
static NODE_FIELDS: &[FieldDef] = &[
|
||||
FieldDef::pk("id"),
|
||||
FieldDef::text_nn("path"),
|
||||
FieldDef::created_at(),
|
||||
];
|
||||
|
||||
static TEXT_SCHEMA: EntitySchema = EntitySchema {
|
||||
name: "doc",
|
||||
table: "docs",
|
||||
fields: NODE_FIELDS,
|
||||
enabled_verbs: &["link", "rank"],
|
||||
fts_columns: None,
|
||||
edge_table: Some("doc_edges"),
|
||||
edge_key_kind: EdgeKeyKind::TextPair,
|
||||
archived_field: None,
|
||||
custom_migrations: &[],
|
||||
};
|
||||
|
||||
static INTEGER_SCHEMA: EntitySchema = EntitySchema {
|
||||
name: "doc",
|
||||
table: "docs_int",
|
||||
fields: NODE_FIELDS,
|
||||
enabled_verbs: &["link", "rank"],
|
||||
fts_columns: None,
|
||||
edge_table: Some("doc_edges_int"),
|
||||
edge_key_kind: EdgeKeyKind::IntegerPair,
|
||||
archived_field: None,
|
||||
custom_migrations: &[],
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn text_pair_link_and_lookup() {
|
||||
let s = Store::open_memory(&TEXT_SCHEMA).unwrap();
|
||||
link::run(
|
||||
s.conn(),
|
||||
&TEXT_SCHEMA,
|
||||
json!({ "from": "a.md", "to": "b.md" }),
|
||||
)
|
||||
.unwrap();
|
||||
let count: i64 = s
|
||||
.conn()
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM doc_edges WHERE src_path='a.md' AND dst_path='b.md'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_pair_link_idempotent() {
|
||||
let s = Store::open_memory(&TEXT_SCHEMA).unwrap();
|
||||
for _ in 0..3 {
|
||||
link::run(
|
||||
s.conn(),
|
||||
&TEXT_SCHEMA,
|
||||
json!({ "from": "a.md", "to": "b.md", "edge_type": "links" }),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let count: i64 = s
|
||||
.conn()
|
||||
.query_row("SELECT COUNT(*) FROM doc_edges", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_pair_rank_returns_string_ids() {
|
||||
let s = Store::open_memory(&TEXT_SCHEMA).unwrap();
|
||||
let pairs = [("a.md", "b.md"), ("a.md", "c.md"), ("b.md", "c.md")];
|
||||
for (from, to) in pairs {
|
||||
link::run(s.conn(), &TEXT_SCHEMA, json!({ "from": from, "to": to })).unwrap();
|
||||
}
|
||||
let v = rank::run(s.conn(), &TEXT_SCHEMA, json!({})).unwrap();
|
||||
let results = v["results"].as_array().unwrap();
|
||||
assert_eq!(results.len(), 3);
|
||||
// c.md has 2 inbound edges → highest rank.
|
||||
assert_eq!(results[0]["id"], "c.md");
|
||||
assert!(results[0]["score"].as_f64().unwrap() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_pair_rejects_integer_input() {
|
||||
let s = Store::open_memory(&TEXT_SCHEMA).unwrap();
|
||||
let err = link::run(s.conn(), &TEXT_SCHEMA, json!({ "from": 1, "to": 2 }))
|
||||
.unwrap_err();
|
||||
assert_eq!(err.exit_code(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_pair_still_works_after_refactor() {
|
||||
// Regression guard — kei-task uses IntegerPair implicitly.
|
||||
let s = Store::open_memory(&INTEGER_SCHEMA).unwrap();
|
||||
link::run(s.conn(), &INTEGER_SCHEMA, json!({ "from": 1, "to": 2 })).unwrap();
|
||||
link::run(s.conn(), &INTEGER_SCHEMA, json!({ "from": 1, "to": 3 })).unwrap();
|
||||
link::run(s.conn(), &INTEGER_SCHEMA, json!({ "from": 2, "to": 3 })).unwrap();
|
||||
let v = rank::run(s.conn(), &INTEGER_SCHEMA, json!({})).unwrap();
|
||||
let results = v["results"].as_array().unwrap();
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0]["id"], 3);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
//! Per-verb integration smoke tests on a fixture schema.
|
||||
|
||||
use kei_entity_store::schema::{EntitySchema, FieldDef};
|
||||
use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef};
|
||||
use kei_entity_store::verbs::{create, delete, get, link, list, rank, search, update};
|
||||
use kei_entity_store::Store;
|
||||
use serde_json::json;
|
||||
|
|
@ -23,6 +23,8 @@ static SCHEMA: EntitySchema = EntitySchema {
|
|||
enabled_verbs: &["create", "get", "list", "search", "update", "delete", "link", "rank"],
|
||||
fts_columns: Some(&["title", "description"]),
|
||||
edge_table: Some("note_edges"),
|
||||
edge_key_kind: EdgeKeyKind::IntegerPair,
|
||||
archived_field: None,
|
||||
custom_migrations: &[],
|
||||
};
|
||||
|
||||
|
|
@ -149,6 +151,8 @@ fn disabled_verb_errors() {
|
|||
enabled_verbs: &["get", "list"],
|
||||
fts_columns: None,
|
||||
edge_table: None,
|
||||
edge_key_kind: EdgeKeyKind::IntegerPair,
|
||||
archived_field: None,
|
||||
custom_migrations: &[],
|
||||
};
|
||||
let s = Store::open_memory(&DISABLED).unwrap();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
//! are not generic CRUD and keep their existing column names so
|
||||
//! `deps.rs` / `milestones.rs` / `graph.rs` don't need to change.
|
||||
|
||||
use kei_entity_store::schema::{EntitySchema, FieldDef};
|
||||
use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef};
|
||||
|
||||
static FIELDS: &[FieldDef] = &[
|
||||
FieldDef::pk("id"),
|
||||
|
|
@ -63,5 +63,7 @@ pub static TASK_SCHEMA: EntitySchema = EntitySchema {
|
|||
enabled_verbs: &["create", "get", "list", "search", "update", "delete"],
|
||||
fts_columns: Some(&["title", "description"]),
|
||||
edge_table: None, // task_deps has bespoke column names — managed by deps.rs
|
||||
edge_key_kind: EdgeKeyKind::IntegerPair,
|
||||
archived_field: None,
|
||||
custom_migrations: &[DDL_SECONDARY],
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue