surrealdb-core 3.3.1

A scalable, distributed, collaborative, document-graph database, for the realtime web
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
//! Method call and closure field call parts.

use std::collections::HashMap;
use std::sync::Arc;

use surrealdb_strand::Strand;
use surrealdb_types::{SqlFormat, ToSql};

use crate::exec::function::MethodDescriptor;
use crate::exec::physical_expr::function::validate_return;
use crate::exec::physical_expr::{BlockPhysicalExpr, EvalContext, PhysicalExpr};
use crate::exec::{AccessMode, BoxFut, CombineAccessModes, ContextLevel, Error as ExecError};
use crate::expr::FlowResult;
use crate::val::{Closure, Value};

// ============================================================================
// MethodPart
// ============================================================================

/// Method call - `.method(args)`.
///
/// Methods are syntactic sugar for function calls. For example:
/// - `"hello".len()` -> `string::len("hello")`
/// - `[1, 2, 3].len()` -> `array::len([1, 2, 3])`
///
/// The method descriptor (resolved at plan time) contains the per-type function
/// dispatch table. At eval time we look up the function for the value's type;
/// when the descriptor has no implementation for that type, the call falls back
/// to closure-field-call semantics (see [`ClosureFieldCallPart`]), so a closure
/// stored under a name that collides with a registered method is still callable.
#[derive(Debug, Clone)]
pub struct MethodPart {
	pub descriptor: Arc<MethodDescriptor>,
	pub args: Vec<Arc<dyn PhysicalExpr>>,
}
impl PhysicalExpr for MethodPart {
	fn name(&self) -> &'static str {
		"Method"
	}

	fn as_any(&self) -> &dyn std::any::Any {
		self
	}

	fn required_context(&self) -> ContextLevel {
		self.args.iter().map(|a| a.required_context()).max().unwrap_or(ContextLevel::Root)
	}

	fn evaluate<'a>(&'a self, ctx: EvalContext<'a>) -> BoxFut<'a, FlowResult<Value>> {
		Box::pin(async move {
			let value = ctx.current_value.cloned().unwrap_or(Value::None);

			// Resolve the function for this value's type. Builtin dispatch on
			// the receiver type takes precedence; when the descriptor has no
			// implementation for it, the call degrades to closure-field-call
			// semantics: an object field of this name holding a closure is
			// invoked, anything else is the unknown-method error. The compute
			// path applies the same precedence, so a name collision between a
			// builtin method and a closure field resolves identically under
			// every planner strategy.
			let Some(func) = self.descriptor.resolve(&value) else {
				return closure_field_call(self.descriptor.name, &value, &self.args, ctx).await;
			};

			// A method is the registry entry it resolves to, so it carries
			// that entry's capability check rather than the method name's:
			// `.len()` on a string is `string::len`. The receiver type decides
			// which entry runs, so the check follows the resolve; it precedes
			// the arguments, where the direct-call path puts it.
			ctx.check_allowed_function(func.name())?;

			// Build the arguments: receiver value first, then method arguments
			let mut func_args = Vec::with_capacity(1 + self.args.len());
			func_args.push(value);
			for arg_expr in &self.args {
				let arg_value = arg_expr.evaluate(ctx.clone()).await?;
				func_args.push(arg_value);
			}

			// Method dispatch reaches the same registry entry a direct call
			// does, so a pure built-in behind an experimental target is gated
			// here too — after the arguments, matching the direct-call path.
			if let Some(target) = crate::exec::function::experimental_target(func.name())
				&& !ctx.capabilities().allows_experimental(&target)
			{
				return Err(crate::exec::Error::InvalidFunction {
					name: self.descriptor.name.to_string(),
					message: format!("Experimental feature {target} is not enabled"),
				}
				.into());
			}

			// Invoke the resolved function
			let result = if func.is_pure() && !func.is_async() {
				func.invoke(func_args)
			} else {
				func.invoke_async(&ctx, func_args).await
			};

			// Rewrite error names for method calls: when invoked as `.extend()`,
			// the error should say "function extend()" not "function object::extend()".
			match result {
				Ok(v) => Ok(v),
				Err(e) => {
					if let Some(crate::expr::Error::InvalidFunctionArguments {
						message,
						..
					}) = e.downcast_ref::<crate::expr::Error>()
					{
						Err(crate::expr::Error::InvalidMethodArguments {
							name: self.descriptor.name.to_string(),
							message: message.clone(),
						}
						.into())
					} else {
						Err(e.into())
					}
				}
			}
		})
	}

	fn access_mode(&self) -> AccessMode {
		// Every function a descriptor can resolve to is a builtin, and no
		// registered method maps to one of the statement-evaluating builtins
		// that need a write transaction (`BuiltinFunctionExec::access_mode`'s
		// write set) — `no_registered_method_resolves_to_a_writing_builtin`
		// in `exec::function::method` pins that. Only the arguments carry
		// user expressions.
		self.args.iter().map(|a| a.access_mode()).combine_all()
	}
}

