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: