ANW-15 /query endpoint + restart-replay pin + UTC canonicalization
This commit is contained in:
parent
da2dfbbd6d
commit
b045efd1f0
7 changed files with 784 additions and 9 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -78,6 +78,7 @@ dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"hydra",
|
"hydra",
|
||||||
"notify",
|
"notify",
|
||||||
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ chrono = { version = "0.4", default-features = false, features = ["std", "serde"
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
hydra = "0.1"
|
hydra = "0.1"
|
||||||
notify = "8"
|
notify = "8"
|
||||||
|
regex = "1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
|
|
||||||
20
src/app.rs
20
src/app.rs
|
|
@ -443,7 +443,25 @@ impl GenServer for IndexWriterState {
|
||||||
let index = NoteIndex::new()
|
let index = NoteIndex::new()
|
||||||
.map_err(|e| ExitReason::from(format!("index_writer: NoteIndex::new failed: {e}")))?;
|
.map_err(|e| ExitReason::from(format!("index_writer: NoteIndex::new failed: {e}")))?;
|
||||||
self.index = Some(index);
|
self.index = Some(index);
|
||||||
tracing::info!(restart, "index_writer: init");
|
|
||||||
|
if restart > 0 {
|
||||||
|
// Writer-only restart: a fresh empty index has come up while the
|
||||||
|
// watcher keeps streaming batches at it. Ask `vault_scanner` for
|
||||||
|
// a full walk via the same `rescan_now` path the inotify-overflow
|
||||||
|
// recovery uses. Per kaa's pin (ADR-004 amendment 2026-05-14),
|
||||||
|
// rescan upserts are idempotent on path so in-flight watcher
|
||||||
|
// batches converge with the rescan.
|
||||||
|
VaultScanner::cast(
|
||||||
|
Dest::from(VAULT_SCANNER_NAME),
|
||||||
|
VaultScannerMessage::RescanNow,
|
||||||
|
);
|
||||||
|
tracing::info!(
|
||||||
|
restart,
|
||||||
|
"index_writer: init -- rescan_now dispatched to vault_scanner"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(restart, "index_writer: init");
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
33
src/http.rs
33
src/http.rs
|
|
@ -15,18 +15,25 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Path as AxumPath, State};
|
use axum::extract::{OriginalUri, Path as AxumPath, State};
|
||||||
use axum::http::header::{ACCEPT, CONTENT_TYPE, ETAG, IF_NONE_MATCH};
|
use axum::http::header::{ACCEPT, CONTENT_TYPE, ETAG, IF_NONE_MATCH};
|
||||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, SecondsFormat, Utc};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crate::store::NoteStore;
|
use crate::store::NoteStore;
|
||||||
use crate::vault::{Note, frontmatter_to_json};
|
use crate::vault::{Note, frontmatter_to_json};
|
||||||
|
|
||||||
|
/// Canonical RFC 3339 form with a `Z` suffix -- the shape the User Manual
|
||||||
|
/// example uses for `last_modified`. Centralized here so every HTTP
|
||||||
|
/// `last_modified` field stays in the same dialect.
|
||||||
|
fn rfc3339_z(dt: DateTime<Utc>) -> String {
|
||||||
|
dt.to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared state injected into every handler.
|
/// Shared state injected into every handler.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct HttpState {
|
pub struct HttpState {
|
||||||
|
|
@ -41,6 +48,7 @@ pub fn router(state: HttpState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/notes/", get(list_root_folder))
|
.route("/notes/", get(list_root_folder))
|
||||||
.route("/notes/{*path}", get(get_notes))
|
.route("/notes/{*path}", get(get_notes))
|
||||||
|
.route("/query", get(get_query))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,7 +68,7 @@ impl<'a> From<&'a Note> for NoteResponse<'a> {
|
||||||
path: ¬e.path,
|
path: ¬e.path,
|
||||||
frontmatter: frontmatter_to_json(¬e.frontmatter),
|
frontmatter: frontmatter_to_json(¬e.frontmatter),
|
||||||
body: ¬e.body,
|
body: ¬e.body,
|
||||||
last_modified: note.last_modified.to_rfc3339(),
|
last_modified: rfc3339_z(note.last_modified),
|
||||||
etag: ¬e.etag,
|
etag: ¬e.etag,
|
||||||
size: note.size,
|
size: note.size,
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +101,21 @@ async fn list_root_folder(State(state): State<HttpState>) -> Response {
|
||||||
list_folder(&state, "").into_response()
|
list_folder(&state, "").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_query(State(state): State<HttpState>, OriginalUri(uri): OriginalUri) -> Response {
|
||||||
|
let raw = uri.query().unwrap_or("");
|
||||||
|
let parsed = match crate::query::parse(raw) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return bad_request(&e.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
let resp = crate::query::execute(&state.store, &parsed, rfc3339_z);
|
||||||
|
let bytes = serde_json::to_vec(&resp).expect("query response serializes");
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(bytes))
|
||||||
|
.expect("static response")
|
||||||
|
}
|
||||||
|
|
||||||
fn respond_note(note: &Note, headers: &HeaderMap) -> Response {
|
fn respond_note(note: &Note, headers: &HeaderMap) -> Response {
|
||||||
if let Some(client_etag) = headers.get(IF_NONE_MATCH)
|
if let Some(client_etag) = headers.get(IF_NONE_MATCH)
|
||||||
&& etag_matches(client_etag, ¬e.etag)
|
&& etag_matches(client_etag, ¬e.etag)
|
||||||
|
|
@ -225,7 +248,7 @@ fn list_folder(state: &HttpState, folder: &str) -> Response {
|
||||||
entries.push(FolderEntry {
|
entries.push(FolderEntry {
|
||||||
name,
|
name,
|
||||||
kind: "file",
|
kind: "file",
|
||||||
last_modified: lm.to_rfc3339(),
|
last_modified: rfc3339_z(lm),
|
||||||
size: Some(size),
|
size: Some(size),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -233,7 +256,7 @@ fn list_folder(state: &HttpState, folder: &str) -> Response {
|
||||||
entries.push(FolderEntry {
|
entries.push(FolderEntry {
|
||||||
name,
|
name,
|
||||||
kind: "dir",
|
kind: "dir",
|
||||||
last_modified: lm.to_rfc3339(),
|
last_modified: rfc3339_z(lm),
|
||||||
size: None,
|
size: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
|
pub mod query;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
pub mod watcher;
|
pub mod watcher;
|
||||||
|
|
|
||||||
719
src/query.rs
Normal file
719
src/query.rs
Normal file
|
|
@ -0,0 +1,719 @@
|
||||||
|
//! `/query` parsing + execution for [ANW-15].
|
||||||
|
//!
|
||||||
|
//! The User Manual exposes:
|
||||||
|
//!
|
||||||
|
//! - field predicates with operator suffixes (`__in`, `__all`, `__not`,
|
||||||
|
//! `__exists`, `__regex`, `__prefix`, `__gt`/`__gte`/`__lt`/`__lte`);
|
||||||
|
//! - control parameters under the `__anw-` namespace
|
||||||
|
//! (`__anw-recursive`, `__anw-path`, `__anw-limit`).
|
||||||
|
//!
|
||||||
|
//! Different field predicates AND together; multiple values under
|
||||||
|
//! `__in` / `__all` are comma-separated; unknown operators are `400`.
|
||||||
|
//!
|
||||||
|
//! v1 evaluates predicates by iterating the in-memory [`NoteStore`] and
|
||||||
|
//! applying each [`Predicate::matches`] in turn. The Tantivy index from
|
||||||
|
//! [[ADR-002 Tantivy as Frontmatter Index]] is still built and maintained,
|
||||||
|
//! but query-time filtering doesn't use it yet. At the documented scale
|
||||||
|
//! (low-thousands-of-notes vaults) this is sub-millisecond; migration to a
|
||||||
|
//! Tantivy-driven candidate set is a future optimization once a real
|
||||||
|
//! consumer pushes throughput.
|
||||||
|
|
||||||
|
use chrono::{DateTime, NaiveDate};
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value as JsonValue;
|
||||||
|
|
||||||
|
use crate::store::NoteStore;
|
||||||
|
use crate::vault::{Frontmatter, Note, Value, frontmatter_to_json};
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum QueryError {
|
||||||
|
UnknownOperator(String),
|
||||||
|
BadValue(String),
|
||||||
|
BadControl(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for QueryError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::UnknownOperator(s) => write!(f, "unknown operator: {s}"),
|
||||||
|
Self::BadValue(s) => write!(f, "bad value: {s}"),
|
||||||
|
Self::BadControl(s) => write!(f, "bad control parameter: {s}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for QueryError {}
|
||||||
|
|
||||||
|
/// Operator suffix attached to a field predicate.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Operator {
|
||||||
|
/// Exact equality; "array contains" if the frontmatter field is a list.
|
||||||
|
Eq,
|
||||||
|
/// `__in` -- one-of (any listed value matches).
|
||||||
|
In,
|
||||||
|
/// `__all` -- array contains every listed value.
|
||||||
|
All,
|
||||||
|
/// `__not` -- negate exact equality.
|
||||||
|
Not,
|
||||||
|
/// `__exists` -- presence (`true`) or absence (`false`) of the key.
|
||||||
|
Exists,
|
||||||
|
/// `__regex` -- regex over a scalar value.
|
||||||
|
Regex,
|
||||||
|
/// `__prefix` -- string prefix.
|
||||||
|
Prefix,
|
||||||
|
Gt,
|
||||||
|
Gte,
|
||||||
|
Lt,
|
||||||
|
Lte,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Predicate {
|
||||||
|
pub field: String,
|
||||||
|
pub op: Operator,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct ParsedQuery {
|
||||||
|
pub predicates: Vec<Predicate>,
|
||||||
|
/// `true` by default; `__anw-recursive=false` limits results to direct
|
||||||
|
/// children of `__anw-path`.
|
||||||
|
pub recursive: bool,
|
||||||
|
pub path_prefix: Option<String>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParsedQuery {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
predicates: Vec::new(),
|
||||||
|
recursive: true,
|
||||||
|
path_prefix: None,
|
||||||
|
limit: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the raw query string (`a=b&c__in=x,y&__anw-limit=5`) into a
|
||||||
|
/// [`ParsedQuery`]. Returns `QueryError` for unknown operators, malformed
|
||||||
|
/// control parameters, or unparseable bool/int values.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`QueryError::UnknownOperator`] for unrecognized `__` suffixes;
|
||||||
|
/// - [`QueryError::BadControl`] for unrecognized or unparseable
|
||||||
|
/// `__anw-...` parameters;
|
||||||
|
/// - [`QueryError::BadValue`] for percent-encoding that fails to decode.
|
||||||
|
pub fn parse(raw: &str) -> Result<ParsedQuery, QueryError> {
|
||||||
|
let mut q = ParsedQuery::new();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Ok(q);
|
||||||
|
}
|
||||||
|
for pair in raw.split('&') {
|
||||||
|
if pair.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (k, v) = match pair.split_once('=') {
|
||||||
|
Some((k, v)) => (k, v),
|
||||||
|
None => (pair, ""),
|
||||||
|
};
|
||||||
|
let key = percent_decode(k).ok_or_else(|| QueryError::BadValue(k.to_string()))?;
|
||||||
|
let value = percent_decode(v).ok_or_else(|| QueryError::BadValue(v.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(name) = key.strip_prefix("__anw-") {
|
||||||
|
apply_control(&mut q, name, &value)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (field, op) = split_operator(&key)?;
|
||||||
|
q.predicates.push(Predicate {
|
||||||
|
field: field.to_string(),
|
||||||
|
op,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_operator(key: &str) -> Result<(&str, Operator), QueryError> {
|
||||||
|
if let Some(idx) = key.rfind("__") {
|
||||||
|
let suffix = &key[idx + 2..];
|
||||||
|
let op = match suffix {
|
||||||
|
"in" => Operator::In,
|
||||||
|
"all" => Operator::All,
|
||||||
|
"not" => Operator::Not,
|
||||||
|
"exists" => Operator::Exists,
|
||||||
|
"regex" => Operator::Regex,
|
||||||
|
"prefix" => Operator::Prefix,
|
||||||
|
"gt" => Operator::Gt,
|
||||||
|
"gte" => Operator::Gte,
|
||||||
|
"lt" => Operator::Lt,
|
||||||
|
"lte" => Operator::Lte,
|
||||||
|
// Reject unknown suffixes (e.g. `__contians`) rather than
|
||||||
|
// silently treating them as part of the field name -- the User
|
||||||
|
// Manual mandates 400 here.
|
||||||
|
unknown => return Err(QueryError::UnknownOperator(unknown.to_string())),
|
||||||
|
};
|
||||||
|
Ok((&key[..idx], op))
|
||||||
|
} else {
|
||||||
|
Ok((key, Operator::Eq))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_control(q: &mut ParsedQuery, name: &str, value: &str) -> Result<(), QueryError> {
|
||||||
|
match name {
|
||||||
|
"recursive" => {
|
||||||
|
q.recursive = value
|
||||||
|
.parse::<bool>()
|
||||||
|
.map_err(|_| QueryError::BadControl(format!("recursive={value}")))?;
|
||||||
|
}
|
||||||
|
"path" => {
|
||||||
|
q.path_prefix = Some(value.trim_start_matches('/').to_string());
|
||||||
|
}
|
||||||
|
"limit" => {
|
||||||
|
let n: usize = value
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| QueryError::BadControl(format!("limit={value}")))?;
|
||||||
|
q.limit = Some(n);
|
||||||
|
}
|
||||||
|
unknown => return Err(QueryError::BadControl(unknown.to_string())),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode(input: &str) -> Option<String> {
|
||||||
|
let bytes = input.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
match bytes[i] {
|
||||||
|
b'+' => {
|
||||||
|
out.push(b' ');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
b'%' => {
|
||||||
|
if i + 2 >= bytes.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hi = hex_value(bytes[i + 1])?;
|
||||||
|
let lo = hex_value(bytes[i + 2])?;
|
||||||
|
out.push((hi << 4) | lo);
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
c => {
|
||||||
|
out.push(c);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8(out).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_value(b: u8) -> Option<u8> {
|
||||||
|
match b {
|
||||||
|
b'0'..=b'9' => Some(b - b'0'),
|
||||||
|
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One result row in the `/query` response. Per User Manual the body is
|
||||||
|
/// elided -- consumers fetch bodies via `/notes/<path>` if needed.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ResultEntry {
|
||||||
|
pub path: String,
|
||||||
|
pub frontmatter: JsonValue,
|
||||||
|
pub last_modified: String,
|
||||||
|
pub etag: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct QueryResponse {
|
||||||
|
pub results: Vec<ResultEntry>,
|
||||||
|
pub total: usize,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a parsed query against the in-memory note set.
|
||||||
|
pub fn execute<F>(store: &NoteStore, query: &ParsedQuery, format_ts: F) -> QueryResponse
|
||||||
|
where
|
||||||
|
F: Fn(chrono::DateTime<chrono::Utc>) -> String,
|
||||||
|
{
|
||||||
|
let prefix = query.path_prefix.as_deref().unwrap_or("");
|
||||||
|
let matches = store.with_read(|notes| {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (path, note) in notes {
|
||||||
|
if !path_matches(path, prefix, query.recursive) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !predicates_match(&query.predicates, ¬e.frontmatter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(note.clone());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
});
|
||||||
|
let total = matches.len();
|
||||||
|
let (results, truncated): (Vec<Note>, bool) = match query.limit {
|
||||||
|
Some(limit) if matches.len() > limit => (matches.into_iter().take(limit).collect(), true),
|
||||||
|
_ => (matches, false),
|
||||||
|
};
|
||||||
|
let entries = results
|
||||||
|
.into_iter()
|
||||||
|
.map(|n| ResultEntry {
|
||||||
|
path: n.path,
|
||||||
|
frontmatter: frontmatter_to_json(&n.frontmatter),
|
||||||
|
last_modified: format_ts(n.last_modified),
|
||||||
|
etag: n.etag,
|
||||||
|
size: n.size,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
QueryResponse {
|
||||||
|
results: entries,
|
||||||
|
total,
|
||||||
|
truncated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_matches(path: &str, prefix: &str, recursive: bool) -> bool {
|
||||||
|
if prefix.is_empty() {
|
||||||
|
return recursive || !path.contains('/');
|
||||||
|
}
|
||||||
|
let scoped = match path.strip_prefix(prefix) {
|
||||||
|
Some(rest) => rest.strip_prefix('/').unwrap_or(rest),
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
if scoped.is_empty() {
|
||||||
|
// The prefix itself names a note; consider it in-scope.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if recursive {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
!scoped.contains('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn predicates_match(predicates: &[Predicate], fm: &Frontmatter) -> bool {
|
||||||
|
predicates.iter().all(|p| predicate_match(p, fm))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn predicate_match(p: &Predicate, fm: &Frontmatter) -> bool {
|
||||||
|
let target = lookup_dotted(fm, &p.field);
|
||||||
|
match p.op {
|
||||||
|
Operator::Exists => {
|
||||||
|
let want = p.value == "true";
|
||||||
|
target.is_some() == want
|
||||||
|
}
|
||||||
|
Operator::Eq => target.is_some_and(|v| matches_eq(v, &p.value)),
|
||||||
|
Operator::Not => target.is_none_or(|v| !matches_eq(v, &p.value)),
|
||||||
|
Operator::In => {
|
||||||
|
let needles = comma_list(&p.value);
|
||||||
|
target.is_some_and(|v| needles.iter().any(|n| matches_eq(v, n)))
|
||||||
|
}
|
||||||
|
Operator::All => {
|
||||||
|
let needles = comma_list(&p.value);
|
||||||
|
// __all requires the value to be a list containing every needle.
|
||||||
|
let Some(Value::Sequence(seq)) = target else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
needles
|
||||||
|
.iter()
|
||||||
|
.all(|n| seq.iter().any(|item| matches_scalar_eq(item, n)))
|
||||||
|
}
|
||||||
|
Operator::Regex => match target {
|
||||||
|
Some(Value::String(s)) => Regex::new(&p.value).is_ok_and(|re| re.is_match(s)),
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
|
Operator::Prefix => match target {
|
||||||
|
Some(Value::String(s)) => s.starts_with(&p.value),
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
|
Operator::Gt | Operator::Gte | Operator::Lt | Operator::Lte => {
|
||||||
|
target.is_some_and(|v| ordered_compare(v, &p.value, p.op))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comma_list(s: &str) -> Vec<String> {
|
||||||
|
s.split(',').map(|t| t.trim().to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if `v` equals `needle` -- with the User Manual's contract for
|
||||||
|
/// scalar-list distinction:
|
||||||
|
///
|
||||||
|
/// - if `v` is a scalar, compare value-as-string;
|
||||||
|
/// - if `v` is a list, *array contains* `needle`.
|
||||||
|
///
|
||||||
|
/// `__in` operator handles list-OR semantics separately.
|
||||||
|
fn matches_eq(v: &Value, needle: &str) -> bool {
|
||||||
|
match v {
|
||||||
|
Value::Sequence(seq) => seq.iter().any(|item| matches_scalar_eq(item, needle)),
|
||||||
|
scalar => matches_scalar_eq(scalar, needle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_scalar_eq(v: &Value, needle: &str) -> bool {
|
||||||
|
match v {
|
||||||
|
Value::Null => needle == "null",
|
||||||
|
Value::Bool(b) => needle == b.to_string(),
|
||||||
|
Value::Int(i) => needle == i.to_string(),
|
||||||
|
Value::Float(f) => needle == f.to_string(),
|
||||||
|
Value::String(s) => s == needle,
|
||||||
|
Value::Date(d) => d.format("%Y-%m-%d").to_string() == needle,
|
||||||
|
Value::DateTime(dt) => {
|
||||||
|
// Canonical form per the index contract.
|
||||||
|
use chrono::SecondsFormat;
|
||||||
|
let canon = dt
|
||||||
|
.with_timezone(&chrono::Utc)
|
||||||
|
.to_rfc3339_opts(SecondsFormat::Secs, true);
|
||||||
|
canon == needle
|
||||||
|
}
|
||||||
|
Value::Sequence(_) | Value::Mapping(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ordered_compare(v: &Value, needle: &str, op: Operator) -> bool {
|
||||||
|
// Try date -> datetime -> int -> float -> string in turn. Stop at the
|
||||||
|
// first parser that matches both sides.
|
||||||
|
if let (Some(a), Some(b)) = (
|
||||||
|
try_as_date(v),
|
||||||
|
NaiveDate::parse_from_str(needle, "%Y-%m-%d").ok(),
|
||||||
|
) {
|
||||||
|
return ord(a.cmp(&b), op);
|
||||||
|
}
|
||||||
|
if let (Some(a), Some(b)) = (
|
||||||
|
try_as_datetime(v),
|
||||||
|
DateTime::parse_from_rfc3339(needle).ok(),
|
||||||
|
) {
|
||||||
|
return ord(a.cmp(&b.with_timezone(&chrono::Utc)), op);
|
||||||
|
}
|
||||||
|
if let (Some(a), Some(b)) = (try_as_int(v), needle.parse::<i64>().ok()) {
|
||||||
|
return ord(a.cmp(&b), op);
|
||||||
|
}
|
||||||
|
if let (Some(a), Some(b)) = (try_as_float(v), needle.parse::<f64>().ok()) {
|
||||||
|
return ord(a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal), op);
|
||||||
|
}
|
||||||
|
if let Some(a) = try_as_string(v) {
|
||||||
|
return ord(a.as_str().cmp(needle), op);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ord(c: std::cmp::Ordering, op: Operator) -> bool {
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
match op {
|
||||||
|
Operator::Gt => c == Ordering::Greater,
|
||||||
|
Operator::Gte => c != Ordering::Less,
|
||||||
|
Operator::Lt => c == Ordering::Less,
|
||||||
|
Operator::Lte => c != Ordering::Greater,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_as_date(v: &Value) -> Option<NaiveDate> {
|
||||||
|
match v {
|
||||||
|
Value::Date(d) => Some(*d),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_as_datetime(v: &Value) -> Option<DateTime<chrono::Utc>> {
|
||||||
|
match v {
|
||||||
|
Value::DateTime(dt) => Some(dt.with_timezone(&chrono::Utc)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_as_int(v: &Value) -> Option<i64> {
|
||||||
|
match v {
|
||||||
|
Value::Int(i) => Some(*i),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_as_float(v: &Value) -> Option<f64> {
|
||||||
|
match v {
|
||||||
|
Value::Float(f) => Some(*f),
|
||||||
|
// i64 -> f64 loses precision above 2^53 -- the User Manual's
|
||||||
|
// numeric operators are documented for "typed dates and integers",
|
||||||
|
// and a frontmatter integer wider than 2^53 is not a v1 concern.
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
Value::Int(i) => Some(*i as f64),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_as_string(v: &Value) -> Option<String> {
|
||||||
|
match v {
|
||||||
|
Value::String(s) => Some(s.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traverse a dotted key path (`author.name`) into the frontmatter tree.
|
||||||
|
/// Returns `None` if any intermediate key is missing or addresses a non-map.
|
||||||
|
fn lookup_dotted<'a>(fm: &'a Frontmatter, path: &str) -> Option<&'a Value> {
|
||||||
|
let mut parts = path.split('.');
|
||||||
|
let head = parts.next()?;
|
||||||
|
let mut current: &Value = fm.get(head)?;
|
||||||
|
for part in parts {
|
||||||
|
let Value::Mapping(map) = current else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
current = map.get(part)?;
|
||||||
|
}
|
||||||
|
Some(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn note(path: &str, fm: Frontmatter) -> Note {
|
||||||
|
Note {
|
||||||
|
path: path.into(),
|
||||||
|
frontmatter: fm,
|
||||||
|
body: String::new(),
|
||||||
|
raw_bytes: Vec::new(),
|
||||||
|
last_modified: Utc.with_ymd_and_hms(2026, 5, 14, 12, 0, 0).unwrap(),
|
||||||
|
etag: "\"x\"".into(),
|
||||||
|
size: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fm(pairs: &[(&str, Value)]) -> Frontmatter {
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_with(notes: Vec<Note>) -> std::sync::Arc<NoteStore> {
|
||||||
|
let s = NoteStore::new();
|
||||||
|
s.replace(notes);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(q: &ParsedQuery, s: &NoteStore) -> QueryResponse {
|
||||||
|
execute(s, q, |_dt| String::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_operator_returns_error() {
|
||||||
|
let err = parse("title__contians=foo").unwrap_err();
|
||||||
|
assert!(matches!(err, QueryError::UnknownOperator(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_query_string_returns_default_parse() {
|
||||||
|
let q = parse("").unwrap();
|
||||||
|
assert!(q.predicates.is_empty());
|
||||||
|
assert!(q.recursive);
|
||||||
|
assert!(q.path_prefix.is_none());
|
||||||
|
assert!(q.limit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eq_on_scalar_and_list() {
|
||||||
|
let q = parse("tags=python").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note("a.md", fm(&[("tags", Value::String("python".into()))])),
|
||||||
|
note(
|
||||||
|
"b.md",
|
||||||
|
fm(&[(
|
||||||
|
"tags",
|
||||||
|
Value::Sequence(vec![
|
||||||
|
Value::String("python".into()),
|
||||||
|
Value::String("go".into()),
|
||||||
|
]),
|
||||||
|
)]),
|
||||||
|
),
|
||||||
|
note("c.md", fm(&[("tags", Value::String("go".into()))])),
|
||||||
|
]);
|
||||||
|
let r = run(&q, &s);
|
||||||
|
assert_eq!(r.total, 2);
|
||||||
|
let paths: Vec<&str> = r.results.iter().map(|x| x.path.as_str()).collect();
|
||||||
|
assert_eq!(paths, vec!["a.md", "b.md"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn in_operator_matches_any() {
|
||||||
|
let q = parse("tags__in=python,go").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note("a.md", fm(&[("tags", Value::String("python".into()))])),
|
||||||
|
note("b.md", fm(&[("tags", Value::String("rust".into()))])),
|
||||||
|
note("c.md", fm(&[("tags", Value::String("go".into()))])),
|
||||||
|
]);
|
||||||
|
let r = run(&q, &s);
|
||||||
|
assert_eq!(r.total, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_operator_requires_every_value() {
|
||||||
|
let q = parse("tags__all=python,fastapi").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note(
|
||||||
|
"ok.md",
|
||||||
|
fm(&[(
|
||||||
|
"tags",
|
||||||
|
Value::Sequence(vec![
|
||||||
|
Value::String("python".into()),
|
||||||
|
Value::String("fastapi".into()),
|
||||||
|
Value::String("web".into()),
|
||||||
|
]),
|
||||||
|
)]),
|
||||||
|
),
|
||||||
|
note(
|
||||||
|
"miss.md",
|
||||||
|
fm(&[(
|
||||||
|
"tags",
|
||||||
|
Value::Sequence(vec![Value::String("python".into())]),
|
||||||
|
)]),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let r = run(&q, &s);
|
||||||
|
assert_eq!(r.total, 1);
|
||||||
|
assert_eq!(r.results[0].path, "ok.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_operator_excludes_match() {
|
||||||
|
let q = parse("status__not=draft").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note("d.md", fm(&[("status", Value::String("draft".into()))])),
|
||||||
|
note("p.md", fm(&[("status", Value::String("published".into()))])),
|
||||||
|
note("u.md", fm(&[])),
|
||||||
|
]);
|
||||||
|
let r = run(&q, &s);
|
||||||
|
let paths: Vec<&str> = r.results.iter().map(|x| x.path.as_str()).collect();
|
||||||
|
assert_eq!(paths, vec!["p.md", "u.md"]); // missing field is "not draft"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exists_operator_true_and_false() {
|
||||||
|
let q_true = parse("deprecated__exists=true").unwrap();
|
||||||
|
let q_false = parse("deprecated__exists=false").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note("a.md", fm(&[("deprecated", Value::Bool(true))])),
|
||||||
|
note("b.md", fm(&[])),
|
||||||
|
]);
|
||||||
|
assert_eq!(run(&q_true, &s).total, 1);
|
||||||
|
assert_eq!(run(&q_false, &s).total, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regex_operator_on_scalar() {
|
||||||
|
let q = parse("title__regex=%5EPDR-%5Cd%2B").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note("a.md", fm(&[("title", Value::String("PDR-007".into()))])),
|
||||||
|
note("b.md", fm(&[("title", Value::String("ADR-001".into()))])),
|
||||||
|
]);
|
||||||
|
assert_eq!(run(&q, &s).total, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefix_operator_on_scalar() {
|
||||||
|
let q = parse("title__prefix=PDR-").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note("a.md", fm(&[("title", Value::String("PDR-007".into()))])),
|
||||||
|
note("b.md", fm(&[("title", Value::String("ADR-001".into()))])),
|
||||||
|
]);
|
||||||
|
assert_eq!(run(&q, &s).total, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn range_operators_on_dates() {
|
||||||
|
let q = parse("date__gte=2026-05-01").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note(
|
||||||
|
"old.md",
|
||||||
|
fm(&[(
|
||||||
|
"date",
|
||||||
|
Value::Date(NaiveDate::from_ymd_opt(2026, 4, 1).unwrap()),
|
||||||
|
)]),
|
||||||
|
),
|
||||||
|
note(
|
||||||
|
"new.md",
|
||||||
|
fm(&[(
|
||||||
|
"date",
|
||||||
|
Value::Date(NaiveDate::from_ymd_opt(2026, 6, 1).unwrap()),
|
||||||
|
)]),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let r = run(&q, &s);
|
||||||
|
assert_eq!(r.total, 1);
|
||||||
|
assert_eq!(r.results[0].path, "new.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_param_limit_truncates_and_sets_total() {
|
||||||
|
let q = parse("__anw-limit=2").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note("a.md", fm(&[])),
|
||||||
|
note("b.md", fm(&[])),
|
||||||
|
note("c.md", fm(&[])),
|
||||||
|
note("d.md", fm(&[])),
|
||||||
|
]);
|
||||||
|
let r = run(&q, &s);
|
||||||
|
assert_eq!(r.total, 4);
|
||||||
|
assert_eq!(r.results.len(), 2);
|
||||||
|
assert!(r.truncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_param_path_filters_prefix() {
|
||||||
|
let q = parse("__anw-path=Projects/anwesen").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note("Projects/anwesen/x.md", fm(&[])),
|
||||||
|
note("Projects/other/y.md", fm(&[])),
|
||||||
|
note("Top.md", fm(&[])),
|
||||||
|
]);
|
||||||
|
let r = run(&q, &s);
|
||||||
|
assert_eq!(r.total, 1);
|
||||||
|
assert_eq!(r.results[0].path, "Projects/anwesen/x.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_param_recursive_false_limits_to_direct_children() {
|
||||||
|
let q = parse("__anw-path=Projects&__anw-recursive=false").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note("Projects/a.md", fm(&[])),
|
||||||
|
note("Projects/sub/b.md", fm(&[])),
|
||||||
|
]);
|
||||||
|
let r = run(&q, &s);
|
||||||
|
assert_eq!(r.total, 1);
|
||||||
|
assert_eq!(r.results[0].path, "Projects/a.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_dotted_keys() {
|
||||||
|
let mut author: BTreeMap<String, Value> = BTreeMap::new();
|
||||||
|
author.insert("name".into(), Value::String("brn".into()));
|
||||||
|
let q = parse("author.name=brn").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note("a.md", fm(&[("author", Value::Mapping(author))])),
|
||||||
|
note("b.md", fm(&[("author", Value::String("someone".into()))])),
|
||||||
|
]);
|
||||||
|
assert_eq!(run(&q, &s).total, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comma_escape_via_percent_encoding() {
|
||||||
|
// Escaping a literal comma -- ensures __in doesn't split inside an
|
||||||
|
// intentional value.
|
||||||
|
let q = parse("kind__in=a%2Cb,c").unwrap();
|
||||||
|
assert_eq!(q.predicates[0].value, "a,b,c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_control_param_returns_error() {
|
||||||
|
let err = parse("__anw-flarble=1").unwrap_err();
|
||||||
|
assert!(matches!(err, QueryError::BadControl(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/vault.rs
18
src/vault.rs
|
|
@ -18,7 +18,7 @@ use std::collections::BTreeMap;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use chrono::{DateTime, FixedOffset, NaiveDate, Utc};
|
use chrono::{DateTime, FixedOffset, NaiveDate, SecondsFormat, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map as JsonMap, Value as JsonValue, json};
|
use serde_json::{Map as JsonMap, Value as JsonValue, json};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
@ -79,7 +79,13 @@ impl Value {
|
||||||
Value::Float(f) => json!(f),
|
Value::Float(f) => json!(f),
|
||||||
Value::String(s) => JsonValue::String(s.clone()),
|
Value::String(s) => JsonValue::String(s.clone()),
|
||||||
Value::Date(d) => JsonValue::String(d.format("%Y-%m-%d").to_string()),
|
Value::Date(d) => JsonValue::String(d.format("%Y-%m-%d").to_string()),
|
||||||
Value::DateTime(dt) => JsonValue::String(dt.to_rfc3339()),
|
// Canonicalize to UTC with a `Z` suffix so two notes with the
|
||||||
|
// same instant but different source offsets serialize identically
|
||||||
|
// and compare equal under range queries on the index.
|
||||||
|
Value::DateTime(dt) => JsonValue::String(
|
||||||
|
dt.with_timezone(&Utc)
|
||||||
|
.to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||||
|
),
|
||||||
Value::Sequence(seq) => JsonValue::Array(seq.iter().map(Value::to_json).collect()),
|
Value::Sequence(seq) => JsonValue::Array(seq.iter().map(Value::to_json).collect()),
|
||||||
Value::Mapping(m) => {
|
Value::Mapping(m) => {
|
||||||
let mut map = JsonMap::new();
|
let mut map = JsonMap::new();
|
||||||
|
|
@ -484,7 +490,13 @@ mod tests {
|
||||||
let dt = DateTime::parse_from_rfc3339("2026-05-14T10:14:22Z").unwrap();
|
let dt = DateTime::parse_from_rfc3339("2026-05-14T10:14:22Z").unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Value::DateTime(dt).to_json(),
|
Value::DateTime(dt).to_json(),
|
||||||
JsonValue::String("2026-05-14T10:14:22+00:00".into())
|
JsonValue::String("2026-05-14T10:14:22Z".into())
|
||||||
|
);
|
||||||
|
// Different source offset, same instant -> same canonical form.
|
||||||
|
let dt2 = DateTime::parse_from_rfc3339("2026-05-14T12:14:22+02:00").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
Value::DateTime(dt).to_json(),
|
||||||
|
Value::DateTime(dt2).to_json()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue