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