impl ToSql for MethodPart {
	fn fmt_sql(&self, f: &mut String, fmt: SqlFormat) {
		f.push('.');
		f.push_str(self.descriptor.name);
		f.push('(');
		for (i, arg) in self.args.iter().enumerate() {
			if i > 0 {
				f.push_str(", ");
			}
			arg.fmt_sql(f, fmt);
		}
		f.push(')');
	}
}

// ============================================================================
// ClosureFieldCallPart
// ============================================================================

/// Closure field call - `.field(args)` where field is not a known method.
///
/// When a method name is not found in the method registry at plan time,
/// the planner creates this part. At runtime, it accesses the named field
/// on the value and, if it contains a closure, invokes it with the provided
/// arguments.
#[derive(Debug, Clone)]
pub struct ClosureFieldCallPart {
	pub field: String,
	pub args: Vec<Arc<dyn PhysicalExpr>>,
}
impl PhysicalExpr for ClosureFieldCallPart {
	fn name(&self) -> &'static str {
		"ClosureFieldCall"
	}

	fn as_any(&self) -> &dyn std::any::Any {
		self
	}

	fn required_context(&self) -> ContextLevel {
		self.args.iter().map(|a| a.required_context()).max().unwrap_or(ContextLevel::Root)
	}

	fn evaluate<'a>(&'a self, ctx: EvalContext<'a>) -> BoxFut<'a, FlowResult<Value>> {
		Box::pin(async move {
			let value = ctx.current_value.cloned().unwrap_or(Value::None);
			closure_field_call(&self.field, &value, &self.args, ctx).await
		})
	}

	fn access_mode(&self) -> AccessMode {
		// The field holds a runtime value the plan cannot inspect, and its
		// body may write, so the call must be assumed to — mirroring
		// `ClosureCallExec::access_mode` for direct closure calls.
		AccessMode::ReadWrite
	}
}

