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/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..ef8ace3 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 } /** @@ -1138,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'); } /** @@ -1150,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/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); + } +}