query: replace CLI flags with pipe syntax

degree > 15 | sort degree | limit 10 | select degree,category
  * | sort weight asc | limit 20
  category = core | count

Output modifiers live in the grammar now, not in CLI flags.
Also adds * wildcard for "all nodes" and string-aware sort fallback.
This commit is contained in:
ProofOfConcept 2026-03-03 11:05:28 -05:00
parent 5c641d9f8a
commit 18face7063
2 changed files with 144 additions and 97 deletions

View file

@ -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<Expr>, Box<Expr>),
Or(Box<Expr>, Box<Expr>),
@ -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<String>),
Count,
}
#[derive(Debug, Clone)]
pub struct Query {
pub expr: Expr,
pub stages: Vec<Stage>,
}
// -- 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<Stage>
= 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<String>
= 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<f64> {
match v {
Value::Num(n) => Some(*n),
@ -172,7 +231,6 @@ fn as_num(v: &Value) -> Option<f64> {
}
}
/// 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<String>,
pub sort_field: Option<String>,
pub limit: Option<usize>,
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<Vec<QueryResult>, 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<Vec<Stage>, 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 }