dataflow_rs/function/testing.rs
1//! One function call outside a run, for a host's unit tests.
2//!
3//! [`call`] runs one call of one function through the path a run takes:
4//!
5//! - the config compiles as it would in a workflow: the function is
6//! registered, its config compiled under a [`Prepare`](super::Prepare) naming it, and the
7//! checks a workflow's compile runs walk the config's expressions;
8//! - the call gets a real [`Ctx`](super::Ctx) and overlay over the message
9//! given, and the layers given run around it, as a run's layers do;
10//! - it settles as a run settles a call: an `Ok`, whatever its [`Outcome`],
11//! applies the call's writes to the message, an `Err` drops them and their
12//! `Change`s, and the records the call added are kept either way.
13//!
14//! No recorder runs, so the message gains no audit entry, no
15//! `metadata.progress` and no record of the call's own `Err`: the [`Called`]
16//! says what the call returned. No registry, compiler, definition or engine
17//! is assembled by hand. [`config`] compiles a config alone, and
18//! [`config_with`] compiles one with the host's own [`ExprEngine`].
19
20pub use super::overlay::Write;
21use super::registry::Entry;
22use super::{
23 BoundCall, BoxFuture, CallInfo, CallLayer, CallStart, Function, FunctionConfig, Outcome,
24 Registry,
25};
26use crate::base::any_rejected;
27use crate::base::{
28 AuthoredPath, CancelToken, Change, Error, ErrorKind, ErrorRecord, Instant, Issue, ProgramId,
29 Result, StepKind, TaskIx, WorkflowIx,
30};
31use crate::expr::{CompileCx, ExprEngine, Path};
32use crate::program::{Callee, CalleeAt, Checks, compile_callee, refused};
33use crate::run::host::{Invoked, Layers, apply_writes, invoke, keep_records};
34use crate::run::{Message, TaskIds};
35use crate::state::{RunContext, Tuning};
36use chrono::Utc;
37use datavalue::OwnedDataValue;
38use serde_json::Value;
39use std::fmt;
40use std::future::IntoFuture;
41use std::sync::Arc;
42
43/// The workflow and task ids a tested call is compiled and run under.
44const TEST: &str = "test";
45
46/// One call of `f`, with its config compiled from `input` as a workflow
47/// would compile it; `.await` runs it.
48///
49/// The builder's setters are optional: an empty message, no layers,
50/// [`ExprEngine::default`], the name `"function"`, changes captured, and no
51/// channel, deadline or cancellation token.
52///
53/// # Panics
54///
55/// When awaited, wherever [`Registry::register`] panics: on a name it
56/// reserves, or on a spec the function's config cannot honour.
57///
58/// ```
59/// # tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async {
60/// use dataflow_rs::datavalue::OwnedDataValue;
61/// use dataflow_rs::expr::Output;
62/// use dataflow_rs::function::testing::{self, Write};
63/// use dataflow_rs::function::{BoxFuture, CallLayer, FunctionConfig, Next};
64/// use dataflow_rs::program::IssueCode;
65/// use dataflow_rs::{Ctx, Expr, Function, Message, Outcome, Result};
66/// use serde_json::json;
67/// use std::collections::BTreeMap;
68/// use std::sync::atomic::{AtomicUsize, Ordering};
69/// use std::sync::Arc;
70///
71/// #[derive(FunctionConfig)]
72/// struct DbRead {
73/// query: Expr<String>,
74/// #[output(default = "data.rows")]
75/// output: Output,
76/// }
77///
78/// /// Answers each query from the rows it was built with, as the host's
79/// /// database would.
80/// struct MyFn {
81/// rows: BTreeMap<String, serde_json::Value>,
82/// }
83///
84/// impl MyFn {
85/// fn new(rows: BTreeMap<String, serde_json::Value>) -> Self {
86/// Self { rows }
87/// }
88/// }
89///
90/// impl Function for MyFn {
91/// type Config = DbRead;
92///
93/// async fn call(&self, ctx: &mut Ctx<'_>, cfg: &DbRead) -> Result<Outcome> {
94/// let query = ctx.eval(&cfg.query)?;
95/// let Some(rows) = self.rows.get(&*query) else {
96/// return Ok(Outcome::Status(404));
97/// };
98/// ctx.emit(&cfg.output, rows.into())?;
99/// Ok(Outcome::Ok)
100/// }
101/// }
102///
103/// /// Counts the calls it wraps.
104/// struct Counted(Arc<AtomicUsize>);
105///
106/// impl CallLayer for Counted {
107/// fn call<'a>(&'a self, ctx: &'a mut Ctx<'_>, next: Next<'a>) -> BoxFuture<'a, Result<Outcome>> {
108/// self.0.fetch_add(1, Ordering::Relaxed);
109/// next.run(ctx)
110/// }
111/// }
112///
113/// let deps = BTreeMap::from([(
114/// "select name from customers where id = 7".to_owned(),
115/// json!([{"name": "Ada"}]),
116/// )]);
117/// let calls = Arc::new(AtomicUsize::new(0));
118/// let input = json!({
119/// "query": {"cat": ["select name from customers where id = ", {"var": "data.id"}]},
120/// "output": "data.rows",
121/// });
122/// let data = OwnedDataValue::from(&json!({"id": 7}));
123/// let out = testing::call(MyFn::new(deps), input)
124/// .message(Message::from_parts(OwnedDataValue::Null, data, OwnedDataValue::Null))
125/// .layer(Counted(Arc::clone(&calls)))
126/// .await;
127///
128/// let rows = OwnedDataValue::from(&json!([{"name": "Ada"}]));
129/// assert!(out.issues.is_empty());
130/// assert_eq!(out.outcome.unwrap(), Outcome::Ok);
131/// assert_eq!(out.writes, [("data.rows".parse().unwrap(), Write::Set(rows.clone()))]);
132/// assert_eq!(out.changes.len(), 1);
133/// assert_eq!(out.message.context().get(&"data.rows".parse().unwrap()), Some(&rows));
134/// assert_eq!(calls.load(Ordering::Relaxed), 1);
135///
136/// // A config alone; `Err` carries every issue.
137/// let cfg: DbRead = testing::config(json!({"query": "select 1"})).ok().unwrap();
138/// assert_eq!(cfg.output.constant().unwrap().to_string(), "data.rows");
139/// let issues = testing::config::<DbRead>(json!({"qeury": "select 1"})).err().unwrap();
140/// let codes: Vec<IssueCode> = issues.iter().map(|i| i.code).collect();
141/// assert_eq!(codes, [IssueCode::LikelyTypo, IssueCode::InputParse]);
142/// # });
143/// ```
144pub fn call<F: Function>(f: F, input: Value) -> Call<F> {
145 Call {
146 f,
147 input,
148 message: None,
149 layers: Vec::new(),
150 exprs: None,
151 name: "function".to_owned(),
152 capture: true,
153 channel: None,
154 routing_bucket: None,
155 deadline: None,
156 cancel: None,
157 }
158}
159
160/// One call of a function, waiting to be awaited: what [`call`] returns.
161///
162/// It is [`IntoFuture`], so `.await` runs it, and the future is `Send` and
163/// `'static`, so a test may also spawn it.
164#[must_use = "a call does nothing until it is awaited"]
165pub struct Call<F: Function> {
166 f: F,
167 input: Value,
168 message: Option<Message>,
169 layers: Vec<Arc<dyn CallLayer>>,
170 exprs: Option<ExprEngine>,
171 name: String,
172 capture: bool,
173 channel: Option<String>,
174 routing_bucket: Option<u8>,
175 deadline: Option<Instant>,
176 cancel: Option<CancelToken>,
177}
178
179impl<F: Function> Call<F> {
180 /// The message the call runs over. By default an empty one: a `null`
181 /// payload and empty roots.
182 pub fn message(mut self, m: Message) -> Self {
183 self.message = Some(m);
184 self
185 }
186
187 /// Adds a layer around the call, as a run's layers wrap a call. The
188 /// first given is the outermost.
189 pub fn layer<L: CallLayer>(mut self, l: L) -> Self {
190 self.layers.push(Arc::new(l));
191 self
192 }
193
194 /// The engine the config compiles with and the call evaluates with,
195 /// secrets included. By default [`ExprEngine::default`].
196 pub fn exprs(mut self, e: ExprEngine) -> Self {
197 self.exprs = Some(e);
198 self
199 }
200
201 /// The name the function is registered and called by: what [`Prepare`](super::Prepare)
202 /// and [`CallInfo::function`] say. By default `"function"`; a built-in's
203 /// name is reserved, as [`Registry::register`] reserves it.
204 pub fn name(mut self, n: &str) -> Self {
205 n.clone_into(&mut self.name);
206 self
207 }
208
209 /// Whether the call records its `Change`s, as a run's
210 /// [`capture_changes`](method@crate::RunOptions::capture_changes) says.
211 /// On by default.
212 pub fn capture_changes(mut self, on: bool) -> Self {
213 self.capture = on;
214 self
215 }
216
217 /// The channel the call's run was started on, as
218 /// [`CallInfo::channel`] says. By default none.
219 pub fn channel(mut self, c: &str) -> Self {
220 self.channel = Some(c.to_owned());
221 self
222 }
223
224 /// The run's routing bucket, as [`CallInfo::routing_bucket`] says. By
225 /// default none.
226 pub fn routing_bucket(mut self, b: u8) -> Self {
227 self.routing_bucket = Some(b);
228 self
229 }
230
231 /// The run's deadline, as [`Ctx::deadline`](super::Ctx::deadline) says.
232 /// The call is checked against it and, natively, raced against it, as
233 /// a run's host call is: one it passes ends `Err` of kind
234 /// [`TimedOut`](ErrorKind::TimedOut), its writes dropped. A deadline
235 /// needs a Tokio runtime with its time driver. By default none.
236 pub fn deadline(mut self, at: Instant) -> Self {
237 self.deadline = Some(at);
238 self
239 }
240
241 /// The run's cancellation token, as [`Ctx::cancel`](super::Ctx::cancel)
242 /// says. The call is checked against it and, natively, raced against
243 /// it, as a run's host call is: one it cancels ends `Err` of kind
244 /// [`Cancelled`](ErrorKind::Cancelled), its writes dropped. By default
245 /// none.
246 pub fn cancel(mut self, token: CancelToken) -> Self {
247 self.cancel = Some(token);
248 self
249 }
250
251 /// Compiles the config, runs the call, and settles it.
252 async fn run(self) -> Called {
253 let Self {
254 f,
255 input,
256 message,
257 layers,
258 exprs,
259 name,
260 capture,
261 channel,
262 routing_bucket,
263 deadline,
264 cancel,
265 } = self;
266 let mut message = message.unwrap_or_else(|| Message::new(OwnedDataValue::Null));
267 // Registered as a host registers it, so `register`'s checks of the
268 // spec run here too.
269 let mut registry = Registry::new(exprs.unwrap_or_default());
270 registry.register(&name, f);
271 let (bound, issues) = compile(®istry, &name, &input);
272 let Some(bound) = bound else {
273 return Called {
274 issues,
275 outcome: Err(Error::new(
276 ErrorKind::Compile,
277 format!("the config of `{name}` was rejected; `issues` says why"),
278 )),
279 writes: Vec::new(),
280 changes: Vec::new(),
281 errors: Vec::new(),
282 message,
283 };
284 };
285 // The clock is read once, as a run reads it, for the records' stamp.
286 let now = Utc::now();
287 let mut now_text = None;
288 let budget = crate::run::TaskBudget::unlimited();
289 let parts = message.parts();
290 let mut rc = RunContext::new(parts.context, Tuning::default());
291 let Invoked {
292 result,
293 overlay,
294 changes,
295 errors,
296 ..
297 } = {
298 let reader = rc.reader();
299 let start = CallStart {
300 reader,
301 binding: None,
302 payload: parts.payload,
303 exprs: registry.exprs(),
304 budget: &budget,
305 info: CallInfo {
306 program: ProgramId::next(),
307 workflow: WorkflowIx::new(0),
308 task: TaskIx::new(0),
309 function: &name,
310 workflow_id: TEST,
311 task_id: TEST,
312 channel: channel.as_deref(),
313 routing_bucket,
314 loop_counter: None,
315 element_index: None,
316 },
317 deadline,
318 cancel: cancel.as_ref(),
319 capture,
320 };
321 let layers = Layers {
322 run: &layers,
323 registry: &[],
324 };
325 invoke(start, &*bound, layers, None, crate::run::timers_available()).await
326 };
327 // The settle, as a run's: the records the call added are stamped and
328 // pushed onto the message, and an `Ok` replays the overlay and keeps
329 // the `Change`s while an `Err` drops both. The settle takes the
330 // overlay, so `writes` is a copy of it.
331 let outcome = result.into_result();
332 let ok = outcome.is_ok();
333 let writes = if ok {
334 overlay.clone().into_writes()
335 } else {
336 Vec::new()
337 };
338 let test_id: Arc<str> = Arc::from(TEST);
339 let ids = TaskIds {
340 workflow: &test_id,
341 task: &test_id,
342 };
343 let errors_before = parts.errors.len();
344 keep_records(ids, now, &mut now_text, parts.errors, errors, None);
345 let errors = parts.errors[errors_before..].to_vec();
346 let changes = apply_writes(&mut rc, ok, overlay, changes);
347 rc.finish();
348 Called {
349 issues,
350 outcome,
351 writes,
352 changes,
353 errors,
354 message,
355 }
356 }
357}
358
359impl<F: Function> IntoFuture for Call<F> {
360 type Output = Called;
361 type IntoFuture = BoxFuture<'static, Called>;
362
363 fn into_future(self) -> Self::IntoFuture {
364 Box::pin(self.run())
365 }
366}
367
368impl<F: Function> fmt::Debug for Call<F> {
369 /// The name, the input, the layer count, whether changes are captured,
370 /// the channel and the deadline; not the function, the layers or the
371 /// token.
372 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373 f.debug_struct("Call")
374 .field("name", &self.name)
375 .field("input", &self.input)
376 .field("layers", &self.layers.len())
377 .field("capture_changes", &self.capture)
378 .field("channel", &self.channel)
379 .field("deadline", &self.deadline)
380 .finish_non_exhaustive()
381 }
382}
383
384/// What one [`call`] did.
385#[derive(Debug)]
386#[non_exhaustive]
387pub struct Called {
388 /// Every issue compiling the config found, at its path below the input,
389 /// each stamped with the task `"test"`. Advisories and Defects may sit
390 /// beside an `Ok`.
391 pub issues: Vec<Issue>,
392 /// What the call returned: through the layers, as a run sees it. `Err`
393 /// of kind [`Compile`](ErrorKind::Compile) when the config was rejected
394 /// and nothing ran.
395 pub outcome: Result<Outcome>,
396 /// The call's writes, in the order it made them. Empty after an `Err`,
397 /// which drops them.
398 pub writes: Vec<(Path, Write)>,
399 /// The call's `Change`s, as the call recorded them: a write at a root
400 /// says [`replaced`](Change::replaced), as a trace's copy does, where
401 /// the task's audit entry, which keeps v3's bytes, does not. Empty after
402 /// an `Err`, or without capture.
403 pub changes: Vec<Change>,
404 /// The records the call added, kept whatever it returned, stamped as a
405 /// run stamps them: the ids (`test` for both) and a timestamp, where
406 /// they carry none.
407 pub errors: Vec<ErrorRecord>,
408 /// The message after the settle: the writes applied on `Ok`, the records
409 /// appended to its errors. Its audit is untouched, since no recorder ran.
410 pub message: Message,
411}
412
413/// A config compiled alone, against [`ExprEngine::default`], with nothing
414/// prepared or checked beyond the config's own compile. `Err` carries every
415/// issue, at its path below the input.
416///
417/// # Errors
418///
419/// Every issue the compile found, when any is Rejected or the config
420/// refused its input.
421pub fn config<C: FunctionConfig>(input: Value) -> std::result::Result<C, Vec<Issue>> {
422 config_with(&ExprEngine::default(), input)
423}
424
425/// A config compiled alone, as [`config`] compiles it, against `exprs`: the
426/// host's engine, with its own operators and secrets.
427///
428/// # Errors
429///
430/// Every issue the compile found, when any is Rejected or the config
431/// refused its input.
432pub fn config_with<C: FunctionConfig>(
433 exprs: &ExprEngine,
434 input: Value,
435) -> std::result::Result<C, Vec<Issue>> {
436 let mut issues = Vec::new();
437 let compiled = {
438 let mut cx = CompileCx::new(exprs, &mut issues);
439 let before = cx.rejected();
440 let compiled = C::compile(&input, &mut cx).filter(|_| cx.rejected() == before);
441 if compiled.is_none() && cx.rejected() == before {
442 refused(&mut cx, std::any::type_name::<C>());
443 }
444 compiled
445 };
446 compiled.ok_or(issues)
447}
448
449/// Compiles `name`'s config from `input` through the compiler's own
450/// [`compile_callee`], as a task's host function at workflow and task
451/// `"test"`, walked with the checks a workflow's compile runs. The bound
452/// call, unless anything Rejected was found, and every issue.
453fn compile(
454 registry: &Registry,
455 name: &str,
456 input: &Value,
457) -> (Option<Box<dyn BoundCall>>, Vec<Issue>) {
458 let entry = registry.entry(name);
459 let Some(entry @ Entry::Host(_)) = entry else {
460 unreachable!("`{name}` was registered as a host function");
461 };
462 let mut issues = Vec::new();
463 let at = CalleeAt {
464 name,
465 workflow_id: TEST,
466 step: (StepKind::Task, TEST),
467 input_at: &AuthoredPath::root(),
468 };
469 let mut checks = Checks::new(registry.exprs());
470 let callee = compile_callee(
471 registry.exprs(),
472 entry,
473 input,
474 &at,
475 &mut checks,
476 &mut issues,
477 );
478 let rejected = any_rejected(&issues);
479 let bound = match callee {
480 Some(Callee::Host(bound)) if !rejected => Some(bound),
481 _ => None,
482 };
483 (bound, issues)
484}