Skip to content

Commit 5c81df1

Browse files
committed
fix: sqlite 21 bad parameter or other API misuse triggered sometimes due to different scaler type
1 parent cf669fb commit 5c81df1

2 files changed

Lines changed: 94 additions & 14 deletions

File tree

src/Libs/Database/PDO/PDOAdapter.php

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -207,13 +207,12 @@ public function insert(iState $entity): iState
207207
}
208208

209209
$this->db->query($this->stmt['insert'], $data, options: [
210-
'on_failure' => function (Throwable $e) use ($entity) {
211-
if (false === str_contains($e->getMessage(), '21 bad parameter or other API misuse')) {
212-
throw $e;
213-
}
214-
$this->stmt['insert'] = null;
215-
return $this->insert($entity);
216-
},
210+
'on_failure' => fn(Throwable $e) => $this->retryPreparedWrite(
211+
key: 'insert',
212+
sql: $this->pdoInsert('state', iState::ENTITY_KEYS),
213+
data: $data,
214+
e: $e,
215+
),
217216
]);
218217

219218
$entity->id = (int) $this->db->lastInsertId();
@@ -432,13 +431,12 @@ public function update(iState $entity): iState
432431
}
433432

434433
$this->db->query($this->stmt['update'], $data, options: [
435-
'on_failure' => function (Throwable $e) use ($entity) {
436-
if (false === str_contains($e->getMessage(), '21 bad parameter or other API misuse')) {
437-
throw $e;
438-
}
439-
$this->stmt['update'] = null;
440-
return $this->update($entity);
441-
},
434+
'on_failure' => fn(Throwable $e) => $this->retryPreparedWrite(
435+
key: 'update',
436+
sql: $this->pdoUpdate('state', iState::ENTITY_KEYS),
437+
data: $data,
438+
e: $e,
439+
),
442440
]);
443441
} catch (PDOException $e) {
444442
$this->stmt['update'] = null;
@@ -810,6 +808,26 @@ private function pdoUpdate(string $table, array $columns): string
810808
return trim(str_replace('{place} = {holder}', implode(', ', $placeholders), $queryString));
811809
}
812810

811+
/**
812+
* Retry a cached write statement once when sqlite reports prepared statement misuse.
813+
*
814+
* @param string $key Cached statement key.
815+
* @param string $sql SQL statement to prepare.
816+
* @param array $data Bound statement values.
817+
* @param Throwable $e Triggering exception.
818+
*/
819+
private function retryPreparedWrite(string $key, string $sql, array $data, Throwable $e): PDOStatement
820+
{
821+
if (false === str_contains($e->getMessage(), '21 bad parameter or other API misuse')) {
822+
throw $e;
823+
}
824+
825+
$statement = $this->db->prepare($sql);
826+
$this->stmt[$key] = $statement;
827+
828+
return $this->db->query($statement, $data);
829+
}
830+
813831
/**
814832
* Find db entity using external id.
815833
* External id format is: (db_name)://(id)

tests/Database/PDOAdapterTest.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
use Monolog\Handler\TestHandler;
2222
use Monolog\Logger;
2323
use PDO;
24+
use PDOException;
25+
use PDOStatement;
2426
use Psr\SimpleCache\CacheInterface;
27+
use ReflectionMethod;
2528
use Symfony\Component\Console\Input\ArrayInput;
2629
use Symfony\Component\Console\Input\InputInterface;
2730
use Symfony\Component\Console\Output\NullOutput;
@@ -346,6 +349,65 @@ public function test_update_conditions(): void
346349
);
347350
}
348351

352+
public function test_retryPreparedWrite_returns_statement_for_bad_parameter_retry(): void
353+
{
354+
assert($this->db instanceof PDOAdapter);
355+
356+
$item = $this->db->insert(new StateEntity($this->testEpisode));
357+
$item->title = 'Retried Title';
358+
359+
$data = $item->getAll();
360+
$data[iState::COLUMN_UPDATED_AT] = time();
361+
362+
foreach (iState::ENTITY_ARRAY_KEYS as $key) {
363+
if (!(null !== ($data[$key] ?? null) && true === is_array($data[$key]))) {
364+
continue;
365+
}
366+
367+
ksort($data[$key]);
368+
$data[$key] = json_encode($data[$key], flags: JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
369+
}
370+
371+
$pdoUpdate = new ReflectionMethod($this->db, 'pdoUpdate');
372+
$sql = $pdoUpdate->invoke($this->db, 'state', iState::ENTITY_KEYS);
373+
374+
$retryPreparedWrite = new ReflectionMethod($this->db, 'retryPreparedWrite');
375+
$stmt = $retryPreparedWrite->invoke(
376+
$this->db,
377+
'update',
378+
$sql,
379+
$data,
380+
new PDOException('21 bad parameter or other API misuse'),
381+
);
382+
383+
$this->assertInstanceOf(PDOStatement::class, $stmt, 'Retry path should return a PDO statement, not an entity.');
384+
$this->assertSame(
385+
'Retried Title',
386+
$this->db->get($item)->title,
387+
'Retry path should execute the rebuilt update statement successfully.'
388+
);
389+
}
390+
391+
public function test_retryPreparedWrite_rethrows_unrelated_errors(): void
392+
{
393+
assert($this->db instanceof PDOAdapter);
394+
395+
$retryPreparedWrite = new ReflectionMethod($this->db, 'retryPreparedWrite');
396+
397+
$this->checkException(
398+
closure: fn() => $retryPreparedWrite->invoke(
399+
$this->db,
400+
'update',
401+
'UPDATE state SET title = :title WHERE id = :id',
402+
['title' => 'Ignored', 'id' => 1],
403+
new PDOException('some other database problem'),
404+
),
405+
reason: 'Retry helper should only intercept sqlite API misuse errors.',
406+
exception: PDOException::class,
407+
exceptionMessage: 'some other database problem',
408+
);
409+
}
410+
349411
public function test_duplicates_uses_cache(): void
350412
{
351413
$cache = $this->makeCacheStub();

0 commit comments

Comments
 (0)