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}