Skip to content

Commit f165a15

Browse files
authored
fix: classify prepared query exceptions (codeigniter4#10182)
1 parent 9bc5e37 commit f165a15

15 files changed

Lines changed: 325 additions & 53 deletions

File tree

system/Database/BaseConnection.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2142,6 +2142,16 @@ public function getLastException(): ?DatabaseException
21422142
return $this->lastException;
21432143
}
21442144

2145+
/**
2146+
* Sets the exception for the last failed database operation.
2147+
*
2148+
* @internal This method is for internal database component use only.
2149+
*/
2150+
public function setLastException(?DatabaseException $exception): void
2151+
{
2152+
$this->lastException = $exception;
2153+
}
2154+
21452155
/**
21462156
* Checks whether the native database error represents a unique constraint violation.
21472157
*/
@@ -2160,8 +2170,10 @@ protected function isRetryableTransactionErrorCode(int|string $code): bool
21602170

21612171
/**
21622172
* Creates the appropriate database exception for a native database error.
2173+
*
2174+
* @internal This method is for internal database component use only.
21632175
*/
2164-
protected function createDatabaseException(
2176+
public function createDatabaseException(
21652177
string $message,
21662178
int|string $code = 0,
21672179
?Throwable $previous = null,

system/Database/BasePreparedQuery.php

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use CodeIgniter\Events\Events;
1919
use CodeIgniter\Exceptions\BadMethodCallException;
2020
use ErrorException;
21+
use Throwable;
2122

2223
/**
2324
* @template TConnection
@@ -49,6 +50,11 @@ abstract class BasePreparedQuery implements PreparedQueryInterface
4950
*/
5051
protected $errorString;
5152

53+
/**
54+
* The typed exception for the last failed prepared query, if any.
55+
*/
56+
protected ?DatabaseException $databaseException = null;
57+
5258
/**
5359
* Holds the prepared query object
5460
* that is cloned during execute.
@@ -121,8 +127,10 @@ public function execute(...$data)
121127

122128
try {
123129
$exception = null;
124-
$result = $this->_execute($data);
125-
} catch (ArgumentCountError|ErrorException $exception) {
130+
$this->db->setLastException(null);
131+
$this->databaseException = null;
132+
$result = $this->_execute($data);
133+
} catch (ArgumentCountError|DatabaseException|ErrorException $exception) {
126134
$result = false;
127135
}
128136

@@ -136,6 +144,8 @@ public function execute(...$data)
136144
// This will trigger a rollback if transactions are being used
137145
$this->db->handleTransStatus();
138146

147+
$databaseException = $this->createDatabaseException($exception);
148+
139149
if ($this->db->DBDebug) {
140150
// We call this function in order to roll-back queries
141151
// if transactions are enabled. If we don't call this here
@@ -154,8 +164,8 @@ public function execute(...$data)
154164
// Let others do something with this query.
155165
Events::trigger('DBQuery', $query);
156166

157-
if ($exception !== null) {
158-
throw new DatabaseException($exception->getMessage(), $exception->getCode(), $exception);
167+
if ($databaseException instanceof DatabaseException) {
168+
throw $databaseException;
159169
}
160170

161171
return false;
@@ -164,6 +174,8 @@ public function execute(...$data)
164174
// Let others do something with this query.
165175
Events::trigger('DBQuery', $query);
166176

177+
$this->db->setLastException($databaseException);
178+
167179
return false;
168180
}
169181

@@ -196,6 +208,34 @@ abstract public function _execute(array $data): bool;
196208
*/
197209
abstract public function _getResult();
198210

211+
/**
212+
* Creates the database exception for a failed prepared query.
213+
*/
214+
private function createDatabaseException(?Throwable $previous): ?DatabaseException
215+
{
216+
if ($previous instanceof DatabaseException) {
217+
return $previous;
218+
}
219+
220+
if ($this->databaseException instanceof DatabaseException) {
221+
return $this->databaseException;
222+
}
223+
224+
if ($previous instanceof Throwable) {
225+
return $this->db->createDatabaseException(
226+
$previous->getMessage(),
227+
$previous->getCode(),
228+
$previous,
229+
);
230+
}
231+
232+
if ($this->errorString === null || $this->errorString === '') {
233+
return null;
234+
}
235+
236+
return $this->db->createDatabaseException($this->errorString, $this->errorCode);
237+
}
238+
199239
/**
200240
* Explicitly closes the prepared statement.
201241
*

system/Database/MySQLi/PreparedQuery.php

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
namespace CodeIgniter\Database\MySQLi;
1515

1616
use CodeIgniter\Database\BasePreparedQuery;
17-
use CodeIgniter\Database\Exceptions\DatabaseException;
1817
use CodeIgniter\Exceptions\BadMethodCallException;
1918
use mysqli;
2019
use mysqli_result;
@@ -49,7 +48,7 @@ public function _prepare(string $sql, array $options = []): PreparedQuery
4948
$this->errorString = $this->db->mysqli->error;
5049

5150
if ($this->db->DBDebug) {
52-
throw new DatabaseException($this->errorString . ' code: ' . $this->errorCode);
51+
throw $this->db->createDatabaseException($this->errorString, $this->errorCode);
5352
}
5453
}
5554

@@ -93,14 +92,29 @@ public function _execute(array $data): bool
9392
}
9493

9594
try {
96-
return $this->statement->execute();
95+
$result = $this->statement->execute();
9796
} catch (mysqli_sql_exception $e) {
97+
$this->errorCode = $e->getCode();
98+
$this->errorString = $e->getMessage();
99+
$this->databaseException = $this->db->createDatabaseException($this->errorString, $this->errorCode, $e);
100+
98101
if ($this->db->DBDebug) {
99-
throw new DatabaseException($e->getMessage(), $e->getCode(), $e);
102+
throw $this->databaseException;
100103
}
101104

102105
return false;
103106
}
107+
108+
if ($result === false) {
109+
$this->errorCode = $this->statement->errno;
110+
$this->errorString = $this->statement->error;
111+
112+
if ($this->db->DBDebug) {
113+
throw $this->db->createDatabaseException($this->errorString, $this->errorCode);
114+
}
115+
}
116+
117+
return $result;
104118
}
105119

106120
/**

system/Database/OCI8/Connection.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,15 @@ class Connection extends BaseConnection
120120
protected function isUniqueConstraintViolation(int|string $code, string $message): bool
121121
{
122122
// ORA-00001: unique constraint violated.
123-
return $code === 1;
123+
return (int) $code === 1;
124124
}
125125

126126
/**
127127
* Checks whether the native database code represents a retryable transaction failure.
128128
*/
129129
protected function isRetryableTransactionErrorCode(int|string $code): bool
130130
{
131-
return in_array($code, [60, 8177], true);
131+
return in_array((int) $code, [60, 8177], true);
132132
}
133133

134134
/**

system/Database/OCI8/PreparedQuery.php

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use CodeIgniter\Database\BasePreparedQuery;
1717
use CodeIgniter\Database\Exceptions\DatabaseException;
1818
use CodeIgniter\Exceptions\BadMethodCallException;
19+
use ErrorException;
1920
use OCILob;
2021

2122
/**
@@ -55,7 +56,7 @@ public function _prepare(string $sql, array $options = []): PreparedQuery
5556
$this->errorString = $error['message'] ?? '';
5657

5758
if ($this->db->DBDebug) {
58-
throw new DatabaseException($this->errorString . ' code: ' . $this->errorCode);
59+
throw $this->db->createDatabaseException($this->errorString, $this->errorCode);
5960
}
6061
}
6162

@@ -86,10 +87,28 @@ public function _execute(array $data): bool
8687
}
8788
}
8889

89-
$result = oci_execute($this->statement, $this->db->commitMode);
90+
try {
91+
$result = oci_execute($this->statement, $this->db->commitMode);
92+
} catch (ErrorException $e) {
93+
$databaseException = $this->setDatabaseExceptionFromStatement($e);
9094

91-
if ($binaryData instanceof OCILob) {
92-
$binaryData->free();
95+
if ($this->db->DBDebug) {
96+
throw $databaseException;
97+
}
98+
99+
return false;
100+
} finally {
101+
if ($binaryData instanceof OCILob) {
102+
$binaryData->free();
103+
}
104+
}
105+
106+
if ($result === false) {
107+
$databaseException = $this->setDatabaseExceptionFromStatement();
108+
109+
if ($this->db->DBDebug) {
110+
throw $databaseException;
111+
}
93112
}
94113

95114
if ($result && $this->lastInsertTableName !== '') {
@@ -117,6 +136,19 @@ protected function _close(): bool
117136
return oci_free_statement($this->statement);
118137
}
119138

139+
/**
140+
* Captures the native OCI statement error for shared database exception classification.
141+
*/
142+
private function setDatabaseExceptionFromStatement(?ErrorException $previous = null): DatabaseException
143+
{
144+
$error = oci_error($this->statement);
145+
$this->errorCode = $error['code'] ?? 0;
146+
$this->errorString = $error['message'] ?? $previous?->getMessage() ?? '';
147+
$this->databaseException = $this->db->createDatabaseException($this->errorString, $this->errorCode, $previous);
148+
149+
return $this->databaseException;
150+
}
151+
120152
/**
121153
* Replaces the ? placeholders with :0, :1, etc parameters for use
122154
* within the prepared query.

system/Database/Postgre/PreparedQuery.php

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
namespace CodeIgniter\Database\Postgre;
1515

1616
use CodeIgniter\Database\BasePreparedQuery;
17-
use CodeIgniter\Database\Exceptions\DatabaseException;
1817
use CodeIgniter\Exceptions\BadMethodCallException;
1918
use Exception;
2019
use PgSql\Connection as PgSqlConnection;
@@ -70,7 +69,7 @@ public function _prepare(string $sql, array $options = []): PreparedQuery
7069
$this->errorString = pg_last_error($this->db->connID);
7170

7271
if ($this->db->DBDebug) {
73-
throw new DatabaseException($this->errorString . ' code: ' . $this->errorCode);
72+
throw $this->db->createDatabaseException($this->errorString, $this->errorCode);
7473
}
7574
}
7675

@@ -93,9 +92,53 @@ public function _execute(array $data): bool
9392
}
9493
}
9594

96-
$this->result = pg_execute($this->db->connID, $this->name, $data);
95+
$sent = pg_send_execute($this->db->connID, $this->name, $data);
9796

98-
return (bool) $this->result;
97+
if ($sent === false || $sent === 0) {
98+
$this->errorCode = 0;
99+
$this->errorString = pg_last_error($this->db->connID);
100+
$this->databaseException = $this->db->createDatabaseException($this->errorString, $this->errorCode);
101+
102+
return false;
103+
}
104+
105+
$this->result = pg_get_result($this->db->connID);
106+
107+
if ($this->result === false) {
108+
$this->errorCode = 0;
109+
$this->errorString = pg_last_error($this->db->connID);
110+
$this->databaseException = $this->db->createDatabaseException($this->errorString, $this->errorCode);
111+
112+
return false;
113+
}
114+
115+
$lastResult = $this->result;
116+
$failedResult = pg_result_status($this->result) === PGSQL_FATAL_ERROR ? $this->result : null;
117+
118+
while (($next = pg_get_result($this->db->connID)) !== false) {
119+
$lastResult = $next;
120+
121+
if (! $failedResult instanceof PgSqlResult && pg_result_status($next) === PGSQL_FATAL_ERROR) {
122+
$failedResult = $next;
123+
}
124+
}
125+
126+
$this->result = $lastResult;
127+
128+
if ($failedResult instanceof PgSqlResult) {
129+
$sqlstate = (string) pg_result_error_field($failedResult, PGSQL_DIAG_SQLSTATE);
130+
$this->errorCode = 0;
131+
$this->errorString = (string) pg_result_error($failedResult);
132+
$this->databaseException = $this->db->createDatabaseException($this->errorString, $sqlstate);
133+
134+
if ($this->db->DBDebug) {
135+
throw $this->databaseException;
136+
}
137+
138+
return false;
139+
}
140+
141+
return true;
99142
}
100143

101144
/**

system/Database/SQLSRV/Connection.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ class Connection extends BaseConnection
9494
*/
9595
protected function isUniqueConstraintViolation(int|string $code, string $message): bool
9696
{
97+
$code = (string) $code;
98+
99+
if (str_contains($code, '/')) {
100+
[$sqlstate, $vendorCode] = explode('/', $code, 2);
101+
102+
if ($sqlstate === '23000' && in_array((int) $vendorCode, [2627, 2601], true)) {
103+
return true;
104+
}
105+
}
106+
97107
$errors = sqlsrv_errors(SQLSRV_ERR_ERRORS);
98108
if (! is_array($errors)) {
99109
return false;

system/Database/SQLSRV/PreparedQuery.php

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,14 @@ public function _prepare(string $sql, array $options = []): PreparedQuery
6565
$this->statement = sqlsrv_prepare($this->db->connID, $sql, $parameters);
6666

6767
if (! $this->statement) {
68+
$info = $this->db->error();
69+
$this->databaseException = $this->db->createDatabaseException($this->db->getAllErrorMessages(), $info['code']);
70+
6871
if ($this->db->DBDebug) {
69-
throw new DatabaseException($this->db->getAllErrorMessages());
72+
throw $this->databaseException;
7073
}
7174

72-
$info = $this->db->error();
73-
$this->errorCode = $info['code'];
75+
$this->errorCode = is_int($info['code']) ? $info['code'] : 0;
7476
$this->errorString = $info['message'];
7577
}
7678

@@ -93,8 +95,16 @@ public function _execute(array $data): bool
9395

9496
$result = sqlsrv_execute($this->statement);
9597

96-
if ($result === false && $this->db->DBDebug) {
97-
throw new DatabaseException($this->db->getAllErrorMessages());
98+
if ($result === false) {
99+
$error = $this->db->error();
100+
101+
$this->errorCode = is_int($error['code']) ? $error['code'] : 0;
102+
$this->errorString = $this->db->getAllErrorMessages();
103+
$this->databaseException = $this->db->createDatabaseException($this->errorString, $error['code']);
104+
105+
if ($this->db->DBDebug) {
106+
throw $this->databaseException;
107+
}
98108
}
99109

100110
return $result;

0 commit comments

Comments
 (0)