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: