1: | <?php declare(strict_types=1); |
2: | |
3: | namespace Salient\Core; |
4: | |
5: | use Salient\Contract\Core\FacadeAwareInterface; |
6: | use Salient\Contract\Core\FacadeInterface; |
7: | use Salient\Contract\Core\Unloadable; |
8: | use Salient\Core\Concern\UnloadsFacades; |
9: | use Salient\Core\Internal\StoreState; |
10: | use Salient\Utility\Exception\InvalidRuntimeConfigurationException; |
11: | use Salient\Utility\File; |
12: | use LogicException; |
13: | use SQLite3; |
14: | use SQLite3Result; |
15: | use SQLite3Stmt; |
16: | use Throwable; |
17: | |
18: | /** |
19: | * Base class for SQLite-backed stores |
20: | * |
21: | * @api |
22: | * |
23: | * @implements FacadeAwareInterface<FacadeInterface<static>> |
24: | */ |
25: | abstract class AbstractStore implements FacadeAwareInterface, Unloadable |
26: | { |
27: | /** @use UnloadsFacades<FacadeInterface<static>> */ |
28: | use UnloadsFacades; |
29: | |
30: | private ?StoreState $State = null; |
31: | private bool $IsCheckRunning = false; |
32: | |
33: | private function __clone() {} |
34: | |
35: | /** |
36: | * Open the database, creating it if necessary |
37: | * |
38: | * @param string $filename Use `":memory:"` to create an in-memory database, |
39: | * or an empty string to create a temporary database on the filesystem. |
40: | * Otherwise, `$filename` is created with file mode `0600` if it doesn't |
41: | * exist. Its parent directory is created with mode `0700` if it doesn't |
42: | * exist. |
43: | * @return $this |
44: | * @throws LogicException if the database is already open. |
45: | */ |
46: | final protected function openDb(string $filename, ?string $query = null) |
47: | { |
48: | if ($this->isOpen()) { |
49: | // @codeCoverageIgnoreStart |
50: | throw new LogicException('Database already open'); |
51: | // @codeCoverageIgnoreEnd |
52: | } |
53: | |
54: | $isTemporary = $filename === '' || $filename === ':memory:'; |
55: | if (!$isTemporary) { |
56: | File::create($filename, 0600, 0700); |
57: | } |
58: | |
59: | $db = new SQLite3($filename); |
60: | |
61: | if (\PHP_VERSION_ID < 80300) { |
62: | $db->enableExceptions(); |
63: | } |
64: | |
65: | $db->busyTimeout(60000); |
66: | $db->exec('PRAGMA journal_mode=WAL'); |
67: | $db->exec('PRAGMA foreign_keys=ON'); |
68: | |
69: | if ($query !== null) { |
70: | $db->exec($query); |
71: | } |
72: | |
73: | $this->State ??= new StoreState(); |
74: | $this->State->Db = $db; |
75: | $this->State->Filename = $filename; |
76: | $this->State->IsTemporary = $isTemporary; |
77: | $this->State->HasTransaction = false; |
78: | $this->State->IsOpen = true; |
79: | |
80: | return $this; |
81: | } |
82: | |
83: | /** |
84: | * @inheritDoc |
85: | */ |
86: | final public function unload(): void |
87: | { |
88: | $this->close(); |
89: | } |
90: | |
91: | /** |
92: | * Close the database and unload any facades where the store is the |
93: | * underlying instance |
94: | */ |
95: | public function close(): void |
96: | { |
97: | $this->closeDb(); |
98: | } |
99: | |
100: | /** |
101: | * If the database is open, close it, and unload any facades where the store |
102: | * is the underlying instance |
103: | */ |
104: | final protected function closeDb(): void |
105: | { |
106: | if (!$this->isOpen()) { |
107: | // Necessary because the database may not have been opened yet |
108: | $this->unloadFacades(); |
109: | return; |
110: | } |
111: | |
112: | $this->State->Db->close(); |
113: | $this->State->IsOpen = false; |
114: | unset($this->State->Db); |
115: | unset($this->State->Filename); |
116: | $this->State->IsTemporary = false; |
117: | $this->State->HasTransaction = false; |
118: | |
119: | $this->unloadFacades(); |
120: | } |
121: | |
122: | /** |
123: | * Close the store without closing the database by detaching the database |
124: | * from the store, and unload any facades where the store is the underlying |
125: | * instance |
126: | */ |
127: | public function detach(): void |
128: | { |
129: | $this->detachDb(); |
130: | } |
131: | |
132: | /** |
133: | * Detach the database from the store and unload any facades where the store |
134: | * is the underlying instance |
135: | */ |
136: | final protected function detachDb(): void |
137: | { |
138: | $this->State = null; |
139: | $this->unloadFacades(); |
140: | } |
141: | |
142: | /** |
143: | * Override to perform an action whenever the underlying SQLite3 instance is |
144: | * accessed |
145: | * |
146: | * Called once per call to {@see AbstractStore::db()}. Use |
147: | * {@see AbstractStore::safeCheck()} to prevent recursion if necessary. |
148: | * |
149: | * @return $this |
150: | */ |
151: | protected function check() |
152: | { |
153: | return $this; |
154: | } |
155: | |
156: | /** |
157: | * Check if the database is open |
158: | * |
159: | * @phpstan-assert-if-true !null $this->State |
160: | */ |
161: | final public function isOpen(): bool |
162: | { |
163: | return $this->State && $this->State->IsOpen; |
164: | } |
165: | |
166: | /** |
167: | * Check if the store is backed by a temporary or in-memory database |
168: | * |
169: | * @throws LogicException if the database is not open. |
170: | */ |
171: | final public function isTemporary(): bool |
172: | { |
173: | $this->assertIsOpen(); |
174: | |
175: | return $this->State->IsTemporary; |
176: | } |
177: | |
178: | /** |
179: | * Get the filename of the database |
180: | * |
181: | * @throws LogicException if the database is not open. |
182: | */ |
183: | final public function getFilename(): string |
184: | { |
185: | $this->assertIsOpen(); |
186: | |
187: | return $this->State->Filename; |
188: | } |
189: | |
190: | /** |
191: | * Check if safeCheck() is currently running |
192: | */ |
193: | final protected function isCheckRunning(): bool |
194: | { |
195: | return $this->IsCheckRunning; |
196: | } |
197: | |
198: | /** |
199: | * Call check() without recursion |
200: | * |
201: | * {@see AbstractStore::db()} calls {@see AbstractStore::check()} via |
202: | * {@see AbstractStore::safeCheck()} to prevent recursion when |
203: | * {@see AbstractStore::check()} calls {@see AbstractStore::db()}. |
204: | * |
205: | * If {@see AbstractStore::check()} may be called directly, it should call |
206: | * itself via {@see AbstractStore::safeCheck()}, for example: |
207: | * |
208: | * ```php |
209: | * protected function check() |
210: | * { |
211: | * if (!$this->isCheckRunning()) { |
212: | * return $this->safeCheck(); |
213: | * } |
214: | * |
215: | * // ... |
216: | * } |
217: | * ``` |
218: | * |
219: | * @return $this |
220: | */ |
221: | final protected function safeCheck() |
222: | { |
223: | $this->IsCheckRunning = true; |
224: | try { |
225: | return $this->check(); |
226: | } finally { |
227: | $this->IsCheckRunning = false; |
228: | } |
229: | } |
230: | |
231: | /** |
232: | * Get the underlying SQLite3 instance |
233: | * |
234: | * @throws LogicException if the database is not open. |
235: | */ |
236: | final protected function db(): SQLite3 |
237: | { |
238: | $this->assertIsOpen(); |
239: | |
240: | if ($this->IsCheckRunning) { |
241: | return $this->State->Db; |
242: | } |
243: | |
244: | $this->safeCheck(); |
245: | $this->assertIsOpen(); |
246: | return $this->State->Db; |
247: | } |
248: | |
249: | /** |
250: | * Prepare a SQL statement for execution |
251: | */ |
252: | final protected function prepare(string $query): SQLite3Stmt |
253: | { |
254: | $stmt = $this->db()->prepare($query); |
255: | assert($stmt !== false); |
256: | return $stmt; |
257: | } |
258: | |
259: | /** |
260: | * Execute a prepared statement |
261: | */ |
262: | final protected function execute(SQLite3Stmt $stmt): SQLite3Result |
263: | { |
264: | $result = $stmt->execute(); |
265: | assert($result !== false); |
266: | return $result; |
267: | } |
268: | |
269: | /** |
270: | * Check if a transaction has been started |
271: | */ |
272: | final protected function hasTransaction(): bool |
273: | { |
274: | $this->assertIsOpen(); |
275: | |
276: | return $this->State->HasTransaction; |
277: | } |
278: | |
279: | /** |
280: | * BEGIN a transaction |
281: | * |
282: | * @return $this |
283: | * @throws LogicException if a transaction has already been started. |
284: | */ |
285: | final protected function beginTransaction() |
286: | { |
287: | $this->assertIsOpen(); |
288: | |
289: | if ($this->State->HasTransaction) { |
290: | throw new LogicException('Transaction already started'); |
291: | } |
292: | |
293: | $this->db()->exec('BEGIN IMMEDIATE'); |
294: | $this->State->HasTransaction = true; |
295: | |
296: | return $this; |
297: | } |
298: | |
299: | /** |
300: | * COMMIT a transaction |
301: | * |
302: | * @return $this |
303: | * @throws LogicException if no transaction has been started. |
304: | */ |
305: | final protected function commitTransaction() |
306: | { |
307: | $this->assertIsOpen(); |
308: | |
309: | if (!$this->State->HasTransaction) { |
310: | throw new LogicException('No transaction started'); |
311: | } |
312: | |
313: | $this->db()->exec('COMMIT'); |
314: | $this->State->HasTransaction = false; |
315: | |
316: | return $this; |
317: | } |
318: | |
319: | /** |
320: | * ROLLBACK a transaction |
321: | * |
322: | * @param bool $ignoreNoTransaction If `true` and no transaction has been |
323: | * started, return without throwing an exception. Useful in `catch` blocks |
324: | * where a transaction may or may not have been started. |
325: | * @return $this |
326: | * @throws LogicException if no transaction has been started. |
327: | */ |
328: | final protected function rollbackTransaction(bool $ignoreNoTransaction = false) |
329: | { |
330: | $this->assertIsOpen(); |
331: | |
332: | if (!$this->State->HasTransaction) { |
333: | if ($ignoreNoTransaction) { |
334: | return $this; |
335: | } |
336: | throw new LogicException('No transaction started'); |
337: | } |
338: | |
339: | $this->db()->exec('ROLLBACK'); |
340: | $this->State->HasTransaction = false; |
341: | |
342: | return $this; |
343: | } |
344: | |
345: | /** |
346: | * BEGIN a transaction, run a callback and COMMIT or ROLLBACK as needed |
347: | * |
348: | * A rollback is attempted if an exception is caught, otherwise the |
349: | * transaction is committed. |
350: | * |
351: | * @template T |
352: | * |
353: | * @param callable(): T $callback |
354: | * @return T |
355: | * @throws LogicException if a transaction has already been started. |
356: | */ |
357: | final protected function callInTransaction(callable $callback) |
358: | { |
359: | $this->beginTransaction(); |
360: | |
361: | try { |
362: | $result = $callback(); |
363: | $this->commitTransaction(); |
364: | } catch (Throwable $ex) { |
365: | $this->rollbackTransaction(); |
366: | throw $ex; |
367: | } |
368: | |
369: | return $result; |
370: | } |
371: | |
372: | /** |
373: | * Throw an exception if the underlying SQLite3 library doesn't support |
374: | * UPSERT queries |
375: | * |
376: | * @link https://www.sqlite.org/lang_UPSERT.html |
377: | */ |
378: | final protected function assertCanUpsert(): void |
379: | { |
380: | if (SQLite3::version()['versionNumber'] < 3024000) { |
381: | // @codeCoverageIgnoreStart |
382: | throw new InvalidRuntimeConfigurationException('SQLite 3.24 or above required'); |
383: | // @codeCoverageIgnoreEnd |
384: | } |
385: | } |
386: | |
387: | /** |
388: | * Throw an exception if the database is not open |
389: | * |
390: | * @phpstan-assert !null $this->State |
391: | */ |
392: | final protected function assertIsOpen(): void |
393: | { |
394: | if (!$this->State || !$this->State->IsOpen) { |
395: | // @codeCoverageIgnoreStart |
396: | throw new LogicException('No database open'); |
397: | // @codeCoverageIgnoreEnd |
398: | } |
399: | } |
400: | } |
401: |