/// Shared semantics for `.name(args)` resolved against a closure field.
///
/// Looks up `name` as a field on an object receiver and invokes the closure it
/// holds with the evaluated arguments. A non-object receiver, a missing field,
/// or a field holding a non-closure value is the unknown-method error naming
/// the receiver's type. Used by [`ClosureFieldCallPart`] for names absent from
/// the method registry, and by [`MethodPart`] when a registered method has no
/// implementation for the receiver's type.
async fn closure_field_call(
	field: &str,
	value: &Value,
	args: &[Arc<dyn PhysicalExpr>],
	ctx: EvalContext<'_>,
) -> FlowResult<Value> {
	use crate::expr::Error as ExprError;

	// Get the field value from the object
	let field_value = match value {
		Value::Object(obj) => obj.get(field).cloned(),
		_ => None,
	};

	// Check if the field contains a closure
	let closure = match field_value {
		Some(Value::Closure(c)) => c,
		_ => {
			let type_name = value.kind_of().to_string();
			return Err(ExecError::InvalidFunction {
				name: field.to_string(),
				message: format!("no such method found for the {} type", type_name),
			}
			.into());
		}
	};

	// Evaluate all argument expressions
	let mut evaluated_args = Vec::with_capacity(args.len());
	for arg_expr in args {
		evaluated_args.push(arg_expr.evaluate(ctx.clone()).await?);
	}

	// Invoke the closure
	match closure.as_ref() {
		Closure::Expr {
			args: arg_spec,
			returns,
			body,
			captures,
		} => {
			// Create isolated execution context with captured variables
			let mut isolated_ctx = ctx.exec_ctx.clone();
			for (name, value) in captures.clone() {
				isolated_ctx = isolated_ctx.with_param(name, value);
			}

			// Check for missing required arguments
			if arg_spec.len() > evaluated_args.len()
				&& let Some((param, kind)) =
					arg_spec[evaluated_args.len()..].iter().find(|(_, k)| !k.can_be_none())
			{
				return Err(ExprError::InvalidFunctionArguments {
					name: "ANONYMOUS".to_string(),
					message: format!(
						"Expected a value of type '{}' for argument {}",
						kind.to_sql(),
						param.to_sql()
					),
				}
				.into());
			}

			// Bind arguments to parameter names with type coercion
			let mut local_params: HashMap<Strand, Value> = HashMap::new();
			for ((param, kind), arg_value) in arg_spec.iter().zip(evaluated_args) {
				let coerced = arg_value.coerce_to_kind(kind).map_err(|_| {
					ExprError::InvalidFunctionArguments {
						name: "ANONYMOUS".to_string(),
						message: format!(
							"Expected a value of type '{}' for argument {}",
							kind.to_sql(),
							param.to_sql()
						),
					}
				})?;
				local_params.insert(param.clone().into_strand(), coerced);
			}

			// Add parameters to the execution context
			for (name, value) in &local_params {
				isolated_ctx = isolated_ctx.with_param(name.clone(), value.clone());
			}

			// Execute the closure body
			let block_expr = BlockPhysicalExpr {
				block: crate::expr::Block(vec![body.clone()]),
				// A closure body is evaluated wherever the closure is
				// called, which need not be the statement that wrote
				// it, so it inherits no MATCHES registrations.
				matches_scope: None,
			};
			let eval_ctx = EvalContext {
				exec_ctx: &isolated_ctx,
				current_value: ctx.current_value,
				local_params: Some(&local_params),
				recursion_ctx: None,
				document_root: ctx.document_root,
				skip_fetch_perms: ctx.skip_fetch_perms,
				computing_record: ctx.computing_record.clone(),
				// The closure body is one re-entry deeper — continue the
				// depth count so nested recursion stays bounded.
				plan_depth: ctx.plan_depth + 1,
			};

			let result = match block_expr.evaluate(eval_ctx).await {
				Ok(v) => v,
				Err(crate::expr::ControlFlow::Return(v)) => v,
				Err(crate::expr::ControlFlow::Break) | Err(crate::expr::ControlFlow::Continue) => {
					return Err(ExecError::InvalidControlFlow.into());
				}
				Err(e) => return Err(e),
			};

			// Coerce return value to declared type if specified
			Ok(validate_return("ANONYMOUS", returns.as_ref(), result)?)
		}
		Closure::Builtin(_) => {
			Err(anyhow::anyhow!("Builtin closures are not yet supported in the streaming executor")
				.into())
		}
	}
}

impl ToSql for ClosureFieldCallPart {
	fn fmt_sql(&self, f: &mut String, fmt: SqlFormat) {
		f.push('.');
		f.push_str(&self.field);
		f.push('(');
		for (i, arg) in self.args.iter().enumerate() {
			if i > 0 {
				f.push_str(", ");
			}
			arg.fmt_sql(f, fmt);
		}
		f.push(')');
	}
}

#[cfg(test)]
mod tests {
	use super::*;
	use crate::exec::ExecutionContext;
	use crate::exec::operators::test_util::{eval_on, physical_expr, root_ctx, val};
	use crate::expr::FlowResult;

	/// Evaluate `tail` (a method-call suffix) with `receiver` bound as the
	/// value the part sees.
	async fn call(receiver: &str, tail: &str, ctx: &ExecutionContext) -> FlowResult<Value> {
		let row = val(&format!("{{ v: {receiver} }}")).await;
		eval_on(&format!("v{tail}"), &row, ctx).await
	}

	fn part(name: &str, args: Vec<Arc<dyn PhysicalExpr>>, ctx: &ExecutionContext) -> MethodPart {
		let descriptor = Arc::clone(
			ctx.function_registry().get_method(name).expect("method should be registered"),
		);
		MethodPart {
			descriptor,
			args,
		}
	}

	// =========================================================================
	// MethodPart -- dispatch
	// =========================================================================

	#[tokio::test]
	async fn one_method_name_dispatches_on_the_receiver_type() {
		// `len` is registered per type with no generic fallback, so the same
		// name has to reach three different functions.
		let ctx = root_ctx();
		assert_eq!(call("'hello'", ".len()", &ctx).await.unwrap(), Value::from(5));
		assert_eq!(call("[1, 2, 3]", ".len()", &ctx).await.unwrap(), Value::from(3));
		assert_eq!(call("{ a: 1, b: 2 }", ".len()", &ctx).await.unwrap(), Value::from(2));
	}

