use super::templated::primary_name;
use super::{BoxFuture, Ctx, Function, FunctionConfig, FunctionSpec, Outcome, Prepare, Primary};
use crate::base::Result;
use crate::expr::{CompileCx, ExprVisitor, FieldSpec, Resolver};
use datavalue::OwnedDataValue;
use serde_json::Value;
use std::sync::Arc;
pub(crate) trait ErasedFunction: Send + Sync {
fn spec(&self) -> &FunctionSpec;
fn fields(&self) -> &[FieldSpec];
fn compile(
&self,
input: &Value,
cx: &mut CompileCx<'_>,
p: &Prepare<'_>,
) -> Option<Box<dyn BoundCall>>;
}
pub(crate) trait BoundCall: Send + Sync {
fn call<'a>(&'a self, ctx: &'a mut Ctx<'_>) -> BoxFuture<'a, Result<Outcome>>;
fn visit(&self, v: &mut dyn ExprVisitor);
fn primary(&self) -> Primary<'_>;
fn resolve(&self, r: &mut dyn Resolver) -> Result<OwnedDataValue>;
}
pub(crate) struct Erased<F: Function> {
f: Arc<F>,
spec: FunctionSpec,
}
impl<F: Function> Erased<F> {
pub(crate) fn new(f: F) -> Self {
let spec = f.spec();
Self {
f: Arc::new(f),
spec,
}
}
}
impl<F: Function> ErasedFunction for Erased<F> {
fn spec(&self) -> &FunctionSpec {
&self.spec
}
fn fields(&self) -> &[FieldSpec] {
match &self.spec.runtime_fields {
Some(fields) => fields,
None => F::Config::fields(),
}
}
fn compile(
&self,
input: &Value,
cx: &mut CompileCx<'_>,
p: &Prepare<'_>,
) -> Option<Box<dyn BoundCall>> {
let before = cx.rejected();
let mut cfg = <F::Config as FunctionConfig>::compile(input, cx)
.filter(|_| cx.rejected() == before)?;
if let Err(issues) = self.f.prepare(&mut cfg, p) {
for issue in issues {
cx.adopt(issue);
}
}
(cx.rejected() == before).then(|| {
Box::new(Bound {
f: Arc::clone(&self.f),
cfg,
primary_field: self.primary_field().map(Box::from),
}) as Box<dyn BoundCall>
})
}
}
impl<F: Function> Erased<F> {
fn primary_field(&self) -> Option<&str> {
primary_name(self.fields())
}
}
struct Bound<F: Function> {
f: Arc<F>,
cfg: F::Config,
primary_field: Option<Box<str>>,
}
impl<F: Function> BoundCall for Bound<F> {
fn call<'a>(&'a self, ctx: &'a mut Ctx<'_>) -> BoxFuture<'a, Result<Outcome>> {
Box::pin(self.f.call(ctx, &self.cfg))
}
fn visit(&self, v: &mut dyn ExprVisitor) {
self.cfg.visit(v);
}
fn primary(&self) -> Primary<'_> {
match (self.cfg.primary_output(), &self.primary_field) {
(Some(out), _) => Primary::Set(out),
(None, Some(field)) => Primary::Unset(field),
(None, None) => Primary::Undeclared,
}
}
fn resolve(&self, r: &mut dyn Resolver) -> Result<OwnedDataValue> {
FunctionConfig::resolve(&self.cfg, r)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::base::{AuthoredPath, Issue, IssueCode, Severity, StepKind};
use crate::expr::{Expr, ExprEngine, FieldKind, Output};
use crate::function::Templated;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(FunctionConfig)]
struct FetchConfig {
connector: String,
#[config(records)]
url: Expr<String>,
#[output(default = "data.response")]
output: Output,
}
#[derive(Default)]
struct Fetch {
prepared: AtomicUsize,
}
impl Function for Fetch {
type Config = FetchConfig;
fn spec(&self) -> FunctionSpec {
FunctionSpec::default().with_category("io")
}
fn prepare(
&self,
cfg: &mut FetchConfig,
p: &Prepare<'_>,
) -> std::result::Result<(), Vec<Issue>> {
self.prepared.fetch_add(1, Ordering::Relaxed);
assert_eq!(
(p.name(), p.workflow_id(), p.task_id()),
("fetch", "w", "t")
);
assert_eq!(p.spec().category, "io");
let mut issues = vec![Issue::new(
IssueCode::UnknownKey,
"retries".parse().unwrap(),
"advisory only",
)];
if cfg.connector == "db2" {
issues.push(Issue::new(
IssueCode::InputParse,
"connector".parse().unwrap(),
"no connector `db2`",
));
}
Err(issues)
}
async fn call(&self, _ctx: &mut Ctx<'_>, _cfg: &FetchConfig) -> Result<Outcome> {
Ok(Outcome::Ok)
}
}
struct Compiled {
bound: Option<Box<dyn BoundCall>>,
issues: Vec<Issue>,
prepared: usize,
}
fn compile(input: &Value) -> Compiled {
let erased = Erased::new(Fetch::default());
let engine = ExprEngine::default();
let mut issues = Vec::new();
let bound = {
let mut cx = CompileCx::new(&engine, &mut issues)
.at_path("tasks[0].function.input".parse().unwrap())
.for_step(StepKind::Task, "t");
let p = Prepare::new("fetch", "w", "t", erased.spec());
erased.compile(input, &mut cx, &p)
};
let prepared = erased.f.prepared.load(Ordering::Relaxed);
Compiled {
bound,
issues,
prepared,
}
}
#[derive(Default)]
struct Paths(Vec<(String, bool)>);
impl ExprVisitor for Paths {
fn expr(&mut self, at: &AuthoredPath, _raw: &Value, records: bool) {
self.0.push((at.to_string(), records));
}
}
#[test]
fn a_compiled_config_is_prepared_and_bound() {
let out = compile(&json!({"connector": "db1", "url": {"var": "data.url"}}));
assert_eq!(out.prepared, 1);
assert_eq!(out.issues.len(), 1, "{:?}", out.issues);
let issue = &out.issues[0];
assert_eq!(issue.severity, Severity::Advisory);
assert_eq!(issue.path.to_string(), "tasks[0].function.input.retries");
assert_eq!(issue.field.as_deref(), Some("retries"));
assert_eq!(issue.step_id.as_deref(), Some("t"));
let mut paths = Paths::default();
out.bound.expect("bound").visit(&mut paths);
assert_eq!(
paths.0,
[("url".to_owned(), true), ("output".to_owned(), true)]
);
}
#[test]
fn a_rejected_prepare_issue_fails_the_call_site() {
let out = compile(&json!({"connector": "db2", "url": "https://x"}));
assert_eq!(out.prepared, 1);
assert!(out.bound.is_none());
let rejected: Vec<_> = out
.issues
.iter()
.filter(|i| i.severity == Severity::Rejected)
.collect();
assert_eq!(rejected.len(), 1, "{:?}", out.issues);
assert_eq!(rejected[0].code, IssueCode::InputParse);
assert_eq!(
rejected[0].path.to_string(),
"tasks[0].function.input.connector"
);
assert_eq!(rejected[0].field.as_deref(), Some("connector"));
assert_eq!(rejected[0].step_kind, Some(StepKind::Task));
}
#[test]
fn prepare_is_not_called_when_the_config_fails() {
let out = compile(&json!({"connector": "db1"}));
assert_eq!(out.prepared, 0);
assert!(out.bound.is_none());
assert_eq!(out.issues.len(), 1, "{:?}", out.issues);
assert_eq!(out.issues[0].code, IssueCode::InputParse);
assert_eq!(
out.issues[0].path.to_string(),
"tasks[0].function.input.url"
);
}
struct Loud;
impl FunctionConfig for Loud {
fn compile(_input: &Value, cx: &mut CompileCx<'_>) -> Option<Self> {
cx.issue(IssueCode::InputParse, "refused");
Some(Self)
}
fn visit(&self, _v: &mut dyn ExprVisitor) {}
fn fields() -> &'static [FieldSpec] {
&[]
}
fn resolve(&self, _r: &mut dyn Resolver) -> Result<OwnedDataValue> {
Ok(OwnedDataValue::Null)
}
}
#[derive(Default)]
struct Counted {
prepared: AtomicUsize,
}
impl Function for Counted {
type Config = Loud;
fn prepare(
&self,
_cfg: &mut Loud,
_p: &Prepare<'_>,
) -> std::result::Result<(), Vec<Issue>> {
self.prepared.fetch_add(1, Ordering::Relaxed);
Ok(())
}
async fn call(&self, _ctx: &mut Ctx<'_>, _cfg: &Loud) -> Result<Outcome> {
Ok(Outcome::Ok)
}
}
#[test]
fn a_config_that_records_a_rejection_is_neither_prepared_nor_bound() {
let erased = Erased::new(Counted::default());
let engine = ExprEngine::default();
let mut issues = Vec::new();
let bound = {
let mut cx = CompileCx::new(&engine, &mut issues)
.at_path("tasks[0].function.input".parse().unwrap())
.for_step(StepKind::Task, "t");
let p = Prepare::new("counted", "w", "t", erased.spec());
erased.compile(&json!({}), &mut cx, &p)
};
assert!(bound.is_none());
assert_eq!(erased.f.prepared.load(Ordering::Relaxed), 0);
assert_eq!(issues.len(), 1, "{issues:?}");
assert_eq!(issues[0].code, IssueCode::InputParse);
}
#[test]
fn the_fields_are_the_runtime_fields_when_the_spec_declares_them() {
struct Plugin;
impl Function for Plugin {
type Config = Templated;
fn spec(&self) -> FunctionSpec {
FunctionSpec::default().with_runtime_fields(vec![FieldSpec::runtime(
"only",
FieldKind::Interpolated,
true,
)])
}
async fn call(&self, _ctx: &mut Ctx<'_>, _cfg: &Templated) -> Result<Outcome> {
Ok(Outcome::Ok)
}
}
let names = |fields: &[FieldSpec]| -> Vec<String> {
fields.iter().map(|f| f.name.to_string()).collect()
};
assert_eq!(names(Erased::new(Plugin).fields()), ["only"]);
assert_eq!(
names(Erased::new(Fetch::default()).fields()),
["connector", "url", "output"]
);
}
}