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, TUPLE_HINT, bracket_list_as_tuple,
98 check_reserved_aliases, 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| {
1407 match bracket_list_as_tuple(select, &e.message) {
1410 None => refused(format!("select: {}", e.message)),
1411 Some(rewritten) => refused_with(
1412 format!("select: {}{TUPLE_HINT}", e.message),
1413 match request {
1414 Some(r) => json!({ "fix": fixes::fix_request(&fixes::Request {
1415 select: Some(Json::String(rewritten)),
1416 ..r.clone()
1417 }) }),
1418 None => json!({}),
1419 },
1420 ),
1421 }
1422 })?;
1423 let root_fix = |links: bool| -> Json {
1426 let span = raw.source.span();
1427 match request {
1428 Some(r) => json!({ "fix": fixes::root_fix(r, select, (span.start, span.end), links) }),
1429 None => json!({}),
1430 }
1431 };
1432 check_root_spellings(&raw).map_err(|e| refused(format!("select: {}", e.message)))?;
1433 check_reserved_aliases(&raw).map_err(|e| refused(format!("select: {}", e.message)))?;
1434 if !matches!(
1435 raw.consumer,
1436 Consumer::Collect | Consumer::First | Consumer::Single
1437 ) {
1438 return Err(refused(format!(
1439 "select: `{}` yields no rows — a selector's consumer is collect, first or single",
1440 raw.consumer.as_str()
1441 )));
1442 }
1443 if raw.values {
1444 return Err(refused("select: a selector yields rows, not `values`"));
1445 }
1446 let root = match root_target(&raw.source) {
1447 None => {
1448 return Err(refused_with(
1449 "select: a selector scans a root collection — `from docs`, `from blocks` or `from nodes`",
1450 root_fix(false),
1451 ));
1452 }
1453 Some(Target::Links) => {
1454 return Err(refused_with(
1455 "select: `links` rows have no effect target — select docs, blocks or nodes",
1456 root_fix(true),
1457 ));
1458 }
1459 Some(Target::Revisions) => {
1461 return Err(refused_with(
1462 "select: `revisions` rows have no effect target — select docs, blocks or nodes",
1463 root_fix(false),
1464 ));
1465 }
1466 Some(t) => t,
1467 };
1468 if raw.follow.is_some() {
1469 return Err(refused(
1470 "select: `follow` is not allowed in a selector — a hit must be a row of the root",
1471 ));
1472 }
1473 if !raw.from.is_empty() {
1474 return Err(refused(
1475 "select: a body `from` re-projects the rows — a hit must be a row of the root",
1476 ));
1477 }
1478 if raw.offset.is_some() {
1479 return Err(refused(
1480 "select: `offset` is not allowed — a selection is a set, not a page",
1481 ));
1482 }
1483 if raw.distinct {
1484 return Err(refused("select: `distinct` is not allowed in a selector"));
1485 }
1486 Ok((raw, root))
1487}
1488
1489pub fn compile(req: &SelectRequest) -> Result<Compiled> {
1494 let expect = validate_expect(req.expect.as_ref())?;
1495 let select = req
1496 .select
1497 .as_str()
1498 .filter(|s| !s.trim().is_empty())
1499 .ok_or_else(|| refused("`select` must be an OQX query (a non-empty string)"))?;
1500 let templates = req
1501 .templates
1502 .as_array()
1503 .ok_or_else(|| refused("`effects` must be an array of effect templates"))?;
1504 if templates.is_empty() {
1505 return Err(refused("`effects` must name at least one template"));
1506 }
1507 let request = fixes::Request {
1509 select: Some(req.select.clone()),
1510 effects: req.templates.clone(),
1511 expect: req.expect.clone(),
1512 };
1513 let (raw, root) = admit_selector(select, Some(&request))?;
1514 let fix = shape_fix(&request, &no_parent);
1517 let templates = templates
1518 .iter()
1519 .enumerate()
1520 .map(|(t, v)| {
1521 let (v, d) = desugar_effect(v, t, Some(&request))?;
1522 admit_template(t, &v, root, false, &d, Some(&fix))
1523 })
1524 .collect::<Result<Vec<_>>>()?;
1525 let mut rewritten = raw.clone();
1526 for tpl in &templates {
1527 for slot in &tpl.slots {
1528 rewritten
1529 .select
1530 .push(renamed(&slot.item, &slot_name(tpl.index, &slot.name)));
1531 }
1532 }
1533 let bound = expect.map_or(SELECTION_CEILING, |e| e.max);
1534 Ok(Compiled {
1535 raw,
1536 root,
1537 templates,
1538 rewritten,
1539 expect,
1540 bound,
1541 request,
1542 })
1543}
1544
1545pub type Row = (String, String);
1549
1550fn slot_data(k: usize, t: Option<usize>, row: &Row, expression: &str) -> Json {
1553 let mut d = json!({
1554 "effect_index": k,
1555 "row": { "id": row.0, "path": row.1 },
1556 "expression": expression,
1557 });
1558 if let Some(t) = t {
1559 d["template_index"] = json!(t);
1560 }
1561 d
1562}
1563
1564struct Origin<'a> {
1567 k: usize,
1568 template: Option<usize>,
1569 row: &'a Row,
1570 root: Target,
1572 request: Option<&'a fixes::Request>,
1574}
1575
1576fn identity_value<'v>(v: Option<&'v Json>, s: &Slot, o: &Origin<'_>) -> Result<&'v Json> {
1580 let data = || slot_data(o.k, o.template, o.row, &s.expression);
1581 let absent = v.is_none_or(Json::is_null);
1582 if absent || v.and_then(Json::as_array).is_some_and(Vec::is_empty) {
1583 return Err(MutationError::with_data(
1584 ErrorCode::TargetMissing,
1585 format!(
1586 "{}: the expression yielded no identity for row {}",
1587 s.here, o.row.0
1588 ),
1589 data(),
1590 )
1591 .into());
1592 }
1593 let v = v.expect("not absent");
1594 if let Some(a) = v.as_array() {
1595 let mut d = data();
1596 d["count"] = json!(a.len());
1597 let first = a
1601 .first()
1602 .and_then(|h| row_ref(h).map(|(id, _)| id).or_else(|| h.as_str()));
1603 if let (Some(req), Some(first)) = (o.request, first)
1604 && let Some(fix) =
1605 fixes::ambiguous_slot(req, &s.here, first, (o.root.as_str(), &o.row.0))
1606 {
1607 d["fix"] = json!(fix);
1608 }
1609 return Err(MutationError::with_data(
1610 ErrorCode::AmbiguousTarget,
1611 format!(
1612 "{}: the expression yielded {} identities for row {}; a target resolves to exactly one",
1613 s.here,
1614 a.len(),
1615 o.row.0
1616 ),
1617 d,
1618 )
1619 .into());
1620 }
1621 if v.as_str().is_none() && row_ref(v).is_none() {
1622 return Err(refused_with(
1623 format!(
1624 "{}: the expression yielded {}, not an identity (a string or a row)",
1625 s.here,
1626 describe(Some(v))
1627 ),
1628 data(),
1629 ));
1630 }
1631 Ok(v)
1632}
1633
1634fn whole_target(
1638 v: &Json,
1639 s: &Slot,
1640 o: &Origin<'_>,
1641 member: Member,
1642 op: &str,
1643 field: Option<&str>,
1644) -> Result<(Json, bool)> {
1645 let is_row = row_ref(v).is_some();
1646 let r = match row_ref(v) {
1647 Some((id, _)) => id,
1648 None => v
1649 .as_str()
1650 .expect("identity_value admitted a string or a row"),
1651 };
1652 if !is_row
1653 && !path_looking(r)
1654 && !is_target_id(r)
1655 && omgbase_mutate::parse_new_symbol(r).is_none()
1656 {
1657 return Err(refused_with(
1658 format!(
1659 "{}: the expression yielded {}, not an identity (a document path, an id or a row)",
1660 s.here,
1661 describe(Some(v))
1662 ),
1663 slot_data(o.k, o.template, o.row, &s.expression),
1664 ));
1665 }
1666 let t = bare_target(r, &s.here, member, op, field)?;
1667 Ok((t, omgbase_mutate::parse_new_symbol(r).is_some()))
1668}
1669
1670fn bind_one(root: Target, hit: &Json, t: &Template, o: &Origin<'_>) -> Result<(Json, Vec<Member>)> {
1676 let mut e = Json::Object(t.base.clone());
1677 let mut symbols = t.symbols.clone();
1678 match t.default_target {
1679 Some(DefaultTarget::Target) => e["target"] = hit_ref(root, &o.row.0),
1680 Some(DefaultTarget::Into) => e["into"] = hit_ref(root, &o.row.0),
1681 Some(DefaultTarget::Targets) => e["targets"] = json!([hit_ref(root, &o.row.0)]),
1682 None => {}
1683 }
1684 let field = t
1685 .base
1686 .get("field")
1687 .and_then(Json::as_str)
1688 .map(str::to_owned);
1689 for s in &t.slots {
1690 let v = hit.get(slot_name(t.index, &s.name));
1691 let absent = v.is_none_or(Json::is_null);
1692 let data = || slot_data(o.k, o.template, o.row, &s.expression);
1693 match &s.kind {
1694 SlotKind::Identity { .. } | SlotKind::TargetsIdentity { .. } => {
1695 let v = identity_value(v, s, o)?;
1696 let (member, value) = match (v.as_str(), row_ref(v)) {
1699 (Some(s), _) => (None, json!(s)),
1700 (None, Some((id, _))) => (Some("id".to_owned()), json!(id)),
1701 (None, None) => unreachable!("identity_value admitted it"),
1702 };
1703 let (target, written) = match &s.kind {
1704 SlotKind::Identity { key, member } => (e.get_mut(key), member.clone()),
1705 SlotKind::TargetsIdentity { k } => (
1706 e.get_mut("targets")
1707 .and_then(Json::as_array_mut)
1708 .and_then(|a| a.get_mut(*k)),
1709 "id".to_owned(),
1710 ),
1711 _ => unreachable!("identity kinds"),
1712 };
1713 if let Some(obj) = target.and_then(Json::as_object_mut) {
1714 obj.insert(member.unwrap_or(written), value);
1715 }
1716 }
1717 SlotKind::WholeTarget { key, member } => {
1718 let v = identity_value(v, s, o)?;
1719 let (target, symbol) = whole_target(v, s, o, *member, &t.op, field.as_deref())?;
1720 if symbol {
1721 symbols.push(*member);
1722 }
1723 e[key] = target;
1724 }
1725 SlotKind::WholeTargets { k } => {
1726 let v = identity_value(v, s, o)?;
1727 let (target, symbol) = whole_target(v, s, o, Member::Targets(*k), &t.op, None)?;
1728 if symbol {
1729 symbols.push(Member::Targets(*k));
1730 }
1731 if let Some(slot) = e
1732 .get_mut("targets")
1733 .and_then(Json::as_array_mut)
1734 .and_then(|a| a.get_mut(*k))
1735 {
1736 *slot = target;
1737 }
1738 }
1739 SlotKind::Str { .. } | SlotKind::At { .. } => {
1740 if absent {
1741 return Err(refused_with(
1742 format!(
1743 "{}: the expression yielded absent for row {}",
1744 s.here, o.row.0
1745 ),
1746 data(),
1747 ));
1748 }
1749 let Some(text) = v.and_then(Json::as_str) else {
1750 return Err(refused_with(
1751 format!(
1752 "{}: the expression yielded {}, expected a string",
1753 s.here,
1754 describe(v)
1755 ),
1756 data(),
1757 ));
1758 };
1759 match &s.kind {
1760 SlotKind::At { member } => e["at"] = json!({ member: text }),
1761 SlotKind::Str { key } => e[key] = json!(text),
1762 _ => unreachable!("string kinds"),
1763 }
1764 }
1765 SlotKind::Value | SlotKind::Frontmatter { .. } => {
1766 if absent {
1767 return Err(refused_with(
1768 format!(
1769 "{}: the expression yielded absent for row {}{}",
1770 s.here,
1771 o.row.0,
1772 if s.kind == SlotKind::Value {
1773 " — use `unset` to remove a key"
1774 } else {
1775 ""
1776 }
1777 ),
1778 data(),
1779 ));
1780 }
1781 let v = v.expect("not absent").clone();
1782 match &s.kind {
1783 SlotKind::Frontmatter { key } => {
1784 if let Some(fm) = e.get_mut("frontmatter").and_then(Json::as_object_mut) {
1785 fm.insert(key.clone(), v);
1786 }
1787 }
1788 _ => e["value"] = v,
1789 }
1790 }
1791 }
1792 }
1793 Effect::validate(&e, o.k).map_err(shape_error)?;
1794 Ok((e, symbols))
1795}
1796
1797pub type Settle<'a> = dyn FnMut(&mut Json, &[Member], &[Effect]) + 'a;
1802
1803pub fn no_settle(_: &mut Json, _: &[Member], _: &[Effect]) {}
1806
1807pub fn settle_symbols(
1812 store: &mut Store,
1813 ctx: &PlanContext,
1814 doc_store: &dyn DocStore,
1815 e: &mut Json,
1816 members: &[Member],
1817 prefix: &[Effect],
1818) {
1819 if prefix.is_empty() {
1820 return;
1821 }
1822 let Ok(plan) = store.plan_selected(ctx, prefix, None, doc_store) else {
1823 return;
1824 };
1825 for m in members {
1826 let slot = match m {
1827 Member::Target => e.get_mut("target"),
1828 Member::Into => e.get_mut("into"),
1829 Member::Targets(k) => e
1830 .get_mut("targets")
1831 .and_then(Json::as_array_mut)
1832 .and_then(|a| a.get_mut(*k)),
1833 };
1834 let Some(obj) = slot.and_then(Json::as_object_mut) else {
1835 continue;
1836 };
1837 let Some(n) = obj
1838 .get("id")
1839 .and_then(Json::as_str)
1840 .and_then(omgbase_mutate::parse_new_symbol)
1841 else {
1842 continue;
1843 };
1844 if let Some(x) = plan.new_ids.get(n) {
1845 obj.insert("kind".to_owned(), json!(x.kind.as_str()));
1846 }
1847 }
1848}
1849
1850fn finish(
1857 e: &mut Json,
1858 k: usize,
1859 symbols: &[Member],
1860 prefix: &[Effect],
1861 settle: &mut Settle<'_>,
1862 first: Effect,
1863) -> Result<Effect> {
1864 if symbols.is_empty() || prefix.is_empty() {
1865 return Ok(first);
1866 }
1867 settle(e, symbols, prefix);
1868 Effect::validate(e, k).map_err(|m| {
1869 let mut err = shape_error(m);
1870 if let Some(d) = err.data.as_mut().and_then(Json::as_object_mut) {
1871 d.insert("effect_index".to_owned(), json!(k));
1872 }
1873 err
1874 })
1875}
1876
1877fn row_of(hit: &Json) -> Row {
1879 (
1880 hit.get(HIT_ID)
1881 .and_then(Json::as_str)
1882 .unwrap_or_default()
1883 .to_owned(),
1884 hit.get(HIT_PATH)
1885 .and_then(Json::as_str)
1886 .unwrap_or_default()
1887 .to_owned(),
1888 )
1889}
1890
1891#[derive(Clone, Debug, Default)]
1894pub struct Bound {
1895 pub effects: Vec<Effect>,
1896 pub rows: Vec<Row>,
1897}
1898
1899pub fn bind(c: &Compiled, hits: &[Json], settle: &mut Settle<'_>) -> Result<Bound> {
1902 let mut b = Bound::default();
1903 for hit in hits {
1904 let row = row_of(hit);
1905 for t in &c.templates {
1906 let k = b.effects.len();
1907 let o = Origin {
1908 k,
1909 template: Some(t.index),
1910 row: &row,
1911 root: c.root,
1912 request: Some(&c.request),
1913 };
1914 let (mut e, symbols) = bind_one(c.root, hit, t, &o)?;
1915 let first = Effect::validate(&e, k).map_err(shape_error)?;
1916 let effect = finish(&mut e, k, &symbols, &b.effects, settle, first)?;
1917 b.effects.push(effect);
1918 }
1919 b.rows.push(row);
1920 }
1921 Ok(b)
1922}
1923
1924fn too_large(truncated: bool, hits: usize, c: &Compiled, count: usize) -> SurfaceError {
1928 let mut data = json!({
1929 "cardinality": if truncated { json!(format!(">{}", c.bound)) } else { json!(hits) },
1930 "max": c.bound,
1931 });
1932 if c.implicit() {
1933 data["implicit"] = json!(true);
1934 }
1935 data["fix"] = json!(fixes::bounded(&c.request, count));
1937 let message = format!(
1938 "the selection has {} hits, over the bound of {}{} — narrow the selector or raise `expect.max`",
1939 if truncated {
1940 format!("more than {}", c.bound)
1941 } else {
1942 hits.to_string()
1943 },
1944 c.bound,
1945 if c.implicit() {
1946 " (the implicit ceiling; pass expect.max to raise it)"
1947 } else {
1948 ""
1949 }
1950 );
1951 MutationError::with_data(ErrorCode::SelectionTooLarge, message, data).into()
1952}
1953
1954#[derive(Clone, Debug)]
1960pub struct Expansion {
1961 pub effects: Vec<Effect>,
1962 pub selection: Option<Selection>,
1963 pub rows: Vec<Row>,
1964 pub templates: Option<Vec<Json>>,
1965}
1966
1967pub fn expand(
1974 store: &mut Store,
1975 ctx: &PlanContext,
1976 doc_store: &dyn DocStore,
1977 req: &SelectRequest,
1978 semantic: HashMap<String, SemanticVec>,
1979) -> Result<Expansion> {
1980 if !req.has_selector() {
1981 return expand_plain(store, ctx, doc_store, req);
1982 }
1983 let c = compile(req)?;
1984 let vocabulary = names::VocabularyCache::default();
1993 {
1994 let own = Query {
1995 select: c
1996 .rewritten
1997 .select
1998 .iter()
1999 .filter(|it| {
2000 !RESERVED_ALIAS_PREFIXES
2001 .iter()
2002 .any(|p| it.name().starts_with(p))
2003 })
2004 .cloned()
2005 .collect(),
2006 ..c.rewritten.clone()
2007 };
2008 names::check_names(
2009 store,
2010 &ctx.repo_id,
2011 &own,
2012 names::UnknownNames::Refuse,
2013 &vocabulary,
2014 )
2015 .map_err(|e| refused(format!("select: {}", e.message)))?;
2016 }
2017 let now_ms = Some(dates::stamp_ms(&ctx.ts));
2019 let opts = QueryOptions {
2020 limit: Some(c.bound.saturating_add(1)),
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 request: None,
2029 };
2030 let res = query_ast(store, &ctx.repo_id, &c.rewritten, opts, semantic)?;
2031 if res.truncated || res.hits.len() > c.bound {
2032 let count = if res.truncated {
2036 let unbounded = QueryOptions {
2037 limit: Some(usize::MAX >> 2),
2038 cursor: None,
2039 provider: None,
2040 in_memory: false,
2041 bare: false,
2042 unknown_names: None,
2043 vocabulary: Some(&vocabulary),
2044 now_ms,
2045 request: None,
2046 };
2047 query_ast(store, &ctx.repo_id, &c.rewritten, unbounded, HashMap::new())?
2048 .hits
2049 .len()
2050 } else {
2051 res.hits.len()
2052 };
2053 return Err(too_large(res.truncated, res.hits.len(), &c, count));
2054 }
2055 let mut settle = |e: &mut Json, m: &[Member], p: &[Effect]| {
2056 settle_symbols(store, ctx, doc_store, e, m, p);
2057 };
2058 let b = bind(&c, &res.hits, &mut settle)?;
2059 let selection = Selection {
2060 query: req.select_str().to_owned(),
2061 expect: c.expect,
2062 templates: req.templates.as_array().cloned().unwrap_or_default(),
2063 cardinality: b.rows.len(),
2064 identities: b.rows.iter().map(|(id, _)| id.clone()).collect(),
2065 };
2066 Ok(Expansion {
2067 effects: b.effects,
2068 selection: Some(selection),
2069 rows: b.rows,
2070 templates: None,
2071 })
2072}
2073
2074#[derive(Clone, Debug, PartialEq, Eq)]
2079enum Missing {
2080 Doc(String),
2081 Block(String),
2082 Parent(String),
2083 Node(String),
2084}
2085
2086impl Missing {
2087 fn error(&self) -> SurfaceError {
2088 let (code, message, data) = match self {
2089 Missing::Doc(id) => (
2090 ErrorCode::DocMissing,
2091 format!("doc {id} not found"),
2092 json!({ "doc": id }),
2093 ),
2094 Missing::Block(id) => (
2095 ErrorCode::BlockMissing,
2096 format!("block {id} not found"),
2097 json!({ "block": id }),
2098 ),
2099 Missing::Parent(id) => (
2100 ErrorCode::ParentMissing,
2101 format!("parent {id} not found"),
2102 json!({}),
2103 ),
2104 Missing::Node(id) => (
2105 ErrorCode::BlockMissing,
2106 format!("node {id} not found"),
2107 json!({}),
2108 ),
2109 };
2110 MutationError::with_data(code, message, data).into()
2111 }
2112}
2113
2114#[derive(Clone, Debug, PartialEq, Eq)]
2117struct Scope {
2118 root: Target,
2119 id: String,
2120 missing: Missing,
2121}
2122
2123fn scope_of(
2129 store: &Store,
2130 repo_id: &str,
2131 e: &Map<String, Json>,
2132 i: usize,
2133 sugar: &Desugared,
2134) -> Result<Scope> {
2135 let here = format!("effects[{i}]");
2136 let op = e.get("op").and_then(Json::as_str).unwrap_or_default();
2137 let first = |kinds: &'static [&'static str]| -> Result<(String, Option<Json>, &'static [&'static str], Member)> {
2138 let list = e.get("targets").and_then(Json::as_array);
2139 if list.is_none_or(Vec::is_empty) {
2140 Effect::validate(&Json::Object(e.clone()), i).map_err(shape_error)?;
2142 }
2143 Ok((
2144 "targets[0]".to_owned(),
2145 list.and_then(|l| l.first()).cloned(),
2146 kinds,
2147 Member::Targets(0),
2148 ))
2149 };
2150 const PLACEMENT: &[&str] = &["document", "section", "block"];
2151 const DOCUMENT: &[&str] = &["document"];
2152 const BLOCK: &[&str] = &["block"];
2153 const SET: &[&str] = &["document", "block", "node"];
2154 let target = |kinds: &'static [&'static str]| {
2155 (
2156 "target".to_owned(),
2157 e.get("target").cloned(),
2158 kinds,
2159 Member::Target,
2160 )
2161 };
2162 let mut placement = false;
2163 let (key, v, kinds, member) = match op {
2164 "create" => {
2165 return Err(refused(format!(
2166 "{here}: a `create` has no row — an expression on a create needs a selector (`select`)"
2167 )));
2168 }
2169 "insert" => {
2170 placement = true;
2171 (
2172 "into".to_owned(),
2173 e.get("into").cloned(),
2174 PLACEMENT,
2175 Member::Into,
2176 )
2177 }
2178 "set" => target(SET),
2179 "unset" => target(DOCUMENT),
2180 "move" => {
2181 if e.contains_key("targets") || e.contains_key("into") {
2182 first(BLOCK)?
2183 } else {
2184 target(DOCUMENT)
2185 }
2186 }
2187 "remove" => {
2188 if e.contains_key("targets") {
2189 first(BLOCK)?
2190 } else {
2191 target(DOCUMENT)
2192 }
2193 }
2194 "split" => target(BLOCK),
2195 "merge" => first(BLOCK)?,
2196 _ => {
2197 Effect::validate(&Json::Object(e.clone()), i).map_err(shape_error)?;
2199 return Err(shape_error(format!(
2200 "{here}: `op` {} is not row-shaped",
2201 Json::String(op.to_owned())
2202 )));
2203 }
2204 };
2205 let where_ = format!("{here}.{key}");
2206 if v.as_ref().is_some_and(|v| {
2207 oqx_of(v).is_some()
2208 || v.as_object().is_some_and(|o| {
2209 o.get("id").is_some_and(|x| oqx_of(x).is_some())
2210 || o.get("path").is_some_and(|x| oqx_of(x).is_some())
2211 })
2212 }) {
2213 return Err(needs_selector(&where_));
2214 }
2215 let kinds_text = kinds
2216 .iter()
2217 .map(|k| format!("\"{k}\""))
2218 .collect::<Vec<_>>()
2219 .join(" | ");
2220 let Some(t) = v
2221 .as_ref()
2222 .and_then(Json::as_object)
2223 .filter(|o| o.get("kind").is_some_and(Json::is_string))
2224 else {
2225 return Err(shape_error(format!(
2226 "{where_}: a target is {{ kind: {kinds_text}, … }}"
2227 )));
2228 };
2229 let kind = t["kind"].as_str().expect("checked");
2230 let id_of = |key: &str| t.get(key).and_then(Json::as_str).map(str::to_owned);
2231 if sugar.symbols.contains(&member)
2233 && let Some(sym) = id_of("id").filter(|id| omgbase_mutate::parse_new_symbol(id).is_some())
2234 {
2235 return Err(refused(format!(
2236 "{where_}: {sym} has no row yet — it is created by this plan; use a literal target or a selector"
2237 )));
2238 }
2239 if !kinds.contains(&kind) {
2240 return Err(shape_error(format!(
2241 "{where_}: `kind` must be one of {} (got \"{kind}\")",
2242 kinds.join(", ")
2243 )));
2244 }
2245 Ok(match kind {
2246 "document" => {
2247 let id = id_of("id");
2248 let path = id_of("path");
2249 if id.is_none() && path.is_none() {
2250 return Err(shape_error(format!(
2251 "{where_}: a document target needs `id` or `path`"
2252 )));
2253 }
2254 let by_id = match &id {
2255 Some(id) => {
2256 let found = store.find_doc_by_ref(repo_id, id)?;
2257 Some(found.ok_or_else(|| Missing::Doc(id.clone()).error())?)
2258 }
2259 None => None,
2260 };
2261 let by_path = match &path {
2262 Some(path) => {
2263 let found = store.find_doc_by_ref(repo_id, storage_path(path))?;
2264 Some(found.ok_or_else(|| Missing::Doc(path.clone()).error())?)
2265 }
2266 None => None,
2267 };
2268 if let (Some(a), Some(b)) = (&by_id, &by_path)
2269 && a.doc_id != b.doc_id
2270 {
2271 return Err(shape_error(format!(
2272 "id {} and path {} name different documents",
2273 Json::String(id.clone().unwrap_or_default()),
2274 Json::String(path.clone().unwrap_or_default())
2275 )));
2276 }
2277 let info = by_id.or(by_path).expect("one of the two");
2278 Scope {
2279 root: Target::Docs,
2280 id: info.doc_id.clone(),
2281 missing: Missing::Doc(info.doc_id),
2282 }
2283 }
2284 "block" => {
2285 let id = id_of("id").unwrap_or_default();
2286 Scope {
2287 root: Target::Blocks,
2288 id: id.clone(),
2289 missing: if placement {
2290 Missing::Parent(id)
2291 } else {
2292 Missing::Block(id)
2293 },
2294 }
2295 }
2296 "section" => {
2297 let id = id_of("heading_id").unwrap_or_default();
2298 Scope {
2299 root: Target::Blocks,
2300 id: id.clone(),
2301 missing: Missing::Parent(id),
2302 }
2303 }
2304 _ => {
2305 let id = id_of("id").unwrap_or_default();
2306 Scope {
2307 root: Target::Nodes,
2308 id: id.clone(),
2309 missing: Missing::Node(id),
2310 }
2311 }
2312 })
2313}
2314
2315enum Plain {
2317 Literal(Json, Vec<Member>),
2319 Bound(Json, Vec<Member>),
2321}
2322
2323pub fn expand_plain(
2334 store: &mut Store,
2335 ctx: &PlanContext,
2336 doc_store: &dyn DocStore,
2337 req: &SelectRequest,
2338) -> Result<Expansion> {
2339 let raw = req
2340 .templates
2341 .as_array()
2342 .ok_or_else(|| shape_error("`effects` must be an array of effects".to_owned()))?;
2343 if raw.is_empty() {
2344 return Err(shape_error(
2345 "`effects` must name at least one effect".to_owned(),
2346 ));
2347 }
2348 let request = fixes::Request {
2350 select: None,
2351 effects: Json::Array(raw.clone()),
2352 expect: None,
2353 };
2354 let mut desugared = Vec::with_capacity(raw.len());
2355 for (i, v) in raw.iter().enumerate() {
2356 desugared.push(desugar_effect(v, i, Some(&request))?);
2357 }
2358 let any_expression = desugared.iter().any(|(v, _)| contains_oqx(v));
2359 let mut staged: Vec<Option<Plain>> = (0..raw.len()).map(|_| None).collect();
2360 if any_expression {
2361 struct Group {
2363 scope: Scope,
2364 members: Vec<Template>,
2365 }
2366 let mut groups: Vec<Group> = Vec::new();
2367 for (i, (v, d)) in desugared.iter().enumerate() {
2368 let Some(o) = v.as_object().filter(|_| contains_oqx(v)) else {
2369 staged[i] = Some(Plain::Literal(v.clone(), d.symbols.clone()));
2370 continue;
2371 };
2372 let scope = scope_of(store, &ctx.repo_id, o, i, d)?;
2373 let lookup = parent_lookup(store);
2374 let fix = shape_fix(&request, &lookup);
2375 let t = admit_template(i, v, scope.root, true, d, Some(&fix))?;
2376 if t.slots.is_empty() {
2377 staged[i] = Some(Plain::Literal(v.clone(), d.symbols.clone()));
2379 continue;
2380 }
2381 match groups.iter_mut().find(|g| g.scope == scope) {
2382 Some(g) => g.members.push(t),
2383 None => groups.push(Group {
2384 scope,
2385 members: vec![t],
2386 }),
2387 }
2388 }
2389 for g in &groups {
2390 let src = format!(
2391 "from {} where id == {}",
2392 g.scope.root.as_str(),
2393 Json::String(g.scope.id.clone())
2394 );
2395 let mut q = oqx::parse_string(&src)?;
2396 for t in &g.members {
2397 for s in &t.slots {
2398 q.select
2399 .push(renamed(&s.item, &slot_name(t.index, &s.name)));
2400 }
2401 }
2402 let opts = QueryOptions {
2403 limit: Some(2),
2404 cursor: None,
2405 provider: None,
2406 in_memory: false,
2407 bare: false,
2408 unknown_names: None,
2409 vocabulary: None,
2410 now_ms: Some(dates::stamp_ms(&ctx.ts)),
2411 request: None,
2412 };
2413 let res = query_ast(store, &ctx.repo_id, &q, opts, HashMap::new())?;
2414 let Some(hit) = res.hits.first() else {
2415 return Err(g.scope.missing.error());
2416 };
2417 let row = row_of(hit);
2418 for t in &g.members {
2419 let o = Origin {
2420 k: t.index,
2421 template: None,
2422 row: &row,
2423 root: g.scope.root,
2424 request: Some(&request),
2425 };
2426 let (e, symbols) = bind_one(g.scope.root, hit, t, &o)?;
2427 staged[t.index] = Some(Plain::Bound(e, symbols));
2428 }
2429 }
2430 } else {
2431 for (i, (v, d)) in desugared.into_iter().enumerate() {
2432 staged[i] = Some(Plain::Literal(v, d.symbols));
2433 }
2434 }
2435 let mut effects: Vec<Effect> = Vec::with_capacity(raw.len());
2438 for (i, p) in staged.into_iter().enumerate() {
2439 let (mut e, symbols) = match p.expect("every effect staged") {
2440 Plain::Literal(e, s) | Plain::Bound(e, s) => (e, s),
2441 };
2442 let first = {
2445 let lookup = parent_lookup(store);
2446 let fix = shape_fix(&request, &lookup);
2447 Effect::validate_with(&e, i, Some(&fix)).map_err(shape_error_of)?
2448 };
2449 let mut settle = |e: &mut Json, m: &[Member], p: &[Effect]| {
2450 settle_symbols(store, ctx, doc_store, e, m, p);
2451 };
2452 let effect = finish(&mut e, i, &symbols, &effects, &mut settle, first)?;
2453 effects.push(effect);
2454 }
2455 Ok(Expansion {
2456 effects,
2457 selection: None,
2458 rows: Vec::new(),
2459 templates: any_expression.then(|| raw.clone()),
2460 })
2461}
2462
2463#[must_use]
2466pub fn annotate(mut e: SurfaceError, rows: &[Row], templates: usize) -> SurfaceError {
2467 if templates == 0 {
2468 return e;
2469 }
2470 if let Some(data) = e.data.as_mut().and_then(Json::as_object_mut)
2471 && let Some(k) = data
2472 .get("effect_index")
2473 .and_then(Json::as_u64)
2474 .and_then(|k| usize::try_from(k).ok())
2475 && !data.contains_key("template_index")
2476 && let Some((id, path)) = rows.get(k / templates)
2477 {
2478 data.insert("template_index".to_owned(), json!(k % templates));
2479 data.insert("row".to_owned(), json!({ "id": id, "path": path }));
2480 }
2481 e
2482}
2483
2484pub fn plan_selected(
2490 store: &mut Store,
2491 doc_store: &dyn DocStore,
2492 ctx: &PlanContext,
2493 req: &SelectRequest,
2494 semantic: HashMap<String, SemanticVec>,
2495) -> Result<omgbase_mutate::Plan> {
2496 let x = expand(store, ctx, doc_store, req, semantic)?;
2497 let templates = if req.has_selector() {
2498 req.templates()
2499 } else {
2500 0
2501 };
2502 let concrete = x.effects.len();
2503 let plan = store
2504 .plan_selected(ctx, &x.effects, x.selection, doc_store)
2505 .map_err(|e| annotate(SurfaceError::from(e), &x.rows, templates));
2506 let mut plan = match plan {
2507 Ok(plan) => plan,
2508 Err(e) => {
2512 let req_fix = fixes::Request {
2513 select: req.has_selector().then(|| req.select.clone()),
2514 effects: req.templates.clone(),
2515 expect: req.expect.clone(),
2516 };
2517 let origins: Option<Vec<fixes::Origin>> = (templates > 0).then(|| {
2518 (0..concrete)
2519 .map(|k| fixes::Origin {
2520 row_id: x
2521 .rows
2522 .get(k / templates)
2523 .map(|(id, _)| id.clone())
2524 .unwrap_or_default(),
2525 template_index: k % templates,
2526 })
2527 .collect()
2528 });
2529 let allocated = |i: usize| -> Option<usize> {
2530 if i == 0 {
2531 return Some(0);
2532 }
2533 store
2534 .plan_selected(ctx, &x.effects[..i.min(concrete)], None, doc_store)
2535 .ok()
2536 .map(|p| p.new_ids.len())
2537 };
2538 return Err(fixes::planning_fix(
2539 &req_fix,
2540 e,
2541 origins.as_deref(),
2542 allocated,
2543 ));
2544 }
2545 };
2546 plan.templates = x.templates;
2547 Ok(plan)
2548}
2549
2550#[must_use]
2555pub fn unbounded_apply(req: &SelectRequest, cardinality: usize) -> SurfaceError {
2556 refused_with(
2557 UNBOUNDED,
2558 json!({
2559 "fix": fixes::bounded(
2560 &fixes::Request {
2561 select: Some(req.select.clone()),
2562 effects: req.templates.clone(),
2563 expect: None,
2564 },
2565 cardinality,
2566 )
2567 }),
2568 )
2569}
2570
2571const UNBOUNDED: &str = "`expect.max` is required on `apply` with `select` — bound the selection, or preview it with `plan`";
2572
2573pub fn require_max(req: &SelectRequest) -> Result<()> {
2577 if req.has_selector() && req.expect.is_none() {
2578 return Err(refused(UNBOUNDED));
2579 }
2580 Ok(())
2581}
2582
2583#[cfg(test)]
2584mod tests {
2585 use super::*;
2586
2587 fn req(select: &str, templates: Json, max: Option<usize>) -> SelectRequest {
2588 SelectRequest {
2589 select: json!(select),
2590 templates,
2591 expect: max.map(|max| json!({ "max": max })),
2592 }
2593 }
2594
2595 const ROLLUP: &str = "from docs where type == \"workstream\" and status == \"completed\"";
2596
2597 fn rollup_templates() -> Json {
2598 json!([
2599 { "op": "insert", "into": { "kind": "document", "path": { "oqx": "project" } }, "at": "end",
2600 "markdown": { "oqx": "\"- completed: [\" + title + \"](\" + path + \")\"" } },
2601 { "op": "set", "field": "frontmatter.rolled_up", "value": true },
2602 ])
2603 }
2604
2605 fn hit(id: &str, path: &str, slots: Json) -> Json {
2606 let mut h = json!({ "id": id, "path": path });
2607 if let Some(o) = slots.as_object() {
2608 for (k, v) in o {
2609 h[k] = v.clone();
2610 }
2611 }
2612 h
2613 }
2614
2615 fn data(e: &SurfaceError) -> &Json {
2616 e.data.as_ref().expect("data")
2617 }
2618
2619 #[test]
2623 fn the_rewrite_appends_slot_projections_after_the_users_items() {
2624 let c = compile(&req(ROLLUP, rollup_templates(), None)).unwrap();
2625 assert_eq!(
2626 oqx::print_query(&c.rewritten).unwrap(),
2627 "select __omg_sel_0_into: project, __omg_sel_0_markdown: \"- completed: [\" + title + \"](\" + path + \")\" from docs where type == \"workstream\" and status == \"completed\""
2628 );
2629 assert_eq!(
2630 c.templates[0].slot_names(),
2631 ["__omg_sel_0_into", "__omg_sel_0_markdown"]
2632 );
2633 assert!(c.templates[1].slot_names().is_empty());
2634 assert_eq!(c.templates[1].default_target, Some(DefaultTarget::Target));
2635 assert_eq!((c.bound, c.implicit()), (SELECTION_CEILING, true));
2636 let c = compile(&req(
2637 "select hub: project from docs",
2638 json!([{ "op": "insert", "into": { "kind": "document", "path": { "oqx": "hub" } }, "markdown": "x" }]),
2639 Some(5),
2640 ))
2641 .unwrap();
2642 assert_eq!(
2643 oqx::print_query(&c.rewritten).unwrap(),
2644 "select hub: project, __omg_sel_0_into: hub from docs"
2645 );
2646 assert_eq!(
2647 oqx::print_query(&oqx::resolve_aliases(&c.rewritten).unwrap()).unwrap(),
2648 "select hub: project, __omg_sel_0_into: project from docs"
2649 );
2650 assert_eq!((c.bound, c.implicit()), (5, false));
2651 let c = compile(&req(
2653 "from docs",
2654 json!([{ "op": "set", "target": { "kind": "document", "id": { "oqx": "^docs single { id values where data.slug == ^hub_slug }" } }, "field": "frontmatter.x", "value": 1 }]),
2655 None,
2656 ))
2657 .unwrap();
2658 assert!(
2659 oqx::print_query(&c.rewritten)
2660 .unwrap()
2661 .starts_with("select __omg_sel_0_target: ^docs single {"),
2662 );
2663 }
2664
2665 #[test]
2669 fn a_bracket_list_in_a_selector_is_fixed_to_a_tuple() {
2670 let t = json!([{ "op": "set", "field": "frontmatter.rolled_up", "value": true }]);
2671 let e = compile(&req(
2672 "from docs where stage in [\"nigredo\", \"albedo\"]",
2673 t.clone(),
2674 None,
2675 ))
2676 .unwrap_err();
2677 assert_eq!(e.code, "filter_invalid");
2678 assert_eq!(
2679 e.message,
2680 "select: unexpected '[' — expected a value (at offset 25); a list is written as a tuple: (…)"
2681 );
2682 let d = e.data.unwrap();
2683 assert_eq!(d["hint"], HINT);
2684 assert_eq!(
2685 d["fix"],
2686 r#"{"effects":[{"field":"frontmatter.rolled_up","op":"set","value":true}],"select":"from docs where stage in (\"nigredo\", \"albedo\")"}"#
2687 );
2688 let e = compile(&req(
2690 "from docs where stage in [\"a\", \"b\"]",
2691 t.clone(),
2692 Some(2),
2693 ))
2694 .unwrap_err();
2695 assert!(
2696 e.data.unwrap()["fix"]
2697 .as_str()
2698 .unwrap()
2699 .contains("\"expect\":{\"max\":2}")
2700 );
2701 let e = compile(&req("from docs where stage in [\"nigredo\"]", t, None)).unwrap_err();
2702 assert_eq!(
2703 e.message,
2704 "select: unexpected '[' — expected a value (at offset 25)"
2705 );
2706 assert!(e.data.unwrap().get("fix").is_none());
2707 }
2708
2709 #[test]
2710 fn the_static_pass_refuses_what_cannot_be_a_selection() {
2711 let t = json!([{ "op": "set", "field": "frontmatter.x", "value": 1 }]);
2712 let refuse = |select: &str, templates: Json, max: Option<usize>| -> SurfaceError {
2713 let e = compile(&req(select, templates, max)).unwrap_err();
2714 assert_eq!(e.code, "filter_invalid", "{select}");
2715 e
2716 };
2717 let selector: [(&str, &str); 11] = [
2718 (
2719 "docs count { }",
2720 "select: `count` yields no rows — a selector's consumer is collect, first or single",
2721 ),
2722 (
2723 "select title values from docs",
2724 "select: a selector yields rows, not `values`",
2725 ),
2726 (
2727 "from links",
2728 "select: `links` rows have no effect target — select docs, blocks or nodes",
2729 ),
2730 (
2731 "from revisions where origin == \"api\"",
2732 "select: `revisions` rows have no effect target — select docs, blocks or nodes",
2733 ),
2734 (
2735 "entries(docs) collect { }",
2736 "select: a selector scans a root collection — `from docs`, `from blocks` or `from nodes`",
2737 ),
2738 (
2739 "from docs follow refs(\"a\")",
2740 "select: `follow` is not allowed in a selector — a hit must be a row of the root",
2741 ),
2742 (
2743 "from docs offset 2",
2744 "select: `offset` is not allowed — a selection is a set, not a page",
2745 ),
2746 (
2747 "select distinct type from docs",
2748 "select: `distinct` is not allowed in a selector",
2749 ),
2750 (
2751 "select __omg_x: 1 from docs",
2752 "select: projection `__omg_x` uses the reserved prefix `__omg_` — choose another alias",
2753 ),
2754 (
2755 "select __oqx_id: 1 from docs",
2756 "select: projection `__oqx_id` uses the reserved prefix `__oqx_` — choose another alias",
2757 ),
2758 (
2759 "from docs where $repo",
2760 "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`",
2761 ),
2762 ];
2763 for (select, message) in selector {
2764 let e = refuse(select, t.clone(), None);
2765 assert_eq!(data(&e)["hint"], HINT, "{select}");
2766 assert_eq!(e.message, message, "{select}");
2767 }
2768 let e = refuse("from docs where (", t.clone(), None);
2770 assert!(e.message.starts_with("select: "), "{}", e.message);
2771 assert_eq!(data(&e)["hint"], HINT);
2772 for ok in [
2774 "from docs where x exists { } order by title limit 3",
2775 "docs first { where status == \"a\" }",
2776 "docs single { }",
2777 ] {
2778 compile(&req(ok, t.clone(), None)).unwrap_or_else(|e| panic!("{ok}: {e}"));
2779 }
2780 let e = compile(&SelectRequest {
2782 select: json!(" "),
2783 templates: t.clone(),
2784 expect: None,
2785 })
2786 .unwrap_err();
2787 assert_eq!(
2788 e.message,
2789 "`select` must be an OQX query (a non-empty string)"
2790 );
2791 let e = compile(&SelectRequest {
2792 select: json!("from docs"),
2793 templates: json!({}),
2794 expect: None,
2795 })
2796 .unwrap_err();
2797 assert_eq!(e.message, "`effects` must be an array of effect templates");
2798 let e = refuse("from docs", json!([]), None);
2799 assert_eq!(e.message, "`effects` must name at least one template");
2800 for (expect, message) in [
2801 (json!(3), "`expect` must be an object { max }"),
2802 (json!({ "max": 1, "x": 2 }), "`expect`: unknown key `x`"),
2803 (
2804 json!({ "max": 1, "x": 2, "y": 3 }),
2805 "`expect`: unknown keys `x`, `y`",
2806 ),
2807 (json!({}), "`expect.max` must be a non-negative integer"),
2808 (
2809 json!({ "max": -1 }),
2810 "`expect.max` must be a non-negative integer",
2811 ),
2812 (
2813 json!({ "max": 1.5 }),
2814 "`expect.max` must be a non-negative integer",
2815 ),
2816 ] {
2817 let e = compile(&SelectRequest {
2818 select: json!("from docs"),
2819 templates: t.clone(),
2820 expect: Some(expect),
2821 })
2822 .unwrap_err();
2823 assert_eq!(e.message, message);
2824 }
2825 let templates: [(Json, &str); 9] = [
2827 (
2828 json!([{ "op": "retarget", "repairs": [] }]),
2829 "effects[0]: `retarget` is not row-shaped — it cannot be a selector template",
2830 ),
2831 (
2832 json!([{ "op": "merge", "separator": " " }]),
2833 "effects[0]: `merge` needs `targets` — one hit cannot be merged with itself",
2834 ),
2835 (
2836 json!([{ "op": "nope" }]),
2837 "effects[0]: `op` must be one of create/insert/set/unset/move/remove/split/merge (got \"nope\")",
2838 ),
2839 (
2840 json!(["x"]),
2841 "effects[0]: a template is an effect object whose `op` is one of create/insert/set/unset/move/remove/split/merge",
2842 ),
2843 (
2844 json!([{ "op": "set", "field": { "oqx": "\"frontmatter.x\"" }, "value": 1 }]),
2845 "effects[0].field: `{ oqx }` is not allowed here",
2846 ),
2847 (
2848 json!([{ "op": "set", "target": { "kind": "document", "id": { "oqx": "a" }, "path": { "oqx": "b" } }, "field": "frontmatter.x", "value": 1 }]),
2849 "effects[0].target: give `id` or `path` as an expression, not both",
2850 ),
2851 (
2852 json!([{ "op": "set", "target": { "kind": { "oqx": "k" }, "id": "d_1" }, "field": "frontmatter.x", "value": 1 }]),
2853 "effects[0].target.kind: `{ oqx }` is not allowed here",
2854 ),
2855 (
2856 json!([{ "op": "remove", "targets": [{ "kind": "block", "path": { "oqx": "p" } }] }]),
2857 "effects[0].targets[0]: a block target has `id`, not `path`",
2858 ),
2859 (
2860 json!([{ "op": "set", "field": "frontmatter.x", "value": { "oqx": "a, b" } }]),
2861 "effects[0].value: `oqx` must be one expression over the hit, not a query",
2862 ),
2863 ];
2864 for (templates, message) in templates {
2865 let e = refuse("from docs", templates, None);
2866 assert_eq!(e.message, message);
2867 assert_eq!(data(&e)["hint"], HINT);
2868 }
2869 compile(&req(
2871 "from docs",
2872 json!([{ "op": "set", "field": "frontmatter.x", "value": { "k": { "oqx": "x" } } }]),
2873 None,
2874 ))
2875 .unwrap();
2876 compile(&req("from docs", json!([{ "op": "create", "path": "n.md", "markdown": "x", "frontmatter": { "k": { "deep": { "oqx": "x" } } } }]), None)).unwrap();
2877 let e = refuse(
2879 "from nodes",
2880 json!([{ "op": "insert", "markdown": "x" }]),
2881 None,
2882 );
2883 assert_eq!(
2884 e.message,
2885 "effects[0].into: `kind` must be one of document, section, block (got \"node\")"
2886 );
2887 assert_eq!(data(&e)["hint"], "arguments");
2888 let e = refuse("from docs", json!([{ "op": "split", "at": [1] }]), None);
2889 assert_eq!(data(&e)["hint"], "arguments");
2890 let e = refuse(
2891 "from blocks",
2892 json!([{ "op": "unset", "field": "frontmatter.x" }]),
2893 None,
2894 );
2895 assert!(e.message.contains("(got \"block\")"), "{}", e.message);
2896 let c = compile(&req(
2898 "from blocks",
2899 json!([{ "op": "remove", "targets": [{ "oqx": "x" }] }]),
2900 None,
2901 ))
2902 .unwrap();
2903 assert_eq!(c.templates[0].slot_names(), ["__omg_sel_0_targets_0"]);
2904 assert!(matches!(
2905 c.templates[0].slots[0].kind,
2906 SlotKind::WholeTargets { k: 0 }
2907 ));
2908 let e = refuse(
2910 "from docs",
2911 json!([{ "op": "set", "field": "frontmatter.x", "value": { "oqx": "1 +" } }]),
2912 None,
2913 );
2914 assert_eq!(
2915 e.message,
2916 "effects[0].value: unexpected 'from' — expected a value (at offset 4)"
2917 );
2918 let e = refuse(
2919 "from docs",
2920 json!([{ "op": "set", "field": "frontmatter.x", "value": { "oqx": "a from blocks" } }]),
2921 None,
2922 );
2923 assert_eq!(
2924 e.message,
2925 "effects[0].value: duplicate `from` clause (at offset 14)"
2926 );
2927 }
2928
2929 #[test]
2930 fn the_request_reads_expect_only_with_a_select() {
2931 assert_eq!(
2932 SelectRequest::from_args(&json!({ "effects": [] })).unwrap(),
2933 None
2934 );
2935 let r = SelectRequest::from_args(
2936 &json!({ "select": "from docs", "effects": [{ "op": "x" }], "expect": { "max": 3 } }),
2937 )
2938 .unwrap()
2939 .unwrap();
2940 assert_eq!(r.expect, Some(json!({ "max": 3 })));
2941 assert_eq!(r.templates(), 1);
2942 let e = SelectRequest::from_args(&json!({ "effects": [], "expect": { "max": 1 } }))
2943 .unwrap_err();
2944 assert_eq!(
2945 e.message,
2946 "`expect` bounds a selection — pass `select` with it"
2947 );
2948 assert!(require_max(&r.clone()).is_ok());
2949 let e = require_max(&SelectRequest { expect: None, ..r }).unwrap_err();
2950 assert_eq!(
2951 e.message,
2952 "`expect.max` is required on `apply` with `select` — bound the selection, or preview it with `plan`"
2953 );
2954 assert_eq!(data(&e)["hint"], HINT);
2955 }
2956
2957 #[test]
2958 fn binding_types_each_slot_and_names_the_row() {
2959 let c = compile(&req(ROLLUP, rollup_templates(), Some(10))).unwrap();
2960 let hits = vec" }),
2965 ),
2966 hit(
2967 "d_5",
2968 "/workstreams/b.md",
2969 json!({ "__omg_sel_0_into": { "id": "d_9", "path": "/projects/y.md" }, "__omg_sel_0_markdown": "- completed: [B](/workstreams/b.md)" }),
2970 ),
2971 ];
2972 let Bound { effects, rows, .. } = bind(&c, &hits, &mut no_settle).unwrap();
2973 assert_eq!(
2974 rows,
2975 [
2976 ("d_2".to_owned(), "/workstreams/a.md".to_owned()),
2977 ("d_5".to_owned(), "/workstreams/b.md".to_owned())
2978 ]
2979 );
2980 let json: Vec<Json> = effects.iter().map(Effect::to_json).collect();
2981 assert_eq!(
2982 json,
2983 [
2984 json!({ "op": "insert", "into": { "kind": "document", "path": "/projects/x.md" }, "at": "end", "markdown": "- completed: [A](/workstreams/a.md)" }),
2985 json!({ "op": "set", "target": { "kind": "document", "id": "d_2" }, "field": "frontmatter.rolled_up", "value": true }),
2986 json!({ "op": "insert", "into": { "kind": "document", "id": "d_9" }, "at": "end", "markdown": "- completed: [B](/workstreams/b.md)" }),
2988 json!({ "op": "set", "target": { "kind": "document", "id": "d_5" }, "field": "frontmatter.rolled_up", "value": true }),
2989 ]
2990 );
2991 for absent in [
2993 json!({}),
2994 json!({ "__omg_sel_0_into": null }),
2995 json!({ "__omg_sel_0_into": [] }),
2996 ] {
2997 let mut h = hit("d_2", "/w.md", absent);
2998 h["__omg_sel_0_markdown"] = json!("x");
2999 let e = bind(&c, &[h], &mut no_settle).unwrap_err();
3000 assert_eq!(e.code, "target_missing");
3001 assert_eq!(
3002 e.message,
3003 "effects[0].into.path: the expression yielded no identity for row d_2"
3004 );
3005 assert_eq!(
3006 *data(&e),
3007 json!({ "effect_index": 0, "template_index": 0, "row": { "id": "d_2", "path": "/w.md" }, "expression": "project" })
3008 );
3009 }
3010 let h = hit(
3012 "d_2",
3013 "/w.md",
3014 json!({ "__omg_sel_0_into": ["/a.md"], "__omg_sel_0_markdown": "x" }),
3015 );
3016 let e = bind(&c, &[h], &mut no_settle).unwrap_err();
3017 assert_eq!(e.code, "ambiguous_target");
3018 assert_eq!(
3019 e.message,
3020 "effects[0].into.path: the expression yielded 1 identities for row d_2; a target resolves to exactly one"
3021 );
3022 assert_eq!(data(&e)["count"], 1);
3023 assert_eq!(data(&e)["expression"], "project");
3024 let h = hit(
3026 "d_2",
3027 "/w.md",
3028 json!({ "__omg_sel_0_into": 3, "__omg_sel_0_markdown": "x" }),
3029 );
3030 let e = bind(&c, &[h], &mut no_settle).unwrap_err();
3031 assert_eq!(e.code, "filter_invalid");
3032 assert_eq!(
3033 e.message,
3034 "effects[0].into.path: the expression yielded a number (3), not an identity (a string or a row)"
3035 );
3036 assert_eq!(
3037 *data(&e),
3038 json!({ "reason": e.message, "hint": HINT, "effect_index": 0, "template_index": 0, "row": { "id": "d_2", "path": "/w.md" }, "expression": "project" })
3039 );
3040 let h = hit(
3042 "d_2",
3043 "/w.md",
3044 json!({ "__omg_sel_0_into": "/p.md", "__omg_sel_0_markdown": 7.5 }),
3045 );
3046 let e = bind(&c, &[h], &mut no_settle).unwrap_err();
3047 assert_eq!(
3048 e.message,
3049 "effects[0].markdown: the expression yielded a number (7.5), expected a string"
3050 );
3051 let h = hit("d_2", "/w.md", json!({ "__omg_sel_0_into": "/p.md" }));
3052 let e = bind(&c, &[h], &mut no_settle).unwrap_err();
3053 assert_eq!(
3054 e.message,
3055 "effects[0].markdown: the expression yielded absent for row d_2"
3056 );
3057 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();
3059 let e = bind(
3060 &c,
3061 &[hit(
3062 "d_1",
3063 "/a.md",
3064 json!({ "__omg_sel_1_into": { "id": "d_2", "path": "/p.md", "extra": 1 } }),
3065 )],
3066 &mut no_settle,
3067 )
3068 .unwrap_err();
3069 assert_eq!(
3070 e.message,
3071 "effects[1].into.path: the expression yielded an object, not an identity (a string or a row)"
3072 );
3073 assert_eq!(data(&e)["effect_index"], 1);
3074 assert_eq!(data(&e)["template_index"], 1);
3075 }
3076
3077 #[test]
3078 fn binding_the_hit_by_kind_and_the_other_slots() {
3079 let c = compile(&req(
3083 "from blocks",
3084 json!([
3085 { "op": "move", "into": { "kind": "block", "id": { "oqx": "parent" } }, "at": { "after": { "oqx": "anchor" } } },
3086 { "op": "set", "field": "attrs.checked", "value": { "oqx": "done" } },
3087 { "op": "remove", "targets": [{ "kind": "block", "id": { "oqx": "sibling" } }, { "kind": "block", "id": { "oqx": "other" } }] },
3088 { "op": "remove" },
3089 { "op": "merge", "targets": [{ "kind": "block", "id": { "oqx": "a" } }, { "kind": "block", "id": "b_5" }], "separator": { "oqx": "sep" } },
3090 { "op": "split", "at": [2] },
3091 ]),
3092 Some(1),
3093 ))
3094 .unwrap();
3095 assert_eq!(c.templates[0].default_target, Some(DefaultTarget::Targets));
3096 assert_eq!(c.templates[1].default_target, Some(DefaultTarget::Target));
3097 assert_eq!(c.templates[2].default_target, None);
3098 assert_eq!(c.templates[3].default_target, Some(DefaultTarget::Targets));
3099 assert_eq!(c.templates[5].default_target, Some(DefaultTarget::Target));
3100 assert_eq!(
3101 c.templates
3102 .iter()
3103 .flat_map(Template::slot_names)
3104 .collect::<Vec<_>>(),
3105 [
3106 "__omg_sel_0_into",
3107 "__omg_sel_0_at",
3108 "__omg_sel_1_value",
3109 "__omg_sel_2_targets_0",
3110 "__omg_sel_2_targets_1",
3111 "__omg_sel_4_targets_0",
3112 "__omg_sel_4_separator",
3113 ]
3114 );
3115 let h = hit(
3116 "b_1",
3117 "/a.md",
3118 json!({
3119 "__omg_sel_0_into": { "id": "b_9", "path": "/a.md" }, "__omg_sel_0_at": "b_8",
3120 "__omg_sel_1_value": false,
3121 "__omg_sel_2_targets_0": "b_2", "__omg_sel_2_targets_1": { "id": "b_3", "path": "/a.md" },
3122 "__omg_sel_4_targets_0": "b_4", "__omg_sel_4_separator": " / ",
3123 }),
3124 );
3125 let Bound { effects, .. } = bind(&c, &[h], &mut no_settle).unwrap();
3126 let json: Vec<Json> = effects.iter().map(Effect::to_json).collect();
3127 assert_eq!(
3128 json[0],
3129 json!({ "op": "move", "targets": [{ "kind": "block", "id": "b_1" }], "into": { "kind": "block", "id": "b_9" }, "at": { "after": "b_8" } })
3130 );
3131 assert_eq!(
3132 json[1],
3133 json!({ "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "attrs.checked", "value": false })
3134 );
3135 assert_eq!(
3136 json[2],
3137 json!({ "op": "remove", "targets": [{ "kind": "block", "id": "b_2" }, { "kind": "block", "id": "b_3" }] })
3138 );
3139 assert_eq!(
3140 json[3],
3141 json!({ "op": "remove", "targets": [{ "kind": "block", "id": "b_1" }] })
3142 );
3143 assert_eq!(
3144 json[4],
3145 json!({ "op": "merge", "targets": [{ "kind": "block", "id": "b_4" }, { "kind": "block", "id": "b_5" }], "separator": " / " })
3146 );
3147 assert_eq!(
3148 json[5],
3149 json!({ "op": "split", "target": { "kind": "block", "id": "b_1" }, "at": [2] })
3150 );
3151 let c = compile(&req("from docs", json!([{ "op": "remove" }, { "op": "move", "to": { "oqx": "\"archive/\" + title + \".md\"" } }]), None)).unwrap();
3153 assert_eq!(c.templates[0].default_target, Some(DefaultTarget::Target));
3154 assert_eq!(c.templates[1].slot_names(), ["__omg_sel_1_to"]);
3155 let Bound { effects, .. } = bind(
3156 &c,
3157 &[hit(
3158 "d_1",
3159 "/a.md",
3160 json!({ "__omg_sel_1_to": "archive/A.md" }),
3161 )],
3162 &mut no_settle,
3163 )
3164 .unwrap();
3165 assert_eq!(
3166 effects[0].to_json(),
3167 json!({ "op": "remove", "target": { "kind": "document", "id": "d_1" } })
3168 );
3169 assert_eq!(
3170 effects[1].to_json(),
3171 json!({ "op": "move", "target": { "kind": "document", "id": "d_1" }, "to": "archive/A.md", "retarget_inbound": true })
3172 );
3173 let c = compile(&req(
3175 "from docs",
3176 json!([{ "op": "set", "field": "frontmatter.hub", "value": { "oqx": "hub" } }]),
3177 None,
3178 ))
3179 .unwrap();
3180 let e = bind(&c, &[hit("d_1", "/a.md", json!({}))], &mut no_settle).unwrap_err();
3181 assert_eq!(
3182 e.message,
3183 "effects[0].value: the expression yielded absent for row d_1 — use `unset` to remove a key"
3184 );
3185 let Bound { effects, .. } = bind(
3186 &c,
3187 &[hit(
3188 "d_1",
3189 "/a.md",
3190 json!({ "__omg_sel_0_value": { "id": "d_2", "path": "/p.md" } }),
3191 )],
3192 &mut no_settle,
3193 )
3194 .unwrap();
3195 assert_eq!(
3196 effects[0].to_json()["value"],
3197 json!({ "id": "d_2", "path": "/p.md" })
3198 );
3199 let c = compile(&req(
3201 "from docs",
3202 json!([{ "op": "create", "path": { "oqx": "\"/notes/\" + slug + \".md\"" }, "markdown": { "oqx": "\"# \" + title" }, "frontmatter": { "source": { "oqx": "path" }, "layer": "draft" } }]),
3203 None,
3204 ))
3205 .unwrap();
3206 assert_eq!(
3207 c.templates[0].slot_names(),
3208 [
3209 "__omg_sel_0_path",
3210 "__omg_sel_0_markdown",
3211 "__omg_sel_0_fm_0"
3212 ]
3213 );
3214 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();
3215 assert_eq!(
3216 effects[0].to_json(),
3217 json!({ "op": "create", "path": "/notes/a.md", "markdown": "# A", "frontmatter": { "layer": "draft", "source": "/a.md" } })
3218 );
3219 let e = bind(
3220 &c,
3221 &[hit(
3222 "d_1",
3223 "/a.md",
3224 json!({ "__omg_sel_0_path": "/notes/a.md", "__omg_sel_0_markdown": "# A" }),
3225 )],
3226 &mut no_settle,
3227 )
3228 .unwrap_err();
3229 assert_eq!(
3230 e.message,
3231 "effects[0].frontmatter.source: the expression yielded absent for row d_1"
3232 );
3233 let Bound { effects, rows, .. } = bind(&c, &[], &mut no_settle).unwrap();
3235 assert!(effects.is_empty() && rows.is_empty());
3236 }
3237
3238 #[test]
3239 fn lowering_errors_gain_the_template_and_the_row() {
3240 let rows = vec![
3241 ("d_1".to_owned(), "/a.md".to_owned()),
3242 ("d_2".to_owned(), "/b.md".to_owned()),
3243 ];
3244 let e = SurfaceError::with_data(
3245 "conflicting_effects",
3246 "x",
3247 json!({ "effects": [1, 3], "effect_index": 3 }),
3248 );
3249 let a = annotate(e, &rows, 2);
3250 let d = a.data.unwrap();
3251 assert_eq!(d["template_index"], 1);
3252 assert_eq!(d["row"], json!({ "id": "d_2", "path": "/b.md" }));
3253 let e = SurfaceError::with_data("doc_missing", "x", json!({ "doc": "d" }));
3255 assert_eq!(annotate(e.clone(), &rows, 2), e);
3256 let e = SurfaceError::new("filter_invalid", "x");
3257 assert_eq!(annotate(e.clone(), &rows, 0), e);
3258 }
3259
3260 #[test]
3261 fn offsets_shift_back_by_the_wrapper() {
3262 assert_eq!(
3263 shift_offsets("unexpected ')' (at offset 20)", -16),
3264 "unexpected ')' (at offset 4)"
3265 );
3266 assert_eq!(shift_offsets("no offset here", -16), "no offset here");
3267 assert_eq!(shift_offsets("x (at offset 3)", -16), "x (at offset 0)");
3268 assert_eq!(shift_offsets("x (at offset 3)", 3), "x (at offset 6)");
3269 assert_eq!(describe(Some(&json!(2.0))), "a number (2)");
3270 assert_eq!(describe(Some(&json!([1, 2]))), "a list of 2");
3271 assert_eq!(describe(Some(&json!("q"))), "a string (\"q\")");
3272 assert_eq!(describe(None), "absent");
3273 }
3274
3275 fn desugar(v: Json) -> Result<Json> {
3278 desugar_effect(&v, 0, None).map(|(v, _)| v)
3279 }
3280
3281 #[test]
3285 fn the_leader_is_exact_and_everything_else_is_literal() {
3286 for literal in ["->x", " -> x", "-->", "->", "a -> b", "${HOME}", "x"] {
3287 let e = json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": literal });
3288 assert_eq!(desugar(e.clone()).unwrap(), e, "{literal}");
3289 assert!(!needs_sugar(&json!([e])), "{literal}");
3290 }
3291 assert_eq!(
3292 desugar(json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "-> \"-> note\"" })).unwrap(),
3293 json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "-> note" })
3294 );
3295 assert_eq!(
3296 desugar(json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "-> title" })).unwrap(),
3297 json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": { "oqx": "title" } })
3298 );
3299 assert_eq!(
3301 desugar(json!({ "op": "set", "target": "d_0", "field": "frontmatter.priority", "value": "-> priority + 1" })).unwrap(),
3302 json!({ "op": "set", "target": { "kind": "document", "id": "d_0" }, "field": "frontmatter.priority", "value": { "oqx": "priority + 1" } })
3303 );
3304 let nested = json!({ "op": "set", "target": "d_0", "field": "frontmatter.x", "value": { "a": "-> b" } });
3306 assert_eq!(desugar(nested.clone()).unwrap()["value"], nested["value"]);
3307 assert!(needs_sugar(
3309 &json!([{ "op": "insert", "into": "hub.md", "markdown": "x" }])
3310 ));
3311 assert!(needs_sugar(
3312 &json!([{ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "-> title" }])
3313 ));
3314 assert!(needs_sugar(
3315 &json!([{ "op": "remove", "targets": ["b_1"] }])
3316 ));
3317 let plain = SelectRequest::from_args(&json!({ "effects": [{ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "->x" }] })).unwrap();
3318 assert!(plain.is_none());
3319 let sugar = SelectRequest::from_args(
3320 &json!({ "effects": [{ "op": "insert", "into": "hub.md", "markdown": "-> x" }] }),
3321 )
3322 .unwrap()
3323 .unwrap();
3324 assert!(!sugar.has_selector());
3325 assert!(require_max(&sugar).is_ok());
3326 }
3327
3328 #[test]
3331 fn constant_folding_of_literal_leaders() {
3332 assert_eq!(desugar_expression("\"x\"", "h").unwrap(), json!("x"));
3333 assert_eq!(desugar_expression("`x`", "h").unwrap(), json!("x"));
3334 assert_eq!(desugar_expression("`a\\`b`", "h").unwrap(), json!("a`b"));
3335 assert_eq!(
3336 desugar_expression("`${title} (${era})`", "h").unwrap(),
3337 json!({ "oqx": "`${title} (${era})`" })
3338 );
3339 assert_eq!(desugar_expression("1", "h").unwrap(), json!({ "oqx": "1" }));
3340 assert_eq!(
3341 desugar_expression("\"a\" + \"b\"", "h").unwrap(),
3342 json!({ "oqx": "\"a\" + \"b\"" })
3343 );
3344 assert_eq!(
3346 desugar(json!({ "op": "insert", "into": "-> \"hub.md\"", "markdown": "x" })).unwrap(),
3347 json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "x" })
3348 );
3349 let e =
3351 desugar(json!({ "op": "insert", "into": "hub.md", "markdown": "-> 1 +" })).unwrap_err();
3352 assert_eq!(e.code, "filter_invalid");
3353 assert_eq!(data(&e)["hint"], HINT);
3354 assert_eq!(
3355 e.message,
3356 "effects[0].markdown: unexpected 'from' — expected a value (at offset 7)"
3357 );
3358 let e =
3359 desugar(json!({ "op": "insert", "into": "hub.md", "markdown": "-> a from blocks" }))
3360 .unwrap_err();
3361 assert!(e.message.ends_with("(at offset 17)"), "{}", e.message);
3362 }
3363
3364 #[test]
3368 fn bare_targets_normalize_before_the_shape_check() {
3369 let d = |v: Json| desugar(v).unwrap();
3370 assert_eq!(
3371 d(json!({ "op": "insert", "into": "hub.md", "markdown": "x" }))["into"],
3372 json!({ "kind": "document", "path": "hub.md" })
3373 );
3374 assert_eq!(
3375 d(json!({ "op": "insert", "into": "/notes/a", "markdown": "x" }))["into"],
3376 json!({ "kind": "document", "path": "/notes/a" })
3377 );
3378 assert_eq!(
3379 d(json!({ "op": "insert", "into": "d_2", "markdown": "x" }))["into"],
3380 json!({ "kind": "document", "id": "d_2" })
3381 );
3382 assert_eq!(
3383 d(json!({ "op": "set", "target": "b_5", "field": "attrs.checked", "value": true }))["target"],
3384 json!({ "kind": "block", "id": "b_5" })
3385 );
3386 assert_eq!(
3387 d(json!({ "op": "set", "target": "n_0123456789ab", "field": "value", "value": 1 }))["target"],
3388 json!({ "kind": "node", "id": "n_0123456789ab" })
3389 );
3390 assert_eq!(
3391 d(json!({ "op": "remove", "targets": ["b_8", { "kind": "block", "id": "b_9" }] }))["targets"],
3392 json!([{ "kind": "block", "id": "b_8" }, { "kind": "block", "id": "b_9" }])
3393 );
3394 let (v, sugar) = desugar_effect(
3396 &json!({ "op": "insert", "into": "$new.0", "markdown": "x" }),
3397 0,
3398 None,
3399 )
3400 .unwrap();
3401 assert_eq!(v["into"], json!({ "kind": "document", "id": "$new.0" }));
3402 assert_eq!(sugar.symbols, [Member::Into]);
3403 let (v, sugar) = desugar_effect(
3404 &json!({ "op": "set", "target": "$new.2", "field": "markdown", "value": "x" }),
3405 0,
3406 None,
3407 )
3408 .unwrap();
3409 assert_eq!(v["target"], json!({ "kind": "block", "id": "$new.2" }));
3410 assert_eq!(sugar.symbols, [Member::Target]);
3411 let (v, sugar) =
3412 desugar_effect(&json!({ "op": "remove", "targets": ["$new.1"] }), 0, None).unwrap();
3413 assert_eq!(v["targets"], json!([{ "kind": "block", "id": "$new.1" }]));
3414 assert_eq!(sugar.symbols, [Member::Targets(0)]);
3415 assert_eq!(
3417 d(json!({ "op": "insert", "into": "-> project", "markdown": "x" }))["into"],
3418 json!({ "oqx": "project" })
3419 );
3420 assert_eq!(
3421 d(
3422 json!({ "op": "insert", "into": { "kind": "document", "path": "-> project" }, "markdown": "x" })
3423 )["into"],
3424 json!({ "kind": "document", "path": { "oqx": "project" } })
3425 );
3426 let e = desugar(json!({ "op": "insert", "into": "hub.md#Status", "markdown": "x" }))
3428 .unwrap_err();
3429 assert_eq!(e.code, "filter_invalid");
3430 assert_eq!(data(&e)["hint"], "arguments");
3431 assert_eq!(data(&e)["fix"], crate::fixes::LOCATOR);
3432 assert_eq!(
3433 e.message,
3434 "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`"
3435 );
3436 let e = desugar(json!({ "op": "insert", "into": "hub", "markdown": "x" })).unwrap_err();
3438 assert_eq!(data(&e)["hint"], "arguments");
3439 assert_eq!(
3440 e.message,
3441 "effects[0].into: a target is { kind: …, … } or a bare document path, a `b_`/`d_`/`n_` id or `$new.n` (got \"hub\")"
3442 );
3443 let e = compile(&req(
3445 "from docs",
3446 json!([{ "op": "split", "target": "d_0", "at": [1] }]),
3447 None,
3448 ))
3449 .unwrap_err();
3450 assert_eq!(
3451 e.message,
3452 "effects[0].target: `kind` must be one of block (got \"document\")"
3453 );
3454 assert_eq!(data(&e)["hint"], "arguments");
3455 }
3456
3457 #[test]
3461 fn a_leader_in_a_non_expression_slot_is_refused() {
3462 let refuse = |v: Json| -> SurfaceError {
3463 let e = desugar(v).unwrap_err();
3464 assert_eq!(e.code, "filter_invalid");
3465 assert_eq!(data(&e)["hint"], HINT);
3466 e
3467 };
3468 for (v, here) in [
3469 (
3470 json!({ "op": "set", "target": "d_0", "field": "-> f", "value": 1 }),
3471 "effects[0].field",
3472 ),
3473 (
3474 json!({ "op": "set", "target": "d_0", "field": "frontmatter.x", "value": 1, "expect": { "revision": "-> r" } }),
3475 "effects[0].expect.revision",
3476 ),
3477 (
3478 json!({ "op": "insert", "into": { "kind": "section", "heading_id": "-> h" }, "markdown": "x" }),
3479 "effects[0].into.heading_id",
3480 ),
3481 (
3482 json!({ "op": "insert", "into": "hub.md", "at": "-> end", "markdown": "x" }),
3483 "effects[0].at",
3484 ),
3485 (
3486 json!({ "op": "insert", "into": "hub.md", "at": { "before": "b_1", "x": "-> y" }, "markdown": "x" }),
3487 "effects[0].at.x",
3488 ),
3489 (
3490 json!({ "op": "split", "target": "b_1", "at": ["-> 2"] }),
3491 "effects[0].at[0]",
3492 ),
3493 (
3494 json!({ "op": "set", "target": "d_0", "field": "frontmatter.x", "value": 1, "bogus": "-> z" }),
3495 "effects[0].bogus",
3496 ),
3497 (
3498 json!({ "op": "retarget", "repairs": [{ "from": "-> a", "to": "b" }] }),
3499 "effects[0].repairs[0].from",
3500 ),
3501 ] {
3502 let e = refuse(v);
3503 assert_eq!(
3504 e.message,
3505 format!("{here}: `-> ` is not allowed here — this member takes no expression")
3506 );
3507 }
3508 for v in [
3510 json!({ "op": "frobnicate", "x": "-> y" }),
3511 json!({ "op": "-> set", "target": "d_0", "field": "frontmatter.x", "value": 1 }),
3512 ] {
3513 assert_eq!(desugar(v.clone()).unwrap(), v);
3514 let e = Effect::validate(&v, 0).map_err(shape_error).unwrap_err();
3515 assert!(e.message.contains("`op` must be one of"), "{}", e.message);
3516 assert_eq!(data(&e)["hint"], "arguments");
3517 }
3518 }
3519
3520 #[test]
3523 fn binding_a_whole_target() {
3524 let c = compile(&req(
3525 "from docs",
3526 json!([{ "op": "insert", "into": "-> project", "markdown": "x" }, { "op": "set", "target": "-> hub", "field": "frontmatter.x", "value": 1 }]),
3527 None,
3528 ))
3529 .unwrap();
3530 let Bound { effects, .. } = bind(
3531 &c,
3532 &[hit("d_1", "/a.md", json!({ "__omg_sel_0_into": "hub.md", "__omg_sel_1_target": { "id": "d_7", "path": "/hub.md" } }))],
3533 &mut no_settle,
3534 )
3535 .unwrap();
3536 assert_eq!(
3537 effects.iter().map(Effect::to_json).collect::<Vec<_>>(),
3538 [
3539 json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "at": "end", "markdown": "x" }),
3540 json!({ "op": "set", "target": { "kind": "document", "id": "d_7" }, "field": "frontmatter.x", "value": 1 }),
3541 ]
3542 );
3543 let Bound { effects, .. } = bind(
3544 &c,
3545 &[hit(
3546 "d_1",
3547 "/a.md",
3548 json!({ "__omg_sel_0_into": "b_3", "__omg_sel_1_target": "d_2" }),
3549 )],
3550 &mut no_settle,
3551 )
3552 .unwrap();
3553 assert_eq!(
3554 effects[0].to_json()["into"],
3555 json!({ "kind": "block", "id": "b_3" })
3556 );
3557 let e = bind(
3558 &c,
3559 &[hit(
3560 "d_1",
3561 "/a.md",
3562 json!({ "__omg_sel_0_into": "hub", "__omg_sel_1_target": "d_2" }),
3563 )],
3564 &mut no_settle,
3565 )
3566 .unwrap_err();
3567 assert_eq!(
3568 e.message,
3569 "effects[0].into: the expression yielded a string (\"hub\"), not an identity (a document path, an id or a row)"
3570 );
3571 assert_eq!(data(&e)["hint"], HINT);
3572 assert_eq!(data(&e)["template_index"], 0);
3573 let mut seen: Vec<(Vec<Member>, usize)> = Vec::new();
3575 let mut settle =
3576 |_: &mut Json, m: &[Member], p: &[Effect]| seen.push((m.to_vec(), p.len()));
3577 let Bound { effects, .. } = bind(
3578 &c,
3579 &[hit(
3580 "d_1",
3581 "/a.md",
3582 json!({ "__omg_sel_0_into": "$new.0", "__omg_sel_1_target": "d_2" }),
3583 )],
3584 &mut settle,
3585 )
3586 .unwrap();
3587 assert_eq!(
3588 effects[0].to_json()["into"],
3589 json!({ "kind": "document", "id": "$new.0" })
3590 );
3591 assert!(seen.is_empty());
3593 }
3594
3595 use omgbase_store::{Config, MemDocStore, SequentialMinter};
3598
3599 const TS: &str = "2026-10-13T10:00:00.000Z";
3600
3601 fn seeded() -> (Store, PlanContext, MemDocStore) {
3602 let mut store =
3603 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
3604 let repo = store.create_repo("fixture").unwrap();
3605 for (path, source) in [
3606 (
3607 "hub.md",
3608 "---\ntype: project\n---\n\n# Hub\n\n## Status\n\nNothing yet.\n",
3609 ),
3610 (
3611 "ws/alpha.md",
3612 "---\ntype: workstream\nstatus: completed\nproject: hub.md\n---\n\n# Alpha\n",
3613 ),
3614 ] {
3615 store
3616 .observe_one(&repo, path, source, TS, &Config::default())
3617 .unwrap();
3618 }
3619 let ctx = PlanContext {
3620 repo_id: repo,
3621 ts: "2026-10-13T10:01:00.000Z".to_owned(),
3622 config: Config::default(),
3623 };
3624 (store, ctx, MemDocStore::new())
3625 }
3626
3627 fn plan_plain(
3628 store: &mut Store,
3629 ctx: &PlanContext,
3630 ds: &MemDocStore,
3631 effects: Json,
3632 ) -> Result<omgbase_mutate::Plan> {
3633 let req = SelectRequest::from_args(&json!({ "effects": effects }))
3634 .unwrap()
3635 .expect("sugar");
3636 plan_selected(store, ds, ctx, &req, HashMap::new())
3637 }
3638
3639 #[test]
3644 fn the_selector_less_path_evaluates_in_the_targets_row() {
3645 let (mut store, ctx, ds) = seeded();
3646 let plan = plan_plain(
3647 &mut store,
3648 &ctx,
3649 &ds,
3650 json!([
3651 { "op": "insert", "into": "hub.md", "markdown": "-> `## ${title} (${type})`" },
3652 { "op": "set", "target": "/ws/alpha.md", "field": "frontmatter.hub", "value": "-> project" },
3653 { "op": "set", "target": "d_1", "field": "frontmatter.n", "value": "-> 1 + 1" },
3654 ]),
3655 )
3656 .unwrap();
3657 assert!(plan.selection.is_none());
3658 assert_eq!(plan.planner_v, "p3");
3659 let effects: Vec<Json> = plan.effects.iter().map(Effect::to_json).collect();
3660 assert_eq!(effects[0]["markdown"], "## Hub (project)");
3661 assert_eq!(effects[1]["value"], "hub.md");
3662 assert_eq!(
3663 effects[1]["target"],
3664 json!({ "kind": "document", "id": "d_1", "path": "ws/alpha.md" })
3665 );
3666 assert_eq!(effects[2]["value"], 2);
3667 let templates = plan.templates.clone().expect("templates");
3668 assert_eq!(templates[0]["markdown"], "-> `## ${title} (${type})`");
3669 assert_eq!(templates[1]["target"], "/ws/alpha.md");
3670 let wire = plan.to_json(false);
3671 assert!(wire.get("templates").is_some());
3672 assert!(plan.core_json().get("templates").is_none());
3673 let literal = store
3675 .plan_effects(
3676 &ctx,
3677 &omgbase_mutate::Effect::list_from_json(&json!([
3678 { "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "## Hub (project)" },
3679 { "op": "set", "target": { "kind": "document", "path": "ws/alpha.md" }, "field": "frontmatter.hub", "value": "hub.md" },
3680 { "op": "set", "target": { "kind": "document", "id": "d_1" }, "field": "frontmatter.n", "value": 2 },
3681 ]))
3682 .unwrap(),
3683 &ds,
3684 )
3685 .unwrap();
3686 assert_eq!(literal.plan_id, plan.plan_id);
3687 assert!(literal.templates.is_none());
3688 let folded = plan_plain(
3690 &mut store,
3691 &ctx,
3692 &ds,
3693 json!([
3694 { "op": "insert", "into": "hub.md", "markdown": "-> \"## Hub (project)\"" },
3695 { "op": "set", "target": "/ws/alpha.md", "field": "frontmatter.hub", "value": "-> `hub.md`" },
3696 { "op": "set", "target": "d_1", "field": "frontmatter.n", "value": 2 },
3697 ]),
3698 )
3699 .unwrap();
3700 assert_eq!(folded.plan_id, plan.plan_id);
3701 assert!(folded.templates.is_none());
3702 let record = omgbase_mutate::effects::intent_record(
3704 &plan.plan_id,
3705 &plan.effects,
3706 None,
3707 plan.templates.as_deref(),
3708 );
3709 assert!(
3710 record.contains("\"templates\":[{\"into\":\"hub.md\""),
3711 "{record}"
3712 );
3713 }
3714
3715 #[test]
3717 fn the_selector_less_path_refuses_what_has_no_row() {
3718 let (mut store, ctx, ds) = seeded();
3719 let refuse = |store: &mut Store, effects: Json| -> SurfaceError {
3720 plan_plain(store, &ctx, &ds, effects).unwrap_err()
3721 };
3722 let e = refuse(
3723 &mut store,
3724 json!([{ "op": "create", "path": "n.md", "markdown": "-> title" }]),
3725 );
3726 assert_eq!(e.code, "filter_invalid");
3727 assert_eq!(data(&e)["hint"], HINT);
3728 assert_eq!(
3729 e.message,
3730 "effects[0]: a `create` has no row — an expression on a create needs a selector (`select`)"
3731 );
3732 let plan = plan_plain(
3734 &mut store,
3735 &ctx,
3736 &ds,
3737 json!([{ "op": "create", "path": "n.md", "markdown": "-> \"# N\"" }]),
3738 )
3739 .unwrap();
3740 assert_eq!(plan.effects[0].to_json()["markdown"], "# N");
3741 assert!(plan.templates.is_none());
3742 let e = refuse(
3743 &mut store,
3744 json!([{ "op": "insert", "into": "-> project", "markdown": "-> title" }]),
3745 );
3746 assert_eq!(
3747 e.message,
3748 "effects[0].into: an expression target needs a selector — without `select` there is no row to read it from"
3749 );
3750 let e = refuse(
3751 &mut store,
3752 json!([{ "op": "set", "target": { "kind": "document", "id": "-> x" }, "field": "frontmatter.a", "value": "-> title" }]),
3753 );
3754 assert!(
3755 e.message
3756 .starts_with("effects[0].target: an expression target needs a selector"),
3757 "{}",
3758 e.message
3759 );
3760 let e = refuse(
3761 &mut store,
3762 json!([
3763 { "op": "create", "path": "n.md", "markdown": "# N" },
3764 { "op": "insert", "into": "$new.0", "markdown": "-> title" },
3765 ]),
3766 );
3767 assert_eq!(
3768 e.message,
3769 "effects[1].into: $new.0 has no row yet — it is created by this plan; use a literal target or a selector"
3770 );
3771 let e = refuse(
3773 &mut store,
3774 json!([{ "op": "set", "target": "gone.md", "field": "frontmatter.a", "value": "-> title" }]),
3775 );
3776 assert_eq!(e.code, "doc_missing");
3777 assert_eq!(data(&e)["doc"], "gone.md");
3778 let e = refuse(
3779 &mut store,
3780 json!([{ "op": "insert", "into": "b_99", "markdown": "-> text" }]),
3781 );
3782 assert_eq!(e.code, "parent_missing");
3783 let e = refuse(
3784 &mut store,
3785 json!([{ "op": "set", "target": "b_99", "field": "markdown", "value": "-> text" }]),
3786 );
3787 assert_eq!(e.code, "block_missing");
3788 let e = refuse(
3790 &mut store,
3791 json!([{ "op": "insert", "into": "hub.md", "markdown": "-> nope" }]),
3792 );
3793 assert_eq!(
3794 e.message,
3795 "effects[0].markdown: the expression yielded absent for row d_0"
3796 );
3797 assert_eq!(
3798 *data(&e),
3799 json!({ "reason": e.message, "hint": HINT, "effect_index": 0, "row": { "id": "d_0", "path": "/hub.md" }, "expression": "nope" })
3800 );
3801 }
3802
3803 #[test]
3807 fn bare_new_symbols_settle_against_the_prefix_plan() {
3808 let (mut store, ctx, ds) = seeded();
3809 let plan = plan_plain(
3810 &mut store,
3811 &ctx,
3812 &ds,
3813 json!([
3814 { "op": "create", "path": "n.md", "markdown": "# N\n\nBody." },
3815 { "op": "insert", "into": "$new.0", "markdown": "Tail." },
3816 { "op": "set", "target": "$new.2", "field": "markdown", "value": "Body, edited." },
3817 { "op": "remove", "targets": ["$new.1"] },
3818 ]),
3819 )
3820 .unwrap();
3821 let effects: Vec<Json> = plan.effects.iter().map(Effect::to_json).collect();
3822 assert_eq!(
3823 effects[1]["into"],
3824 json!({ "kind": "document", "id": "$new.0", "path": "n.md" })
3825 );
3826 assert_eq!(
3827 effects[2]["target"],
3828 json!({ "kind": "block", "id": "$new.2" })
3829 );
3830 assert_eq!(
3831 effects[3]["targets"],
3832 json!([{ "kind": "block", "id": "$new.1" }])
3833 );
3834 assert!(plan.templates.is_none());
3835 let e = plan_plain(
3836 &mut store,
3837 &ctx,
3838 &ds,
3839 json!([
3840 { "op": "create", "path": "n.md", "markdown": "# N" },
3841 { "op": "unset", "target": "$new.1", "field": "frontmatter.k" },
3842 ]),
3843 )
3844 .unwrap_err();
3845 assert_eq!(e.code, "filter_invalid");
3846 assert_eq!(
3847 e.message,
3848 "effects[1].target: `kind` must be one of document (got \"block\")"
3849 );
3850 assert_eq!(data(&e)["effect_index"], 1);
3851 assert_eq!(data(&e)["hint"], "arguments");
3852 let e = plan_plain(
3853 &mut store,
3854 &ctx,
3855 &ds,
3856 json!([{ "op": "insert", "into": "$new.3", "markdown": "x" }]),
3857 )
3858 .unwrap_err();
3859 assert_eq!(e.code, "target_missing");
3860 assert_eq!(data(&e)["symbol"], "$new.3");
3861 }
3862}