From 595439e8180e6b25adc757a14122825f743bc6b4 Mon Sep 17 00:00:00 2001 From: Dave Barnwell Date: Sat, 7 Mar 2026 22:31:10 +0000 Subject: [PATCH 1/3] Fix SQLite inserts and statement cache isolation --- README.md | 4 +- src/Model/Model.php | 25 ++++-- .../Model/IsolatedConnectionCategoryA.php | 14 ++++ .../Model/IsolatedConnectionCategoryB.php | 14 ++++ test-src/Model/SqliteCategory.php | 16 ++++ tests/Model/SqliteModelTest.php | 77 +++++++++++++++++++ 6 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 test-src/Model/IsolatedConnectionCategoryA.php create mode 100644 test-src/Model/IsolatedConnectionCategoryB.php create mode 100644 test-src/Model/SqliteCategory.php create mode 100644 tests/Model/SqliteModelTest.php diff --git a/README.md b/README.md index 7259ce9..e6aff0a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ It is designed for projects that value straightforward PHP, direct PDO access, a - PDO-first: use the ORM helpers when they help and drop down to raw SQL when they do not. - Familiar model flow: create, hydrate, validate, save, update, count, find, and delete. - Dynamic finders: call methods such as `findByName()`, `findOneByName()`, `countByName()`, and more. -- Multi-database support: tested against MySQL/MariaDB and PostgreSQL, with SQLite code paths also supported. +- Multi-database support: tested against MySQL/MariaDB, PostgreSQL, and SQLite. ## Installation @@ -193,7 +193,7 @@ Freshsauce\Model\Model::connectDb( ); ``` -SQLite is supported in the library code paths, but the automated test suite currently covers MySQL/MariaDB and PostgreSQL. +SQLite is supported in the library and covered by the automated test suite alongside MySQL/MariaDB and PostgreSQL. ## Quality diff --git a/src/Model/Model.php b/src/Model/Model.php index f1abe48..d6a3f79 100644 --- a/src/Model/Model.php +++ b/src/Model/Model.php @@ -41,9 +41,9 @@ class Model // sub class must also redeclare public static $_db; /** - * @var \PDOStatement[] + * @var array> */ - protected static $_stmt = array(); // prepared statements cache + protected static $_stmt = array(); // prepared statements cache keyed by PDO connection and SQL /** * @var string|null @@ -226,10 +226,13 @@ public function __isset($name) */ public static function connectDb(string $dsn, string $username, string $password, array $driverOptions = array()): void { + $previousDb = static::$_db; + if ($previousDb instanceof \PDO) { + unset(static::$_stmt[spl_object_id($previousDb)]); + } static::$_db = new \PDO($dsn, $username, $password, $driverOptions); static::$_db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); // Set Errorhandling to Exception static::$_identifier_quote_character = null; - static::$_stmt = array(); self::$_tableColumns = array(); static::_setup_identifier_quote_character(); } @@ -966,7 +969,7 @@ public function insert($autoTimestamp = true, $allowSetPrimaryKey = false) return false; } - if ($set['sql'] === '') { + if (count($set['columns']) === 0) { if ($driver === 'sqlite' || $driver === 'sqlite2') { $query = 'INSERT INTO ' . static::_quote_identifier(static::$_tableName) . ' DEFAULT VALUES'; $st = static::execute($query); @@ -975,7 +978,9 @@ public function insert($autoTimestamp = true, $allowSetPrimaryKey = false) $st = static::execute($query); } } else { - $query = 'INSERT INTO ' . static::_quote_identifier(static::$_tableName) . ' SET ' . $set['sql']; + $query = 'INSERT INTO ' . static::_quote_identifier(static::$_tableName) . + ' (' . implode(', ', $set['columns']) . ')' . + ' VALUES (' . implode(', ', $set['values']) . ')'; $st = static::execute($query, $set['params']); } if ($st->rowCount() == 1) { @@ -1051,11 +1056,15 @@ protected static function _prepare($query) if (!$db) { throw new \Exception('No database connection setup'); } - if (!isset(static::$_stmt[$query])) { + $connectionId = spl_object_id($db); + if (!isset(static::$_stmt[$connectionId])) { + static::$_stmt[$connectionId] = array(); + } + if (!isset(static::$_stmt[$connectionId][$query])) { // cache prepared query if not seen before - static::$_stmt[$query] = $db->prepare($query); + static::$_stmt[$connectionId][$query] = $db->prepare($query); } - return static::$_stmt[$query]; // return cache copy + return static::$_stmt[$connectionId][$query]; // return cache copy } /** diff --git a/test-src/Model/IsolatedConnectionCategoryA.php b/test-src/Model/IsolatedConnectionCategoryA.php new file mode 100644 index 0000000..c218f9a --- /dev/null +++ b/test-src/Model/IsolatedConnectionCategoryA.php @@ -0,0 +1,14 @@ + 'SQLite Fiction', + ]); + + $this->assertTrue($category->save()); + $this->assertSame('SQLite Fiction', $category->name); + $this->assertSame('1', (string) $category->id); + $this->assertNotEmpty($category->created_at); + $this->assertNotEmpty($category->updated_at); + + /** @var App\Model\SqliteCategory|null $reloaded */ + $reloaded = App\Model\SqliteCategory::getById((int) $category->id); + + $this->assertNotNull($reloaded); + $this->assertSame('SQLite Fiction', $reloaded->name); + } + + public function testPreparedStatementsStayBoundToTheirOwnConnection(): void + { + App\Model\IsolatedConnectionCategoryA::connectDb('sqlite::memory:', '', ''); + App\Model\IsolatedConnectionCategoryB::connectDb('sqlite::memory:', '', ''); + + App\Model\IsolatedConnectionCategoryA::execute( + 'CREATE TABLE items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NULL + )' + ); + App\Model\IsolatedConnectionCategoryB::execute( + 'CREATE TABLE items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NULL + )' + ); + + App\Model\IsolatedConnectionCategoryA::execute('INSERT INTO `items` (`name`) VALUES (?)', ['from-a']); + App\Model\IsolatedConnectionCategoryB::execute('INSERT INTO `items` (`name`) VALUES (?)', ['from-b']); + + /** @var App\Model\IsolatedConnectionCategoryA|null $fromA */ + $fromA = App\Model\IsolatedConnectionCategoryA::fetchOneWhere('`id` = ?', [1]); + /** @var App\Model\IsolatedConnectionCategoryB|null $fromB */ + $fromB = App\Model\IsolatedConnectionCategoryB::fetchOneWhere('`id` = ?', [1]); + + $this->assertNotNull($fromA); + $this->assertNotNull($fromB); + $this->assertSame('from-a', $fromA->name); + $this->assertSame('from-b', $fromB->name); + } +} From 1bad33caa4bc10a79a40c0e1be930780ec6231ce Mon Sep 17 00:00:00 2001 From: Dave Barnwell Date: Sat, 7 Mar 2026 22:36:02 +0000 Subject: [PATCH 2/3] Cover SQLite in integration tests --- .github/workflows/ci.yml | 12 ++++++++---- src/Model/Model.php | 4 ++-- tests/Model/CategoryTest.php | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11831bf..31579c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: with: php-version: "8.3" coverage: none - extensions: pdo, pdo_mysql, pdo_pgsql + extensions: pdo, pdo_mysql, pdo_pgsql, pdo_sqlite - name: Install dependencies run: composer install --no-interaction --no-progress --prefer-dist @@ -34,7 +34,7 @@ jobs: fail-fast: false matrix: php: ["8.3", "8.4"] - db: ["mysql", "postgres"] + db: ["mysql", "postgres", "sqlite"] services: mysql: @@ -71,7 +71,7 @@ jobs: with: php-version: ${{ matrix.php }} coverage: none - extensions: pdo, pdo_mysql, pdo_pgsql + extensions: pdo, pdo_mysql, pdo_pgsql, pdo_sqlite - name: Install dependencies run: composer install --no-interaction --no-progress --prefer-dist @@ -82,10 +82,14 @@ jobs: echo "MODEL_ORM_TEST_DSN=mysql:host=127.0.0.1;port=3306" >> "$GITHUB_ENV" echo "MODEL_ORM_TEST_USER=root" >> "$GITHUB_ENV" echo "MODEL_ORM_TEST_PASS=" >> "$GITHUB_ENV" - else + elif [ "${{ matrix.db }}" = "postgres" ]; then echo "MODEL_ORM_TEST_DSN=pgsql:host=127.0.0.1;port=5432;dbname=categorytest" >> "$GITHUB_ENV" echo "MODEL_ORM_TEST_USER=postgres" >> "$GITHUB_ENV" echo "MODEL_ORM_TEST_PASS=postgres" >> "$GITHUB_ENV" + else + echo "MODEL_ORM_TEST_DSN=sqlite::memory:" >> "$GITHUB_ENV" + echo "MODEL_ORM_TEST_USER=" >> "$GITHUB_ENV" + echo "MODEL_ORM_TEST_PASS=" >> "$GITHUB_ENV" fi - name: Run tests diff --git a/src/Model/Model.php b/src/Model/Model.php index d6a3f79..ef8ace3 100644 --- a/src/Model/Model.php +++ b/src/Model/Model.php @@ -1147,7 +1147,7 @@ protected function setString($ignorePrimary = true) protected static function supportsUpdateLimit() { $driver = static::getDriverName(); - return ($driver === 'mysql' || $driver === 'sqlite' || $driver === 'sqlite2'); + return ($driver === 'mysql'); } /** @@ -1159,7 +1159,7 @@ protected static function supportsUpdateLimit() protected static function supportsDeleteLimit() { $driver = static::getDriverName(); - return ($driver === 'mysql' || $driver === 'sqlite' || $driver === 'sqlite2'); + return ($driver === 'mysql'); } /** diff --git a/tests/Model/CategoryTest.php b/tests/Model/CategoryTest.php index 2350609..1170b90 100644 --- a/tests/Model/CategoryTest.php +++ b/tests/Model/CategoryTest.php @@ -8,6 +8,7 @@ class CategoryTest extends TestCase { private const TEST_DB_NAME = 'categorytest'; + private const SQLITE_SEQUENCE_TABLE = 'sqlite_sequence'; private static ?string $driverName = null; /** @@ -53,6 +54,16 @@ public static function setUpBeforeClass(): void "created_at" TIMESTAMP NULL )', ]; + } elseif (self::$driverName === 'sqlite') { + $sql_setup = [ + 'DROP TABLE IF EXISTS `categories`', + 'CREATE TABLE `categories` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `name` VARCHAR(120) NULL, + `updated_at` TEXT NULL, + `created_at` TEXT NULL + )', + ]; } else { throw new RuntimeException('Unsupported PDO driver for tests: ' . self::$driverName); } @@ -81,6 +92,12 @@ public function setUp(): void Freshsauce\Model\Model::execute('TRUNCATE TABLE `categories`'); } elseif (self::$driverName === 'pgsql') { Freshsauce\Model\Model::execute('TRUNCATE TABLE "categories" RESTART IDENTITY'); + } elseif (self::$driverName === 'sqlite') { + Freshsauce\Model\Model::execute('DELETE FROM `categories`'); + Freshsauce\Model\Model::execute( + 'DELETE FROM `' . self::SQLITE_SEQUENCE_TABLE . '` WHERE `name` = ?', + ['categories'] + ); } } From 0af91248f31a4162a25816c3d74d592a8f8996ff Mon Sep 17 00:00:00 2001 From: Dave Barnwell Date: Sat, 7 Mar 2026 22:39:57 +0000 Subject: [PATCH 3/3] Handle SQLite review feedback --- tests/Model/SqliteModelTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Model/SqliteModelTest.php b/tests/Model/SqliteModelTest.php index f33c0c3..bd50bd4 100644 --- a/tests/Model/SqliteModelTest.php +++ b/tests/Model/SqliteModelTest.php @@ -1,11 +1,16 @@