string, 'type' => string, 'default' => string|null]. * The core columns (storage_key, storage_timestamp, checksum, key) are always * present. Additional columns are type-specific and supplied by the caller. */ private $columns; /** @var array Index definitions as ['name' => 'columns_sql'] */ private $indexes; /** * @param string $dbPath Absolute path to the SQLite database file * @param string $schemaVersion Version string stored in flex_meta for rebuild detection * @param array $columns Extra column definitions beyond the core four. * Each entry: ['name' => string, 'type' => string, 'default' => string|null] * @param array $indexes Index definitions as ['index_name' => 'col1, col2'] */ public function __construct(string $dbPath, string $schemaVersion, array $columns = [], array $indexes = []) { $this->dbPath = $dbPath; $this->schemaVersion = $schemaVersion; $this->columns = $columns; $this->indexes = $indexes; } public static function isAvailable(): bool { return extension_loaded('pdo_sqlite'); } public function needsRebuild(string $expectedVersion): bool { try { $pdo = $this->connect(); $version = $this->getSchemaVersion($pdo); return $version !== $expectedVersion; } catch (PDOException $e) { return true; } } /** * @return array Returns [storage_key => meta_array, ...] */ public function getAllEntries(): array { try { $pdo = $this->connect(); $stmt = $pdo->query('SELECT * FROM flex_index ORDER BY storage_key'); $entries = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $key = $row['storage_key']; $entries[$key] = $this->rowToMeta($row); } return $entries; } catch (PDOException $e) { return []; } } /** * @param string $where SQL WHERE clause (use ? placeholders) * @param array $params Bound parameter values * @return string[] Matching storage_keys */ public function queryKeys(string $where, array $params = []): array { try { $pdo = $this->connect(); $stmt = $pdo->prepare("SELECT storage_key FROM flex_index WHERE {$where}"); $stmt->execute($params); return $stmt->fetchAll(PDO::FETCH_COLUMN, 0); } catch (PDOException $e) { return []; } } public function upsertEntry(string $key, array $meta): void { try { $pdo = $this->connect(); $this->doUpsert($pdo, $key, $meta); } catch (PDOException $e) { // Silently fail — caller should fall back to YAML } } /** * @param array $entries [storage_key => meta_array, ...] */ public function upsertEntries(array $entries): void { try { $pdo = $this->connect(); $pdo->beginTransaction(); foreach ($entries as $key => $meta) { $this->doUpsert($pdo, (string)$key, $meta); } $pdo->commit(); } catch (PDOException $e) { if ($this->pdo && $this->pdo->inTransaction()) { $this->pdo->rollBack(); } } } public function removeEntry(string $key): void { try { $pdo = $this->connect(); $stmt = $pdo->prepare('DELETE FROM flex_index WHERE storage_key = ?'); $stmt->execute([$key]); } catch (PDOException $e) { // Silently fail } } /** * @param string[] $keys */ public function removeEntries(array $keys): void { if (!$keys) { return; } try { $pdo = $this->connect(); $placeholders = implode(',', array_fill(0, count($keys), '?')); $stmt = $pdo->prepare("DELETE FROM flex_index WHERE storage_key IN ({$placeholders})"); $stmt->execute(array_values($keys)); } catch (PDOException $e) { // Silently fail } } /** * Full rebuild: drops all rows, re-scans storage, re-inserts everything. * * @param FlexStorageInterface $storage * @param callable $metaBuilder Callable that receives (&$meta, $data, $storage) */ public function rebuild(FlexStorageInterface $storage, callable $metaBuilder): void { try { $pdo = $this->connect(); // Get all existing keys from storage $existingKeys = $storage->getExistingKeys(); if (!$existingKeys) { $pdo->exec('DELETE FROM flex_index'); $this->setSchemaVersion($pdo, $this->schemaVersion); return; } // Read all data rows in chunks $allEntries = []; $chunks = array_chunk($existingKeys, 100, true); foreach ($chunks as $chunk) { $keys = array_fill_keys(array_keys($chunk), null); $rows = $storage->readRows($keys); $keyField = $storage->getKeyField(); foreach ($rows as $storageKey => $row) { if ($row === null) { continue; } $entry = $existingKeys[$storageKey] + ['key' => $storageKey]; if ($keyField !== 'storage_key' && isset($row[$keyField])) { $entry['key'] = $row[$keyField]; } $metaBuilder($entry, $row, $storage); $allEntries[$storageKey] = $entry; } } // Replace all data in a single transaction $pdo->beginTransaction(); $pdo->exec('DELETE FROM flex_index'); foreach ($allEntries as $key => $meta) { $this->doUpsert($pdo, (string)$key, $meta); } $this->setSchemaVersion($pdo, $this->schemaVersion); $pdo->commit(); } catch (PDOException $e) { if ($this->pdo && $this->pdo->inTransaction()) { $this->pdo->rollBack(); } } } /** * @return int Number of entries in the index */ public function count(): int { try { $pdo = $this->connect(); $result = $pdo->query('SELECT COUNT(*) FROM flex_index'); return (int)$result->fetchColumn(); } catch (PDOException $e) { return 0; } } // -- Internal -------------------------------------------------------- private function connect(): PDO { if ($this->pdo !== null) { return $this->pdo; } $dir = dirname($this->dbPath); if (!is_dir($dir)) { mkdir($dir, 0775, true); } $pdo = new PDO('sqlite:' . $this->dbPath); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->exec('PRAGMA journal_mode=WAL'); $pdo->exec('PRAGMA synchronous=NORMAL'); $this->ensureSchema($pdo); $this->pdo = $pdo; return $pdo; } private function ensureSchema(PDO $pdo): void { // Check if tables exist $result = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='flex_index'"); if ($result->fetchColumn() !== false) { return; } // Build column definitions: core + type-specific $colDefs = [ 'storage_key TEXT PRIMARY KEY', 'storage_timestamp INTEGER NOT NULL', 'checksum TEXT', 'key TEXT NOT NULL', ]; foreach ($this->columns as $col) { $line = $col['name'] . ' ' . $col['type']; if (isset($col['default'])) { $line .= ' DEFAULT ' . $col['default']; } $colDefs[] = $line; } $pdo->exec('CREATE TABLE flex_index (' . implode(', ', $colDefs) . ')'); // Create indexes foreach ($this->indexes as $name => $columnsSql) { $pdo->exec("CREATE INDEX {$name} ON flex_index({$columnsSql})"); } $pdo->exec(<<<'SQL' CREATE TABLE flex_meta ( meta_key TEXT PRIMARY KEY, meta_value TEXT ) SQL ); } private function getSchemaVersion(PDO $pdo): ?string { try { $stmt = $pdo->prepare('SELECT meta_value FROM flex_meta WHERE meta_key = ?'); $stmt->execute(['schema_version']); $value = $stmt->fetchColumn(); return $value !== false ? (string)$value : null; } catch (PDOException $e) { return null; } } private function setSchemaVersion(PDO $pdo, string $version): void { $stmt = $pdo->prepare('INSERT OR REPLACE INTO flex_meta (meta_key, meta_value) VALUES (?, ?)'); $stmt->execute(['schema_version', $version]); } private function doUpsert(PDO $pdo, string $key, array $meta): void { // Build column list: core + type-specific $allColumns = ['storage_key', 'storage_timestamp', 'checksum', 'key']; foreach ($this->columns as $col) { $allColumns[] = $col['name']; } $placeholders = implode(', ', array_fill(0, count($allColumns), '?')); $columnList = implode(', ', $allColumns); $stmt = $pdo->prepare("INSERT OR REPLACE INTO flex_index ({$columnList}) VALUES ({$placeholders})"); // Build values: core first, then type-specific in order $values = [ $key, (int)($meta['storage_timestamp'] ?? 0), $meta['checksum'] ?? null, (string)($meta['key'] ?? $key), ]; foreach ($this->columns as $col) { $name = $col['name']; $type = strtoupper($col['type']); $raw = $meta[$name] ?? null; if ($raw === null) { $values[] = null; } elseif (strpos($type, 'INTEGER') !== false) { $values[] = (int)$raw; } else { $values[] = (string)$raw; } } $stmt->execute($values); } /** * Convert a SQLite row back to the meta array format expected by FlexIndex. */ private function rowToMeta(array $row): array { $meta = [ 'storage_key' => $row['storage_key'], 'storage_timestamp' => (int)$row['storage_timestamp'], 'checksum' => $row['checksum'], 'key' => $row['key'], ]; foreach ($this->columns as $col) { $name = $col['name']; $type = strtoupper($col['type']); $raw = $row[$name] ?? null; if ($raw === null) { $meta[$name] = null; } elseif (strpos($type, 'INTEGER') !== false) { // Check if this is a boolean-like column (name contains 'is_') if (strpos($name, 'is_') === 0) { $meta[$name] = (bool)$raw; } else { $meta[$name] = (int)$raw; } } else { $meta[$name] = (string)$raw; } } return $meta; } }