	#[tokio::test]
	async fn a_generic_method_reaches_types_that_have_no_specific_implementation() {
		// Booleans have no per-type method table at all, so this can only work
		// through the descriptor's fallback.
		let ctx = root_ctx();
		assert_eq!(call("true", ".to_string()", &ctx).await.unwrap(), Value::from("true"));
		assert_eq!(call("1", ".to_string()", &ctx).await.unwrap(), Value::from("1"));
	}

	#[tokio::test]
	async fn a_file_method_is_gated_on_the_files_experimental_target() {
		// A file value can outlive the capability that created it — stored on a
		// record, or bound as a parameter — so method dispatch has to apply the
		// same experimental gate a direct `file::bucket()` call does. The value
		// is built directly because SurrealQL cannot parse a file literal while
		// the target is off, which is also why this is not a language test.
		let ctx = root_ctx();
		let receiver = Value::File(crate::val::File::new("bucket".to_string(), "/key".to_string()));
		let err = part("bucket", Vec::new(), &ctx)
			.evaluate(EvalContext::from_exec_ctx(&ctx).with_value_and_doc(&receiver))
			.await
			.unwrap_err();
		assert!(
			err.to_string().contains("Experimental feature"),
			"expected the experimental gate, got {err}"
		);
	}

	#[tokio::test]
	async fn a_typed_only_method_on_an_unlisted_receiver_type_is_rejected() {
		// No per-type entry and no fallback: the call degrades to a closure
		// field call, and a non-object receiver has no fields, so the result
		// is the unknown-method error rather than an arbitrary implementation.
		let ctx = root_ctx();
		let err = call("true", ".len()", &ctx).await.unwrap_err();
		assert!(
			err.to_string().contains("no such method found for the bool type"),
			"expected the unknown-method error, got {err}"
		);
	}

	#[tokio::test]
	async fn a_closure_field_named_after_an_unimplemented_method_is_invoked() {
		// `get` is registered (for file receivers), so the planner emits a
		// MethodPart rather than a ClosureFieldCallPart — but with no object
		// implementation the closure field must still be reached, exactly as
		// if the name had not been registered at all.
		let ctx = root_ctx();
		let out = call("{ get: |$x: number| $x * 2 }", ".get(4)", &ctx).await.unwrap();
		assert_eq!(out, Value::from(8));
	}

	#[tokio::test]
	async fn a_method_implemented_for_the_receiver_type_wins_over_a_closure_field() {
		// `len` has an object implementation, so the builtin counts the keys;
		// the same-named closure field must not shadow it.
		let ctx = root_ctx();
		let out = call("{ len: || 42, other: true }", ".len()", &ctx).await.unwrap();
		assert_eq!(out, Value::from(2));
	}

	#[tokio::test]
	async fn an_unimplemented_method_without_a_closure_field_is_the_unknown_method_error() {
		// The degraded path ends exactly like ClosureFieldCallPart: colliding
		// with a registered name earns no different error.
		let ctx = root_ctx();
		let err = call("{ x: 1 }", ".get('x')", &ctx).await.unwrap_err();
		assert!(
			err.to_string().contains("no such method found for the object type"),
			"expected the unknown-method error, got {err}"
		);
	}

	// =========================================================================
	// MethodPart -- receiver and argument threading
	// =========================================================================

	#[tokio::test]
	async fn the_receiver_is_passed_as_the_first_argument() {
		// `starts_with` is asymmetric, so swapping receiver and argument gives a
		// different answer -- this pins the order rather than just the wiring.
		let ctx = root_ctx();
		assert_eq!(call("'hello'", ".starts_with('he')", &ctx).await.unwrap(), Value::Bool(true));
		assert_eq!(call("'he'", ".starts_with('hello')", &ctx).await.unwrap(), Value::Bool(false));
	}

	#[tokio::test]
	async fn arguments_keep_their_written_order_after_the_receiver() {
		let ctx = root_ctx();
		// `replace(haystack, from, to)` -- reversing the two arguments changes
		// the result, so positional order is observable.
		assert_eq!(call("'a-b'", ".replace('-', '+')", &ctx).await.unwrap(), Value::from("a+b"));
		assert_eq!(call("'a-b'", ".replace('+', '-')", &ctx).await.unwrap(), Value::from("a-b"));
	}

