diff --git a/src/main.rs b/src/main.rs index 90b4c25..bcbbef0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -195,10 +195,9 @@ Commands: export [FILE|--all] Export store nodes to markdown file(s) journal-write TEXT Write a journal entry to the store journal-tail [N] [--full] Show last N journal entries (default 20, --full for content) - query EXPR [--fields F] [--sort F] [--limit N] [--count] - Query the memory graph with expressions - Examples: \"degree > 15\", \"key ~ 'journal.*'\", - \"neighbors('identity.md') WHERE strength > 0.5\""); + query 'EXPR | stages' Query the memory graph + Stages: sort F [asc], limit N, select F,F, count + Ex: \"degree > 15 | sort degree | limit 10\""); } fn cmd_search(args: &[String]) -> Result<(), String> { @@ -1624,48 +1623,29 @@ fn cmd_interference(args: &[String]) -> Result<(), String> { fn cmd_query(args: &[String]) -> Result<(), String> { if args.is_empty() { - return Err("Usage: poc-memory query EXPR [--fields F,F,...] [--sort F] [--limit N] [--count]".into()); + return Err("Usage: poc-memory query 'EXPR | stage | stage ...'\n\n\ +Expressions:\n \ + degree > 15 property filter\n \ + key ~ 'journal.*' AND degree > 10 boolean + regex\n \ + neighbors('identity.md') WHERE ... graph traversal\n \ + community_id = community('key') function as value\n \ + * all nodes\n\n\ +Pipe stages:\n \ + | sort FIELD [asc] sort (desc by default)\n \ + | limit N cap results\n \ + | select F,F,... output fields as TSV\n \ + | count just show count".into()); } - // Parse flags — query string is the first non-flag arg - let mut opts = query::QueryOpts::default(); - let mut query_str = None; - let mut i = 0; - while i < args.len() { - match args[i].as_str() { - "--fields" if i + 1 < args.len() => { - opts.fields = args[i + 1].split(',').map(|s| s.trim().to_string()).collect(); - i += 2; - } - "--sort" if i + 1 < args.len() => { - opts.sort_field = Some(args[i + 1].clone()); - i += 2; - } - "--limit" if i + 1 < args.len() => { - opts.limit = Some(args[i + 1].parse().map_err(|_| "invalid --limit")?); - i += 2; - } - "--count" => { - opts.count_only = true; - i += 1; - } - _ if query_str.is_none() => { - query_str = Some(args[i].clone()); - i += 1; - } - _ => { - return Err(format!("unexpected argument: {}", args[i])); - } - } - } - - let query_str = query_str.ok_or("missing query expression")?; + let query_str = args.join(" "); let store = capnp_store::Store::load()?; let graph = store.build_graph(); - let results = query::execute_query(&store, &graph, &query_str, &opts)?; + let stages = query::output_stages(&query_str)?; + let results = query::execute_query(&store, &graph, &query_str)?; - if opts.count_only { + // Check for count stage + if stages.iter().any(|s| matches!(s, query::Stage::Count)) { println!("{}", results.len()); return Ok(()); } @@ -1675,15 +1655,20 @@ fn cmd_query(args: &[String]) -> Result<(), String> { return Ok(()); } - // If --fields specified, show as TSV with header - if !opts.fields.is_empty() { + // Check for select stage + let fields: Option<&Vec> = stages.iter().find_map(|s| match s { + query::Stage::Select(f) => Some(f), + _ => None, + }); + + if let Some(fields) = fields { let mut header = vec!["key".to_string()]; - header.extend(opts.fields.iter().cloned()); + header.extend(fields.iter().cloned()); println!("{}", header.join("\t")); for r in &results { let mut row = vec![r.key.clone()]; - for f in &opts.fields { + for f in fields { row.push(query::format_field(f, &r.key, &store, &graph)); } println!("{}", row.join("\t")); diff --git a/src/query.rs b/src/query.rs index c3c170c..22a502c 100644 --- a/src/query.rs +++ b/src/query.rs @@ -3,6 +3,23 @@ // Grammar-driven: the peg definition IS the language spec. // Evaluates against node properties, graph metrics, and edge attributes. // Designed for ad-hoc exploration without memorizing 35+ subcommands. +// +// Syntax: +// expr | stage | stage ... +// +// Stages (piped): +// sort FIELD sort descending (default for exploration) +// sort FIELD asc sort ascending +// limit N cap results +// select F,F,... output specific fields as TSV +// count just show count +// +// Examples: +// degree > 15 | sort degree | limit 10 +// category = core | select degree,weight +// neighbors('identity.md') WHERE strength > 0.5 | sort strength +// key ~ 'journal.*' AND degree > 10 | count +// * | sort weight asc | limit 20 use crate::capnp_store::{NodeType, Provenance, RelationType, Store}; use crate::graph::Graph; @@ -12,6 +29,7 @@ use regex::Regex; #[derive(Debug, Clone)] pub enum Expr { + All, Comparison { field: String, op: CmpOp, value: Value }, And(Box, Box), Or(Box, Box), @@ -38,12 +56,53 @@ pub enum CmpOp { Gt, Lt, Ge, Le, Eq, Ne, Match, } +#[derive(Debug, Clone)] +pub enum Stage { + Sort { field: String, ascending: bool }, + Limit(usize), + Select(Vec), + Count, +} + +#[derive(Debug, Clone)] +pub struct Query { + pub expr: Expr, + pub stages: Vec, +} + // -- PEG grammar -- peg::parser! { pub grammar query_parser() for str { rule _() = [' ' | '\t']* + pub rule query() -> Query + = e:expr() s:stages() { Query { expr: e, stages: s } } + + rule stages() -> Vec + = s:(_ "|" _ s:stage() { s })* { s } + + rule stage() -> Stage + = "sort" _ f:field() _ a:asc_desc() { Stage::Sort { field: f, ascending: a } } + / "limit" _ n:integer() { Stage::Limit(n) } + / "select" _ f:field_list() { Stage::Select(f) } + / "count" { Stage::Count } + + rule asc_desc() -> bool + = "asc" { true } + / "desc" { false } + / { false } // default: descending + + rule field_list() -> Vec + = f:field() fs:(_ "," _ f:field() { f })* { + let mut v = vec![f]; + v.extend(fs); + v + } + + rule integer() -> usize + = n:$(['0'..='9']+) { n.parse().unwrap() } + pub rule expr() -> Expr = precedence! { a:(@) _ "OR" _ b:@ { Expr::Or(Box::new(a), Box::new(b)) } -- @@ -57,6 +116,7 @@ peg::parser! { f:field() _ op:cmp_op() _ v:value() { Expr::Comparison { field: f, op, value: v } } + "*" { Expr::All } "(" _ e:expr() _ ")" { e } } @@ -162,7 +222,6 @@ fn rel_type_label(r: RelationType) -> &'static str { // -- Comparison logic -- -/// Extract numeric value for comparison fn as_num(v: &Value) -> Option { match v { Value::Num(n) => Some(*n), @@ -172,7 +231,6 @@ fn as_num(v: &Value) -> Option { } } -/// Extract string value for comparison fn as_str(v: &Value) -> String { match v { Value::Str(s) | Value::Ident(s) => s.clone(), @@ -181,7 +239,6 @@ fn as_str(v: &Value) -> String { } } -/// Compare two values with the given operator fn compare(lhs: &Value, op: CmpOp, rhs: &Value) -> bool { match op { CmpOp::Match => { @@ -193,7 +250,6 @@ fn compare(lhs: &Value, op: CmpOp, rhs: &Value) -> bool { } } CmpOp::Eq => { - // Try numeric first, fall back to string if let (Some(a), Some(b)) = (as_num(lhs), as_num(rhs)) { a == b } else { @@ -223,7 +279,6 @@ fn compare(lhs: &Value, op: CmpOp, rhs: &Value) -> bool { // -- Evaluator -- -/// Resolve function calls that return values (community ID, degree of specific node) fn resolve_fn(f: &FnCall, store: &Store, graph: &Graph) -> Value { match f { FnCall::Community(key) => { @@ -239,7 +294,6 @@ fn resolve_fn(f: &FnCall, store: &Store, graph: &Graph) -> Value { } } -/// Resolve a Value, evaluating function calls fn resolve_value(v: &Value, store: &Store, graph: &Graph) -> Value { match v { Value::FnCall(f) => resolve_fn(f, store, graph), @@ -247,9 +301,9 @@ fn resolve_value(v: &Value, store: &Store, graph: &Graph) -> Value { } } -/// Evaluate an expression against a node fn eval_node(expr: &Expr, key: &str, store: &Store, graph: &Graph) -> bool { match expr { + Expr::All => true, Expr::Comparison { field, op, value } => { let lhs = match resolve_field(field, key, store, graph) { Some(v) => v, @@ -265,11 +319,10 @@ fn eval_node(expr: &Expr, key: &str, store: &Store, graph: &Graph) -> bool { eval_node(a, key, store, graph) || eval_node(b, key, store, graph) } Expr::Not(e) => !eval_node(e, key, store, graph), - Expr::Neighbors { .. } => false, // neighbors() is a top-level expression, not a predicate + Expr::Neighbors { .. } => false, } } -/// Evaluate a WHERE clause against an edge fn eval_edge( expr: &Expr, _source: &str, @@ -280,12 +333,11 @@ fn eval_edge( graph: &Graph, ) -> bool { match expr { + Expr::All => true, Expr::Comparison { field, op, value } => { - // Edge-context fields let lhs = match field.as_str() { "strength" => Value::Num(strength as f64), "rel_type" => Value::Str(rel_type_label(rel_type).to_string()), - // Fall through to node fields on the target _ => match resolve_field(field, target, store, graph) { Some(v) => v, None => return false, @@ -313,40 +365,17 @@ pub struct QueryResult { pub key: String, } -// -- Query options -- - -pub struct QueryOpts { - pub fields: Vec, - pub sort_field: Option, - pub limit: Option, - pub count_only: bool, -} - -impl Default for QueryOpts { - fn default() -> Self { - QueryOpts { - fields: Vec::new(), - sort_field: None, - limit: None, - count_only: false, - } - } -} - // -- Query executor -- -/// Parse and execute a query, returning matching node keys. pub fn execute_query( store: &Store, graph: &Graph, query_str: &str, - opts: &QueryOpts, ) -> Result, String> { - let expr = query_parser::expr(query_str) + let q = query_parser::query(query_str) .map_err(|e| format!("Parse error: {}", e))?; - let mut results = match &expr { - // neighbors() is a set-returning expression + let mut results = match &q.expr { Expr::Neighbors { key, filter } => { let resolved = store.resolve_key(key).unwrap_or_else(|_| key.clone()); let edges = graph.edges_of(&resolved); @@ -365,12 +394,11 @@ pub fn execute_query( } out } - // Everything else: scan all nodes _ => { let mut out = Vec::new(); for key in store.nodes.keys() { if store.nodes[key].deleted { continue; } - if eval_node(&expr, key, store, graph) { + if eval_node(&q.expr, key, store, graph) { out.push(QueryResult { key: key.clone() }); } } @@ -378,26 +406,61 @@ pub fn execute_query( } }; - // Sort - let sort_field = opts.sort_field.as_deref().unwrap_or("degree"); - results.sort_by(|a, b| { - let va = resolve_field(sort_field, &a.key, store, graph) - .and_then(|v| as_num(&v)) - .unwrap_or(0.0); - let vb = resolve_field(sort_field, &b.key, store, graph) - .and_then(|v| as_num(&v)) - .unwrap_or(0.0); - vb.partial_cmp(&va).unwrap_or(std::cmp::Ordering::Equal) - }); + // Apply pipeline stages + let mut has_sort = false; + for stage in &q.stages { + match stage { + Stage::Sort { field, ascending } => { + has_sort = true; + let asc = *ascending; + results.sort_by(|a, b| { + let va = resolve_field(field, &a.key, store, graph) + .and_then(|v| as_num(&v)); + let vb = resolve_field(field, &b.key, store, graph) + .and_then(|v| as_num(&v)); + match (va, vb) { + (Some(a), Some(b)) => if asc { + a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal) + } else { + b.partial_cmp(&a).unwrap_or(std::cmp::Ordering::Equal) + }, + // String fallback for non-numeric fields + _ => { + let sa = resolve_field(field, &a.key, store, graph) + .map(|v| as_str(&v)).unwrap_or_default(); + let sb = resolve_field(field, &b.key, store, graph) + .map(|v| as_str(&v)).unwrap_or_default(); + if asc { sa.cmp(&sb) } else { sb.cmp(&sa) } + } + } + }); + } + Stage::Limit(n) => { + results.truncate(*n); + } + Stage::Select(_) | Stage::Count => {} // handled in output + } + } - // Limit - if let Some(limit) = opts.limit { - results.truncate(limit); + // Default sort by degree desc if no explicit sort + if !has_sort { + results.sort_by(|a, b| { + let da = graph.degree(&a.key); + let db = graph.degree(&b.key); + db.cmp(&da) + }); } Ok(results) } +/// Extract the output stages from a parsed query (for cmd_query formatting) +pub fn output_stages(query_str: &str) -> Result, String> { + let q = query_parser::query(query_str) + .map_err(|e| format!("Parse error: {}", e))?; + Ok(q.stages) +} + /// Format a field value for display pub fn format_field(field: &str, key: &str, store: &Store, graph: &Graph) -> String { match resolve_field(field, key, store, graph) { @@ -409,7 +472,6 @@ pub fn format_field(field: &str, key: &str, store: &Store, graph: &Graph) -> Str } } Some(Value::Str(s)) => { - // Truncate content for display if field == "content" { let truncated: String = s.chars().take(80).collect(); if s.len() > 80 { format!("{}...", truncated) } else { truncated }