1use std::collections::HashMap;
79
80use omgbase_mutate::effects::{SELECTION_CEILING, Selection, SelectionExpect, js_number};
81use omgbase_mutate::{Effect, ErrorCode, MutationError};
82use omgbase_store::effects_plan::PlanContext;
83use omgbase_store::mutate::is_id_ref;
84use omgbase_store::{DocStore, Store};
85use oqx::Consumer;
86use oqx::ast::{Expr, Query, SelectItem, TemplatePart};
87use serde_json::{Map, Value as Json, json};
88
89use crate::context::{SemanticVec, Target};
90use crate::error::{Result, SurfaceError};
91use crate::fixes;
92use crate::paths::storage_path;
93use crate::planner::root_target;
94use crate::query::{
95 HIT_ID, HIT_PATH, QueryOptions, check_reserved_aliases, check_root_spellings, query_ast,
96};
97
98pub const HINT: &str = "selection";
100pub const SLOT_PREFIX: &str = "__omg_sel_";
102const WRAP_HEAD: &str = "select __omg_x: ";
103const WRAP_TAIL: &str = " from docs";
104const PROBE: &str = "$sel";
107const OPS: &str = "create/insert/set/unset/move/remove/split/merge";
108pub const LEADER: &str = "-> ";
111
112fn refused_with(message: impl Into<String>, extra: Json) -> SurfaceError {
115 let message = message.into();
116 let mut data = json!({ "reason": message, "hint": HINT });
117 if let (Some(d), Some(x)) = (data.as_object_mut(), extra.as_object()) {
118 for (k, v) in x {
119 d.insert(k.clone(), v.clone());
120 }
121 }
122 SurfaceError::with_data("filter_invalid", message, data)
123}
124
125fn refused(message: impl Into<String>) -> SurfaceError {
126 refused_with(message, json!({}))
127}
128
129fn shape_error(message: String) -> SurfaceError {
132 SurfaceError::filter_invalid(message, "arguments")
133}
134
135fn leader_of(v: &Json) -> Option<&str> {
141 v.as_str()?.strip_prefix(LEADER)
142}
143
144fn contains_leader(v: &Json) -> bool {
146 match v {
147 Json::String(_) => leader_of(v).is_some(),
148 Json::Array(a) => a.iter().any(contains_leader),
149 Json::Object(o) => o.values().any(contains_leader),
150 _ => false,
151 }
152}
153
154fn path_looking(s: &str) -> bool {
157 s.contains('/') || s.ends_with(".md")
158}
159
160fn is_node_id(s: &str) -> bool {
162 s.len() == 14
163 && s.starts_with("n_")
164 && s[2..]
165 .bytes()
166 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
167}
168
169fn is_target_id(s: &str) -> bool {
173 is_id_ref(s, "b") || is_id_ref(s, "d") || is_node_id(s)
174}
175
176#[derive(Clone, Copy, Debug, PartialEq, Eq)]
181pub enum Member {
182 Target,
183 Into,
184 Targets(usize),
185}
186
187fn symbol_kind(member: Member, op: &str, field: Option<&str>) -> &'static str {
191 match member {
192 Member::Targets(_) => "block",
193 Member::Into => "document",
194 Member::Target => match op {
195 "split" => "block",
196 "set" => match field {
197 Some(f) if f == "markdown" || f == "attrs.checked" => "block",
198 _ => "document",
199 },
200 _ => "document",
201 },
202 }
203}
204
205fn bare_target(s: &str, here: &str, member: Member, op: &str, field: Option<&str>) -> Result<Json> {
211 if s.contains('#') {
212 let message = format!(
213 "{here}: {} is a locator, not a target — a bare target is a document path, a `b_`/`d_`/`n_` id or `$new.n`",
214 Json::String(s.to_owned())
215 );
216 return Err(SurfaceError::with_data(
217 "filter_invalid",
218 message.clone(),
219 json!({ "reason": message, "hint": "arguments", "fix": crate::fixes::LOCATOR }),
220 ));
221 }
222 if omgbase_mutate::parse_new_symbol(s).is_some() {
223 return Ok(json!({ "kind": symbol_kind(member, op, field), "id": s }));
224 }
225 if is_id_ref(s, "b") {
226 return Ok(json!({ "kind": "block", "id": s }));
227 }
228 if is_id_ref(s, "d") {
229 return Ok(json!({ "kind": "document", "id": s }));
230 }
231 if is_node_id(s) {
232 return Ok(json!({ "kind": "node", "id": s }));
233 }
234 if path_looking(s) {
235 return Ok(json!({ "kind": "document", "path": s }));
236 }
237 Err(shape_error(format!(
238 "{here}: a target is {{ kind: …, … }} or a bare document path, a `b_`/`d_`/`n_` id or `$new.n` (got {})",
239 Json::String(s.to_owned())
240 )))
241}
242
243fn desugar_expression(src: &str, here: &str) -> Result<Json> {
249 let wrapped = format!("{WRAP_HEAD}{src}{WRAP_TAIL}");
250 let head = WRAP_HEAD.chars().count();
251 let shift = LEADER.chars().count() as isize - head as isize;
252 let q = oqx::parse_string(&wrapped)
253 .map_err(|e| refused(format!("{here}: {}", shift_offsets(&e.message, shift))))?;
254 let one = q.select.len() == 1
255 && matches!(&q.source, Expr::Ident { name, .. } if name == "docs")
256 && q.from.is_empty()
257 && q.r#where.is_none()
258 && q.order_by.is_none()
259 && q.follow.is_none()
260 && q.frontier.is_none()
261 && q.limit.is_none()
262 && q.offset.is_none()
263 && !q.distinct
264 && !q.values
265 && q.consumer == Consumer::Collect;
266 if !one {
267 return Err(refused(format!(
268 "{here}: `oqx` must be one expression over the hit, not a query"
269 )));
270 }
271 let folded = match q.select.first() {
272 Some(SelectItem::Field { expr, .. }) => match expr {
273 Expr::Lit {
274 value: oqx::Value::Str(text),
275 ..
276 } => Some(text.clone()),
277 Expr::Template { parts, .. } if parts.len() == 1 => match &parts[0] {
278 TemplatePart::Text(text) => Some(text.clone()),
279 TemplatePart::Expr(_) => None,
280 },
281 _ => None,
282 },
283 _ => None,
284 };
285 Ok(match folded {
286 Some(text) => Json::String(text),
287 None => json!({ "oqx": src }),
288 })
289}
290
291pub(crate) fn literal_of(src: &str) -> String {
295 match desugar_expression(src, "") {
296 Ok(Json::String(text)) => text,
297 _ => src.to_owned(),
298 }
299}
300
301#[derive(Clone, Debug, Default, PartialEq, Eq)]
304struct Desugared {
305 symbols: Vec<Member>,
306}
307
308fn refuse_leader(v: &Json, here: &str, request: Option<&fixes::Request>) -> Result<()> {
312 match v {
313 Json::String(_) if leader_of(v).is_some() => {
314 let message =
315 format!("{here}: `-> ` is not allowed here — this member takes no expression");
316 Err(match request.and_then(|r| fixes::literal_slot(r, here)) {
317 Some(fix) => refused_with(message, json!({ "fix": fix })),
318 None => refused(message),
319 })
320 }
321 Json::Array(a) => a
322 .iter()
323 .enumerate()
324 .try_for_each(|(k, x)| refuse_leader(x, &format!("{here}[{k}]"), request)),
325 Json::Object(o) => o
326 .iter()
327 .try_for_each(|(k, x)| refuse_leader(x, &format!("{here}.{k}"), request)),
328 _ => Ok(()),
329 }
330}
331
332fn desugar_effect(
339 raw: &Json,
340 index: usize,
341 request: Option<&fixes::Request>,
342) -> Result<(Json, Desugared)> {
343 let mut d = Desugared::default();
344 let Some(o) = raw.as_object() else {
345 return Ok((raw.clone(), d));
346 };
347 let Some(op) = o.get("op").and_then(Json::as_str).map(str::to_owned) else {
348 return Ok((raw.clone(), d));
349 };
350 let here = format!("effects[{index}]");
351 let field = o.get("field").and_then(Json::as_str).map(str::to_owned);
352 let mut out = o.clone();
353 let mut slot_keys: Vec<&str> = vec!["op"];
354
355 fn leader_at(
356 out: &mut Map<String, Json>,
357 slot_keys: &mut Vec<&str>,
358 key: &'static str,
359 here: &str,
360 ) -> Result<()> {
361 slot_keys.push(key);
362 if let Some(src) = out.get(key).and_then(leader_of).map(str::to_owned) {
363 let v = desugar_expression(&src, &format!("{here}.{key}"))?;
364 out.insert(key.to_owned(), v);
365 }
366 Ok(())
367 }
368 fn one_target(
372 v: &Json,
373 here: &str,
374 member: Member,
375 op: &str,
376 field: Option<&str>,
377 d: &mut Desugared,
378 request: Option<&fixes::Request>,
379 ) -> Result<Json> {
380 match v {
381 Json::String(s) => {
382 let literal = match leader_of(v) {
383 Some(src) => match desugar_expression(src, here)? {
384 Json::String(folded) => folded,
385 directive => return Ok(directive),
386 },
387 None => s.clone(),
388 };
389 let t = bare_target(&literal, here, member, op, field)?;
390 if omgbase_mutate::parse_new_symbol(&literal).is_some() {
391 d.symbols.push(member);
392 }
393 Ok(t)
394 }
395 Json::Object(t) => {
396 let mut out = t.clone();
397 for m in ["id", "path"] {
398 if let Some(src) = t.get(m).and_then(leader_of) {
399 let v = desugar_expression(src, &format!("{here}.{m}"))?;
400 out.insert(m.to_owned(), v);
401 }
402 }
403 for (k, x) in t {
404 if k != "id" && k != "path" {
405 refuse_leader(x, &format!("{here}.{k}"), request)?;
406 }
407 }
408 Ok(Json::Object(out))
409 }
410 other => Ok(other.clone()),
411 }
412 }
413 let target = |out: &mut Map<String, Json>,
414 slot_keys: &mut Vec<&str>,
415 key: &'static str,
416 member: Member,
417 d: &mut Desugared|
418 -> Result<()> {
419 slot_keys.push(key);
420 if let Some(v) = out.get(key).cloned() {
421 let t = one_target(
422 &v,
423 &format!("{here}.{key}"),
424 member,
425 &op,
426 field.as_deref(),
427 d,
428 request,
429 )?;
430 out.insert(key.to_owned(), t);
431 }
432 Ok(())
433 };
434 let targets =
435 |out: &mut Map<String, Json>, slot_keys: &mut Vec<&str>, d: &mut Desugared| -> Result<()> {
436 slot_keys.push("targets");
437 if let Some(Json::Array(list)) = out.get("targets").cloned() {
438 let mut items = Vec::with_capacity(list.len());
439 for (k, v) in list.iter().enumerate() {
440 items.push(one_target(
441 v,
442 &format!("{here}.targets[{k}]"),
443 Member::Targets(k),
444 &op,
445 field.as_deref(),
446 d,
447 request,
448 )?);
449 }
450 out.insert("targets".to_owned(), Json::Array(items));
451 }
452 Ok(())
453 };
454 let at = |out: &mut Map<String, Json>, slot_keys: &mut Vec<&str>| -> Result<()> {
455 slot_keys.push("at");
456 let Some(a) = out.get("at").cloned() else {
457 return Ok(());
458 };
459 let Some(a) = a.as_object() else {
460 return refuse_leader(&a, &format!("{here}.at"), request);
461 };
462 let mut next = a.clone();
463 for m in ["before", "after"] {
464 if let Some(src) = a.get(m).and_then(leader_of) {
465 let v = desugar_expression(src, &format!("{here}.at.{m}"))?;
466 next.insert(m.to_owned(), v);
467 }
468 }
469 for (k, x) in a {
470 if k != "before" && k != "after" {
471 refuse_leader(x, &format!("{here}.at.{k}"), request)?;
472 }
473 }
474 out.insert("at".to_owned(), Json::Object(next));
475 Ok(())
476 };
477 match op.as_str() {
478 "create" => {
479 leader_at(&mut out, &mut slot_keys, "path", &here)?;
480 leader_at(&mut out, &mut slot_keys, "markdown", &here)?;
481 slot_keys.push("frontmatter");
482 if let Some(Json::Object(fm)) = o.get("frontmatter") {
483 let mut next = fm.clone();
484 for (k, v) in fm {
485 if let Some(src) = leader_of(v) {
486 let x = desugar_expression(src, &format!("{here}.frontmatter.{k}"))?;
487 next.insert(k.clone(), x);
488 }
489 }
490 out.insert("frontmatter".to_owned(), Json::Object(next));
491 }
492 }
493 "insert" => {
494 target(&mut out, &mut slot_keys, "into", Member::Into, &mut d)?;
495 at(&mut out, &mut slot_keys)?;
496 leader_at(&mut out, &mut slot_keys, "markdown", &here)?;
497 }
498 "set" => {
499 target(&mut out, &mut slot_keys, "target", Member::Target, &mut d)?;
500 leader_at(&mut out, &mut slot_keys, "value", &here)?;
501 }
502 "unset" => target(&mut out, &mut slot_keys, "target", Member::Target, &mut d)?,
503 "move" => {
504 if o.contains_key("targets") || o.contains_key("into") {
505 targets(&mut out, &mut slot_keys, &mut d)?;
506 target(&mut out, &mut slot_keys, "into", Member::Into, &mut d)?;
507 at(&mut out, &mut slot_keys)?;
508 } else {
509 target(&mut out, &mut slot_keys, "target", Member::Target, &mut d)?;
510 leader_at(&mut out, &mut slot_keys, "to", &here)?;
511 }
512 }
513 "remove" => {
514 if o.contains_key("targets") {
515 targets(&mut out, &mut slot_keys, &mut d)?;
516 } else {
517 target(&mut out, &mut slot_keys, "target", Member::Target, &mut d)?;
518 }
519 }
520 "split" => target(&mut out, &mut slot_keys, "target", Member::Target, &mut d)?,
522 "merge" => {
523 targets(&mut out, &mut slot_keys, &mut d)?;
524 leader_at(&mut out, &mut slot_keys, "separator", &here)?;
525 }
526 "retarget" => {}
528 _ => return Ok((Json::Object(out), d)),
530 }
531 for (key, v) in o {
532 if !slot_keys.contains(&key.as_str()) {
533 refuse_leader(v, &format!("{here}.{key}"), request)?;
534 }
535 }
536 Ok((Json::Object(out), d))
537}
538
539fn needs_sugar(effects: &Json) -> bool {
545 let Some(list) = effects.as_array() else {
546 return false;
547 };
548 list.iter().any(|e| {
549 let Some(o) = e.as_object() else {
550 return false;
551 };
552 o.get("target").is_some_and(Json::is_string)
553 || o.get("into").is_some_and(Json::is_string)
554 || o.get("targets")
555 .and_then(Json::as_array)
556 .is_some_and(|a| a.iter().any(Json::is_string))
557 || contains_leader(e)
558 || contains_oqx(e)
559 })
560}
561
562#[derive(Clone, Debug, PartialEq)]
570pub struct SelectRequest {
571 pub select: Json,
572 pub templates: Json,
573 pub expect: Option<Json>,
574}
575
576impl SelectRequest {
577 pub fn from_args(args: &Json) -> Result<Option<Self>> {
583 let select = args.get("select").filter(|v| !v.is_null());
584 let expect = args.get("expect").filter(|v| !v.is_null());
585 let effects = args.get("effects").cloned().unwrap_or(Json::Null);
586 if select.is_none() && expect.is_none() {
587 if needs_sugar(&effects) {
588 return Ok(Some(Self {
589 select: Json::Null,
590 templates: effects,
591 expect: None,
592 }));
593 }
594 return Ok(None);
595 }
596 let Some(select) = select else {
597 return Err(refused(
598 "`expect` bounds a selection — pass `select` with it",
599 ));
600 };
601 Ok(Some(Self {
602 select: select.clone(),
603 templates: effects,
604 expect: expect.cloned(),
605 }))
606 }
607
608 #[must_use]
610 pub fn has_selector(&self) -> bool {
611 !self.select.is_null()
612 }
613
614 #[must_use]
616 pub fn select_str(&self) -> &str {
617 self.select.as_str().unwrap_or_default()
618 }
619
620 #[must_use]
622 pub fn templates(&self) -> usize {
623 self.templates.as_array().map_or(0, Vec::len)
624 }
625}
626
627fn validate_expect(v: Option<&Json>) -> Result<Option<SelectionExpect>> {
629 let Some(v) = v else {
630 return Ok(None);
631 };
632 let obj = v
633 .as_object()
634 .ok_or_else(|| refused("`expect` must be an object { max }"))?;
635 let extra: Vec<String> = obj
636 .keys()
637 .filter(|k| *k != "max")
638 .map(|k| format!("`{k}`"))
639 .collect();
640 if !extra.is_empty() {
641 return Err(refused(format!(
642 "`expect`: unknown key{} {}",
643 if extra.len() > 1 { "s" } else { "" },
644 extra.join(", ")
645 )));
646 }
647 let max = obj
648 .get("max")
649 .and_then(Json::as_u64)
650 .and_then(|n| usize::try_from(n).ok())
651 .ok_or_else(|| refused("`expect.max` must be a non-negative integer"))?;
652 Ok(Some(SelectionExpect { max }))
653}
654
655#[derive(Clone, Debug, PartialEq, Eq)]
659enum SlotKind {
660 Identity { key: String, member: String },
663 TargetsIdentity { k: usize },
665 WholeTarget { key: String, member: Member },
669 WholeTargets { k: usize },
671 Str { key: String },
673 At { member: String },
675 Value,
677 Frontmatter { key: String },
679}
680
681#[derive(Clone, Debug)]
682struct Slot {
683 name: String,
686 kind: SlotKind,
687 here: String,
689 expression: String,
690 item: SelectItem,
693}
694
695#[derive(Clone, Copy, Debug, PartialEq, Eq)]
697enum DefaultTarget {
698 Target,
699 Into,
700 Targets,
701}
702
703#[derive(Clone, Debug)]
707pub struct Template {
708 index: usize,
709 base: Map<String, Json>,
710 slots: Vec<Slot>,
711 default_target: Option<DefaultTarget>,
712 symbols: Vec<Member>,
713 op: String,
714}
715
716impl Template {
717 #[must_use]
719 pub fn slot_names(&self) -> Vec<String> {
720 self.slots
721 .iter()
722 .map(|s| slot_name(self.index, &s.name))
723 .collect()
724 }
725}
726
727fn slot_name(t: usize, slot: &str) -> String {
728 format!("{SLOT_PREFIX}{t}_{slot}")
729}
730
731fn oqx_of(v: &Json) -> Option<&str> {
733 let obj = v.as_object()?;
734 if obj.len() != 1 {
735 return None;
736 }
737 obj.get("oqx")?.as_str()
738}
739
740fn contains_oqx(v: &Json) -> bool {
741 if oqx_of(v).is_some() {
742 return true;
743 }
744 match v {
745 Json::Array(a) => a.iter().any(contains_oqx),
746 Json::Object(o) => o.values().any(contains_oqx),
747 _ => false,
748 }
749}
750
751fn row_ref(v: &Json) -> Option<(&str, &str)> {
754 let o = v.as_object()?;
755 if o.len() != 2 {
756 return None;
757 }
758 Some((o.get(HIT_ID)?.as_str()?, o.get(HIT_PATH)?.as_str()?))
759}
760
761fn describe(v: Option<&Json>) -> String {
763 match v {
764 None | Some(Json::Null) => "absent".to_owned(),
765 Some(Json::String(s)) => format!("a string ({})", Json::String(s.clone())),
766 Some(Json::Number(n)) => format!(
767 "a number ({})",
768 n.as_f64().map_or_else(|| n.to_string(), js_number)
769 ),
770 Some(Json::Bool(b)) => format!("a boolean ({b})"),
771 Some(Json::Array(a)) => format!("a list of {}", a.len()),
772 Some(Json::Object(_)) => "an object".to_owned(),
773 }
774}
775
776fn shift_offsets(message: &str, delta: isize) -> String {
779 const MARK: &str = "(at offset ";
780 let Some(i) = message.find(MARK) else {
781 return message.to_owned();
782 };
783 let rest = &message[i + MARK.len()..];
784 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
785 if digits.is_empty() {
786 return message.to_owned();
787 }
788 let n: isize = digits.parse().unwrap_or(0);
789 format!(
790 "{}{MARK}{}{}",
791 &message[..i],
792 (n + delta).max(0),
793 &rest[digits.len()..]
794 )
795}
796
797fn parse_expression(source: &str, here: &str) -> Result<SelectItem> {
800 let wrapped = format!("{WRAP_HEAD}{source}{WRAP_TAIL}");
801 let head = WRAP_HEAD.chars().count();
802 let mut q = oqx::parse_string(&wrapped).map_err(|e| {
803 refused(format!(
804 "{here}: {}",
805 shift_offsets(&e.message, -(head as isize))
806 ))
807 })?;
808 let one = q.select.len() == 1
809 && matches!(&q.source, Expr::Ident { name, .. } if name == "docs")
810 && q.from.is_empty()
811 && q.r#where.is_none()
812 && q.order_by.is_none()
813 && q.follow.is_none()
814 && q.frontier.is_none()
815 && q.limit.is_none()
816 && q.offset.is_none()
817 && !q.distinct
818 && !q.values
819 && q.consumer == Consumer::Collect;
820 if !one {
821 return Err(refused(format!(
822 "{here}: `oqx` must be one expression over the hit, not a query"
823 )));
824 }
825 Ok(q.select.pop().expect("one item"))
826}
827
828fn renamed(item: &SelectItem, name: &str) -> SelectItem {
829 match item {
830 SelectItem::Field {
831 expr, lift, span, ..
832 } => SelectItem::Field {
833 name: name.to_owned(),
834 expr: expr.clone(),
835 lift: *lift,
836 span: *span,
837 },
838 SelectItem::Collect { op, span, .. } => SelectItem::Collect {
839 name: name.to_owned(),
840 op: op.clone(),
841 span: *span,
842 },
843 }
844}
845
846fn hit_kind(root: Target) -> &'static str {
848 match root {
849 Target::Docs => "document",
850 Target::Blocks => "block",
851 Target::Nodes => "node",
852 Target::Links => "link",
853 }
854}
855
856fn hit_ref(root: Target, id: &str) -> Json {
857 json!({ "kind": hit_kind(root), "id": id })
858}
859
860fn needs_selector(where_: &str) -> SurfaceError {
863 refused(format!(
864 "{where_}: an expression target needs a selector — without `select` there is no row to read it from"
865 ))
866}
867
868struct Admit<'a> {
870 here: String,
871 raw: &'a Map<String, Json>,
872 root: Target,
873 plain: bool,
876 base: Map<String, Json>,
877 probe: Map<String, Json>,
878 slots: Vec<Slot>,
879 slot_keys: Vec<&'static str>,
881}
882
883impl Admit<'_> {
884 fn push(&mut self, name: String, kind: SlotKind, at: &str, expression: &str) -> Result<()> {
885 let here = format!("{}.{at}", self.here);
886 let item = parse_expression(expression, &here)?;
887 self.slots.push(Slot {
888 name,
889 kind,
890 here,
891 expression: expression.to_owned(),
892 item,
893 });
894 Ok(())
895 }
896
897 fn string_slot(&mut self, key: &'static str) -> Result<()> {
899 self.slot_keys.push(key);
900 let Some(src) = self.raw.get(key).and_then(oqx_of) else {
901 return Ok(());
902 };
903 let src = src.to_owned();
904 self.base.remove(key);
905 self.probe.insert(key.to_owned(), json!(PROBE));
906 self.push(
907 key.to_owned(),
908 SlotKind::Str {
909 key: key.to_owned(),
910 },
911 key,
912 &src,
913 )
914 }
915
916 fn value_slot(&mut self) -> Result<()> {
918 self.slot_keys.push("value");
919 let Some(src) = self.raw.get("value").and_then(oqx_of) else {
920 return Ok(());
921 };
922 let src = src.to_owned();
923 self.base.remove("value");
924 self.probe.insert("value".to_owned(), Json::Null);
925 self.push("value".to_owned(), SlotKind::Value, "value", &src)
926 }
927
928 fn target_slot(
933 &mut self,
934 key: &'static str,
935 defaultable: bool,
936 probe_kind: &str,
937 primary: bool,
938 ) -> Result<bool> {
939 self.slot_keys.push(key);
940 let Some(t) = self.raw.get(key) else {
941 if !defaultable || self.plain {
942 return Ok(false);
943 }
944 self.probe.insert(key.to_owned(), hit_ref(self.root, PROBE));
945 return Ok(true);
946 };
947 if let Some(src) = oqx_of(t).map(str::to_owned) {
948 if self.plain && primary {
949 return Err(needs_selector(&format!("{}.{key}", self.here)));
950 }
951 self.base.remove(key);
952 self.probe
953 .insert(key.to_owned(), json!({ "kind": probe_kind, "id": PROBE }));
954 let member = if key == "into" {
955 Member::Into
956 } else {
957 Member::Target
958 };
959 self.push(
960 key.to_owned(),
961 SlotKind::WholeTarget {
962 key: key.to_owned(),
963 member,
964 },
965 key,
966 &src,
967 )?;
968 return Ok(false);
969 }
970 if let Some(t) = t.as_object() {
971 let id_x = t.get("id").and_then(oqx_of).map(str::to_owned);
972 let path_x = t.get("path").and_then(oqx_of).map(str::to_owned);
973 if id_x.is_some() && path_x.is_some() {
974 return Err(refused(format!(
975 "{}.{key}: give `id` or `path` as an expression, not both",
976 self.here
977 )));
978 }
979 if let Some((member, src)) = id_x
980 .map(|s| ("id", s))
981 .or_else(|| path_x.map(|s| ("path", s)))
982 {
983 if self.plain && primary {
984 return Err(needs_selector(&format!("{}.{key}", self.here)));
985 }
986 let mut literal = t.clone();
987 literal.remove(member);
988 self.base.insert(key.to_owned(), Json::Object(literal));
989 if let Some(p) = self.probe.get_mut(key).and_then(Json::as_object_mut) {
990 p.insert(member.to_owned(), json!(PROBE));
991 }
992 self.push(
993 key.to_owned(),
994 SlotKind::Identity {
995 key: key.to_owned(),
996 member: member.to_owned(),
997 },
998 &format!("{key}.{member}"),
999 &src,
1000 )?;
1001 }
1002 for (k, v) in t {
1003 if k != "id" && k != "path" && contains_oqx(v) {
1004 return Err(refused(format!(
1005 "{}.{key}.{k}: `{{ oqx }}` is not allowed here",
1006 self.here
1007 )));
1008 }
1009 }
1010 }
1011 Ok(false)
1012 }
1013
1014 fn targets_slot(&mut self, defaultable: bool) -> Result<bool> {
1018 self.slot_keys.push("targets");
1019 let Some(list) = self.raw.get("targets") else {
1020 if !defaultable || self.plain {
1021 return Ok(false);
1022 }
1023 self.probe
1024 .insert("targets".to_owned(), json!([hit_ref(self.root, PROBE)]));
1025 return Ok(true);
1026 };
1027 let Some(list) = list.as_array() else {
1028 return Ok(false);
1029 };
1030 for (k, t) in list.iter().enumerate() {
1031 if let Some(src) = oqx_of(t).map(str::to_owned) {
1032 if self.plain {
1033 return Err(needs_selector(&format!("{}.targets[{k}]", self.here)));
1034 }
1035 if let Some(arr) = self.base.get_mut("targets").and_then(Json::as_array_mut) {
1036 arr[k] = json!({});
1037 }
1038 if let Some(arr) = self.probe.get_mut("targets").and_then(Json::as_array_mut) {
1039 arr[k] = json!({ "kind": "block", "id": PROBE });
1040 }
1041 self.push(
1042 format!("targets_{k}"),
1043 SlotKind::WholeTargets { k },
1044 &format!("targets[{k}]"),
1045 &src,
1046 )?;
1047 continue;
1048 }
1049 let Some(t) = t.as_object() else {
1050 continue;
1051 };
1052 if t.get("path").and_then(oqx_of).is_some() {
1053 return Err(refused(format!(
1054 "{}.targets[{k}]: a block target has `id`, not `path`",
1055 self.here
1056 )));
1057 }
1058 if let Some(src) = t.get("id").and_then(oqx_of).map(str::to_owned) {
1059 if self.plain {
1060 return Err(needs_selector(&format!("{}.targets[{k}].id", self.here)));
1061 }
1062 let mut literal = t.clone();
1063 literal.remove("id");
1064 if let Some(arr) = self.base.get_mut("targets").and_then(Json::as_array_mut) {
1065 arr[k] = Json::Object(literal);
1066 }
1067 if let Some(p) = self
1068 .probe
1069 .get_mut("targets")
1070 .and_then(Json::as_array_mut)
1071 .and_then(|a| a.get_mut(k))
1072 .and_then(Json::as_object_mut)
1073 {
1074 p.insert("id".to_owned(), json!(PROBE));
1075 }
1076 self.push(
1077 format!("targets_{k}"),
1078 SlotKind::TargetsIdentity { k },
1079 &format!("targets[{k}].id"),
1080 &src,
1081 )?;
1082 }
1083 for (key, v) in t {
1084 if key != "id" && contains_oqx(v) {
1085 return Err(refused(format!(
1086 "{}.targets[{k}].{key}: `{{ oqx }}` is not allowed here",
1087 self.here
1088 )));
1089 }
1090 }
1091 }
1092 Ok(false)
1093 }
1094
1095 fn at_slot(&mut self) -> Result<()> {
1097 self.slot_keys.push("at");
1098 let Some(at) = self.raw.get("at").and_then(Json::as_object) else {
1099 return Ok(());
1100 };
1101 for member in ["before", "after"] {
1102 let Some(src) = at.get(member).and_then(oqx_of).map(str::to_owned) else {
1103 continue;
1104 };
1105 self.base.insert("at".to_owned(), json!({}));
1106 self.probe.insert("at".to_owned(), json!({ member: PROBE }));
1107 self.push(
1108 "at".to_owned(),
1109 SlotKind::At {
1110 member: member.to_owned(),
1111 },
1112 &format!("at.{member}"),
1113 &src,
1114 )?;
1115 }
1116 Ok(())
1117 }
1118
1119 fn frontmatter_slots(&mut self) -> Result<()> {
1120 self.slot_keys.push("frontmatter");
1121 let Some(fm) = self.raw.get("frontmatter").and_then(Json::as_object) else {
1122 return Ok(());
1123 };
1124 for (j, (k, v)) in fm.iter().enumerate() {
1125 let Some(src) = oqx_of(v).map(str::to_owned) else {
1126 continue;
1127 };
1128 if let Some(b) = self
1129 .base
1130 .get_mut("frontmatter")
1131 .and_then(Json::as_object_mut)
1132 {
1133 b.remove(k);
1134 }
1135 if let Some(p) = self
1136 .probe
1137 .get_mut("frontmatter")
1138 .and_then(Json::as_object_mut)
1139 {
1140 p.insert(k.clone(), Json::Null);
1141 }
1142 self.push(
1143 format!("fm_{j}"),
1144 SlotKind::Frontmatter { key: k.clone() },
1145 &format!("frontmatter.{k}"),
1146 &src,
1147 )?;
1148 }
1149 Ok(())
1150 }
1151}
1152
1153fn admit_template(
1159 index: usize,
1160 raw: &Json,
1161 root: Target,
1162 plain: bool,
1163 sugar: &Desugared,
1164) -> Result<Template> {
1165 let here = format!("effects[{index}]");
1166 let Some(op) = raw
1167 .as_object()
1168 .and_then(|o| o.get("op"))
1169 .and_then(Json::as_str)
1170 else {
1171 return Err(refused(format!(
1172 "{here}: a template is an effect object whose `op` is one of {OPS}"
1173 )));
1174 };
1175 let raw = raw.as_object().expect("checked");
1176 if op == "retarget" {
1177 return Err(refused(format!(
1178 "{here}: `retarget` is not row-shaped — it cannot be a selector template"
1179 )));
1180 }
1181 if !matches!(
1182 op,
1183 "create" | "insert" | "set" | "unset" | "move" | "remove" | "split" | "merge"
1184 ) {
1185 return Err(refused(format!(
1186 "{here}: `op` must be one of {OPS} (got \"{op}\")"
1187 )));
1188 }
1189 let mut a = Admit {
1190 here: here.clone(),
1191 raw,
1192 root,
1193 plain,
1194 base: raw.clone(),
1195 probe: raw.clone(),
1196 slots: Vec::new(),
1197 slot_keys: Vec::new(),
1198 };
1199 let mut default_target = None;
1200 match op {
1201 "create" => {
1202 a.string_slot("path")?;
1203 a.string_slot("markdown")?;
1204 a.frontmatter_slots()?;
1205 }
1206 "insert" => {
1207 if a.target_slot("into", true, "document", true)? {
1208 default_target = Some(DefaultTarget::Into);
1209 }
1210 a.at_slot()?;
1211 a.string_slot("markdown")?;
1212 }
1213 "set" => {
1214 if a.target_slot("target", true, "document", true)? {
1215 default_target = Some(DefaultTarget::Target);
1216 }
1217 a.value_slot()?;
1218 }
1219 "unset" => {
1220 if a.target_slot("target", true, "document", true)? {
1221 default_target = Some(DefaultTarget::Target);
1222 }
1223 }
1224 "move" => {
1225 if raw.contains_key("into") {
1226 if a.targets_slot(true)? {
1228 default_target = Some(DefaultTarget::Targets);
1229 }
1230 a.target_slot("into", false, "document", false)?;
1231 a.at_slot()?;
1232 } else {
1233 if a.target_slot("target", true, "document", true)? {
1234 default_target = Some(DefaultTarget::Target);
1235 }
1236 a.string_slot("to")?;
1237 }
1238 }
1239 "remove" => {
1240 let doc_form = raw.contains_key("target")
1241 || (!raw.contains_key("targets") && root == Target::Docs);
1242 if doc_form {
1243 if a.target_slot("target", true, "document", true)? {
1244 default_target = Some(DefaultTarget::Target);
1245 }
1246 } else if a.targets_slot(true)? {
1247 default_target = Some(DefaultTarget::Targets);
1248 }
1249 }
1250 "split" => {
1251 if a.target_slot("target", true, "block", true)? {
1252 default_target = Some(DefaultTarget::Target);
1253 }
1254 }
1255 "merge" => {
1256 if !raw.contains_key("targets") {
1257 return Err(refused(format!(
1258 "{here}: `merge` needs `targets` — one hit cannot be merged with itself"
1259 )));
1260 }
1261 a.targets_slot(false)?;
1262 a.string_slot("separator")?;
1263 }
1264 _ => unreachable!("the ops above"),
1265 }
1266 for (key, v) in raw {
1268 if key == "op" || a.slot_keys.contains(&key.as_str()) {
1269 continue;
1270 }
1271 if contains_oqx(v) {
1272 return Err(refused(format!(
1273 "{here}.{key}: `{{ oqx }}` is not allowed here"
1274 )));
1275 }
1276 }
1277 let Admit {
1278 base, probe, slots, ..
1279 } = a;
1280 Effect::validate(&Json::Object(probe), index).map_err(shape_error)?;
1282 Ok(Template {
1283 index,
1284 base,
1285 slots,
1286 default_target,
1287 symbols: sugar.symbols.clone(),
1288 op: op.to_owned(),
1289 })
1290}
1291
1292#[derive(Clone, Debug)]
1297pub struct Compiled {
1298 pub raw: Query,
1299 pub root: Target,
1300 pub templates: Vec<Template>,
1301 pub rewritten: Query,
1302 pub expect: Option<SelectionExpect>,
1303 pub bound: usize,
1305 pub request: fixes::Request,
1308}
1309
1310impl Compiled {
1311 #[must_use]
1313 pub fn implicit(&self) -> bool {
1314 self.expect.is_none()
1315 }
1316}
1317
1318fn admit_selector(select: &str, request: Option<&fixes::Request>) -> Result<(Query, Target)> {
1321 let raw = oqx::parse_string(select).map_err(|e| refused(format!("select: {}", e.message)))?;
1322 let root_fix = |links: bool| -> Json {
1325 let span = raw.source.span();
1326 match request {
1327 Some(r) => json!({ "fix": fixes::root_fix(r, select, (span.start, span.end), links) }),
1328 None => json!({}),
1329 }
1330 };
1331 check_root_spellings(&raw).map_err(|e| refused(format!("select: {}", e.message)))?;
1332 check_reserved_aliases(&raw).map_err(|e| refused(format!("select: {}", e.message)))?;
1333 if !matches!(
1334 raw.consumer,
1335 Consumer::Collect | Consumer::First | Consumer::Single
1336 ) {
1337 return Err(refused(format!(
1338 "select: `{}` yields no rows — a selector's consumer is collect, first or single",
1339 raw.consumer.as_str()
1340 )));
1341 }
1342 if raw.values {
1343 return Err(refused("select: a selector yields rows, not `values`"));
1344 }
1345 let root = match root_target(&raw.source) {
1346 None => {
1347 return Err(refused_with(
1348 "select: a selector scans a root collection — `from docs`, `from blocks` or `from nodes`",
1349 root_fix(false),
1350 ));
1351 }
1352 Some(Target::Links) => {
1353 return Err(refused_with(
1354 "select: `links` rows have no effect target — select docs, blocks or nodes",
1355 root_fix(true),
1356 ));
1357 }
1358 Some(t) => t,
1359 };
1360 if raw.follow.is_some() {
1361 return Err(refused(
1362 "select: `follow` is not allowed in a selector — a hit must be a row of the root",
1363 ));
1364 }
1365 if !raw.from.is_empty() {
1366 return Err(refused(
1367 "select: a body `from` re-projects the rows — a hit must be a row of the root",
1368 ));
1369 }
1370 if raw.offset.is_some() {
1371 return Err(refused(
1372 "select: `offset` is not allowed — a selection is a set, not a page",
1373 ));
1374 }
1375 if raw.distinct {
1376 return Err(refused("select: `distinct` is not allowed in a selector"));
1377 }
1378 Ok((raw, root))
1379}
1380
1381pub fn compile(req: &SelectRequest) -> Result<Compiled> {
1386 let expect = validate_expect(req.expect.as_ref())?;
1387 let select = req
1388 .select
1389 .as_str()
1390 .filter(|s| !s.trim().is_empty())
1391 .ok_or_else(|| refused("`select` must be an OQX query (a non-empty string)"))?;
1392 let templates = req
1393 .templates
1394 .as_array()
1395 .ok_or_else(|| refused("`effects` must be an array of effect templates"))?;
1396 if templates.is_empty() {
1397 return Err(refused("`effects` must name at least one template"));
1398 }
1399 let request = fixes::Request {
1401 select: Some(req.select.clone()),
1402 effects: req.templates.clone(),
1403 expect: req.expect.clone(),
1404 };
1405 let (raw, root) = admit_selector(select, Some(&request))?;
1406 let templates = templates
1407 .iter()
1408 .enumerate()
1409 .map(|(t, v)| {
1410 let (v, d) = desugar_effect(v, t, Some(&request))?;
1411 admit_template(t, &v, root, false, &d)
1412 })
1413 .collect::<Result<Vec<_>>>()?;
1414 let mut rewritten = raw.clone();
1415 for tpl in &templates {
1416 for slot in &tpl.slots {
1417 rewritten
1418 .select
1419 .push(renamed(&slot.item, &slot_name(tpl.index, &slot.name)));
1420 }
1421 }
1422 let bound = expect.map_or(SELECTION_CEILING, |e| e.max);
1423 Ok(Compiled {
1424 raw,
1425 root,
1426 templates,
1427 rewritten,
1428 expect,
1429 bound,
1430 request,
1431 })
1432}
1433
1434pub type Row = (String, String);
1438
1439fn slot_data(k: usize, t: Option<usize>, row: &Row, expression: &str) -> Json {
1442 let mut d = json!({
1443 "effect_index": k,
1444 "row": { "id": row.0, "path": row.1 },
1445 "expression": expression,
1446 });
1447 if let Some(t) = t {
1448 d["template_index"] = json!(t);
1449 }
1450 d
1451}
1452
1453struct Origin<'a> {
1456 k: usize,
1457 template: Option<usize>,
1458 row: &'a Row,
1459 root: Target,
1461 request: Option<&'a fixes::Request>,
1463}
1464
1465fn identity_value<'v>(v: Option<&'v Json>, s: &Slot, o: &Origin<'_>) -> Result<&'v Json> {
1469 let data = || slot_data(o.k, o.template, o.row, &s.expression);
1470 let absent = v.is_none_or(Json::is_null);
1471 if absent || v.and_then(Json::as_array).is_some_and(Vec::is_empty) {
1472 return Err(MutationError::with_data(
1473 ErrorCode::TargetMissing,
1474 format!(
1475 "{}: the expression yielded no identity for row {}",
1476 s.here, o.row.0
1477 ),
1478 data(),
1479 )
1480 .into());
1481 }
1482 let v = v.expect("not absent");
1483 if let Some(a) = v.as_array() {
1484 let mut d = data();
1485 d["count"] = json!(a.len());
1486 let first = a
1490 .first()
1491 .and_then(|h| row_ref(h).map(|(id, _)| id).or_else(|| h.as_str()));
1492 if let (Some(req), Some(first)) = (o.request, first)
1493 && let Some(fix) =
1494 fixes::ambiguous_slot(req, &s.here, first, (o.root.as_str(), &o.row.0))
1495 {
1496 d["fix"] = json!(fix);
1497 }
1498 return Err(MutationError::with_data(
1499 ErrorCode::AmbiguousTarget,
1500 format!(
1501 "{}: the expression yielded {} identities for row {}; a target resolves to exactly one",
1502 s.here,
1503 a.len(),
1504 o.row.0
1505 ),
1506 d,
1507 )
1508 .into());
1509 }
1510 if v.as_str().is_none() && row_ref(v).is_none() {
1511 return Err(refused_with(
1512 format!(
1513 "{}: the expression yielded {}, not an identity (a string or a row)",
1514 s.here,
1515 describe(Some(v))
1516 ),
1517 data(),
1518 ));
1519 }
1520 Ok(v)
1521}
1522
1523fn whole_target(
1527 v: &Json,
1528 s: &Slot,
1529 o: &Origin<'_>,
1530 member: Member,
1531 op: &str,
1532 field: Option<&str>,
1533) -> Result<(Json, bool)> {
1534 let is_row = row_ref(v).is_some();
1535 let r = match row_ref(v) {
1536 Some((id, _)) => id,
1537 None => v
1538 .as_str()
1539 .expect("identity_value admitted a string or a row"),
1540 };
1541 if !is_row
1542 && !path_looking(r)
1543 && !is_target_id(r)
1544 && omgbase_mutate::parse_new_symbol(r).is_none()
1545 {
1546 return Err(refused_with(
1547 format!(
1548 "{}: the expression yielded {}, not an identity (a document path, an id or a row)",
1549 s.here,
1550 describe(Some(v))
1551 ),
1552 slot_data(o.k, o.template, o.row, &s.expression),
1553 ));
1554 }
1555 let t = bare_target(r, &s.here, member, op, field)?;
1556 Ok((t, omgbase_mutate::parse_new_symbol(r).is_some()))
1557}
1558
1559fn bind_one(root: Target, hit: &Json, t: &Template, o: &Origin<'_>) -> Result<(Json, Vec<Member>)> {
1565 let mut e = Json::Object(t.base.clone());
1566 let mut symbols = t.symbols.clone();
1567 match t.default_target {
1568 Some(DefaultTarget::Target) => e["target"] = hit_ref(root, &o.row.0),
1569 Some(DefaultTarget::Into) => e["into"] = hit_ref(root, &o.row.0),
1570 Some(DefaultTarget::Targets) => e["targets"] = json!([hit_ref(root, &o.row.0)]),
1571 None => {}
1572 }
1573 let field = t
1574 .base
1575 .get("field")
1576 .and_then(Json::as_str)
1577 .map(str::to_owned);
1578 for s in &t.slots {
1579 let v = hit.get(slot_name(t.index, &s.name));
1580 let absent = v.is_none_or(Json::is_null);
1581 let data = || slot_data(o.k, o.template, o.row, &s.expression);
1582 match &s.kind {
1583 SlotKind::Identity { .. } | SlotKind::TargetsIdentity { .. } => {
1584 let v = identity_value(v, s, o)?;
1585 let (member, value) = match (v.as_str(), row_ref(v)) {
1588 (Some(s), _) => (None, json!(s)),
1589 (None, Some((id, _))) => (Some("id".to_owned()), json!(id)),
1590 (None, None) => unreachable!("identity_value admitted it"),
1591 };
1592 let (target, written) = match &s.kind {
1593 SlotKind::Identity { key, member } => (e.get_mut(key), member.clone()),
1594 SlotKind::TargetsIdentity { k } => (
1595 e.get_mut("targets")
1596 .and_then(Json::as_array_mut)
1597 .and_then(|a| a.get_mut(*k)),
1598 "id".to_owned(),
1599 ),
1600 _ => unreachable!("identity kinds"),
1601 };
1602 if let Some(obj) = target.and_then(Json::as_object_mut) {
1603 obj.insert(member.unwrap_or(written), value);
1604 }
1605 }
1606 SlotKind::WholeTarget { key, member } => {
1607 let v = identity_value(v, s, o)?;
1608 let (target, symbol) = whole_target(v, s, o, *member, &t.op, field.as_deref())?;
1609 if symbol {
1610 symbols.push(*member);
1611 }
1612 e[key] = target;
1613 }
1614 SlotKind::WholeTargets { k } => {
1615 let v = identity_value(v, s, o)?;
1616 let (target, symbol) = whole_target(v, s, o, Member::Targets(*k), &t.op, None)?;
1617 if symbol {
1618 symbols.push(Member::Targets(*k));
1619 }
1620 if let Some(slot) = e
1621 .get_mut("targets")
1622 .and_then(Json::as_array_mut)
1623 .and_then(|a| a.get_mut(*k))
1624 {
1625 *slot = target;
1626 }
1627 }
1628 SlotKind::Str { .. } | SlotKind::At { .. } => {
1629 if absent {
1630 return Err(refused_with(
1631 format!(
1632 "{}: the expression yielded absent for row {}",
1633 s.here, o.row.0
1634 ),
1635 data(),
1636 ));
1637 }
1638 let Some(text) = v.and_then(Json::as_str) else {
1639 return Err(refused_with(
1640 format!(
1641 "{}: the expression yielded {}, expected a string",
1642 s.here,
1643 describe(v)
1644 ),
1645 data(),
1646 ));
1647 };
1648 match &s.kind {
1649 SlotKind::At { member } => e["at"] = json!({ member: text }),
1650 SlotKind::Str { key } => e[key] = json!(text),
1651 _ => unreachable!("string kinds"),
1652 }
1653 }
1654 SlotKind::Value | SlotKind::Frontmatter { .. } => {
1655 if absent {
1656 return Err(refused_with(
1657 format!(
1658 "{}: the expression yielded absent for row {}{}",
1659 s.here,
1660 o.row.0,
1661 if s.kind == SlotKind::Value {
1662 " — use `unset` to remove a key"
1663 } else {
1664 ""
1665 }
1666 ),
1667 data(),
1668 ));
1669 }
1670 let v = v.expect("not absent").clone();
1671 match &s.kind {
1672 SlotKind::Frontmatter { key } => {
1673 if let Some(fm) = e.get_mut("frontmatter").and_then(Json::as_object_mut) {
1674 fm.insert(key.clone(), v);
1675 }
1676 }
1677 _ => e["value"] = v,
1678 }
1679 }
1680 }
1681 }
1682 Effect::validate(&e, o.k).map_err(shape_error)?;
1683 Ok((e, symbols))
1684}
1685
1686pub type Settle<'a> = dyn FnMut(&mut Json, &[Member], &[Effect]) + 'a;
1691
1692pub fn no_settle(_: &mut Json, _: &[Member], _: &[Effect]) {}
1695
1696pub fn settle_symbols(
1701 store: &mut Store,
1702 ctx: &PlanContext,
1703 doc_store: &dyn DocStore,
1704 e: &mut Json,
1705 members: &[Member],
1706 prefix: &[Effect],
1707) {
1708 if prefix.is_empty() {
1709 return;
1710 }
1711 let Ok(plan) = store.plan_selected(ctx, prefix, None, doc_store) else {
1712 return;
1713 };
1714 for m in members {
1715 let slot = match m {
1716 Member::Target => e.get_mut("target"),
1717 Member::Into => e.get_mut("into"),
1718 Member::Targets(k) => e
1719 .get_mut("targets")
1720 .and_then(Json::as_array_mut)
1721 .and_then(|a| a.get_mut(*k)),
1722 };
1723 let Some(obj) = slot.and_then(Json::as_object_mut) else {
1724 continue;
1725 };
1726 let Some(n) = obj
1727 .get("id")
1728 .and_then(Json::as_str)
1729 .and_then(omgbase_mutate::parse_new_symbol)
1730 else {
1731 continue;
1732 };
1733 if let Some(x) = plan.new_ids.get(n) {
1734 obj.insert("kind".to_owned(), json!(x.kind.as_str()));
1735 }
1736 }
1737}
1738
1739fn finish(
1746 e: &mut Json,
1747 k: usize,
1748 symbols: &[Member],
1749 prefix: &[Effect],
1750 settle: &mut Settle<'_>,
1751) -> Result<Effect> {
1752 if symbols.is_empty() || prefix.is_empty() {
1753 return Effect::validate(e, k).map_err(shape_error);
1754 }
1755 Effect::validate(e, k).map_err(shape_error)?;
1756 settle(e, symbols, prefix);
1757 Effect::validate(e, k).map_err(|m| {
1758 let mut err = shape_error(m);
1759 if let Some(d) = err.data.as_mut().and_then(Json::as_object_mut) {
1760 d.insert("effect_index".to_owned(), json!(k));
1761 }
1762 err
1763 })
1764}
1765
1766fn row_of(hit: &Json) -> Row {
1768 (
1769 hit.get(HIT_ID)
1770 .and_then(Json::as_str)
1771 .unwrap_or_default()
1772 .to_owned(),
1773 hit.get(HIT_PATH)
1774 .and_then(Json::as_str)
1775 .unwrap_or_default()
1776 .to_owned(),
1777 )
1778}
1779
1780#[derive(Clone, Debug, Default)]
1783pub struct Bound {
1784 pub effects: Vec<Effect>,
1785 pub rows: Vec<Row>,
1786}
1787
1788pub fn bind(c: &Compiled, hits: &[Json], settle: &mut Settle<'_>) -> Result<Bound> {
1791 let mut b = Bound::default();
1792 for hit in hits {
1793 let row = row_of(hit);
1794 for t in &c.templates {
1795 let k = b.effects.len();
1796 let o = Origin {
1797 k,
1798 template: Some(t.index),
1799 row: &row,
1800 root: c.root,
1801 request: Some(&c.request),
1802 };
1803 let (mut e, symbols) = bind_one(c.root, hit, t, &o)?;
1804 let effect = finish(&mut e, k, &symbols, &b.effects, settle)?;
1805 b.effects.push(effect);
1806 }
1807 b.rows.push(row);
1808 }
1809 Ok(b)
1810}
1811
1812fn too_large(truncated: bool, hits: usize, c: &Compiled, count: usize) -> SurfaceError {
1816 let mut data = json!({
1817 "cardinality": if truncated { json!(format!(">{}", c.bound)) } else { json!(hits) },
1818 "max": c.bound,
1819 });
1820 if c.implicit() {
1821 data["implicit"] = json!(true);
1822 }
1823 data["fix"] = json!(fixes::bounded(&c.request, count));
1825 let message = format!(
1826 "the selection has {} hits, over the bound of {}{} — narrow the selector or raise `expect.max`",
1827 if truncated {
1828 format!("more than {}", c.bound)
1829 } else {
1830 hits.to_string()
1831 },
1832 c.bound,
1833 if c.implicit() {
1834 " (the implicit ceiling; pass expect.max to raise it)"
1835 } else {
1836 ""
1837 }
1838 );
1839 MutationError::with_data(ErrorCode::SelectionTooLarge, message, data).into()
1840}
1841
1842#[derive(Clone, Debug)]
1848pub struct Expansion {
1849 pub effects: Vec<Effect>,
1850 pub selection: Option<Selection>,
1851 pub rows: Vec<Row>,
1852 pub templates: Option<Vec<Json>>,
1853}
1854
1855pub fn expand(
1862 store: &mut Store,
1863 ctx: &PlanContext,
1864 doc_store: &dyn DocStore,
1865 req: &SelectRequest,
1866 semantic: HashMap<String, SemanticVec>,
1867) -> Result<Expansion> {
1868 if !req.has_selector() {
1869 return expand_plain(store, ctx, doc_store, req);
1870 }
1871 let c = compile(req)?;
1872 let opts = QueryOptions {
1873 limit: Some(c.bound.saturating_add(1)),
1874 cursor: None,
1875 provider: None,
1876 in_memory: false,
1877 bare: false,
1878 };
1879 let res = query_ast(store, &ctx.repo_id, &c.rewritten, opts, semantic)?;
1880 if res.truncated || res.hits.len() > c.bound {
1881 let count = if res.truncated {
1885 let unbounded = QueryOptions {
1886 limit: Some(usize::MAX >> 2),
1887 cursor: None,
1888 provider: None,
1889 in_memory: false,
1890 bare: false,
1891 };
1892 query_ast(store, &ctx.repo_id, &c.rewritten, unbounded, HashMap::new())?
1893 .hits
1894 .len()
1895 } else {
1896 res.hits.len()
1897 };
1898 return Err(too_large(res.truncated, res.hits.len(), &c, count));
1899 }
1900 let mut settle = |e: &mut Json, m: &[Member], p: &[Effect]| {
1901 settle_symbols(store, ctx, doc_store, e, m, p);
1902 };
1903 let b = bind(&c, &res.hits, &mut settle)?;
1904 let selection = Selection {
1905 query: req.select_str().to_owned(),
1906 expect: c.expect,
1907 templates: req.templates.as_array().cloned().unwrap_or_default(),
1908 cardinality: b.rows.len(),
1909 identities: b.rows.iter().map(|(id, _)| id.clone()).collect(),
1910 };
1911 Ok(Expansion {
1912 effects: b.effects,
1913 selection: Some(selection),
1914 rows: b.rows,
1915 templates: None,
1916 })
1917}
1918
1919#[derive(Clone, Debug, PartialEq, Eq)]
1924enum Missing {
1925 Doc(String),
1926 Block(String),
1927 Parent(String),
1928 Node(String),
1929}
1930
1931impl Missing {
1932 fn error(&self) -> SurfaceError {
1933 let (code, message, data) = match self {
1934 Missing::Doc(id) => (
1935 ErrorCode::DocMissing,
1936 format!("doc {id} not found"),
1937 json!({ "doc": id }),
1938 ),
1939 Missing::Block(id) => (
1940 ErrorCode::BlockMissing,
1941 format!("block {id} not found"),
1942 json!({ "block": id }),
1943 ),
1944 Missing::Parent(id) => (
1945 ErrorCode::ParentMissing,
1946 format!("parent {id} not found"),
1947 json!({}),
1948 ),
1949 Missing::Node(id) => (
1950 ErrorCode::BlockMissing,
1951 format!("node {id} not found"),
1952 json!({}),
1953 ),
1954 };
1955 MutationError::with_data(code, message, data).into()
1956 }
1957}
1958
1959#[derive(Clone, Debug, PartialEq, Eq)]
1962struct Scope {
1963 root: Target,
1964 id: String,
1965 missing: Missing,
1966}
1967
1968fn scope_of(
1974 store: &Store,
1975 repo_id: &str,
1976 e: &Map<String, Json>,
1977 i: usize,
1978 sugar: &Desugared,
1979) -> Result<Scope> {
1980 let here = format!("effects[{i}]");
1981 let op = e.get("op").and_then(Json::as_str).unwrap_or_default();
1982 let first = |kinds: &'static [&'static str]| -> Result<(String, Option<Json>, &'static [&'static str], Member)> {
1983 let list = e.get("targets").and_then(Json::as_array);
1984 if list.is_none_or(Vec::is_empty) {
1985 Effect::validate(&Json::Object(e.clone()), i).map_err(shape_error)?;
1987 }
1988 Ok((
1989 "targets[0]".to_owned(),
1990 list.and_then(|l| l.first()).cloned(),
1991 kinds,
1992 Member::Targets(0),
1993 ))
1994 };
1995 const PLACEMENT: &[&str] = &["document", "section", "block"];
1996 const DOCUMENT: &[&str] = &["document"];
1997 const BLOCK: &[&str] = &["block"];
1998 const SET: &[&str] = &["document", "block", "node"];
1999 let target = |kinds: &'static [&'static str]| {
2000 (
2001 "target".to_owned(),
2002 e.get("target").cloned(),
2003 kinds,
2004 Member::Target,
2005 )
2006 };
2007 let mut placement = false;
2008 let (key, v, kinds, member) = match op {
2009 "create" => {
2010 return Err(refused(format!(
2011 "{here}: a `create` has no row — an expression on a create needs a selector (`select`)"
2012 )));
2013 }
2014 "insert" => {
2015 placement = true;
2016 (
2017 "into".to_owned(),
2018 e.get("into").cloned(),
2019 PLACEMENT,
2020 Member::Into,
2021 )
2022 }
2023 "set" => target(SET),
2024 "unset" => target(DOCUMENT),
2025 "move" => {
2026 if e.contains_key("targets") || e.contains_key("into") {
2027 first(BLOCK)?
2028 } else {
2029 target(DOCUMENT)
2030 }
2031 }
2032 "remove" => {
2033 if e.contains_key("targets") {
2034 first(BLOCK)?
2035 } else {
2036 target(DOCUMENT)
2037 }
2038 }
2039 "split" => target(BLOCK),
2040 "merge" => first(BLOCK)?,
2041 _ => {
2042 Effect::validate(&Json::Object(e.clone()), i).map_err(shape_error)?;
2044 return Err(shape_error(format!(
2045 "{here}: `op` {} is not row-shaped",
2046 Json::String(op.to_owned())
2047 )));
2048 }
2049 };
2050 let where_ = format!("{here}.{key}");
2051 if v.as_ref().is_some_and(|v| {
2052 oqx_of(v).is_some()
2053 || v.as_object().is_some_and(|o| {
2054 o.get("id").is_some_and(|x| oqx_of(x).is_some())
2055 || o.get("path").is_some_and(|x| oqx_of(x).is_some())
2056 })
2057 }) {
2058 return Err(needs_selector(&where_));
2059 }
2060 let kinds_text = kinds
2061 .iter()
2062 .map(|k| format!("\"{k}\""))
2063 .collect::<Vec<_>>()
2064 .join(" | ");
2065 let Some(t) = v
2066 .as_ref()
2067 .and_then(Json::as_object)
2068 .filter(|o| o.get("kind").is_some_and(Json::is_string))
2069 else {
2070 return Err(shape_error(format!(
2071 "{where_}: a target is {{ kind: {kinds_text}, … }}"
2072 )));
2073 };
2074 let kind = t["kind"].as_str().expect("checked");
2075 let id_of = |key: &str| t.get(key).and_then(Json::as_str).map(str::to_owned);
2076 if sugar.symbols.contains(&member)
2078 && let Some(sym) = id_of("id").filter(|id| omgbase_mutate::parse_new_symbol(id).is_some())
2079 {
2080 return Err(refused(format!(
2081 "{where_}: {sym} has no row yet — it is created by this plan; use a literal target or a selector"
2082 )));
2083 }
2084 if !kinds.contains(&kind) {
2085 return Err(shape_error(format!(
2086 "{where_}: `kind` must be one of {} (got \"{kind}\")",
2087 kinds.join(", ")
2088 )));
2089 }
2090 Ok(match kind {
2091 "document" => {
2092 let id = id_of("id");
2093 let path = id_of("path");
2094 if id.is_none() && path.is_none() {
2095 return Err(shape_error(format!(
2096 "{where_}: a document target needs `id` or `path`"
2097 )));
2098 }
2099 let by_id = match &id {
2100 Some(id) => {
2101 let found = store.find_doc_by_ref(repo_id, id)?;
2102 Some(found.ok_or_else(|| Missing::Doc(id.clone()).error())?)
2103 }
2104 None => None,
2105 };
2106 let by_path = match &path {
2107 Some(path) => {
2108 let found = store.find_doc_by_ref(repo_id, storage_path(path))?;
2109 Some(found.ok_or_else(|| Missing::Doc(path.clone()).error())?)
2110 }
2111 None => None,
2112 };
2113 if let (Some(a), Some(b)) = (&by_id, &by_path)
2114 && a.doc_id != b.doc_id
2115 {
2116 return Err(shape_error(format!(
2117 "id {} and path {} name different documents",
2118 Json::String(id.clone().unwrap_or_default()),
2119 Json::String(path.clone().unwrap_or_default())
2120 )));
2121 }
2122 let info = by_id.or(by_path).expect("one of the two");
2123 Scope {
2124 root: Target::Docs,
2125 id: info.doc_id.clone(),
2126 missing: Missing::Doc(info.doc_id),
2127 }
2128 }
2129 "block" => {
2130 let id = id_of("id").unwrap_or_default();
2131 Scope {
2132 root: Target::Blocks,
2133 id: id.clone(),
2134 missing: if placement {
2135 Missing::Parent(id)
2136 } else {
2137 Missing::Block(id)
2138 },
2139 }
2140 }
2141 "section" => {
2142 let id = id_of("heading_id").unwrap_or_default();
2143 Scope {
2144 root: Target::Blocks,
2145 id: id.clone(),
2146 missing: Missing::Parent(id),
2147 }
2148 }
2149 _ => {
2150 let id = id_of("id").unwrap_or_default();
2151 Scope {
2152 root: Target::Nodes,
2153 id: id.clone(),
2154 missing: Missing::Node(id),
2155 }
2156 }
2157 })
2158}
2159
2160enum Plain {
2162 Literal(Json, Vec<Member>),
2164 Bound(Json, Vec<Member>),
2166}
2167
2168pub fn expand_plain(
2179 store: &mut Store,
2180 ctx: &PlanContext,
2181 doc_store: &dyn DocStore,
2182 req: &SelectRequest,
2183) -> Result<Expansion> {
2184 let raw = req
2185 .templates
2186 .as_array()
2187 .ok_or_else(|| shape_error("`effects` must be an array of effects".to_owned()))?;
2188 if raw.is_empty() {
2189 return Err(shape_error(
2190 "`effects` must name at least one effect".to_owned(),
2191 ));
2192 }
2193 let request = fixes::Request {
2195 select: None,
2196 effects: Json::Array(raw.clone()),
2197 expect: None,
2198 };
2199 let mut desugared = Vec::with_capacity(raw.len());
2200 for (i, v) in raw.iter().enumerate() {
2201 desugared.push(desugar_effect(v, i, Some(&request))?);
2202 }
2203 let any_expression = desugared.iter().any(|(v, _)| contains_oqx(v));
2204 let mut staged: Vec<Option<Plain>> = (0..raw.len()).map(|_| None).collect();
2205 if any_expression {
2206 struct Group {
2208 scope: Scope,
2209 members: Vec<Template>,
2210 }
2211 let mut groups: Vec<Group> = Vec::new();
2212 for (i, (v, d)) in desugared.iter().enumerate() {
2213 let Some(o) = v.as_object().filter(|_| contains_oqx(v)) else {
2214 staged[i] = Some(Plain::Literal(v.clone(), d.symbols.clone()));
2215 continue;
2216 };
2217 let scope = scope_of(store, &ctx.repo_id, o, i, d)?;
2218 let t = admit_template(i, v, scope.root, true, d)?;
2219 if t.slots.is_empty() {
2220 staged[i] = Some(Plain::Literal(v.clone(), d.symbols.clone()));
2222 continue;
2223 }
2224 match groups.iter_mut().find(|g| g.scope == scope) {
2225 Some(g) => g.members.push(t),
2226 None => groups.push(Group {
2227 scope,
2228 members: vec![t],
2229 }),
2230 }
2231 }
2232 for g in &groups {
2233 let src = format!(
2234 "from {} where id == {}",
2235 g.scope.root.as_str(),
2236 Json::String(g.scope.id.clone())
2237 );
2238 let mut q = oqx::parse_string(&src)?;
2239 for t in &g.members {
2240 for s in &t.slots {
2241 q.select
2242 .push(renamed(&s.item, &slot_name(t.index, &s.name)));
2243 }
2244 }
2245 let opts = QueryOptions {
2246 limit: Some(2),
2247 cursor: None,
2248 provider: None,
2249 in_memory: false,
2250 bare: false,
2251 };
2252 let res = query_ast(store, &ctx.repo_id, &q, opts, HashMap::new())?;
2253 let Some(hit) = res.hits.first() else {
2254 return Err(g.scope.missing.error());
2255 };
2256 let row = row_of(hit);
2257 for t in &g.members {
2258 let o = Origin {
2259 k: t.index,
2260 template: None,
2261 row: &row,
2262 root: g.scope.root,
2263 request: Some(&request),
2264 };
2265 let (e, symbols) = bind_one(g.scope.root, hit, t, &o)?;
2266 staged[t.index] = Some(Plain::Bound(e, symbols));
2267 }
2268 }
2269 } else {
2270 for (i, (v, d)) in desugared.into_iter().enumerate() {
2271 staged[i] = Some(Plain::Literal(v, d.symbols));
2272 }
2273 }
2274 let mut effects: Vec<Effect> = Vec::with_capacity(raw.len());
2277 for (i, p) in staged.into_iter().enumerate() {
2278 let (mut e, symbols) = match p.expect("every effect staged") {
2279 Plain::Literal(e, s) | Plain::Bound(e, s) => (e, s),
2280 };
2281 let mut settle = |e: &mut Json, m: &[Member], p: &[Effect]| {
2282 settle_symbols(store, ctx, doc_store, e, m, p);
2283 };
2284 let effect = finish(&mut e, i, &symbols, &effects, &mut settle)?;
2285 effects.push(effect);
2286 }
2287 Ok(Expansion {
2288 effects,
2289 selection: None,
2290 rows: Vec::new(),
2291 templates: any_expression.then(|| raw.clone()),
2292 })
2293}
2294
2295#[must_use]
2298pub fn annotate(mut e: SurfaceError, rows: &[Row], templates: usize) -> SurfaceError {
2299 if templates == 0 {
2300 return e;
2301 }
2302 if let Some(data) = e.data.as_mut().and_then(Json::as_object_mut)
2303 && let Some(k) = data
2304 .get("effect_index")
2305 .and_then(Json::as_u64)
2306 .and_then(|k| usize::try_from(k).ok())
2307 && !data.contains_key("template_index")
2308 && let Some((id, path)) = rows.get(k / templates)
2309 {
2310 data.insert("template_index".to_owned(), json!(k % templates));
2311 data.insert("row".to_owned(), json!({ "id": id, "path": path }));
2312 }
2313 e
2314}
2315
2316pub fn plan_selected(
2322 store: &mut Store,
2323 doc_store: &dyn DocStore,
2324 ctx: &PlanContext,
2325 req: &SelectRequest,
2326 semantic: HashMap<String, SemanticVec>,
2327) -> Result<omgbase_mutate::Plan> {
2328 let x = expand(store, ctx, doc_store, req, semantic)?;
2329 let templates = if req.has_selector() {
2330 req.templates()
2331 } else {
2332 0
2333 };
2334 let concrete = x.effects.len();
2335 let plan = store
2336 .plan_selected(ctx, &x.effects, x.selection, doc_store)
2337 .map_err(|e| annotate(SurfaceError::from(e), &x.rows, templates));
2338 let mut plan = match plan {
2339 Ok(plan) => plan,
2340 Err(e) => {
2344 let req_fix = fixes::Request {
2345 select: req.has_selector().then(|| req.select.clone()),
2346 effects: req.templates.clone(),
2347 expect: req.expect.clone(),
2348 };
2349 let origins: Option<Vec<fixes::Origin>> = (templates > 0).then(|| {
2350 (0..concrete)
2351 .map(|k| fixes::Origin {
2352 row_id: x
2353 .rows
2354 .get(k / templates)
2355 .map(|(id, _)| id.clone())
2356 .unwrap_or_default(),
2357 template_index: k % templates,
2358 })
2359 .collect()
2360 });
2361 let allocated = |i: usize| -> Option<usize> {
2362 if i == 0 {
2363 return Some(0);
2364 }
2365 store
2366 .plan_selected(ctx, &x.effects[..i.min(concrete)], None, doc_store)
2367 .ok()
2368 .map(|p| p.new_ids.len())
2369 };
2370 return Err(fixes::planning_fix(
2371 &req_fix,
2372 e,
2373 origins.as_deref(),
2374 allocated,
2375 ));
2376 }
2377 };
2378 plan.templates = x.templates;
2379 Ok(plan)
2380}
2381
2382#[must_use]
2387pub fn unbounded_apply(req: &SelectRequest, cardinality: usize) -> SurfaceError {
2388 refused_with(
2389 UNBOUNDED,
2390 json!({
2391 "fix": fixes::bounded(
2392 &fixes::Request {
2393 select: Some(req.select.clone()),
2394 effects: req.templates.clone(),
2395 expect: None,
2396 },
2397 cardinality,
2398 )
2399 }),
2400 )
2401}
2402
2403const UNBOUNDED: &str = "`expect.max` is required on `apply` with `select` — bound the selection, or preview it with `plan`";
2404
2405pub fn require_max(req: &SelectRequest) -> Result<()> {
2409 if req.has_selector() && req.expect.is_none() {
2410 return Err(refused(UNBOUNDED));
2411 }
2412 Ok(())
2413}
2414
2415#[cfg(test)]
2416mod tests {
2417 use super::*;
2418
2419 fn req(select: &str, templates: Json, max: Option<usize>) -> SelectRequest {
2420 SelectRequest {
2421 select: json!(select),
2422 templates,
2423 expect: max.map(|max| json!({ "max": max })),
2424 }
2425 }
2426
2427 const ROLLUP: &str = "from docs where type == \"workstream\" and status == \"completed\"";
2428
2429 fn rollup_templates() -> Json {
2430 json!([
2431 { "op": "insert", "into": { "kind": "document", "path": { "oqx": "project" } }, "at": "end",
2432 "markdown": { "oqx": "\"- completed: [\" + title + \"](\" + path + \")\"" } },
2433 { "op": "set", "field": "frontmatter.rolled_up", "value": true },
2434 ])
2435 }
2436
2437 fn hit(id: &str, path: &str, slots: Json) -> Json {
2438 let mut h = json!({ "id": id, "path": path });
2439 if let Some(o) = slots.as_object() {
2440 for (k, v) in o {
2441 h[k] = v.clone();
2442 }
2443 }
2444 h
2445 }
2446
2447 fn data(e: &SurfaceError) -> &Json {
2448 e.data.as_ref().expect("data")
2449 }
2450
2451 #[test]
2455 fn the_rewrite_appends_slot_projections_after_the_users_items() {
2456 let c = compile(&req(ROLLUP, rollup_templates(), None)).unwrap();
2457 assert_eq!(
2458 oqx::print_query(&c.rewritten).unwrap(),
2459 "select __omg_sel_0_into: project, __omg_sel_0_markdown: \"- completed: [\" + title + \"](\" + path + \")\" from docs where type == \"workstream\" and status == \"completed\""
2460 );
2461 assert_eq!(
2462 c.templates[0].slot_names(),
2463 ["__omg_sel_0_into", "__omg_sel_0_markdown"]
2464 );
2465 assert!(c.templates[1].slot_names().is_empty());
2466 assert_eq!(c.templates[1].default_target, Some(DefaultTarget::Target));
2467 assert_eq!((c.bound, c.implicit()), (SELECTION_CEILING, true));
2468 let c = compile(&req(
2469 "select hub: project from docs",
2470 json!([{ "op": "insert", "into": { "kind": "document", "path": { "oqx": "hub" } }, "markdown": "x" }]),
2471 Some(5),
2472 ))
2473 .unwrap();
2474 assert_eq!(
2475 oqx::print_query(&c.rewritten).unwrap(),
2476 "select hub: project, __omg_sel_0_into: hub from docs"
2477 );
2478 assert_eq!(
2479 oqx::print_query(&oqx::resolve_aliases(&c.rewritten).unwrap()).unwrap(),
2480 "select hub: project, __omg_sel_0_into: project from docs"
2481 );
2482 assert_eq!((c.bound, c.implicit()), (5, false));
2483 let c = compile(&req(
2485 "from docs",
2486 json!([{ "op": "set", "target": { "kind": "document", "id": { "oqx": "^docs single { id values where data.slug == ^hub_slug }" } }, "field": "frontmatter.x", "value": 1 }]),
2487 None,
2488 ))
2489 .unwrap();
2490 assert!(
2491 oqx::print_query(&c.rewritten)
2492 .unwrap()
2493 .starts_with("select __omg_sel_0_target: ^docs single {"),
2494 );
2495 }
2496
2497 #[test]
2498 fn the_static_pass_refuses_what_cannot_be_a_selection() {
2499 let t = json!([{ "op": "set", "field": "frontmatter.x", "value": 1 }]);
2500 let refuse = |select: &str, templates: Json, max: Option<usize>| -> SurfaceError {
2501 let e = compile(&req(select, templates, max)).unwrap_err();
2502 assert_eq!(e.code, "filter_invalid", "{select}");
2503 e
2504 };
2505 let selector: [(&str, &str); 10] = [
2506 (
2507 "docs count { }",
2508 "select: `count` yields no rows — a selector's consumer is collect, first or single",
2509 ),
2510 (
2511 "select title values from docs",
2512 "select: a selector yields rows, not `values`",
2513 ),
2514 (
2515 "from links",
2516 "select: `links` rows have no effect target — select docs, blocks or nodes",
2517 ),
2518 (
2519 "entries(docs) collect { }",
2520 "select: a selector scans a root collection — `from docs`, `from blocks` or `from nodes`",
2521 ),
2522 (
2523 "from docs follow refs(\"a\")",
2524 "select: `follow` is not allowed in a selector — a hit must be a row of the root",
2525 ),
2526 (
2527 "from docs offset 2",
2528 "select: `offset` is not allowed — a selection is a set, not a page",
2529 ),
2530 (
2531 "select distinct type from docs",
2532 "select: `distinct` is not allowed in a selector",
2533 ),
2534 (
2535 "select __omg_x: 1 from docs",
2536 "select: projection `__omg_x` uses the reserved prefix `__omg_` — choose another alias",
2537 ),
2538 (
2539 "select __oqx_id: 1 from docs",
2540 "select: projection `__oqx_id` uses the reserved prefix `__oqx_` — choose another alias",
2541 ),
2542 (
2543 "from docs where $repo",
2544 "select: `$repo` was removed in surface 2.0 — reach the repository's collections through the root row: `^docs` from a top-level row (one caret per enclosing block, or the absolute `0^docs`), and the repository id as `^id` / `0^id`",
2545 ),
2546 ];
2547 for (select, message) in selector {
2548 let e = refuse(select, t.clone(), None);
2549 assert_eq!(data(&e)["hint"], HINT, "{select}");
2550 assert_eq!(e.message, message, "{select}");
2551 }
2552 let e = refuse("from docs where (", t.clone(), None);
2554 assert!(e.message.starts_with("select: "), "{}", e.message);
2555 assert_eq!(data(&e)["hint"], HINT);
2556 for ok in [
2558 "from docs where x exists { } order by title limit 3",
2559 "docs first { where status == \"a\" }",
2560 "docs single { }",
2561 ] {
2562 compile(&req(ok, t.clone(), None)).unwrap_or_else(|e| panic!("{ok}: {e}"));
2563 }
2564 let e = compile(&SelectRequest {
2566 select: json!(" "),
2567 templates: t.clone(),
2568 expect: None,
2569 })
2570 .unwrap_err();
2571 assert_eq!(
2572 e.message,
2573 "`select` must be an OQX query (a non-empty string)"
2574 );
2575 let e = compile(&SelectRequest {
2576 select: json!("from docs"),
2577 templates: json!({}),
2578 expect: None,
2579 })
2580 .unwrap_err();
2581 assert_eq!(e.message, "`effects` must be an array of effect templates");
2582 let e = refuse("from docs", json!([]), None);
2583 assert_eq!(e.message, "`effects` must name at least one template");
2584 for (expect, message) in [
2585 (json!(3), "`expect` must be an object { max }"),
2586 (json!({ "max": 1, "x": 2 }), "`expect`: unknown key `x`"),
2587 (
2588 json!({ "max": 1, "x": 2, "y": 3 }),
2589 "`expect`: unknown keys `x`, `y`",
2590 ),
2591 (json!({}), "`expect.max` must be a non-negative integer"),
2592 (
2593 json!({ "max": -1 }),
2594 "`expect.max` must be a non-negative integer",
2595 ),
2596 (
2597 json!({ "max": 1.5 }),
2598 "`expect.max` must be a non-negative integer",
2599 ),
2600 ] {
2601 let e = compile(&SelectRequest {
2602 select: json!("from docs"),
2603 templates: t.clone(),
2604 expect: Some(expect),
2605 })
2606 .unwrap_err();
2607 assert_eq!(e.message, message);
2608 }
2609 let templates: [(Json, &str); 9] = [
2611 (
2612 json!([{ "op": "retarget", "repairs": [] }]),
2613 "effects[0]: `retarget` is not row-shaped — it cannot be a selector template",
2614 ),
2615 (
2616 json!([{ "op": "merge", "separator": " " }]),
2617 "effects[0]: `merge` needs `targets` — one hit cannot be merged with itself",
2618 ),
2619 (
2620 json!([{ "op": "nope" }]),
2621 "effects[0]: `op` must be one of create/insert/set/unset/move/remove/split/merge (got \"nope\")",
2622 ),
2623 (
2624 json!(["x"]),
2625 "effects[0]: a template is an effect object whose `op` is one of create/insert/set/unset/move/remove/split/merge",
2626 ),
2627 (
2628 json!([{ "op": "set", "field": { "oqx": "\"frontmatter.x\"" }, "value": 1 }]),
2629 "effects[0].field: `{ oqx }` is not allowed here",
2630 ),
2631 (
2632 json!([{ "op": "set", "target": { "kind": "document", "id": { "oqx": "a" }, "path": { "oqx": "b" } }, "field": "frontmatter.x", "value": 1 }]),
2633 "effects[0].target: give `id` or `path` as an expression, not both",
2634 ),
2635 (
2636 json!([{ "op": "set", "target": { "kind": { "oqx": "k" }, "id": "d_1" }, "field": "frontmatter.x", "value": 1 }]),
2637 "effects[0].target.kind: `{ oqx }` is not allowed here",
2638 ),
2639 (
2640 json!([{ "op": "remove", "targets": [{ "kind": "block", "path": { "oqx": "p" } }] }]),
2641 "effects[0].targets[0]: a block target has `id`, not `path`",
2642 ),
2643 (
2644 json!([{ "op": "set", "field": "frontmatter.x", "value": { "oqx": "a, b" } }]),
2645 "effects[0].value: `oqx` must be one expression over the hit, not a query",
2646 ),
2647 ];
2648 for (templates, message) in templates {
2649 let e = refuse("from docs", templates, None);
2650 assert_eq!(e.message, message);
2651 assert_eq!(data(&e)["hint"], HINT);
2652 }
2653 compile(&req(
2655 "from docs",
2656 json!([{ "op": "set", "field": "frontmatter.x", "value": { "k": { "oqx": "x" } } }]),
2657 None,
2658 ))
2659 .unwrap();
2660 compile(&req("from docs", json!([{ "op": "create", "path": "n.md", "markdown": "x", "frontmatter": { "k": { "deep": { "oqx": "x" } } } }]), None)).unwrap();
2661 let e = refuse(
2663 "from nodes",
2664 json!([{ "op": "insert", "markdown": "x" }]),
2665 None,
2666 );
2667 assert_eq!(
2668 e.message,
2669 "effects[0].into: `kind` must be one of document, section, block (got \"node\")"
2670 );
2671 assert_eq!(data(&e)["hint"], "arguments");
2672 let e = refuse("from docs", json!([{ "op": "split", "at": [1] }]), None);
2673 assert_eq!(data(&e)["hint"], "arguments");
2674 let e = refuse(
2675 "from blocks",
2676 json!([{ "op": "unset", "field": "frontmatter.x" }]),
2677 None,
2678 );
2679 assert!(e.message.contains("(got \"block\")"), "{}", e.message);
2680 let c = compile(&req(
2682 "from blocks",
2683 json!([{ "op": "remove", "targets": [{ "oqx": "x" }] }]),
2684 None,
2685 ))
2686 .unwrap();
2687 assert_eq!(c.templates[0].slot_names(), ["__omg_sel_0_targets_0"]);
2688 assert!(matches!(
2689 c.templates[0].slots[0].kind,
2690 SlotKind::WholeTargets { k: 0 }
2691 ));
2692 let e = refuse(
2694 "from docs",
2695 json!([{ "op": "set", "field": "frontmatter.x", "value": { "oqx": "1 +" } }]),
2696 None,
2697 );
2698 assert_eq!(
2699 e.message,
2700 "effects[0].value: unexpected 'from' — expected a value (at offset 4)"
2701 );
2702 let e = refuse(
2703 "from docs",
2704 json!([{ "op": "set", "field": "frontmatter.x", "value": { "oqx": "a from blocks" } }]),
2705 None,
2706 );
2707 assert_eq!(
2708 e.message,
2709 "effects[0].value: duplicate `from` clause (at offset 14)"
2710 );
2711 }
2712
2713 #[test]
2714 fn the_request_reads_expect_only_with_a_select() {
2715 assert_eq!(
2716 SelectRequest::from_args(&json!({ "effects": [] })).unwrap(),
2717 None
2718 );
2719 let r = SelectRequest::from_args(
2720 &json!({ "select": "from docs", "effects": [{ "op": "x" }], "expect": { "max": 3 } }),
2721 )
2722 .unwrap()
2723 .unwrap();
2724 assert_eq!(r.expect, Some(json!({ "max": 3 })));
2725 assert_eq!(r.templates(), 1);
2726 let e = SelectRequest::from_args(&json!({ "effects": [], "expect": { "max": 1 } }))
2727 .unwrap_err();
2728 assert_eq!(
2729 e.message,
2730 "`expect` bounds a selection — pass `select` with it"
2731 );
2732 assert!(require_max(&r.clone()).is_ok());
2733 let e = require_max(&SelectRequest { expect: None, ..r }).unwrap_err();
2734 assert_eq!(
2735 e.message,
2736 "`expect.max` is required on `apply` with `select` — bound the selection, or preview it with `plan`"
2737 );
2738 assert_eq!(data(&e)["hint"], HINT);
2739 }
2740
2741 #[test]
2742 fn binding_types_each_slot_and_names_the_row() {
2743 let c = compile(&req(ROLLUP, rollup_templates(), Some(10))).unwrap();
2744 let hits = vec" }),
2749 ),
2750 hit(
2751 "d_5",
2752 "/workstreams/b.md",
2753 json!({ "__omg_sel_0_into": { "id": "d_9", "path": "/projects/y.md" }, "__omg_sel_0_markdown": "- completed: [B](/workstreams/b.md)" }),
2754 ),
2755 ];
2756 let Bound { effects, rows, .. } = bind(&c, &hits, &mut no_settle).unwrap();
2757 assert_eq!(
2758 rows,
2759 [
2760 ("d_2".to_owned(), "/workstreams/a.md".to_owned()),
2761 ("d_5".to_owned(), "/workstreams/b.md".to_owned())
2762 ]
2763 );
2764 let json: Vec<Json> = effects.iter().map(Effect::to_json).collect();
2765 assert_eq!(
2766 json,
2767 [
2768 json!({ "op": "insert", "into": { "kind": "document", "path": "/projects/x.md" }, "at": "end", "markdown": "- completed: [A](/workstreams/a.md)" }),
2769 json!({ "op": "set", "target": { "kind": "document", "id": "d_2" }, "field": "frontmatter.rolled_up", "value": true }),
2770 json!({ "op": "insert", "into": { "kind": "document", "id": "d_9" }, "at": "end", "markdown": "- completed: [B](/workstreams/b.md)" }),
2772 json!({ "op": "set", "target": { "kind": "document", "id": "d_5" }, "field": "frontmatter.rolled_up", "value": true }),
2773 ]
2774 );
2775 for absent in [
2777 json!({}),
2778 json!({ "__omg_sel_0_into": null }),
2779 json!({ "__omg_sel_0_into": [] }),
2780 ] {
2781 let mut h = hit("d_2", "/w.md", absent);
2782 h["__omg_sel_0_markdown"] = json!("x");
2783 let e = bind(&c, &[h], &mut no_settle).unwrap_err();
2784 assert_eq!(e.code, "target_missing");
2785 assert_eq!(
2786 e.message,
2787 "effects[0].into.path: the expression yielded no identity for row d_2"
2788 );
2789 assert_eq!(
2790 *data(&e),
2791 json!({ "effect_index": 0, "template_index": 0, "row": { "id": "d_2", "path": "/w.md" }, "expression": "project" })
2792 );
2793 }
2794 let h = hit(
2796 "d_2",
2797 "/w.md",
2798 json!({ "__omg_sel_0_into": ["/a.md"], "__omg_sel_0_markdown": "x" }),
2799 );
2800 let e = bind(&c, &[h], &mut no_settle).unwrap_err();
2801 assert_eq!(e.code, "ambiguous_target");
2802 assert_eq!(
2803 e.message,
2804 "effects[0].into.path: the expression yielded 1 identities for row d_2; a target resolves to exactly one"
2805 );
2806 assert_eq!(data(&e)["count"], 1);
2807 assert_eq!(data(&e)["expression"], "project");
2808 let h = hit(
2810 "d_2",
2811 "/w.md",
2812 json!({ "__omg_sel_0_into": 3, "__omg_sel_0_markdown": "x" }),
2813 );
2814 let e = bind(&c, &[h], &mut no_settle).unwrap_err();
2815 assert_eq!(e.code, "filter_invalid");
2816 assert_eq!(
2817 e.message,
2818 "effects[0].into.path: the expression yielded a number (3), not an identity (a string or a row)"
2819 );
2820 assert_eq!(
2821 *data(&e),
2822 json!({ "reason": e.message, "hint": HINT, "effect_index": 0, "template_index": 0, "row": { "id": "d_2", "path": "/w.md" }, "expression": "project" })
2823 );
2824 let h = hit(
2826 "d_2",
2827 "/w.md",
2828 json!({ "__omg_sel_0_into": "/p.md", "__omg_sel_0_markdown": 7.5 }),
2829 );
2830 let e = bind(&c, &[h], &mut no_settle).unwrap_err();
2831 assert_eq!(
2832 e.message,
2833 "effects[0].markdown: the expression yielded a number (7.5), expected a string"
2834 );
2835 let h = hit("d_2", "/w.md", json!({ "__omg_sel_0_into": "/p.md" }));
2836 let e = bind(&c, &[h], &mut no_settle).unwrap_err();
2837 assert_eq!(
2838 e.message,
2839 "effects[0].markdown: the expression yielded absent for row d_2"
2840 );
2841 let c = compile(&req("from docs", json!([{ "op": "set", "field": "frontmatter.x", "value": 1 }, { "op": "insert", "into": { "kind": "document", "path": { "oqx": "p" } }, "markdown": "x" }]), None)).unwrap();
2843 let e = bind(
2844 &c,
2845 &[hit(
2846 "d_1",
2847 "/a.md",
2848 json!({ "__omg_sel_1_into": { "id": "d_2", "path": "/p.md", "extra": 1 } }),
2849 )],
2850 &mut no_settle,
2851 )
2852 .unwrap_err();
2853 assert_eq!(
2854 e.message,
2855 "effects[1].into.path: the expression yielded an object, not an identity (a string or a row)"
2856 );
2857 assert_eq!(data(&e)["effect_index"], 1);
2858 assert_eq!(data(&e)["template_index"], 1);
2859 }
2860
2861 #[test]
2862 fn binding_the_hit_by_kind_and_the_other_slots() {
2863 let c = compile(&req(
2867 "from blocks",
2868 json!([
2869 { "op": "move", "into": { "kind": "block", "id": { "oqx": "parent" } }, "at": { "after": { "oqx": "anchor" } } },
2870 { "op": "set", "field": "attrs.checked", "value": { "oqx": "done" } },
2871 { "op": "remove", "targets": [{ "kind": "block", "id": { "oqx": "sibling" } }, { "kind": "block", "id": { "oqx": "other" } }] },
2872 { "op": "remove" },
2873 { "op": "merge", "targets": [{ "kind": "block", "id": { "oqx": "a" } }, { "kind": "block", "id": "b_5" }], "separator": { "oqx": "sep" } },
2874 { "op": "split", "at": [2] },
2875 ]),
2876 Some(1),
2877 ))
2878 .unwrap();
2879 assert_eq!(c.templates[0].default_target, Some(DefaultTarget::Targets));
2880 assert_eq!(c.templates[1].default_target, Some(DefaultTarget::Target));
2881 assert_eq!(c.templates[2].default_target, None);
2882 assert_eq!(c.templates[3].default_target, Some(DefaultTarget::Targets));
2883 assert_eq!(c.templates[5].default_target, Some(DefaultTarget::Target));
2884 assert_eq!(
2885 c.templates
2886 .iter()
2887 .flat_map(Template::slot_names)
2888 .collect::<Vec<_>>(),
2889 [
2890 "__omg_sel_0_into",
2891 "__omg_sel_0_at",
2892 "__omg_sel_1_value",
2893 "__omg_sel_2_targets_0",
2894 "__omg_sel_2_targets_1",
2895 "__omg_sel_4_targets_0",
2896 "__omg_sel_4_separator",
2897 ]
2898 );
2899 let h = hit(
2900 "b_1",
2901 "/a.md",
2902 json!({
2903 "__omg_sel_0_into": { "id": "b_9", "path": "/a.md" }, "__omg_sel_0_at": "b_8",
2904 "__omg_sel_1_value": false,
2905 "__omg_sel_2_targets_0": "b_2", "__omg_sel_2_targets_1": { "id": "b_3", "path": "/a.md" },
2906 "__omg_sel_4_targets_0": "b_4", "__omg_sel_4_separator": " / ",
2907 }),
2908 );
2909 let Bound { effects, .. } = bind(&c, &[h], &mut no_settle).unwrap();
2910 let json: Vec<Json> = effects.iter().map(Effect::to_json).collect();
2911 assert_eq!(
2912 json[0],
2913 json!({ "op": "move", "targets": [{ "kind": "block", "id": "b_1" }], "into": { "kind": "block", "id": "b_9" }, "at": { "after": "b_8" } })
2914 );
2915 assert_eq!(
2916 json[1],
2917 json!({ "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "attrs.checked", "value": false })
2918 );
2919 assert_eq!(
2920 json[2],
2921 json!({ "op": "remove", "targets": [{ "kind": "block", "id": "b_2" }, { "kind": "block", "id": "b_3" }] })
2922 );
2923 assert_eq!(
2924 json[3],
2925 json!({ "op": "remove", "targets": [{ "kind": "block", "id": "b_1" }] })
2926 );
2927 assert_eq!(
2928 json[4],
2929 json!({ "op": "merge", "targets": [{ "kind": "block", "id": "b_4" }, { "kind": "block", "id": "b_5" }], "separator": " / " })
2930 );
2931 assert_eq!(
2932 json[5],
2933 json!({ "op": "split", "target": { "kind": "block", "id": "b_1" }, "at": [2] })
2934 );
2935 let c = compile(&req("from docs", json!([{ "op": "remove" }, { "op": "move", "to": { "oqx": "\"archive/\" + title + \".md\"" } }]), None)).unwrap();
2937 assert_eq!(c.templates[0].default_target, Some(DefaultTarget::Target));
2938 assert_eq!(c.templates[1].slot_names(), ["__omg_sel_1_to"]);
2939 let Bound { effects, .. } = bind(
2940 &c,
2941 &[hit(
2942 "d_1",
2943 "/a.md",
2944 json!({ "__omg_sel_1_to": "archive/A.md" }),
2945 )],
2946 &mut no_settle,
2947 )
2948 .unwrap();
2949 assert_eq!(
2950 effects[0].to_json(),
2951 json!({ "op": "remove", "target": { "kind": "document", "id": "d_1" } })
2952 );
2953 assert_eq!(
2954 effects[1].to_json(),
2955 json!({ "op": "move", "target": { "kind": "document", "id": "d_1" }, "to": "archive/A.md", "retarget_inbound": true })
2956 );
2957 let c = compile(&req(
2959 "from docs",
2960 json!([{ "op": "set", "field": "frontmatter.hub", "value": { "oqx": "hub" } }]),
2961 None,
2962 ))
2963 .unwrap();
2964 let e = bind(&c, &[hit("d_1", "/a.md", json!({}))], &mut no_settle).unwrap_err();
2965 assert_eq!(
2966 e.message,
2967 "effects[0].value: the expression yielded absent for row d_1 — use `unset` to remove a key"
2968 );
2969 let Bound { effects, .. } = bind(
2970 &c,
2971 &[hit(
2972 "d_1",
2973 "/a.md",
2974 json!({ "__omg_sel_0_value": { "id": "d_2", "path": "/p.md" } }),
2975 )],
2976 &mut no_settle,
2977 )
2978 .unwrap();
2979 assert_eq!(
2980 effects[0].to_json()["value"],
2981 json!({ "id": "d_2", "path": "/p.md" })
2982 );
2983 let c = compile(&req(
2985 "from docs",
2986 json!([{ "op": "create", "path": { "oqx": "\"/notes/\" + slug + \".md\"" }, "markdown": { "oqx": "\"# \" + title" }, "frontmatter": { "source": { "oqx": "path" }, "layer": "draft" } }]),
2987 None,
2988 ))
2989 .unwrap();
2990 assert_eq!(
2991 c.templates[0].slot_names(),
2992 [
2993 "__omg_sel_0_path",
2994 "__omg_sel_0_markdown",
2995 "__omg_sel_0_fm_0"
2996 ]
2997 );
2998 let Bound { effects, .. } = bind(&c, &[hit("d_1", "/a.md", json!({ "__omg_sel_0_path": "/notes/a.md", "__omg_sel_0_markdown": "# A", "__omg_sel_0_fm_0": "/a.md" }))], &mut no_settle).unwrap();
2999 assert_eq!(
3000 effects[0].to_json(),
3001 json!({ "op": "create", "path": "/notes/a.md", "markdown": "# A", "frontmatter": { "layer": "draft", "source": "/a.md" } })
3002 );
3003 let e = bind(
3004 &c,
3005 &[hit(
3006 "d_1",
3007 "/a.md",
3008 json!({ "__omg_sel_0_path": "/notes/a.md", "__omg_sel_0_markdown": "# A" }),
3009 )],
3010 &mut no_settle,
3011 )
3012 .unwrap_err();
3013 assert_eq!(
3014 e.message,
3015 "effects[0].frontmatter.source: the expression yielded absent for row d_1"
3016 );
3017 let Bound { effects, rows, .. } = bind(&c, &[], &mut no_settle).unwrap();
3019 assert!(effects.is_empty() && rows.is_empty());
3020 }
3021
3022 #[test]
3023 fn lowering_errors_gain_the_template_and_the_row() {
3024 let rows = vec![
3025 ("d_1".to_owned(), "/a.md".to_owned()),
3026 ("d_2".to_owned(), "/b.md".to_owned()),
3027 ];
3028 let e = SurfaceError::with_data(
3029 "conflicting_effects",
3030 "x",
3031 json!({ "effects": [1, 3], "effect_index": 3 }),
3032 );
3033 let a = annotate(e, &rows, 2);
3034 let d = a.data.unwrap();
3035 assert_eq!(d["template_index"], 1);
3036 assert_eq!(d["row"], json!({ "id": "d_2", "path": "/b.md" }));
3037 let e = SurfaceError::with_data("doc_missing", "x", json!({ "doc": "d" }));
3039 assert_eq!(annotate(e.clone(), &rows, 2), e);
3040 let e = SurfaceError::new("filter_invalid", "x");
3041 assert_eq!(annotate(e.clone(), &rows, 0), e);
3042 }
3043
3044 #[test]
3045 fn offsets_shift_back_by_the_wrapper() {
3046 assert_eq!(
3047 shift_offsets("unexpected ')' (at offset 20)", -16),
3048 "unexpected ')' (at offset 4)"
3049 );
3050 assert_eq!(shift_offsets("no offset here", -16), "no offset here");
3051 assert_eq!(shift_offsets("x (at offset 3)", -16), "x (at offset 0)");
3052 assert_eq!(shift_offsets("x (at offset 3)", 3), "x (at offset 6)");
3053 assert_eq!(describe(Some(&json!(2.0))), "a number (2)");
3054 assert_eq!(describe(Some(&json!([1, 2]))), "a list of 2");
3055 assert_eq!(describe(Some(&json!("q"))), "a string (\"q\")");
3056 assert_eq!(describe(None), "absent");
3057 }
3058
3059 fn desugar(v: Json) -> Result<Json> {
3062 desugar_effect(&v, 0, None).map(|(v, _)| v)
3063 }
3064
3065 #[test]
3069 fn the_leader_is_exact_and_everything_else_is_literal() {
3070 for literal in ["->x", " -> x", "-->", "->", "a -> b", "${HOME}", "x"] {
3071 let e = json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": literal });
3072 assert_eq!(desugar(e.clone()).unwrap(), e, "{literal}");
3073 assert!(!needs_sugar(&json!([e])), "{literal}");
3074 }
3075 assert_eq!(
3076 desugar(json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "-> \"-> note\"" })).unwrap(),
3077 json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "-> note" })
3078 );
3079 assert_eq!(
3080 desugar(json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "-> title" })).unwrap(),
3081 json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": { "oqx": "title" } })
3082 );
3083 assert_eq!(
3085 desugar(json!({ "op": "set", "target": "d_0", "field": "frontmatter.priority", "value": "-> priority + 1" })).unwrap(),
3086 json!({ "op": "set", "target": { "kind": "document", "id": "d_0" }, "field": "frontmatter.priority", "value": { "oqx": "priority + 1" } })
3087 );
3088 let nested = json!({ "op": "set", "target": "d_0", "field": "frontmatter.x", "value": { "a": "-> b" } });
3090 assert_eq!(desugar(nested.clone()).unwrap()["value"], nested["value"]);
3091 assert!(needs_sugar(
3093 &json!([{ "op": "insert", "into": "hub.md", "markdown": "x" }])
3094 ));
3095 assert!(needs_sugar(
3096 &json!([{ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "-> title" }])
3097 ));
3098 assert!(needs_sugar(
3099 &json!([{ "op": "remove", "targets": ["b_1"] }])
3100 ));
3101 let plain = SelectRequest::from_args(&json!({ "effects": [{ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "->x" }] })).unwrap();
3102 assert!(plain.is_none());
3103 let sugar = SelectRequest::from_args(
3104 &json!({ "effects": [{ "op": "insert", "into": "hub.md", "markdown": "-> x" }] }),
3105 )
3106 .unwrap()
3107 .unwrap();
3108 assert!(!sugar.has_selector());
3109 assert!(require_max(&sugar).is_ok());
3110 }
3111
3112 #[test]
3115 fn constant_folding_of_literal_leaders() {
3116 assert_eq!(desugar_expression("\"x\"", "h").unwrap(), json!("x"));
3117 assert_eq!(desugar_expression("`x`", "h").unwrap(), json!("x"));
3118 assert_eq!(desugar_expression("`a\\`b`", "h").unwrap(), json!("a`b"));
3119 assert_eq!(
3120 desugar_expression("`${title} (${era})`", "h").unwrap(),
3121 json!({ "oqx": "`${title} (${era})`" })
3122 );
3123 assert_eq!(desugar_expression("1", "h").unwrap(), json!({ "oqx": "1" }));
3124 assert_eq!(
3125 desugar_expression("\"a\" + \"b\"", "h").unwrap(),
3126 json!({ "oqx": "\"a\" + \"b\"" })
3127 );
3128 assert_eq!(
3130 desugar(json!({ "op": "insert", "into": "-> \"hub.md\"", "markdown": "x" })).unwrap(),
3131 json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "x" })
3132 );
3133 let e =
3135 desugar(json!({ "op": "insert", "into": "hub.md", "markdown": "-> 1 +" })).unwrap_err();
3136 assert_eq!(e.code, "filter_invalid");
3137 assert_eq!(data(&e)["hint"], HINT);
3138 assert_eq!(
3139 e.message,
3140 "effects[0].markdown: unexpected 'from' — expected a value (at offset 7)"
3141 );
3142 let e =
3143 desugar(json!({ "op": "insert", "into": "hub.md", "markdown": "-> a from blocks" }))
3144 .unwrap_err();
3145 assert!(e.message.ends_with("(at offset 17)"), "{}", e.message);
3146 }
3147
3148 #[test]
3152 fn bare_targets_normalize_before_the_shape_check() {
3153 let d = |v: Json| desugar(v).unwrap();
3154 assert_eq!(
3155 d(json!({ "op": "insert", "into": "hub.md", "markdown": "x" }))["into"],
3156 json!({ "kind": "document", "path": "hub.md" })
3157 );
3158 assert_eq!(
3159 d(json!({ "op": "insert", "into": "/notes/a", "markdown": "x" }))["into"],
3160 json!({ "kind": "document", "path": "/notes/a" })
3161 );
3162 assert_eq!(
3163 d(json!({ "op": "insert", "into": "d_2", "markdown": "x" }))["into"],
3164 json!({ "kind": "document", "id": "d_2" })
3165 );
3166 assert_eq!(
3167 d(json!({ "op": "set", "target": "b_5", "field": "attrs.checked", "value": true }))["target"],
3168 json!({ "kind": "block", "id": "b_5" })
3169 );
3170 assert_eq!(
3171 d(json!({ "op": "set", "target": "n_0123456789ab", "field": "value", "value": 1 }))["target"],
3172 json!({ "kind": "node", "id": "n_0123456789ab" })
3173 );
3174 assert_eq!(
3175 d(json!({ "op": "remove", "targets": ["b_8", { "kind": "block", "id": "b_9" }] }))["targets"],
3176 json!([{ "kind": "block", "id": "b_8" }, { "kind": "block", "id": "b_9" }])
3177 );
3178 let (v, sugar) = desugar_effect(
3180 &json!({ "op": "insert", "into": "$new.0", "markdown": "x" }),
3181 0,
3182 None,
3183 )
3184 .unwrap();
3185 assert_eq!(v["into"], json!({ "kind": "document", "id": "$new.0" }));
3186 assert_eq!(sugar.symbols, [Member::Into]);
3187 let (v, sugar) = desugar_effect(
3188 &json!({ "op": "set", "target": "$new.2", "field": "markdown", "value": "x" }),
3189 0,
3190 None,
3191 )
3192 .unwrap();
3193 assert_eq!(v["target"], json!({ "kind": "block", "id": "$new.2" }));
3194 assert_eq!(sugar.symbols, [Member::Target]);
3195 let (v, sugar) =
3196 desugar_effect(&json!({ "op": "remove", "targets": ["$new.1"] }), 0, None).unwrap();
3197 assert_eq!(v["targets"], json!([{ "kind": "block", "id": "$new.1" }]));
3198 assert_eq!(sugar.symbols, [Member::Targets(0)]);
3199 assert_eq!(
3201 d(json!({ "op": "insert", "into": "-> project", "markdown": "x" }))["into"],
3202 json!({ "oqx": "project" })
3203 );
3204 assert_eq!(
3205 d(
3206 json!({ "op": "insert", "into": { "kind": "document", "path": "-> project" }, "markdown": "x" })
3207 )["into"],
3208 json!({ "kind": "document", "path": { "oqx": "project" } })
3209 );
3210 let e = desugar(json!({ "op": "insert", "into": "hub.md#Status", "markdown": "x" }))
3212 .unwrap_err();
3213 assert_eq!(e.code, "filter_invalid");
3214 assert_eq!(data(&e)["hint"], "arguments");
3215 assert_eq!(data(&e)["fix"], crate::fixes::LOCATOR);
3216 assert_eq!(
3217 e.message,
3218 "effects[0].into: \"hub.md#Status\" is a locator, not a target — a bare target is a document path, a `b_`/`d_`/`n_` id or `$new.n`"
3219 );
3220 let e = desugar(json!({ "op": "insert", "into": "hub", "markdown": "x" })).unwrap_err();
3222 assert_eq!(data(&e)["hint"], "arguments");
3223 assert_eq!(
3224 e.message,
3225 "effects[0].into: a target is { kind: …, … } or a bare document path, a `b_`/`d_`/`n_` id or `$new.n` (got \"hub\")"
3226 );
3227 let e = compile(&req(
3229 "from docs",
3230 json!([{ "op": "split", "target": "d_0", "at": [1] }]),
3231 None,
3232 ))
3233 .unwrap_err();
3234 assert_eq!(
3235 e.message,
3236 "effects[0].target: `kind` must be one of block (got \"document\")"
3237 );
3238 assert_eq!(data(&e)["hint"], "arguments");
3239 }
3240
3241 #[test]
3245 fn a_leader_in_a_non_expression_slot_is_refused() {
3246 let refuse = |v: Json| -> SurfaceError {
3247 let e = desugar(v).unwrap_err();
3248 assert_eq!(e.code, "filter_invalid");
3249 assert_eq!(data(&e)["hint"], HINT);
3250 e
3251 };
3252 for (v, here) in [
3253 (
3254 json!({ "op": "set", "target": "d_0", "field": "-> f", "value": 1 }),
3255 "effects[0].field",
3256 ),
3257 (
3258 json!({ "op": "set", "target": "d_0", "field": "frontmatter.x", "value": 1, "expect": { "revision": "-> r" } }),
3259 "effects[0].expect.revision",
3260 ),
3261 (
3262 json!({ "op": "insert", "into": { "kind": "section", "heading_id": "-> h" }, "markdown": "x" }),
3263 "effects[0].into.heading_id",
3264 ),
3265 (
3266 json!({ "op": "insert", "into": "hub.md", "at": "-> end", "markdown": "x" }),
3267 "effects[0].at",
3268 ),
3269 (
3270 json!({ "op": "insert", "into": "hub.md", "at": { "before": "b_1", "x": "-> y" }, "markdown": "x" }),
3271 "effects[0].at.x",
3272 ),
3273 (
3274 json!({ "op": "split", "target": "b_1", "at": ["-> 2"] }),
3275 "effects[0].at[0]",
3276 ),
3277 (
3278 json!({ "op": "set", "target": "d_0", "field": "frontmatter.x", "value": 1, "bogus": "-> z" }),
3279 "effects[0].bogus",
3280 ),
3281 (
3282 json!({ "op": "retarget", "repairs": [{ "from": "-> a", "to": "b" }] }),
3283 "effects[0].repairs[0].from",
3284 ),
3285 ] {
3286 let e = refuse(v);
3287 assert_eq!(
3288 e.message,
3289 format!("{here}: `-> ` is not allowed here — this member takes no expression")
3290 );
3291 }
3292 for v in [
3294 json!({ "op": "frobnicate", "x": "-> y" }),
3295 json!({ "op": "-> set", "target": "d_0", "field": "frontmatter.x", "value": 1 }),
3296 ] {
3297 assert_eq!(desugar(v.clone()).unwrap(), v);
3298 let e = Effect::validate(&v, 0).map_err(shape_error).unwrap_err();
3299 assert!(e.message.contains("`op` must be one of"), "{}", e.message);
3300 assert_eq!(data(&e)["hint"], "arguments");
3301 }
3302 }
3303
3304 #[test]
3307 fn binding_a_whole_target() {
3308 let c = compile(&req(
3309 "from docs",
3310 json!([{ "op": "insert", "into": "-> project", "markdown": "x" }, { "op": "set", "target": "-> hub", "field": "frontmatter.x", "value": 1 }]),
3311 None,
3312 ))
3313 .unwrap();
3314 let Bound { effects, .. } = bind(
3315 &c,
3316 &[hit("d_1", "/a.md", json!({ "__omg_sel_0_into": "hub.md", "__omg_sel_1_target": { "id": "d_7", "path": "/hub.md" } }))],
3317 &mut no_settle,
3318 )
3319 .unwrap();
3320 assert_eq!(
3321 effects.iter().map(Effect::to_json).collect::<Vec<_>>(),
3322 [
3323 json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "at": "end", "markdown": "x" }),
3324 json!({ "op": "set", "target": { "kind": "document", "id": "d_7" }, "field": "frontmatter.x", "value": 1 }),
3325 ]
3326 );
3327 let Bound { effects, .. } = bind(
3328 &c,
3329 &[hit(
3330 "d_1",
3331 "/a.md",
3332 json!({ "__omg_sel_0_into": "b_3", "__omg_sel_1_target": "d_2" }),
3333 )],
3334 &mut no_settle,
3335 )
3336 .unwrap();
3337 assert_eq!(
3338 effects[0].to_json()["into"],
3339 json!({ "kind": "block", "id": "b_3" })
3340 );
3341 let e = bind(
3342 &c,
3343 &[hit(
3344 "d_1",
3345 "/a.md",
3346 json!({ "__omg_sel_0_into": "hub", "__omg_sel_1_target": "d_2" }),
3347 )],
3348 &mut no_settle,
3349 )
3350 .unwrap_err();
3351 assert_eq!(
3352 e.message,
3353 "effects[0].into: the expression yielded a string (\"hub\"), not an identity (a document path, an id or a row)"
3354 );
3355 assert_eq!(data(&e)["hint"], HINT);
3356 assert_eq!(data(&e)["template_index"], 0);
3357 let mut seen: Vec<(Vec<Member>, usize)> = Vec::new();
3359 let mut settle =
3360 |_: &mut Json, m: &[Member], p: &[Effect]| seen.push((m.to_vec(), p.len()));
3361 let Bound { effects, .. } = bind(
3362 &c,
3363 &[hit(
3364 "d_1",
3365 "/a.md",
3366 json!({ "__omg_sel_0_into": "$new.0", "__omg_sel_1_target": "d_2" }),
3367 )],
3368 &mut settle,
3369 )
3370 .unwrap();
3371 assert_eq!(
3372 effects[0].to_json()["into"],
3373 json!({ "kind": "document", "id": "$new.0" })
3374 );
3375 assert!(seen.is_empty());
3377 }
3378
3379 use omgbase_store::{Config, MemDocStore, SequentialMinter};
3382
3383 const TS: &str = "2026-10-13T10:00:00.000Z";
3384
3385 fn seeded() -> (Store, PlanContext, MemDocStore) {
3386 let mut store =
3387 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
3388 let repo = store.create_repo("fixture").unwrap();
3389 for (path, source) in [
3390 (
3391 "hub.md",
3392 "---\ntype: project\n---\n\n# Hub\n\n## Status\n\nNothing yet.\n",
3393 ),
3394 (
3395 "ws/alpha.md",
3396 "---\ntype: workstream\nstatus: completed\nproject: hub.md\n---\n\n# Alpha\n",
3397 ),
3398 ] {
3399 store
3400 .observe_one(&repo, path, source, TS, &Config::default())
3401 .unwrap();
3402 }
3403 let ctx = PlanContext {
3404 repo_id: repo,
3405 ts: "2026-10-13T10:01:00.000Z".to_owned(),
3406 config: Config::default(),
3407 };
3408 (store, ctx, MemDocStore::new())
3409 }
3410
3411 fn plan_plain(
3412 store: &mut Store,
3413 ctx: &PlanContext,
3414 ds: &MemDocStore,
3415 effects: Json,
3416 ) -> Result<omgbase_mutate::Plan> {
3417 let req = SelectRequest::from_args(&json!({ "effects": effects }))
3418 .unwrap()
3419 .expect("sugar");
3420 plan_selected(store, ds, ctx, &req, HashMap::new())
3421 }
3422
3423 #[test]
3428 fn the_selector_less_path_evaluates_in_the_targets_row() {
3429 let (mut store, ctx, ds) = seeded();
3430 let plan = plan_plain(
3431 &mut store,
3432 &ctx,
3433 &ds,
3434 json!([
3435 { "op": "insert", "into": "hub.md", "markdown": "-> `## ${title} (${type})`" },
3436 { "op": "set", "target": "/ws/alpha.md", "field": "frontmatter.hub", "value": "-> project" },
3437 { "op": "set", "target": "d_1", "field": "frontmatter.n", "value": "-> 1 + 1" },
3438 ]),
3439 )
3440 .unwrap();
3441 assert!(plan.selection.is_none());
3442 assert_eq!(plan.planner_v, "p3");
3443 let effects: Vec<Json> = plan.effects.iter().map(Effect::to_json).collect();
3444 assert_eq!(effects[0]["markdown"], "## Hub (project)");
3445 assert_eq!(effects[1]["value"], "hub.md");
3446 assert_eq!(
3447 effects[1]["target"],
3448 json!({ "kind": "document", "id": "d_1", "path": "ws/alpha.md" })
3449 );
3450 assert_eq!(effects[2]["value"], 2);
3451 let templates = plan.templates.clone().expect("templates");
3452 assert_eq!(templates[0]["markdown"], "-> `## ${title} (${type})`");
3453 assert_eq!(templates[1]["target"], "/ws/alpha.md");
3454 let wire = plan.to_json(false);
3455 assert!(wire.get("templates").is_some());
3456 assert!(plan.core_json().get("templates").is_none());
3457 let literal = store
3459 .plan_effects(
3460 &ctx,
3461 &omgbase_mutate::Effect::list_from_json(&json!([
3462 { "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "## Hub (project)" },
3463 { "op": "set", "target": { "kind": "document", "path": "ws/alpha.md" }, "field": "frontmatter.hub", "value": "hub.md" },
3464 { "op": "set", "target": { "kind": "document", "id": "d_1" }, "field": "frontmatter.n", "value": 2 },
3465 ]))
3466 .unwrap(),
3467 &ds,
3468 )
3469 .unwrap();
3470 assert_eq!(literal.plan_id, plan.plan_id);
3471 assert!(literal.templates.is_none());
3472 let folded = plan_plain(
3474 &mut store,
3475 &ctx,
3476 &ds,
3477 json!([
3478 { "op": "insert", "into": "hub.md", "markdown": "-> \"## Hub (project)\"" },
3479 { "op": "set", "target": "/ws/alpha.md", "field": "frontmatter.hub", "value": "-> `hub.md`" },
3480 { "op": "set", "target": "d_1", "field": "frontmatter.n", "value": 2 },
3481 ]),
3482 )
3483 .unwrap();
3484 assert_eq!(folded.plan_id, plan.plan_id);
3485 assert!(folded.templates.is_none());
3486 let record = omgbase_mutate::effects::intent_record(
3488 &plan.plan_id,
3489 &plan.effects,
3490 None,
3491 plan.templates.as_deref(),
3492 );
3493 assert!(
3494 record.contains("\"templates\":[{\"into\":\"hub.md\""),
3495 "{record}"
3496 );
3497 }
3498
3499 #[test]
3501 fn the_selector_less_path_refuses_what_has_no_row() {
3502 let (mut store, ctx, ds) = seeded();
3503 let refuse = |store: &mut Store, effects: Json| -> SurfaceError {
3504 plan_plain(store, &ctx, &ds, effects).unwrap_err()
3505 };
3506 let e = refuse(
3507 &mut store,
3508 json!([{ "op": "create", "path": "n.md", "markdown": "-> title" }]),
3509 );
3510 assert_eq!(e.code, "filter_invalid");
3511 assert_eq!(data(&e)["hint"], HINT);
3512 assert_eq!(
3513 e.message,
3514 "effects[0]: a `create` has no row — an expression on a create needs a selector (`select`)"
3515 );
3516 let plan = plan_plain(
3518 &mut store,
3519 &ctx,
3520 &ds,
3521 json!([{ "op": "create", "path": "n.md", "markdown": "-> \"# N\"" }]),
3522 )
3523 .unwrap();
3524 assert_eq!(plan.effects[0].to_json()["markdown"], "# N");
3525 assert!(plan.templates.is_none());
3526 let e = refuse(
3527 &mut store,
3528 json!([{ "op": "insert", "into": "-> project", "markdown": "-> title" }]),
3529 );
3530 assert_eq!(
3531 e.message,
3532 "effects[0].into: an expression target needs a selector — without `select` there is no row to read it from"
3533 );
3534 let e = refuse(
3535 &mut store,
3536 json!([{ "op": "set", "target": { "kind": "document", "id": "-> x" }, "field": "frontmatter.a", "value": "-> title" }]),
3537 );
3538 assert!(
3539 e.message
3540 .starts_with("effects[0].target: an expression target needs a selector"),
3541 "{}",
3542 e.message
3543 );
3544 let e = refuse(
3545 &mut store,
3546 json!([
3547 { "op": "create", "path": "n.md", "markdown": "# N" },
3548 { "op": "insert", "into": "$new.0", "markdown": "-> title" },
3549 ]),
3550 );
3551 assert_eq!(
3552 e.message,
3553 "effects[1].into: $new.0 has no row yet — it is created by this plan; use a literal target or a selector"
3554 );
3555 let e = refuse(
3557 &mut store,
3558 json!([{ "op": "set", "target": "gone.md", "field": "frontmatter.a", "value": "-> title" }]),
3559 );
3560 assert_eq!(e.code, "doc_missing");
3561 assert_eq!(data(&e)["doc"], "gone.md");
3562 let e = refuse(
3563 &mut store,
3564 json!([{ "op": "insert", "into": "b_99", "markdown": "-> text" }]),
3565 );
3566 assert_eq!(e.code, "parent_missing");
3567 let e = refuse(
3568 &mut store,
3569 json!([{ "op": "set", "target": "b_99", "field": "markdown", "value": "-> text" }]),
3570 );
3571 assert_eq!(e.code, "block_missing");
3572 let e = refuse(
3574 &mut store,
3575 json!([{ "op": "insert", "into": "hub.md", "markdown": "-> nope" }]),
3576 );
3577 assert_eq!(
3578 e.message,
3579 "effects[0].markdown: the expression yielded absent for row d_0"
3580 );
3581 assert_eq!(
3582 *data(&e),
3583 json!({ "reason": e.message, "hint": HINT, "effect_index": 0, "row": { "id": "d_0", "path": "/hub.md" }, "expression": "nope" })
3584 );
3585 }
3586
3587 #[test]
3591 fn bare_new_symbols_settle_against_the_prefix_plan() {
3592 let (mut store, ctx, ds) = seeded();
3593 let plan = plan_plain(
3594 &mut store,
3595 &ctx,
3596 &ds,
3597 json!([
3598 { "op": "create", "path": "n.md", "markdown": "# N\n\nBody." },
3599 { "op": "insert", "into": "$new.0", "markdown": "Tail." },
3600 { "op": "set", "target": "$new.2", "field": "markdown", "value": "Body, edited." },
3601 { "op": "remove", "targets": ["$new.1"] },
3602 ]),
3603 )
3604 .unwrap();
3605 let effects: Vec<Json> = plan.effects.iter().map(Effect::to_json).collect();
3606 assert_eq!(
3607 effects[1]["into"],
3608 json!({ "kind": "document", "id": "$new.0", "path": "n.md" })
3609 );
3610 assert_eq!(
3611 effects[2]["target"],
3612 json!({ "kind": "block", "id": "$new.2" })
3613 );
3614 assert_eq!(
3615 effects[3]["targets"],
3616 json!([{ "kind": "block", "id": "$new.1" }])
3617 );
3618 assert!(plan.templates.is_none());
3619 let e = plan_plain(
3620 &mut store,
3621 &ctx,
3622 &ds,
3623 json!([
3624 { "op": "create", "path": "n.md", "markdown": "# N" },
3625 { "op": "unset", "target": "$new.1", "field": "frontmatter.k" },
3626 ]),
3627 )
3628 .unwrap_err();
3629 assert_eq!(e.code, "filter_invalid");
3630 assert_eq!(
3631 e.message,
3632 "effects[1].target: `kind` must be one of document (got \"block\")"
3633 );
3634 assert_eq!(data(&e)["effect_index"], 1);
3635 assert_eq!(data(&e)["hint"], "arguments");
3636 let e = plan_plain(
3637 &mut store,
3638 &ctx,
3639 &ds,
3640 json!([{ "op": "insert", "into": "$new.3", "markdown": "x" }]),
3641 )
3642 .unwrap_err();
3643 assert_eq!(e.code, "target_missing");
3644 assert_eq!(data(&e)["symbol"], "$new.3");
3645 }
3646}