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