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::CratestackError;
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<(), CratestackError> {
91 if !self.descriptor.is_materialized {
92 return Err(CratestackError::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 // `AssertSqlSafe`: `view_name` comes from the compile-time view
102 // descriptor the macro emitted, not from request data — there is no
103 // untrusted content in this string (sqlx 0.9's `SqlSafeStr` bound).
104 sqlx::query(sqlx::AssertSqlSafe(sql))
105 .execute(self.runtime.pool())
106 .await
107 .map_err(|error| CratestackError::Database(error.to_string()))?;
108 Ok(())
109 }
110}
111
112/// View delegate for views declared `@@no_unique`. Exposes only
113/// `find_many` — `find_unique` and `refresh()` are absent at the type
114/// level because:
115///
116/// - `find_unique` needs an `@id` field, which `@@no_unique` views
117/// opt out of (validator-enforced).
118/// - `@@materialized` + `@@no_unique` is a parse-time error
119/// (concurrent refresh requires a unique index), so a
120/// `ViewDelegateNoUnique` can never be materialized.
121///
122/// `PK` is fixed to `()` because the underlying `ViewDescriptor<V, ()>`
123/// stores an empty `primary_key` string — preventing any code path
124/// from constructing one with a real PK type.
125#[derive(Clone, Copy)]
126pub struct ViewDelegateNoUnique<'a, V: 'static> {
127 runtime: &'a SqlxRuntime,
128 descriptor: &'static ViewDescriptor<V, ()>,
129}
130
131impl<'a, V: 'static> ViewDelegateNoUnique<'a, V> {
132 pub fn new(runtime: &'a SqlxRuntime, descriptor: &'static ViewDescriptor<V, ()>) -> Self {
133 Self {
134 runtime,
135 descriptor,
136 }
137 }
138
139 pub fn descriptor(&self) -> &'static ViewDescriptor<V, ()> {
140 self.descriptor
141 }
142
143 pub fn find_many(&self) -> FindMany<'a, V, ()> {
144 FindMany {
145 runtime: self.runtime,
146 descriptor: self.descriptor,
147 filters: Vec::new(),
148 order_by: Vec::new(),
149 limit: None,
150 offset: None,
151 for_update: false,
152 }
153 }
154}