Skip to main content

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}