Skip to content

Commit d5a4e0b

Browse files
authored
fix: isolate statement cache and fix SQLite inserts (#20)
* Fix SQLite inserts and statement cache isolation * Cover SQLite in integration tests * Handle SQLite review feedback
1 parent 6ccece4 commit d5a4e0b

8 files changed

Lines changed: 172 additions & 16 deletions

File tree

.github/workflows/ci.yml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
with:
1818
php-version: "8.3"
1919
coverage: none
20-
extensions: pdo, pdo_mysql, pdo_pgsql
20+
extensions: pdo, pdo_mysql, pdo_pgsql, pdo_sqlite
2121

2222
- name: Install dependencies
2323
run: composer install --no-interaction --no-progress --prefer-dist
@@ -34,7 +34,7 @@ jobs:
3434
fail-fast: false
3535
matrix:
3636
php: ["8.3", "8.4"]
37-
db: ["mysql", "postgres"]
37+
db: ["mysql", "postgres", "sqlite"]
3838

3939
services:
4040
mysql:
@@ -71,7 +71,7 @@ jobs:
7171
with:
7272
php-version: ${{ matrix.php }}
7373
coverage: none
74-
extensions: pdo, pdo_mysql, pdo_pgsql
74+
extensions: pdo, pdo_mysql, pdo_pgsql, pdo_sqlite
7575

7676
- name: Install dependencies
7777
run: composer install --no-interaction --no-progress --prefer-dist
@@ -82,10 +82,14 @@ jobs:
8282
echo "MODEL_ORM_TEST_DSN=mysql:host=127.0.0.1;port=3306" >> "$GITHUB_ENV"
8383
echo "MODEL_ORM_TEST_USER=root" >> "$GITHUB_ENV"
8484
echo "MODEL_ORM_TEST_PASS=" >> "$GITHUB_ENV"
85-
else
85+
elif [ "${{ matrix.db }}" = "postgres" ]; then
8686
echo "MODEL_ORM_TEST_DSN=pgsql:host=127.0.0.1;port=5432;dbname=categorytest" >> "$GITHUB_ENV"
8787
echo "MODEL_ORM_TEST_USER=postgres" >> "$GITHUB_ENV"
8888
echo "MODEL_ORM_TEST_PASS=postgres" >> "$GITHUB_ENV"
89+
else
90+
echo "MODEL_ORM_TEST_DSN=sqlite::memory:" >> "$GITHUB_ENV"
91+
echo "MODEL_ORM_TEST_USER=" >> "$GITHUB_ENV"
92+
echo "MODEL_ORM_TEST_PASS=" >> "$GITHUB_ENV"
8993
fi
9094
9195
- name: Run tests

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ It is designed for projects that value straightforward PHP, direct PDO access, a
1414
- PDO-first: use the ORM helpers when they help and drop down to raw SQL when they do not.
1515
- Familiar model flow: create, hydrate, validate, save, update, count, find, and delete.
1616
- Dynamic finders: call methods such as `findByName()`, `findOneByName()`, `countByName()`, and more.
17-
- Multi-database support: tested against MySQL/MariaDB and PostgreSQL, with SQLite code paths also supported.
17+
- Multi-database support: tested against MySQL/MariaDB, PostgreSQL, and SQLite.
1818

1919
## Installation
2020

@@ -193,7 +193,7 @@ Freshsauce\Model\Model::connectDb(
193193
);
194194
```
195195

196-
SQLite is supported in the library code paths, but the automated test suite currently covers MySQL/MariaDB and PostgreSQL.
196+
SQLite is supported in the library and covered by the automated test suite alongside MySQL/MariaDB and PostgreSQL.
197197

198198
## Quality
199199

src/Model/Model.php

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ class Model
4141
// sub class must also redeclare public static $_db;
4242

4343
/**
44-
* @var \PDOStatement[]
44+
* @var array<int, array<string, \PDOStatement>>
4545
*/
46-
protected static $_stmt = array(); // prepared statements cache
46+
protected static $_stmt = array(); // prepared statements cache keyed by PDO connection and SQL
4747

4848
/**
4949
* @var string|null
@@ -226,10 +226,13 @@ public function __isset($name)
226226
*/
227227
public static function connectDb(string $dsn, string $username, string $password, array $driverOptions = array()): void
228228
{
229+
$previousDb = static::$_db;
230+
if ($previousDb instanceof \PDO) {
231+
unset(static::$_stmt[spl_object_id($previousDb)]);
232+
}
229233
static::$_db = new \PDO($dsn, $username, $password, $driverOptions);
230234
static::$_db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); // Set Errorhandling to Exception
231235
static::$_identifier_quote_character = null;
232-
static::$_stmt = array();
233236
self::$_tableColumns = array();
234237
static::_setup_identifier_quote_character();
235238
}
@@ -966,7 +969,7 @@ public function insert($autoTimestamp = true, $allowSetPrimaryKey = false)
966969
return false;
967970
}
968971

969-
if ($set['sql'] === '') {
972+
if (count($set['columns']) === 0) {
970973
if ($driver === 'sqlite' || $driver === 'sqlite2') {
971974
$query = 'INSERT INTO ' . static::_quote_identifier(static::$_tableName) . ' DEFAULT VALUES';
972975
$st = static::execute($query);
@@ -975,7 +978,9 @@ public function insert($autoTimestamp = true, $allowSetPrimaryKey = false)
975978
$st = static::execute($query);
976979
}
977980
} else {
978-
$query = 'INSERT INTO ' . static::_quote_identifier(static::$_tableName) . ' SET ' . $set['sql'];
981+
$query = 'INSERT INTO ' . static::_quote_identifier(static::$_tableName) .
982+
' (' . implode(', ', $set['columns']) . ')' .
983+
' VALUES (' . implode(', ', $set['values']) . ')';
979984
$st = static::execute($query, $set['params']);
980985
}
981986
if ($st->rowCount() == 1) {
@@ -1051,11 +1056,15 @@ protected static function _prepare($query)
10511056
if (!$db) {
10521057
throw new \Exception('No database connection setup');
10531058
}
1054-
if (!isset(static::$_stmt[$query])) {
1059+
$connectionId = spl_object_id($db);
1060+
if (!isset(static::$_stmt[$connectionId])) {
1061+
static::$_stmt[$connectionId] = array();
1062+
}
1063+
if (!isset(static::$_stmt[$connectionId][$query])) {
10551064
// cache prepared query if not seen before
1056-
static::$_stmt[$query] = $db->prepare($query);
1065+
static::$_stmt[$connectionId][$query] = $db->prepare($query);
10571066
}
1058-
return static::$_stmt[$query]; // return cache copy
1067+
return static::$_stmt[$connectionId][$query]; // return cache copy
10591068
}
10601069

10611070
/**
@@ -1138,7 +1147,7 @@ protected function setString($ignorePrimary = true)
11381147
protected static function supportsUpdateLimit()
11391148
{
11401149
$driver = static::getDriverName();
1141-
return ($driver === 'mysql' || $driver === 'sqlite' || $driver === 'sqlite2');
1150+
return ($driver === 'mysql');
11421151
}
11431152

11441153
/**
@@ -1150,7 +1159,7 @@ protected static function supportsUpdateLimit()
11501159
protected static function supportsDeleteLimit()
11511160
{
11521161
$driver = static::getDriverName();
1153-
return ($driver === 'mysql' || $driver === 'sqlite' || $driver === 'sqlite2');
1162+
return ($driver === 'mysql');
11541163
}
11551164

11561165
/**
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace App\Model;
4+
5+
/**
6+
* @property int|null $id
7+
* @property string|null $name
8+
*/
9+
class IsolatedConnectionCategoryA extends \Freshsauce\Model\Model
10+
{
11+
public static $_db;
12+
13+
protected static $_tableName = 'items';
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace App\Model;
4+
5+
/**
6+
* @property int|null $id
7+
* @property string|null $name
8+
*/
9+
class IsolatedConnectionCategoryB extends \Freshsauce\Model\Model
10+
{
11+
public static $_db;
12+
13+
protected static $_tableName = 'items';
14+
}

test-src/Model/SqliteCategory.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace App\Model;
4+
5+
/**
6+
* @property int|null $id
7+
* @property string|null $name
8+
* @property string|null $updated_at
9+
* @property string|null $created_at
10+
*/
11+
class SqliteCategory extends \Freshsauce\Model\Model
12+
{
13+
public static $_db;
14+
15+
protected static $_tableName = 'categories';
16+
}

tests/Model/CategoryTest.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
class CategoryTest extends TestCase
99
{
1010
private const TEST_DB_NAME = 'categorytest';
11+
private const SQLITE_SEQUENCE_TABLE = 'sqlite_sequence';
1112
private static ?string $driverName = null;
1213

1314
/**
@@ -53,6 +54,16 @@ public static function setUpBeforeClass(): void
5354
"created_at" TIMESTAMP NULL
5455
)',
5556
];
57+
} elseif (self::$driverName === 'sqlite') {
58+
$sql_setup = [
59+
'DROP TABLE IF EXISTS `categories`',
60+
'CREATE TABLE `categories` (
61+
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
62+
`name` VARCHAR(120) NULL,
63+
`updated_at` TEXT NULL,
64+
`created_at` TEXT NULL
65+
)',
66+
];
5667
} else {
5768
throw new RuntimeException('Unsupported PDO driver for tests: ' . self::$driverName);
5869
}
@@ -81,6 +92,12 @@ public function setUp(): void
8192
Freshsauce\Model\Model::execute('TRUNCATE TABLE `categories`');
8293
} elseif (self::$driverName === 'pgsql') {
8394
Freshsauce\Model\Model::execute('TRUNCATE TABLE "categories" RESTART IDENTITY');
95+
} elseif (self::$driverName === 'sqlite') {
96+
Freshsauce\Model\Model::execute('DELETE FROM `categories`');
97+
Freshsauce\Model\Model::execute(
98+
'DELETE FROM `' . self::SQLITE_SEQUENCE_TABLE . '` WHERE `name` = ?',
99+
['categories']
100+
);
84101
}
85102
}
86103

tests/Model/SqliteModelTest.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
use PHPUnit\Framework\TestCase;
4+
use PHPUnit\Framework\SkippedTestSuiteError;
5+
6+
class SqliteModelTest extends TestCase
7+
{
8+
public static function setUpBeforeClass(): void
9+
{
10+
if (!in_array('sqlite', \PDO::getAvailableDrivers(), true)) {
11+
throw new SkippedTestSuiteError('The pdo_sqlite extension is required to run SQLite-specific tests.');
12+
}
13+
14+
App\Model\SqliteCategory::connectDb('sqlite::memory:', '', '');
15+
App\Model\SqliteCategory::execute(
16+
'CREATE TABLE categories (
17+
id INTEGER PRIMARY KEY AUTOINCREMENT,
18+
name TEXT NULL,
19+
updated_at TEXT NULL,
20+
created_at TEXT NULL
21+
)'
22+
);
23+
}
24+
25+
protected function setUp(): void
26+
{
27+
App\Model\SqliteCategory::execute('DELETE FROM `categories`');
28+
App\Model\SqliteCategory::execute('DELETE FROM sqlite_sequence WHERE name = ?', ['categories']);
29+
}
30+
31+
public function testSqliteInsertWithDirtyFields(): void
32+
{
33+
/** @var App\Model\SqliteCategory $category */
34+
$category = new App\Model\SqliteCategory([
35+
'name' => 'SQLite Fiction',
36+
]);
37+
38+
$this->assertTrue($category->save());
39+
$this->assertSame('SQLite Fiction', $category->name);
40+
$this->assertSame('1', (string) $category->id);
41+
$this->assertNotEmpty($category->created_at);
42+
$this->assertNotEmpty($category->updated_at);
43+
44+
/** @var App\Model\SqliteCategory|null $reloaded */
45+
$reloaded = App\Model\SqliteCategory::getById((int) $category->id);
46+
47+
$this->assertNotNull($reloaded);
48+
$this->assertSame('SQLite Fiction', $reloaded->name);
49+
}
50+
51+
public function testPreparedStatementsStayBoundToTheirOwnConnection(): void
52+
{
53+
App\Model\IsolatedConnectionCategoryA::connectDb('sqlite::memory:', '', '');
54+
App\Model\IsolatedConnectionCategoryB::connectDb('sqlite::memory:', '', '');
55+
56+
App\Model\IsolatedConnectionCategoryA::execute(
57+
'CREATE TABLE items (
58+
id INTEGER PRIMARY KEY AUTOINCREMENT,
59+
name TEXT NULL
60+
)'
61+
);
62+
App\Model\IsolatedConnectionCategoryB::execute(
63+
'CREATE TABLE items (
64+
id INTEGER PRIMARY KEY AUTOINCREMENT,
65+
name TEXT NULL
66+
)'
67+
);
68+
69+
App\Model\IsolatedConnectionCategoryA::execute('INSERT INTO `items` (`name`) VALUES (?)', ['from-a']);
70+
App\Model\IsolatedConnectionCategoryB::execute('INSERT INTO `items` (`name`) VALUES (?)', ['from-b']);
71+
72+
/** @var App\Model\IsolatedConnectionCategoryA|null $fromA */
73+
$fromA = App\Model\IsolatedConnectionCategoryA::fetchOneWhere('`id` = ?', [1]);
74+
/** @var App\Model\IsolatedConnectionCategoryB|null $fromB */
75+
$fromB = App\Model\IsolatedConnectionCategoryB::fetchOneWhere('`id` = ?', [1]);
76+
77+
$this->assertNotNull($fromA);
78+
$this->assertNotNull($fromB);
79+
$this->assertSame('from-a', $fromA->name);
80+
$this->assertSame('from-b', $fromB->name);
81+
}
82+
}

0 commit comments

Comments
 (0)