uni_plugin/traits/procedure.rs
1//! Cypher procedure plugins — `CALL ... YIELD ...`.
2//!
3//! Procedures differ from scalar functions in three ways: they can perform
4//! writes, they return streams of rows (`YIELD a, b, c`), and they may
5//! take optional input streams (`CALL ... { } IN TRANSACTIONS OF N`).
6
7use std::any::Any;
8use std::time::Duration;
9
10use arrow_schema::Field;
11use datafusion::execution::SendableRecordBatchStream;
12use datafusion::logical_expr::ColumnarValue;
13use datafusion::scalar::ScalarValue;
14use smol_str::SmolStr;
15
16use crate::capability::SideEffects;
17use crate::errors::FnError;
18use crate::traits::connector::Principal;
19use crate::traits::scalar::ArgType;
20
21/// A Cypher procedure plugin — `CALL uni.foo.bar(args) YIELD ...`.
22///
23/// Procedures return a stream of `RecordBatch`es; the host attaches the
24/// stream to the surrounding query plan via a `ProcedureCallExec` node.
25pub trait ProcedurePlugin: Send + Sync {
26 /// Static signature.
27 fn signature(&self) -> &ProcedureSignature;
28
29 /// Invoke the procedure with the given arguments and execution context.
30 ///
31 /// The returned stream is consumed lazily by downstream `YIELD`. The
32 /// procedure is responsible for cooperatively yielding to the executor
33 /// (no long blocking calls; use `tokio::task::yield_now` between batches).
34 ///
35 /// # Errors
36 ///
37 /// Returns [`FnError`] if the procedure cannot start (validation
38 /// failure, capability check). Errors raised *during* stream production
39 /// are signaled via `Err` items in the stream.
40 fn invoke(
41 &self,
42 ctx: ProcedureContext<'_>,
43 args: &[ColumnarValue],
44 ) -> Result<SendableRecordBatchStream, FnError>;
45}
46
47/// Static signature of a procedure.
48#[derive(Clone, Debug)]
49pub struct ProcedureSignature {
50 /// Named arguments, in declaration order.
51 pub args: Vec<NamedArgType>,
52 /// Schema of the `YIELD` columns.
53 pub yields: Vec<Field>,
54 /// Mode declaration — drives capability requirements.
55 pub mode: ProcedureMode,
56 /// Declared side-effects.
57 pub side_effects: SideEffects,
58 /// Optional retry contract for atomic / CAS-style procedures.
59 pub retry_contract: Option<RetryContract>,
60 /// Optional batch-input shape for `CALL { } IN TRANSACTIONS OF N`.
61 pub batch_input: Option<BatchInputShape>,
62 /// Markdown docs surfaced via `uni.plugin.help`.
63 pub docs: String,
64}
65
66/// Named procedure argument.
67#[derive(Clone, Debug)]
68pub struct NamedArgType {
69 /// Argument name (as `CALL fn(name => value)`).
70 pub name: SmolStr,
71 /// Argument type.
72 pub ty: ArgType,
73 /// Default value if omitted at call site.
74 pub default: Option<ScalarValue>,
75 /// Human-readable description.
76 pub doc: String,
77}
78
79impl NamedArgType {
80 /// The optional trailing projection-config argument every GraphCompute
81 /// algorithm accepts (`{nodeLabels, edgeTypes, projectAll, ...}`).
82 ///
83 /// Guest-loader algorithm signatures append this so `coerce_config_json`
84 /// accounts for the config object the CALL convention places *after* the
85 /// guest's own arguments — the adapter strips it back off (via
86 /// `GraphProjectionSpec::take_from_args`) before invoking the guest. Without
87 /// it, declaring any typed `args` would reject every scoped CALL as having
88 /// one argument too many. Mirrors the first-party `gcpagerank` provider's
89 /// trailing `config` arg; opaque (`CypherValue`) and defaulted (`Null`) so it
90 /// is always optional and accepts any projection object.
91 #[must_use]
92 pub fn projection_config() -> Self {
93 Self {
94 name: "config".into(),
95 ty: ArgType::CypherValue,
96 default: Some(ScalarValue::Null),
97 doc: "Optional {nodeLabels, edgeTypes, projectAll, ...} projection config.".to_owned(),
98 }
99 }
100}
101
102/// Procedure-mode declaration.
103#[derive(Clone, Copy, Debug, PartialEq, Eq)]
104#[non_exhaustive]
105pub enum ProcedureMode {
106 /// Read-only; requires `Capability::Procedure`.
107 Read,
108 /// May mutate graph; requires `Capability::Procedure + ProcedureWrites`.
109 Write,
110 /// May issue DDL; requires `Capability::Procedure + ProcedureSchema`.
111 Schema,
112 /// Administrative; requires `Capability::Procedure + ProcedureDbms`.
113 Dbms,
114}
115
116/// Retry contract for procedures with optimistic-CAS semantics.
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118#[non_exhaustive]
119pub enum RetryContract {
120 /// Host will re-run the procedure on retryable conflict up to
121 /// `max_retries` times.
122 Atomic {
123 /// Maximum retry count before giving up.
124 max_retries: u32,
125 },
126}
127
128/// Shape of an optional input stream for `CALL { } IN TRANSACTIONS OF N`.
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130#[non_exhaustive]
131pub enum BatchInputShape {
132 /// Plain rows; the host batches them into N-row groups.
133 Rows,
134}
135
136/// Marker trait for the host's procedure execution facilities.
137///
138/// Concrete hosts (such as `uni-query`'s `QueryProcedureHost`) implement
139/// this and expose typed accessors on the concrete type. Plugins
140/// downcast through [`ProcedureHost::as_any`] when they need
141/// host-specific facilities (snapshot, schema manager, vector search,
142/// algorithm registry). The trait is intentionally tiny — adding a new
143/// host accessor does NOT touch the plugin ABI.
144///
145/// The proposal-spec `session: &Session` / `tx: Option<&Transaction>`
146/// fields land in M6 once the public `Session` trait stabilizes; until
147/// then the host pointer is the interim bridge for in-tree built-ins.
148pub trait ProcedureHost: Send + Sync + Any {
149 /// Returns the host as a downcastable `&dyn Any`.
150 fn as_any(&self) -> &dyn Any;
151}
152
153/// Per-call context passed to [`ProcedurePlugin::invoke`].
154///
155/// Carries an optional host pointer (for in-tree built-ins that need
156/// snapshot / schema / algorithm access), an optional principal (for
157/// capability gating), and an optional wall-clock deadline. All fields
158/// are `Option` so pure procedures and unit tests can construct a
159/// context with [`ProcedureContext::default`].
160#[derive(Default)]
161#[non_exhaustive]
162pub struct ProcedureContext<'a> {
163 /// Host services pointer; `None` in pure procedure tests.
164 pub host: Option<&'a dyn ProcedureHost>,
165 /// Optional wall-clock deadline for the procedure invocation.
166 pub deadline: Option<Duration>,
167 /// Authenticated principal, if any.
168 pub principal: Option<&'a Principal>,
169 /// Lifetime marker. The plugin ABI keeps `'a` exposed so future
170 /// fields (session / transaction) can borrow without a breaking
171 /// change.
172 pub _marker: std::marker::PhantomData<&'a ()>,
173}
174
175impl<'a> ProcedureContext<'a> {
176 /// Construct a context with every field set to `None`.
177 #[must_use]
178 pub fn new() -> Self {
179 Self::default()
180 }
181
182 /// Attach a host pointer.
183 #[must_use]
184 pub fn with_host(mut self, host: &'a dyn ProcedureHost) -> Self {
185 self.host = Some(host);
186 self
187 }
188
189 /// Attach a wall-clock deadline.
190 #[must_use]
191 pub fn with_deadline(mut self, deadline: Duration) -> Self {
192 self.deadline = Some(deadline);
193 self
194 }
195
196 /// Attach an authenticated principal.
197 #[must_use]
198 pub fn with_principal(mut self, principal: &'a Principal) -> Self {
199 self.principal = Some(principal);
200 self
201 }
202}
203
204impl std::fmt::Debug for ProcedureContext<'_> {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 f.debug_struct("ProcedureContext")
207 .field("host", &self.host.map(|_| "<host>"))
208 .field("deadline", &self.deadline)
209 .field("principal", &self.principal)
210 .finish()
211 }
212}