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