Skip to main content

cove_runtime/
database.rs

1//! `database`: connections, and the queries made on them.
2//!
3//! The Language Card lists the database among the operations that are typed
4//! Host APIs, and `examples/callbacks/main.cove` shows the shape it expects:
5//!
6//! ```cove
7//! let repository = database.connect("bookings")?
8//! repository.query("insert into bookings ...")?
9//! ```
10//!
11//! That is a *host resource handle*: `connect` hands back a name, and later
12//! calls are made on that name rather than on the module. ADR 0013 is what
13//! makes one possible — [`crate::host::ResourceHandle`] is the value, and
14//! `Connection` in this module's [`crate::schema::ResourceSchema`] is what
15//! says which operations it answers and that it may cross a task boundary.
16//!
17//! What a connection *is* stays here, on the host's side. A handle carries a
18//! number and nothing else, so the only way to learn anything through one is
19//! to call an operation the schema declares, and a handle whose connection
20//! has been closed finds nothing to call: that is a reported error, not a
21//! call on whatever occupies the slot now.
22//!
23//! There is still no real implementation, and this module does not pretend
24//! otherwise. Connecting to a database means speaking a wire protocol to a
25//! server, and the runtime depends on nothing but the standard library, which
26//! cannot. What exists is the pair the Language Card promises for the ones
27//! that cannot be real: [`Database::recorded`], a fake whose connections
28//! answer from a table of canned rows, and [`Database::denied`], which
29//! refuses to connect and says why. The CLI installs the denied one, so a
30//! program that asks for `database` is told that this host has none rather
31//! than being told that `database` does not exist.
32
33use std::collections::BTreeMap;
34use std::sync::atomic::{AtomicU64, Ordering};
35use std::sync::Mutex;
36
37use crate::error::RuntimeError;
38use crate::host::{HostApi, Reentry, ResourceHandle};
39use crate::schema::ModuleSchema;
40use crate::value::{Repr, Value};
41
42/// `database`: querying a database, when the host has one.
43pub struct Database {
44    source: DatabaseSource,
45    /// Which connections this host still has open, by the identity it
46    /// issued.
47    ///
48    /// A handle addresses an entry here and nothing else. What is stored is
49    /// the name the program connected to, because a fake has nothing else to
50    /// keep; a real host would store the socket in the same place, and
51    /// nothing above this line would change.
52    open: Mutex<BTreeMap<u64, String>>,
53    /// The identity the next connection gets.
54    next_id: AtomicU64,
55}
56
57enum DatabaseSource {
58    /// Canned rows, keyed by the exact query text.
59    Recorded(BTreeMap<String, Vec<String>>),
60    /// A host with no database. Every query is refused.
61    Denied,
62}
63
64/// What `database` declares about itself.
65///
66/// The table is [`cove_schema::hosts::DATABASE`], so the description the
67/// compiler checks a call against and the one the boundary dispatches through
68/// are the same bytes.
69const SCHEMA: ModuleSchema = cove_schema::hosts::DATABASE;
70
71impl Database {
72    /// A fake that answers each query from a table of canned rows, for tests.
73    ///
74    /// The key is the query text exactly as the program writes it. This is a
75    /// recorded answer, not a query engine: a fake that interpreted SQL would
76    /// be a database this project did not write and cannot vouch for.
77    pub fn recorded(rows: BTreeMap<String, Vec<String>>) -> Self {
78        Database::with_source(DatabaseSource::Recorded(rows))
79    }
80
81    /// A host with no database, which refuses every query and says so.
82    ///
83    /// The Language Card lists a denied implementation beside the real, fake,
84    /// and filtered ones. Denying here rather than leaving the module out
85    /// means the interface is still visible — `query`'s signature is in the
86    /// schema — and a run that asks is told what is missing instead of being
87    /// told that `database` is not a host module.
88    pub fn denied() -> Self {
89        Database::with_source(DatabaseSource::Denied)
90    }
91
92    fn with_source(source: DatabaseSource) -> Self {
93        Database {
94            source,
95            open: Mutex::new(BTreeMap::new()),
96            next_id: AtomicU64::new(1),
97        }
98    }
99
100    /// Opens a connection to `name` and issues the handle that names it.
101    fn connect(&self, name: &str) -> Value {
102        match &self.source {
103            DatabaseSource::Recorded(_) => {
104                let id = self.next_id.fetch_add(1, Ordering::Relaxed);
105                self.locked().insert(id, name.to_string());
106                Value::ok(Value(Repr::Resource(ResourceHandle::new(
107                    "database",
108                    &SCHEMA.resources[0],
109                    id,
110                ))))
111            }
112            DatabaseSource::Denied => Value::err(Value::error(
113                "database: this host has no database, so nothing can connect",
114            )),
115        }
116    }
117
118    fn locked(&self) -> std::sync::MutexGuard<'_, BTreeMap<u64, String>> {
119        self.open
120            .lock()
121            .unwrap_or_else(|poisoned| poisoned.into_inner())
122    }
123
124    fn query(&self, sql: &str) -> Result<Vec<String>, String> {
125        match &self.source {
126            DatabaseSource::Recorded(rows) => rows
127                .get(sql)
128                .cloned()
129                .ok_or_else(|| format!("database: no recorded answer for `{sql}`")),
130            DatabaseSource::Denied => {
131                Err("database: this host has no database, so no query can run".to_string())
132            }
133        }
134    }
135}
136
137impl HostApi for Database {
138    fn module_schema(&self) -> ModuleSchema {
139        SCHEMA
140    }
141
142    fn call(&self, op: &str, args: Vec<Value>) -> Result<Value, RuntimeError> {
143        match op {
144            "query" => {
145                let [Value(Repr::Str(sql))] = args.as_slice() else {
146                    unreachable!("checked by HostRegistry::call")
147                };
148                Ok(rows_of(self.query(sql)))
149            }
150            "connect" => {
151                let [Value(Repr::Str(name))] = args.as_slice() else {
152                    unreachable!("checked by HostRegistry::call")
153                };
154                Ok(self.connect(name))
155            }
156            _ => unreachable!("checked by HostRegistry::call"),
157        }
158    }
159
160    fn call_resource(
161        &self,
162        handle: &ResourceHandle,
163        op: &str,
164        args: Vec<Value>,
165        _back: &mut dyn Reentry,
166    ) -> Result<Value, RuntimeError> {
167        match op {
168            "query" => {
169                let [Value(Repr::Str(sql))] = args.as_slice() else {
170                    unreachable!("checked by HostRegistry::call")
171                };
172                if !self.locked().contains_key(&handle.id) {
173                    return Err(closed(handle, "query"));
174                }
175                Ok(rows_of(self.query(sql)))
176            }
177            "close" => match self.locked().remove(&handle.id) {
178                Some(_) => Ok(Value::ok(Value(Repr::Unit))),
179                None => Err(closed(handle, "close")),
180            },
181            _ => unreachable!("checked by HostRegistry::call_resource"),
182        }
183    }
184}
185
186/// `Ok(rows)` or `Err(Error(message))`, as Cove reads it.
187fn rows_of(answer: Result<Vec<String>, String>) -> Value {
188    match answer {
189        Ok(rows) => Value::ok(Value(Repr::Array(
190            rows.into_iter()
191                .map(|row| Value(Repr::Str(row.into())))
192                .collect(),
193        ))),
194        Err(message) => Value::err(Value::error(message)),
195    }
196}
197
198/// A call on a handle whose connection this host no longer has.
199///
200/// This is a [`RuntimeError`] rather than a Cove `Err`, and deliberately: a
201/// query against a connection that was closed is not an expected failure the
202/// program should handle, it is the program having kept a name past the thing
203/// it named.
204fn closed(handle: &ResourceHandle, op: &str) -> RuntimeError {
205    RuntimeError::new(format!(
206        "`{handle}` is closed, so `{op}` has nothing to act on"
207    ))
208    .with_rule(
209        "A host resource handle names a resource the host owns. Closing the resource ends the handle; the name outlives it and addresses nothing.",
210    )
211    .with_help("open a new one, or move the `close` after the last use")
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::host::{Grants, HostRegistry, NoReentry};
218
219    fn str_arg(text: &str) -> Value {
220        Value(Repr::Str(text.into()))
221    }
222
223    fn rows(value: Value) -> Vec<String> {
224        match value.ok_payload() {
225            Some(payload) => match payload.first() {
226                Some(Value(Repr::Array(items))) => items.iter().map(ToString::to_string).collect(),
227                other => panic!("expected `Ok(Array)`, found {other:?}"),
228            },
229            None => panic!("expected `Ok(...)`, found {value}"),
230        }
231    }
232
233    fn err_message(value: Value) -> String {
234        match value.err_payload() {
235            Some(payload) => payload.first().map(ToString::to_string).unwrap_or_default(),
236            None => panic!("expected `Err(...)`, found {value}"),
237        }
238    }
239
240    fn recorded() -> Database {
241        Database::recorded(BTreeMap::from([(
242            "select id from bookings".to_string(),
243            vec!["b-1".to_string(), "b-2".to_string()],
244        )]))
245    }
246
247    #[test]
248    fn a_recorded_query_answers_its_rows() {
249        let database = recorded();
250
251        let answer = database
252            .call("query", vec![str_arg("select id from bookings")])
253            .unwrap();
254        assert_eq!(rows(answer), ["b-1", "b-2"]);
255    }
256
257    #[test]
258    fn a_query_the_fake_has_no_answer_for_says_so() {
259        let database = recorded();
260
261        let answer = database
262            .call("query", vec![str_arg("select id from invoices")])
263            .unwrap();
264        assert_eq!(
265            err_message(answer),
266            "database: no recorded answer for `select id from invoices`"
267        );
268    }
269
270    #[test]
271    fn a_denied_host_refuses_every_query() {
272        let database = Database::denied();
273
274        for sql in ["select 1", "select id from bookings"] {
275            let answer = database.call("query", vec![str_arg(sql)]).unwrap();
276            assert_eq!(
277                err_message(answer),
278                "database: this host has no database, so no query can run"
279            );
280        }
281    }
282
283    #[test]
284    fn a_run_without_the_database_grant_cannot_query() {
285        let mut hosts = HostRegistry::new(Grants::new(["console"]));
286        hosts.register(Box::new(Database::denied()));
287
288        let error = hosts
289            .call("database", "query", vec![str_arg("select 1")])
290            .expect_err("the call should be rejected");
291        assert_eq!(
292            error.message,
293            "`database.query` requires the `database` capability, which this run was not granted"
294        );
295    }
296
297    /// Denying is an implementation, not an absence: the grant still passes
298    /// and the operation still exists, so the run is told what is missing
299    /// rather than that `database` is not a host module.
300    #[test]
301    fn a_granted_denied_host_answers_the_call_with_the_refusal() {
302        let mut hosts = HostRegistry::new(Grants::new(["database"]));
303        hosts.register(Box::new(Database::denied()));
304
305        let answer = hosts
306            .call("database", "query", vec![str_arg("select 1")])
307            .expect("the call should be allowed");
308        assert_eq!(
309            err_message(answer),
310            "database: this host has no database, so no query can run"
311        );
312    }
313
314    #[test]
315    fn signatures_read_like_source() {
316        let database = Database::denied();
317        let rendered: Vec<String> = database
318            .module_schema()
319            .operations
320            .iter()
321            .map(|op| op.signature())
322            .collect();
323        assert_eq!(
324            rendered,
325            [
326                "query(String) -> Result<Array<String>, Error>",
327                "connect(String) -> Result<database.Connection, Error>",
328            ]
329        );
330        let rendered: Vec<String> = SCHEMA.resources[0]
331            .operations
332            .iter()
333            .map(|op| op.signature())
334            .collect();
335        assert_eq!(
336            rendered,
337            [
338                "query(String) -> Result<Array<String>, Error>",
339                "close() -> Result<Unit, Error>",
340            ]
341        );
342    }
343
344    /// A handle is a name, and closing the resource ends what it named.
345    #[test]
346    fn a_closed_connection_reports_that_its_handle_addresses_nothing() {
347        let mut hosts = HostRegistry::new(Grants::new(["database"]));
348        hosts.register(Box::new(recorded()));
349
350        let opened = hosts
351            .call("database", "connect", vec![str_arg("bookings")])
352            .expect("the call should be allowed");
353        let Value(Repr::Enum(result)) = opened else {
354            panic!("expected `Ok(...)`");
355        };
356        let Some(Value(Repr::Resource(handle))) = result.payload.into_vec().into_iter().next()
357        else {
358            panic!("`connect` answers with a handle");
359        };
360        assert_eq!(handle.qualified_type(), "database.Connection");
361        assert!(handle.task_safe);
362
363        let rows = hosts
364            .call_resource(
365                &handle,
366                "query",
367                vec![str_arg("select id from bookings")],
368                &mut NoReentry,
369            )
370            .expect("a query on an open connection is allowed");
371        assert_eq!(super::tests::rows(rows), ["b-1", "b-2"]);
372
373        hosts
374            .call_resource(&handle, "close", Vec::new(), &mut NoReentry)
375            .expect("closing an open connection is allowed");
376
377        let error = hosts
378            .call_resource(
379                &handle,
380                "query",
381                vec![str_arg("select id from bookings")],
382                &mut NoReentry,
383            )
384            .expect_err("a query on a closed connection is refused");
385        assert_eq!(
386            error.message,
387            "`database.Connection#1` is closed, so `query` has nothing to act on"
388        );
389    }
390
391    /// The grant gates a handle's operations exactly as it gates the
392    /// module's: the boundary is one choke point, not two.
393    #[test]
394    fn a_run_without_the_database_grant_cannot_use_a_handle() {
395        let mut hosts = HostRegistry::new(Grants::new(["console"]));
396        hosts.register(Box::new(Database::denied()));
397        let handle = ResourceHandle::new("database", &SCHEMA.resources[0], 1);
398
399        let error = hosts
400            .call_resource(&handle, "query", vec![str_arg("select 1")], &mut NoReentry)
401            .expect_err("the call should be rejected");
402        assert_eq!(
403            error.message,
404            "`database.Connection.query` requires the `database` capability, which this run was not granted"
405        );
406    }
407}