	#[tokio::test]
	async fn an_argument_idiom_resolves_against_the_receiver_not_the_document_row() {
		// Arguments are evaluated with the part's own `EvalContext`, whose
		// current value is the receiver. A bare field name in an argument
		// therefore looks the field up on the *receiver*: with a string receiver
		// there is no such field and the argument arrives as NONE, which the
		// function then rejects on type.
		let ctx = root_ctx();
		let row = val("{ text: 'a-b', sep: '-' }").await;

		let err = eval_on("text.split(sep)", &row, &ctx).await.unwrap_err();
		assert!(
			err.to_string().contains("Expected `string` but found `NONE`"),
			"`sep` resolved against the row rather than the receiver: {err}"
		);

		// `$parent` is the reader that does reach the enclosing row.
		let with_parent = eval_on("text.split($parent.sep)", &row, &ctx).await.unwrap();
		assert_eq!(with_parent, val("['a', 'b']").await);
	}

	#[tokio::test]
	async fn an_argument_idiom_reads_fields_of_an_object_receiver() {
		// The mirror image of the rule above: when the receiver is an object, a
		// bare field name in an argument resolves against it.
		let ctx = root_ctx();
		let out = call("{ a: 1, extra: { b: 2 } }", ".extend(extra)", &ctx).await.unwrap();
		assert_eq!(out, val("{ a: 1, b: 2, extra: { b: 2 } }").await);
	}

	// =========================================================================
	// MethodPart -- error reporting
	// =========================================================================

	#[tokio::test]
	async fn an_arity_error_names_the_method_not_the_underlying_function() {
		// `.len()` dispatches to `string::len`, but the user wrote `len()`, so
		// the message must not leak the internal function name.
		let ctx = root_ctx();
		let err = call("'hello'", ".len(1)", &ctx).await.unwrap_err();
		let message = err.to_string();
		assert!(message.contains("method len()"), "expected a method-arity error, got {message}");
		assert!(!message.contains("string::len"), "the function name leaked: {message}");
	}

	#[tokio::test]
	async fn a_non_arity_failure_inside_the_function_is_left_untouched() {
		// Only `InvalidFunctionArguments` is rewritten; every other failure has
		// to reach the caller as it was raised.
		let ctx = root_ctx();
		let err = call("'not a number'", ".to_int()", &ctx).await.unwrap_err();
		assert!(
			!err.to_string().contains("Incorrect arguments for method"),
			"an unrelated failure was rewritten as an arity error: {err}"
		);
	}

	// =========================================================================
	// MethodPart -- plan metadata the engine acts on
	// =========================================================================

	#[tokio::test]
	async fn required_context_and_access_mode_come_from_the_arguments() {
		// The executor validates `required_context` before evaluating and the
		// planner picks the transaction mode from `access_mode`; a method with no
		// arguments constrains neither.
		let ctx = root_ctx();
		let bare = part("len", vec![], &ctx);
		assert_eq!(bare.required_context(), ContextLevel::Root);
		assert_eq!(bare.access_mode(), AccessMode::ReadOnly);

		// An argument that dereferences a record id needs a transaction, and the
		// method call has to report that requirement on its behalf.
		let deref = physical_expr("person:1.name", &ctx).await;
		assert_eq!(deref.required_context(), ContextLevel::Database, "fixture must need a txn");
		let with_arg = part("len", vec![deref], &ctx);
		assert_eq!(with_arg.required_context(), ContextLevel::Database);
	}

	#[tokio::test]
	async fn sql_rendering_puts_the_arguments_inside_the_method_call() {
		let ctx = root_ctx();
		let arg = physical_expr("','", &ctx).await;
		assert_eq!(part("split", vec![arg], &ctx).to_sql(), ".split(',')");
		assert_eq!(part("len", vec![], &ctx).to_sql(), ".len()");
	}

	// =========================================================================
	// ClosureFieldCallPart
	// =========================================================================

	#[tokio::test]
	async fn an_unregistered_method_name_reports_the_receiver_type() {
		// The planner turns an unknown name into a closure field call; at
		// runtime a non-object receiver has no such field, so the error names
		// the method and the receiver's type.
		let ctx = root_ctx();
		let err = call("'hello'", ".no_such_method()", &ctx).await.unwrap_err();
		let message = err.to_string();
		assert!(message.contains("no_such_method"), "expected the method name, got {message}");
		assert!(
			message.contains("no such method found for the string type"),
			"expected the receiver type, got {message}"
		);
	}

	#[tokio::test]
	async fn an_object_field_that_is_not_a_closure_reports_the_receiver_type() {
		let ctx = root_ctx();
		let err = call("{ thing: 1 }", ".thing()", &ctx).await.unwrap_err();
		assert!(
			err.to_string().contains("no such method found for the object type"),
			"expected the receiver type, got {err}"
		);
	}

