memory = $path === ':memory:' || $path === ''; $this->readOnly = $readOnly; $this->hdr = ['freeTrunk' => 0, 'freeCount' => 0, 'cookie' => 0, 'format' => 4, 'userVersion' => 0, 'counter' => 0, 'autoVacuum' => 0, 'appId' => 0, 'cacheSize' => 0]; if ($this->memory) return; if (!is_file($path)) { if ($readOnly || !$create) throw new DbException("データベースのファイルがありません:{$path}", 14, 'HY000'); $dir = dirname($path); if (!is_dir($dir)) throw new DbException("ディレクトリがありません:{$dir}", 14, 'HY000'); } $fh = @fopen($path, $readOnly ? 'rb' : 'c+b'); if ($fh === false && !$readOnly && is_file($path)) { // 書けないファイル $fh = @fopen($path, 'rb'); $this->readOnly = true; } if ($fh === false) throw new DbException("データベースを開けません:{$path}", 14, 'HY000'); $this->fh = $fh; } public function __destruct() { $this->close(); } public function close(): void { if ($this->inTxn) $this->rollback(); $this->unlock(); if ($this->fh) @fclose($this->fh); $this->fh = null; } public function isMemory(): bool { return $this->memory; } public function inTransaction(): bool { return $this->inTxn; } /////////////// // ロック /** * 読み込みを始める(共有ロック、他の接続の変更を確認する) * * @throws DbException */ public function beginRead(): void { if ($this->memory || $this->lock !== 0) return; $this->acquire(LOCK_SH); // 途中で止まった書き込みのジャーナル(ホットジャーナル) if ($this->hasHotJournal()) { $this->unlock(); $this->acquire(LOCK_EX); if ($this->hasHotJournal()) { if ($this->readOnly) throw new DbException('データベースを元に戻す必要がありますが、読み込み専用です', 8, 'HY000'); $this->playback(); } flock($this->fh, LOCK_SH); $this->lock = LOCK_SH; } $this->readHeader(); } /** * 書き込みを始める(排他ロック) * * @throws DbException */ public function beginWrite(): void { if ($this->readOnly) throw new DbException('読み込み専用のデータベースです', 8, 'HY000'); if ($this->inTxn) return; if (!$this->memory) { $before = $this->lock !== 0 ? $this->hdr['counter'] : null; // 共有ロックの間に読んだデータが有る(その後で他の接続が変更したら、読んだデータは古い) $hadCache = $before !== null && $this->readShared; if ($this->lock !== LOCK_EX) $this->acquire(LOCK_EX); if ($this->hasHotJournal()) $this->playback(); $this->readHeader(); if ($hadCache && $before !== $this->hdr['counter']) { // 読んだ後で他の接続が変更した(flockのロックの変更は一度外れる) $this->downgrade(); throw new DbException('データベースはロックされています(他の接続が変更しました)', 5, 'HY000'); } } $this->inTxn = true; $this->origPages = $this->nPages; $this->txnHdr = $this->hdr; $this->journaled = []; $this->journalOpen = false; } /** 読み込みを終える(トランザクションの外なら、ロックを外す) */ public function endRead(): void { if (!$this->inTxn && !$this->hold) $this->unlock(); } private function acquire(int $op): void { $deadline = microtime(true) + $this->busyTimeout / 1000; while (!flock($this->fh, $op | LOCK_NB)) { if (microtime(true) >= $deadline) throw new DbException('データベースはロックされています', 5, 'HY000'); usleep(random_int(2000, 20000)); } $this->lock = $op; $this->readShared = false; } private function downgrade(): void { if ($this->memory) return; flock($this->fh, LOCK_SH); $this->lock = LOCK_SH; } private function unlock(): void { if ($this->fh && $this->lock !== 0) flock($this->fh, LOCK_UN); $this->lock = 0; } /////////////// // ヘッダー /** ファイルのヘッダーを読む(変更されていれば、キャッシュを捨てる) */ private function readHeader(): void { fseek($this->fh, 0); $h = (string)fread($this->fh, 100); clearstatcache(true, $this->path); $fileSize = (int)@filesize($this->path); if ($h === '' || $fileSize === 0) { if ($this->nPages !== 0 || $this->hdr['counter'] !== 0) $this->reset(); $this->nPages = 0; $this->hdr['counter'] = 0; return; } if (strlen($h) < 100 || substr($h, 0, 16) !== self::HEADER) throw new DbException('SQLiteのデータベースではありません', 26, 'HY000'); $ps = unpack('n', $h, 16)[1]; $ps = $ps === 1 ? 65536 : $ps; if ($ps < 512 || ($ps & ($ps - 1))) throw new DbException("不正なページの大きさ:{$ps}", 11, 'HY000'); $reserved = ord($h[20]); $counter = unpack('N', $h, 24)[1]; if ($counter !== $this->hdr['counter'] || $ps !== $this->pageSize) $this->reset(); $this->pageSize = $ps; $this->usable = $ps - $reserved; if (ord($h[18]) > 2 || ord($h[19]) > 2) throw new DbException('このSQLiteのファイル形式は未対応です', 26, 'HY000'); if (ord($h[19]) === 2 && is_file($this->path.'-wal') && filesize($this->path.'-wal') > 0) { throw new DbException('WALモードのデータベースは未対応です(sqlite3で「PRAGMA journal_mode=DELETE」にして下さい)', 26, 'HY000'); } $enc = unpack('N', $h, 56)[1]; if ($enc !== 1 && $enc !== 0) throw new DbException('UTF-8でないデータベースは未対応です', 26, 'HY000'); $n = unpack('N', $h, 28)[1]; $valid = unpack('N', $h, 92)[1]; // 古いバージョンが書いたファイルは、ページ数をファイルの大きさから if ($n === 0 || $valid !== $counter) $n = intdiv($fileSize, $ps); $this->nPages = $n; $this->hdr = [ 'counter' => $counter, 'freeTrunk' => unpack('N', $h, 32)[1], 'freeCount' => unpack('N', $h, 36)[1], 'cookie' => unpack('N', $h, 40)[1], 'format' => unpack('N', $h, 44)[1], 'cacheSize' => unpack('N', $h, 48)[1], 'autoVacuum' => unpack('N', $h, 52)[1], 'userVersion' => unpack('N', $h, 60)[1], 'appId' => unpack('N', $h, 68)[1], ]; // 自動バキューム(ポインターマップ)・予約された領域が有るファイルには書かない if ($this->hdr['autoVacuum'] !== 0 || $reserved !== 0) $this->readOnly = true; } /** ヘッダー(100バイト) */ public function header(): string { $ps = $this->pageSize === 65536 ? 1 : $this->pageSize; return self::HEADER.pack('n', $ps)."\x01\x01".chr($this->pageSize - $this->usable)."\x40\x20\x20" .pack('NNNNNNNNNNNN', $this->hdr['counter'], $this->nPages, $this->hdr['freeTrunk'], $this->hdr['freeCount'], $this->hdr['cookie'], $this->hdr['format'] ?: 4, $this->hdr['cacheSize'], $this->hdr['autoVacuum'], 1, $this->hdr['userVersion'], 0, $this->hdr['appId']) .str_repeat("\0", 20).pack('NN', $this->hdr['counter'], 3045001); } /** キャッシュを捨てる */ private function reset(): void { $this->cache = []; $this->generation++; } /////////////// // ページ /** * ページを読む * * @param int $pgno ページ番号(1から) * @return string */ public function read(int $pgno): string { if ($this->lock === LOCK_SH) $this->readShared = true; if (isset($this->cache[$pgno])) return $this->cache[$pgno]; if ($pgno < 1 || $pgno > $this->nPages) throw new DbException("データベースが壊れています(ページ{$pgno})", 11, 'HY000'); if ($this->memory) return str_repeat("\0", $this->pageSize); fseek($this->fh, ($pgno - 1) * $this->pageSize); $data = (string)fread($this->fh, $this->pageSize); if (strlen($data) < $this->pageSize) $data = str_pad($data, $this->pageSize, "\0"); if (count($this->cache) > 4000 && empty($this->dirty)) $this->cache = []; return $this->cache[$pgno] = $data; } /** * ページを書く(トランザクションの中で) * * @param int $pgno * @param string $data ページの全部(ページ1の最初の100バイトはヘッダーで上書きする) */ public function write(int $pgno, string $data): void { if (!$this->inTxn) throw new \LogicException('SQLite:トランザクションの外で書き込み'); foreach ($this->snaps as $k => $snap) { if (!array_key_exists($pgno, $snap['pages'])) { $this->snaps[$k]['pages'][$pgno] = $pgno <= $snap['nPages'] ? $this->read($pgno) : null; } } if (!array_key_exists($pgno, $this->dirty)) { // 元のページ(ジャーナル・ロールバック用)、新しいページ・ジャーナルに書いたページはnull $this->dirty[$pgno] = $pgno <= $this->origPages && !isset($this->journaled[$pgno]) ? $this->read($pgno) : null; } $this->cache[$pgno] = $data; if (count($this->dirty) > $this->spillPages && !$this->memory) $this->spill(); } /** ページ1のヘッダーを今の値にする */ private function writeHeader(): void { $p1 = $this->nPages >= 1 ? $this->read(1) : str_repeat("\0", $this->pageSize); $this->write(1, $this->header().substr($p1, 100)); } /** * 新しいページ(フリーリスト又はファイルの最後) * * @return int ページ番号(中身は呼び出し側が全部書く) */ public function allocate(): int { if ($this->hdr['freeCount'] > 0 && $this->hdr['freeTrunk'] > 0) { $trunk = $this->hdr['freeTrunk']; $t = $this->read($trunk); $n = unpack('N', $t, 4)[1]; $this->hdr['freeCount']--; if ($n > 0) { $leaf = unpack('N', $t, 8 + ($n - 1) * 4)[1]; $this->write($trunk, substr($t, 0, 4).pack('N', $n - 1).substr($t, 8)); return $leaf; } $this->hdr['freeTrunk'] = unpack('N', $t)[1]; return $trunk; } $this->nPages++; // ロック用のバイトの有るページは使わない if ($this->nPages === intdiv(0x40000000, $this->pageSize) + 1) $this->nPages++; $this->cache[$this->nPages] = str_repeat("\0", $this->pageSize); return $this->nPages; } /** * ページをフリーリストに入れる * * @param int $pgno */ public function free(int $pgno): void { $trunk = $this->hdr['freeTrunk']; $max = intdiv($this->usable, 4) - 2; if ($trunk > 0) { $t = $this->read($trunk); $n = unpack('N', $t, 4)[1]; if ($n < $max - 6) { $t = substr($t, 0, 4).pack('N', $n + 1).substr($t, 8, $n * 4).pack('N', $pgno).substr($t, 12 + $n * 4); $this->write($trunk, $t); $this->hdr['freeCount']++; return; } } // 新しいトランク $this->write($pgno, pack('NN', $trunk, 0).str_repeat("\0", $this->pageSize - 8)); $this->hdr['freeTrunk'] = $pgno; $this->hdr['freeCount']++; } /////////////// // トランザクション /** * スナップショット(文・セーブポイント):失敗したら、その後の変更だけを元に戻す * * @return int 番号(snapPop・snapRestore用) */ public function snapPush(): int { $this->snaps[] = ['pages' => [], 'nPages' => $this->nPages, 'hdr' => $this->hdr]; return count($this->snaps) - 1; } /** スナップショットを捨てる(変更は残す) */ public function snapPop(?int $n): void { if ($n === null) return; array_splice($this->snaps, $n); } /** スナップショットの時に戻す(スナップショットは残す) */ public function snapRestore(?int $n): void { if ($n === null || !isset($this->snaps[$n])) return; $s = $this->snaps[$n]; foreach ($s['pages'] as $pgno => $data) { if ($data === null) { unset($this->cache[$pgno]); } else { $this->cache[$pgno] = $data; } } $this->nPages = $s['nPages']; $this->hdr = $s['hdr']; array_splice($this->snaps, $n + 1); $this->snaps[$n]['pages'] = []; } /** * コミット * * @throws DbException */ public function commit(): void { if (!$this->inTxn) return; $this->snaps = []; if (!empty($this->dirty) || $this->journalOpen) { $this->hdr['counter'] = ($this->hdr['counter'] + 1) & 0xFFFFFFFF; $this->writeHeader(); if (!$this->memory) { $this->writeJournal(); $this->writePages(); if (function_exists('fsync')) @fsync($this->fh); $this->closeJournal(true); } } $this->dirty = []; $this->journaled = []; $this->inTxn = false; $this->downgrade(); } /** ロールバック */ public function rollback(): void { if (!$this->inTxn) return; $this->snaps = []; if ($this->journalOpen) { // 一部をファイルに書いた:ジャーナルから戻す $this->closeJournal(false); $this->playback(); $this->cache = []; } else { foreach ($this->dirty as $pgno => $orig) { if ($orig === null) { unset($this->cache[$pgno]); } else { $this->cache[$pgno] = $orig; } } if ($this->memory) { foreach (array_keys($this->cache) as $pgno) if ($pgno > $this->origPages) unset($this->cache[$pgno]); } } $this->dirty = []; $this->journaled = []; $this->inTxn = false; $this->generation++; $this->nPages = $this->origPages; $this->hdr = $this->txnHdr; if (!$this->memory) { $this->downgrade(); $this->readHeader(); } } /** 変更されたページが多過ぎる:ジャーナルに書いてから、ファイルに書く */ private function spill(): void { $this->writeJournal(); $this->writePages(); } /** 元のページをジャーナルに書く */ private function writeJournal(): void { if (!$this->journalOpen) { $jh = @fopen($this->path.'-journal', 'w+b'); if ($jh === false) throw new DbException('ジャーナルを作れません:'.$this->path.'-journal', 14, 'HY000'); $this->jh = $jh; $this->nonce = random_bytes(4); $head = self::JOURNAL_MAGIC.pack('N', 0xFFFFFFFF).$this->nonce.pack('NNN', $this->origPages, self::SECTOR, $this->pageSize); fwrite($jh, str_pad($head, self::SECTOR, "\0")); $this->journalOpen = true; } $init = unpack('N', $this->nonce)[1]; $buf = ''; foreach ($this->dirty as $pgno => $orig) { if ($pgno > $this->origPages || isset($this->journaled[$pgno])) continue; if ($orig === null) { fseek($this->fh, ($pgno - 1) * $this->pageSize); $orig = str_pad((string)fread($this->fh, $this->pageSize), $this->pageSize, "\0"); } $sum = $init; for ($i = $this->pageSize - 200; $i > 0; $i -= 200) $sum += ord($orig[$i]); $buf .= pack('N', $pgno).$orig.pack('N', $sum & 0xFFFFFFFF); $this->journaled[$pgno] = true; if (strlen($buf) > 1048576) { fwrite($this->jh, $buf); $buf = ''; } } fwrite($this->jh, $buf); fflush($this->jh); if (function_exists('fsync')) @fsync($this->jh); } /** 変更されたページをファイルに書く */ private function writePages(): void { $pages = array_keys($this->dirty); sort($pages); foreach ($pages as $pgno) { if ($pgno > $this->nPages) continue; fseek($this->fh, ($pgno - 1) * $this->pageSize); fwrite($this->fh, $this->cache[$pgno]); } fflush($this->fh); clearstatcache(true, $this->path); // 書かなかった新しいページ(すぐにフリーリストに入れた物)が有っても、ファイルの大きさはページ数と同じに if ((int)filesize($this->path) !== $this->nPages * $this->pageSize) ftruncate($this->fh, $this->nPages * $this->pageSize); foreach ($pages as $pgno) $this->dirty[$pgno] = null; // ジャーナルに書いたページは、もう元のページを持たなくても良い foreach ($pages as $pgno) if ($pgno > $this->origPages || isset($this->journaled[$pgno])) unset($this->dirty[$pgno]); } private function closeJournal(bool $delete): void { if ($this->jh) @fclose($this->jh); $this->jh = null; $this->journalOpen = false; if ($delete) @unlink($this->path.'-journal'); } private function hasHotJournal(): bool { $j = $this->path.'-journal'; clearstatcache(true, $j); return is_file($j) && filesize($j) > 0; } /** ジャーナルの元のページをファイルに戻す(ロールバック、途中で止まった書き込み) */ private function playback(): void { $j = $this->path.'-journal'; $data = (string)@file_get_contents($j); $pos = 0; $origSize = null; while ($pos + 28 <= strlen($data) && substr($data, $pos, 8) === self::JOURNAL_MAGIC) { $nRec = unpack('N', $data, $pos + 8)[1]; $nonce = unpack('N', $data, $pos + 12)[1]; $size = unpack('N', $data, $pos + 16)[1]; $sector = unpack('N', $data, $pos + 20)[1] ?: self::SECTOR; $ps = unpack('N', $data, $pos + 24)[1]; if ($ps < 512 || $ps > 65536) break; $origSize ??= $size; $pos += max($sector, 28); if ($nRec === 0xFFFFFFFF) $nRec = intdiv(strlen($data) - $pos, $ps + 8); for ($i = 0; $i < $nRec && $pos + $ps + 8 <= strlen($data); $i++) { $pgno = unpack('N', $data, $pos)[1]; $page = substr($data, $pos + 4, $ps); $sum = $nonce; for ($k = $ps - 200; $k > 0; $k -= 200) $sum += ord($page[$k]); if (($sum & 0xFFFFFFFF) !== unpack('N', $data, $pos + 4 + $ps)[1]) break 2; if ($pgno > 0 && $pgno <= $size) { fseek($this->fh, ($pgno - 1) * $ps); fwrite($this->fh, $page); } $pos += $ps + 8; } // 次のヘッダーはセクターの境界 if ($pos % $sector) $pos += $sector - $pos % $sector; } if ($origSize !== null) ftruncate($this->fh, $origSize * ($ps ?? $this->pageSize)); fflush($this->fh); if (function_exists('fsync')) @fsync($this->fh); @unlink($j); $this->cache = []; $this->generation++; } /** * 空のデータベースを作る(最初の書き込みの時) * * @param int $pageSize */ public function initialize(int $pageSize = 4096): void { $this->pageSize = $this->usable = $pageSize; $this->nPages = 1; $this->hdr = ['freeTrunk' => 0, 'freeCount' => 0, 'cookie' => 0, 'format' => 4, 'userVersion' => 0, 'counter' => 0, 'autoVacuum' => 0, 'appId' => 0, 'cacheSize' => 0]; // ページ1:sqlite_schemaの空の葉 $page = $this->header()."\x0D\0\0\0\0".pack('n', $pageSize === 65536 ? 0 : $pageSize)."\0"; $this->write(1, str_pad($page, $pageSize, "\0")); } }