Skip to main content

keelson_core/clause/
lock.rs

1use std::borrow::Cow;
2
3use crate::writer::{Expression, SqlWriter};
4
5use super::{MaybeAbsent, write_present, write_quoted_list};
6
7/// Every `FOR …` locking clause on one statement.
8///
9/// A statement may carry several, one per table, so this is a list. It writes no
10/// keyword of its own — each [`Lock`] starts with `FOR` — so a query renders it as
11/// `w.write_if(!locks.is_empty(), " ", &locks, "")`.
12#[derive(Debug, Clone, Default)]
13pub struct Locks {
14    /// The locking clauses, in order.
15    pub locks: Vec<Lock>,
16}
17
18impl Locks {
19    /// Append a locking clause.
20    pub fn append_lock(&mut self, lock: Lock) {
21        self.locks.push(lock);
22    }
23
24    /// Whether the clause is absent.
25    pub fn is_empty(&self) -> bool {
26        self.locks.is_empty()
27    }
28}
29
30impl Expression for Locks {
31    fn write_sql(&self, w: &mut SqlWriter<'_>) {
32        write_present(w, &self.locks, "", " ", "");
33    }
34}
35
36/// A statement that can take a row lock.
37pub trait HasLocks {
38    /// The locking clauses to modify.
39    fn locks_mut(&mut self) -> &mut Locks;
40}
41
42impl HasLocks for Locks {
43    fn locks_mut(&mut self) -> &mut Locks {
44        self
45    }
46}
47
48/// `FOR <strength> [OF table, …] [NOWAIT | SKIP LOCKED]`
49///
50/// From PostgreSQL 17:
51///
52/// ```text
53/// FOR { UPDATE | NO KEY UPDATE | SHARE | KEY SHARE }
54///     [ OF table_name [, ...] ] [ NOWAIT | SKIP LOCKED ]
55/// ```
56///
57/// Never contributes a bound argument: the `OF` list is table names.
58#[derive(Debug, Clone, Default)]
59pub struct Lock {
60    /// How strong a lock. `None` is how a default-constructed lock stays absent —
61    /// there is no `FOR` clause without a strength.
62    pub strength: Option<LockStrength>,
63    /// Restrict the lock to these tables of the statement. Quoted.
64    pub tables: Vec<Cow<'static, str>>,
65    /// What to do when a row is already locked. The default is to wait.
66    pub wait: Option<LockWait>,
67}
68
69impl Lock {
70    /// A lock of `strength` over every table in the statement.
71    pub fn new(strength: LockStrength) -> Self {
72        Lock {
73            strength: Some(strength),
74            ..Lock::default()
75        }
76    }
77
78    /// Restrict the lock to these tables.
79    pub fn append_table(&mut self, tables: impl IntoIterator<Item = impl Into<Cow<'static, str>>>) {
80        self.tables.extend(tables.into_iter().map(Into::into));
81    }
82
83    /// Whether the clause is absent.
84    pub fn is_empty(&self) -> bool {
85        self.strength.is_none()
86    }
87}
88
89impl Expression for Lock {
90    fn write_sql(&self, w: &mut SqlWriter<'_>) {
91        let Some(strength) = &self.strength else {
92            return;
93        };
94
95        w.push_str("FOR ");
96        w.push_str(strength.as_str());
97
98        write_quoted_list(w, &self.tables, " OF ", ", ", "");
99
100        if let Some(wait) = &self.wait {
101            w.push_str(" ");
102            w.push_str(wait.as_str());
103        }
104    }
105}
106
107/// How strong a row lock is, weakest last.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum LockStrength {
110    /// `FOR UPDATE`.
111    Update,
112    /// `FOR NO KEY UPDATE` — PostgreSQL only; weaker than `UPDATE`, and does not
113    /// block a foreign-key reference.
114    NoKeyUpdate,
115    /// `FOR SHARE`.
116    Share,
117    /// `FOR KEY SHARE` — PostgreSQL only; the weakest.
118    KeyShare,
119}
120
121impl LockStrength {
122    /// The keyword, as written.
123    pub fn as_str(self) -> &'static str {
124        match self {
125            LockStrength::Update => "UPDATE",
126            LockStrength::NoKeyUpdate => "NO KEY UPDATE",
127            LockStrength::Share => "SHARE",
128            LockStrength::KeyShare => "KEY SHARE",
129        }
130    }
131}
132
133/// What to do about a row someone else has already locked.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum LockWait {
136    /// `NOWAIT` — fail rather than wait.
137    NoWait,
138    /// `SKIP LOCKED` — leave the row out of the result.
139    SkipLocked,
140}
141
142impl LockWait {
143    /// The keyword, as written.
144    pub fn as_str(self) -> &'static str {
145        match self {
146            LockWait::NoWait => "NOWAIT",
147            LockWait::SkipLocked => "SKIP LOCKED",
148        }
149    }
150}
151
152impl MaybeAbsent for Lock {
153    fn is_absent(&self) -> bool {
154        self.is_empty()
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use keelson_sqlcheck::testing::assert_frag_sql;
161
162    use super::*;
163    use crate::dialect::testing::Numbered;
164    use crate::writer::{Expression, build};
165
166    /// A locking clause is the last thing in a `SELECT`. `OF` names a table, so the
167    /// frame that carries an `OF` list has to have those tables in its `FROM`.
168    const FRAME: &str = r#"SELECT "id" FROM users {}"#;
169    const TWO_TABLE_FRAME: &str = r#"SELECT "users"."id" FROM users, posts {}"#;
170
171    fn sql(e: &impl Expression) -> String {
172        build(&Numbered, e).expect("render").0
173    }
174
175    #[test]
176    fn a_lock_without_a_strength_writes_nothing() {
177        assert_frag_sql(FRAME, &sql(&Lock::default()), "");
178        assert_frag_sql(FRAME, &sql(&Locks::default()), "");
179        assert!(Lock::default().is_empty());
180        assert!(Locks::default().is_empty());
181    }
182
183    #[test]
184    fn a_bare_lock_is_just_for_and_the_strength() {
185        // No trailing space: bob writes `FOR KEY SHARE ` because it pads before
186        // the optional OF list rather than inside it.
187        assert_frag_sql(
188            FRAME,
189            &sql(&Lock::new(LockStrength::KeyShare)),
190            "FOR KEY SHARE",
191        );
192    }
193
194    #[test]
195    fn strength_tables_and_wait_render_in_grammar_order() {
196        // PostgreSQL 17: FOR strength [ OF table … ] [ NOWAIT | SKIP LOCKED ]
197        let mut l = Lock::new(LockStrength::Update);
198        l.append_table(["users", "posts"]);
199        l.wait = Some(LockWait::SkipLocked);
200
201        let (rendered, args) = build(&Numbered, &l).unwrap();
202        assert_frag_sql(
203            TWO_TABLE_FRAME,
204            &rendered,
205            r#"FOR UPDATE OF "users", "posts" SKIP LOCKED"#,
206        );
207        assert!(args.is_empty(), "table names are identifiers");
208    }
209
210    #[test]
211    fn every_strength_and_wait_has_its_spelling() {
212        for (strength, keyword) in [
213            (LockStrength::Update, "FOR UPDATE"),
214            (LockStrength::NoKeyUpdate, "FOR NO KEY UPDATE"),
215            (LockStrength::Share, "FOR SHARE"),
216            (LockStrength::KeyShare, "FOR KEY SHARE"),
217        ] {
218            assert_frag_sql(FRAME, &sql(&Lock::new(strength)), keyword);
219        }
220
221        let mut l = Lock::new(LockStrength::Share);
222        l.wait = Some(LockWait::NoWait);
223        assert_frag_sql(FRAME, &sql(&l), "FOR SHARE NOWAIT");
224    }
225
226    #[test]
227    fn several_locks_are_space_separated() {
228        let mut locks = Locks::default();
229        let mut first = Lock::new(LockStrength::Update);
230        first.append_table(["users"]);
231        let mut second = Lock::new(LockStrength::Share);
232        second.append_table(["posts"]);
233        locks.append_lock(first);
234        locks.append_lock(second);
235
236        assert_frag_sql(
237            TWO_TABLE_FRAME,
238            &sql(&locks),
239            r#"FOR UPDATE OF "users" FOR SHARE OF "posts""#,
240        );
241    }
242}