	#[tokio::test]
	async fn an_object_field_holding_a_closure_is_invoked_with_the_given_arguments() {
		let ctx = root_ctx();
		let out = call("{ double: |$x: number| $x * 2 }", ".double(4)", &ctx).await.unwrap();
		assert_eq!(out, Value::from(8));
	}

	#[tokio::test]
	async fn a_closure_argument_is_coerced_to_its_declared_type() {
		let ctx = root_ctx();
		// An int argument reaches a float parameter as a float.
		let out = call("{ f: |$x: float| $x }", ".f(1)", &ctx).await.unwrap();
		assert_eq!(out, val("1.0f").await);

		// A value that cannot be coerced is an argument error, not a silent pass.
		let err = call("{ f: |$x: number| $x }", ".f('nope')", &ctx).await.unwrap_err();
		assert!(
			err.to_string().contains("Expected a value of type 'number'"),
			"expected a coercion failure, got {err}"
		);
	}

	#[tokio::test]
	async fn a_missing_required_closure_argument_is_rejected() {
		let ctx = root_ctx();
		let err = call("{ f: |$x: number| $x }", ".f()", &ctx).await.unwrap_err();
		let message = err.to_string();
		assert!(
			message.contains("Expected a value of type 'number'"),
			"expected the missing-argument error, got {message}"
		);
		// Closures have no name, so the report is anonymous.
		assert!(message.contains("ANONYMOUS"), "expected an anonymous report, got {message}");
	}

	#[tokio::test]
	async fn an_omitted_optional_closure_argument_is_left_unbound() {
		// A parameter whose kind admits NONE passes the missing-argument check,
		// but only supplied arguments are zipped into the parameter bindings, so
		// the body finds no such parameter at all rather than reading NONE.
		let ctx = root_ctx();
		let err = call("{ f: |$x: option<number>| $x }", ".f()", &ctx).await.unwrap_err();
		assert!(
			err.to_string().contains("Parameter not found: $x"),
			"expected the unbound-parameter error, got {err}"
		);

		// Supplying the argument binds it as normal.
		let out = call("{ f: |$x: option<number>| $x }", ".f(3)", &ctx).await.unwrap();
		assert_eq!(out, Value::from(3));
	}

	#[tokio::test]
	async fn a_declared_closure_return_type_is_enforced() {
		let ctx = root_ctx();
		let ok = call("{ f: |$x: number| -> float { $x } }", ".f(1)", &ctx).await.unwrap();
		assert_eq!(ok, val("1.0f").await);

		let err = call("{ f: |$x: number| -> string { $x } }", ".f(1)", &ctx).await.unwrap_err();
		assert!(
			err.to_string().contains("string"),
			"expected a return-type failure naming the declared type, got {err}"
		);
	}

	#[tokio::test]
	async fn a_return_inside_the_closure_body_becomes_its_value() {
		// The body is a block: a RETURN unwinds to the closure boundary and is
		// the call's result rather than propagating out of the enclosing query.
		let ctx = root_ctx();
		let out = call("{ f: |$x: number| { RETURN $x * 3 } }", ".f(2)", &ctx).await.unwrap();
		assert_eq!(out, Value::from(6));
	}

	#[tokio::test]
	async fn break_or_continue_out_of_a_closure_body_is_an_error() {
		// Neither signal has a loop to bind to at the closure boundary, so both
		// must surface as errors instead of escaping the call.
		let ctx = root_ctx();
		for body in ["{ BREAK }", "{ CONTINUE }"] {
			let err = call(&format!("{{ f: || {body} }}"), ".f()", &ctx).await.unwrap_err();
			assert!(
				err.to_string().contains("Invalid control flow"),
				"expected InvalidControlFlow for {body}, got {err}"
			);
		}
	}

	#[test]
	fn a_closure_field_call_reports_a_write_access_mode() {
		// The field holds a runtime value: whether its body writes is
		// invisible at plan time, so the part must claim a write up front
		// even with no arguments at all. The planner picks the transaction
		// mode and the ordering barriers from this.
		let call_part = ClosureFieldCallPart {
			field: "w".to_string(),
			args: vec![],
		};
		assert_eq!(call_part.access_mode(), AccessMode::ReadWrite);
	}

	#[tokio::test]
	async fn closure_field_call_sql_rendering_keeps_the_field_name() {
		let ctx = root_ctx();
		let arg = physical_expr("1", &ctx).await;
		let call_part = ClosureFieldCallPart {
			field: "double".to_string(),
			args: vec![arg],
		};
		assert_eq!(call_part.to_sql(), ".double(1)");
	}
}