Skip to main content

cratestack_sqlx/delegate/
view.rs

1//! `ViewDelegate` — the per-view entry point handed out by the
2//! generated `Cratestack::views().<view>()` accessor (ADR-0003).
3//!
4//! Views are read-only. The delegate exposes `find_many` and
5//! `find_unique` only — never any write primitive. The read-only-ness
6//! guarantee is at the *type* level: the builder methods return the
7//! same `FindMany` / `FindUnique` types used by `ModelDelegate`, but
8//! the descriptor carried through is a `ViewDescriptor<V, PK>` which
9//! does not implement `WriteSource`. The macro can't accidentally
10//! wire a view through a write builder because the bound doesn't
11//! hold.
12//!
13//! For `@@materialized` views (server-only, ADR-0003 §"Materialized
14//! views") the delegate also exposes `refresh()` which emits
15//! `REFRESH MATERIALIZED VIEW CONCURRENTLY <name>`. Concurrent
16//! refresh requires the unique index the materialized DDL emits on
17//! the `@id` column, which is why `@@materialized` + `@@no_unique`
18//! is a parse-time error.
19
20use cratestack_core::CoolError;
21use cratestack_sql::ViewDescriptor;
22
23use crate::{FindMany, FindUnique, SqlxRuntime, sqlx};
24
25/// View delegate for views that declared an `@id` field. Exposes
26/// `find_many` + `find_unique` (and `refresh()` on materialized
27/// views). Views declared `@@no_unique` get [`ViewDelegateNoUnique`]
28/// instead, which omits `find_unique` at the type level so a call
29/// like `runtime.views().<v>().find_unique(())` is a compile error
30/// rather than a runtime "WHERE  = $1" footgun.
31#[derive(Clone, Copy)]
32pub struct ViewDelegate<'a, V: 'static, PK: 'static> {
33    pub(super) runtime: &'a SqlxRuntime,
34    pub(super) descriptor: &'static ViewDescriptor<V, PK>,
35}
36
37impl<'a, V: 'static, PK: 'static> ViewDelegate<'a, V, PK> {
38    pub fn new(runtime: &'a SqlxRuntime, descriptor: &'static ViewDescriptor<V, PK>) -> Self {
39        Self {
40            runtime,
41            descriptor,
42        }
43    }
44
45    /// The typed descriptor the delegate was constructed with.
46    /// Useful for callers that need to inspect view metadata (e.g.
47    /// `is_materialized`, `source_tables`) without going through the
48    /// runtime.
49    pub fn descriptor(&self) -> &'static ViewDescriptor<V, PK> {
50        self.descriptor
51    }
52
53    pub fn find_many(&self) -> FindMany<'a, V, PK> {
54        FindMany {
55            runtime: self.runtime,
56            descriptor: self.descriptor,
57            filters: Vec::new(),
58            order_by: Vec::new(),
59            limit: None,
60            offset: None,
61            for_update: false,
62        }
63    }
64
65    /// Single-row lookup by primary key. Only available on views
66    /// with an `@id` field — `@@no_unique` views get
67    /// [`ViewDelegateNoUnique`], which doesn't expose this method.
68    pub fn find_unique(&self, id: PK) -> FindUnique<'a, V, PK> {
69        FindUnique {
70            runtime: self.runtime,
71            descriptor: self.descriptor,
72            id,
73            for_update: false,
74            policy_kind: crate::query::ReadPolicyKind::Detail,
75        }
76    }
77
78    /// `REFRESH MATERIALIZED VIEW CONCURRENTLY <name>` — only valid
79    /// on `@@materialized` views. Concurrent refresh holds an
80    /// `ACCESS SHARE` lock (instead of the `ACCESS EXCLUSIVE`
81    /// non-concurrent refresh takes), letting readers continue
82    /// against the previous snapshot while the rebuild runs.
83    ///
84    /// Returns a `Forbidden` error if called on a non-materialized
85    /// view — the macro emits this method unconditionally for
86    /// `ViewDescriptor` consumers, with the gate enforced at runtime
87    /// so the wire contract is uniform. (At codegen time the macro
88    /// can also choose to omit the method entirely on non-materialized
89    /// descriptors; the runtime gate is the safety net.)
90    pub async fn refresh(&self) -> Result<(), CoolError> {
91        if !self.descriptor.is_materialized {
92            return Err(CoolError::Forbidden(format!(
93                "view `{}` is not `@@materialized`; refresh() is only valid on materialized views",
94                self.descriptor.view_name
95            )));
96        }
97        let sql = format!(
98            "REFRESH MATERIALIZED VIEW CONCURRENTLY {}",
99            self.descriptor.view_name
100        );
101        sqlx::query(&sql)
102            .execute(self.runtime.pool())
103            .await
104            .map_err(|error| CoolError::Database(error.to_string()))?;
105        Ok(())
106    }
107}
108
109/// View delegate for views declared `@@no_unique`. Exposes only
110/// `find_many` — `find_unique` and `refresh()` are absent at the type
111/// level because:
112///
113/// - `find_unique` needs an `@id` field, which `@@no_unique` views
114///   opt out of (validator-enforced).
115/// - `@@materialized` + `@@no_unique` is a parse-time error
116///   (concurrent refresh requires a unique index), so a
117///   `ViewDelegateNoUnique` can never be materialized.
118///
119/// `PK` is fixed to `()` because the underlying `ViewDescriptor<V, ()>`
120/// stores an empty `primary_key` string — preventing any code path
121/// from constructing one with a real PK type.
122#[derive(Clone, Copy)]
123pub struct ViewDelegateNoUnique<'a, V: 'static> {
124    runtime: &'a SqlxRuntime,
125    descriptor: &'static ViewDescriptor<V, ()>,
126}
127
128impl<'a, V: 'static> ViewDelegateNoUnique<'a, V> {
129    pub fn new(runtime: &'a SqlxRuntime, descriptor: &'static ViewDescriptor<V, ()>) -> Self {
130        Self {
131            runtime,
132            descriptor,
133        }
134    }
135
136    pub fn descriptor(&self) -> &'static ViewDescriptor<V, ()> {
137        self.descriptor
138    }
139
140    pub fn find_many(&self) -> FindMany<'a, V, ()> {
141        FindMany {
142            runtime: self.runtime,
143            descriptor: self.descriptor,
144            filters: Vec::new(),
145            order_by: Vec::new(),
146            limit: None,
147            offset: None,
148            for_update: false,
149        }
150    }
151}