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