Skip to content

Commit 39628cd

Browse files
committed
fix decimal bug, improve transpiler, throw proper exception for unique
1 parent 459db08 commit 39628cd

5 files changed

Lines changed: 372 additions & 59 deletions

File tree

code/SQLite3Connector.php

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -340,40 +340,10 @@ private function throwRelevantError(
340340
// DataObjectTest_UniqueIndexObject.Name, DataObjectTest_UniqueIndexObject.Code
341341
preg_match('/UNIQUE constraint failed: (?P<fields>[^\']+)?/', $message, $matches);
342342

343-
$matches = explode(",", $matches['fields'] ?? '');
344-
$fields = [];
345-
$table = null;
346-
foreach ($matches as $field) {
347-
$field = trim($field);
348-
349-
// Remove table name from field
350-
if (str_contains($field, '.')) {
351-
$parts = explode('.', $field);
352-
$field = array_pop($parts);
353-
$table = $parts[0] ?? $table;
354-
$fields[] = $field;
355-
}
356-
}
343+
$resolver = new SQLite3DuplicateEntryResolver($this->dbConn, [$this, 'parsePreparedParameters']);
344+
$resolved = $resolver->resolve($matches['fields'] ?? '', $sql, $parameters);
357345

358-
// Sqlite doesn't provide index name
359-
$key = implode(", ", $fields);
360-
361-
// Sqlite doesn't provide value in error message
362-
$val = $parameters[1] ?? '';
363-
364-
// HACK: comply with unit tests
365-
// if ($table === 'DataObjectTest_UniqueIndexObject') {
366-
// // Single constraint takes precedence
367-
// if (count($fields) > 1 && $val !== 'Same Value') {
368-
// $key = 'MultiFieldIndex';
369-
// $val = 'Same Value';
370-
// } else {
371-
// $key = 'SingleFieldIndex';
372-
// $val = 'Same Value';
373-
// }
374-
// }
375-
376-
$this->duplicateEntryError($message, $key, (string)$val, $sql, $parameters);
346+
$this->duplicateEntryError($message, $resolved['key'], $resolved['value'], $sql, $parameters);
377347
} else {
378348
$this->databaseError($message, $errorLevel, $sql, $parameters);
379349
}
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
<?php
2+
3+
namespace SilverStripe\SQLite;
4+
5+
use SQLite3;
6+
7+
class SQLite3DuplicateEntryResolver
8+
{
9+
private SQLite3 $connection;
10+
11+
/**
12+
* @var callable
13+
*/
14+
private $parsePreparedParameters;
15+
16+
public function __construct(SQLite3 $connection, callable $parsePreparedParameters)
17+
{
18+
$this->connection = $connection;
19+
$this->parsePreparedParameters = $parsePreparedParameters;
20+
}
21+
22+
/**
23+
* @return array{key: string|null, value: string}
24+
*/
25+
public function resolve(string $constraintFields, ?string $sql, array $parameters): array
26+
{
27+
[$table, $fields] = $this->parseUniqueConstraintFields($constraintFields);
28+
$key = $this->resolveDuplicateKeyName($table, $fields, $sql, $parameters);
29+
$value = $this->resolveDuplicatedValue($table, $key, $sql, $parameters);
30+
31+
return [
32+
'key' => $key,
33+
'value' => $value,
34+
];
35+
}
36+
37+
/**
38+
* @return array{0: string|null, 1: array}
39+
*/
40+
private function parseUniqueConstraintFields(string $constraintFields): array
41+
{
42+
$fields = [];
43+
$table = null;
44+
45+
foreach (explode(',', $constraintFields) as $field) {
46+
$field = trim($field);
47+
if ($field === '') {
48+
continue;
49+
}
50+
51+
if (str_contains($field, '.')) {
52+
$parts = explode('.', $field);
53+
$field = array_pop($parts);
54+
$table = $parts[0] ?? $table;
55+
}
56+
57+
$fields[] = $this->normaliseIdentifier($field);
58+
}
59+
60+
return [$table, $fields];
61+
}
62+
63+
private function resolveDuplicateKeyName(?string $table, array $fields, ?string $sql, array $parameters): ?string
64+
{
65+
if (!$table) {
66+
return implode(', ', $fields);
67+
}
68+
69+
$indexes = $this->getUniqueIndexes($table);
70+
$attemptedValues = $this->extractAttemptedValues($sql, $parameters);
71+
$violatedIndexes = [];
72+
73+
foreach ($indexes as $indexName => $columns) {
74+
if ($this->isUniqueIndexViolated($table, $columns, $attemptedValues)) {
75+
$violatedIndexes[$indexName] = $columns;
76+
}
77+
}
78+
79+
if ($violatedIndexes) {
80+
uasort(
81+
$violatedIndexes,
82+
static fn(array $left, array $right): int => count($left) <=> count($right)
83+
);
84+
return array_key_first($violatedIndexes);
85+
}
86+
87+
foreach ($indexes as $indexName => $columns) {
88+
if ($columns === $fields) {
89+
return $indexName;
90+
}
91+
}
92+
93+
return implode(', ', $fields);
94+
}
95+
96+
private function resolveDuplicatedValue(?string $table, ?string $key, ?string $sql, array $parameters): string
97+
{
98+
if (!$table || !$key) {
99+
return '';
100+
}
101+
102+
$indexes = $this->getUniqueIndexes($table);
103+
$columns = $indexes[$key] ?? [];
104+
if (count($columns) !== 1) {
105+
return '';
106+
}
107+
108+
$attemptedValues = $this->extractAttemptedValues($sql, $parameters);
109+
$column = $columns[0];
110+
111+
return array_key_exists($column, $attemptedValues)
112+
? (string) $attemptedValues[$column]
113+
: '';
114+
}
115+
116+
/**
117+
* @return array<string, array<int, string>>
118+
*/
119+
private function getUniqueIndexes(string $table): array
120+
{
121+
$indexes = [];
122+
$escapedTable = SQLite3::escapeString($table);
123+
$result = $this->connection->query("PRAGMA index_list(\"$escapedTable\")");
124+
if (!$result) {
125+
return $indexes;
126+
}
127+
128+
while ($index = $result->fetchArray(SQLITE3_ASSOC)) {
129+
if (empty($index['unique'])) {
130+
continue;
131+
}
132+
133+
$indexName = SQLite3::escapeString($index['name'] ?? '');
134+
$columns = [];
135+
$details = $this->connection->query("PRAGMA index_info(\"$indexName\")");
136+
if ($details) {
137+
while ($detail = $details->fetchArray(SQLITE3_ASSOC)) {
138+
$columns[] = $this->normaliseIdentifier($detail['name'] ?? '');
139+
}
140+
$details->finalize();
141+
}
142+
143+
if ($columns) {
144+
$indexes[$this->normaliseIndexName($table, $index['name'] ?? '')] = $columns;
145+
}
146+
}
147+
148+
$result->finalize();
149+
150+
return $indexes;
151+
}
152+
153+
/**
154+
* @return array<string, mixed>
155+
*/
156+
private function extractAttemptedValues(?string $sql, array $parameters): array
157+
{
158+
if (!$sql) {
159+
return [];
160+
}
161+
162+
$parameterValues = array_values($parameters);
163+
$insertPattern = '/^\s*INSERT\s+INTO\s+.+?\((?<columns>[^)]+)\)\s*'
164+
. '(?:VALUES\s*\(|SELECT\s+)/is';
165+
if (preg_match($insertPattern, $sql, $matches)) {
166+
$columns = array_map(
167+
fn(string $column): string => $this->normaliseIdentifier($column),
168+
explode(',', $matches['columns'])
169+
);
170+
171+
$values = [];
172+
foreach ($columns as $index => $column) {
173+
if (array_key_exists($index, $parameterValues)) {
174+
$values[$column] = $parameterValues[$index];
175+
}
176+
}
177+
178+
return $values;
179+
}
180+
181+
if (preg_match('/^\s*UPDATE\s+.+?\s+SET\s+(?<assignments>.+?)(?:\s+WHERE\s+.+)?\s*$/is', $sql, $matches)) {
182+
$values = [];
183+
$parameterIndex = 0;
184+
$assignments = preg_split('/\s*,\s*/', trim($matches['assignments'])) ?: [];
185+
186+
foreach ($assignments as $assignment) {
187+
if (preg_match('/^(?<column>[^=]+)=\s*\?/i', trim($assignment), $assignmentMatch)) {
188+
$column = $this->normaliseIdentifier($assignmentMatch['column']);
189+
if (array_key_exists($parameterIndex, $parameterValues)) {
190+
$values[$column] = $parameterValues[$parameterIndex];
191+
}
192+
$parameterIndex++;
193+
continue;
194+
}
195+
196+
$parameterIndex += substr_count($assignment, '?');
197+
}
198+
199+
return $values;
200+
}
201+
202+
return [];
203+
}
204+
205+
private function isUniqueIndexViolated(string $table, array $columns, array $attemptedValues): bool
206+
{
207+
foreach ($columns as $column) {
208+
if (!array_key_exists($column, $attemptedValues)) {
209+
return false;
210+
}
211+
}
212+
213+
$clauses = [];
214+
foreach ($columns as $column) {
215+
$escapedColumn = SQLite3::escapeString($column);
216+
$clauses[] = sprintf('"%s" = ?', $escapedColumn);
217+
}
218+
219+
$escapedTable = SQLite3::escapeString($table);
220+
$statement = $this->connection->prepare(
221+
sprintf('SELECT 1 FROM "%s" WHERE %s LIMIT 1', $escapedTable, implode(' AND ', $clauses))
222+
);
223+
if (!$statement) {
224+
return false;
225+
}
226+
227+
$parsedParameters = ($this->parsePreparedParameters)(array_map(
228+
fn(string $column) => $attemptedValues[$column],
229+
$columns
230+
));
231+
232+
foreach ($parsedParameters as $index => $parameter) {
233+
$statement->bindValue($index + 1, $parameter['value'], $parameter['type']);
234+
}
235+
236+
$result = $statement->execute();
237+
if (!$result) {
238+
return false;
239+
}
240+
241+
$row = $result->fetchArray(SQLITE3_ASSOC);
242+
$result->finalize();
243+
244+
return $row !== false;
245+
}
246+
247+
private function normaliseIdentifier(string $identifier): string
248+
{
249+
$identifier = trim($identifier);
250+
if (str_contains($identifier, '.')) {
251+
$parts = explode('.', $identifier);
252+
$identifier = array_pop($parts);
253+
}
254+
255+
return preg_replace('/^"?(.*?)"?$/', '$1', $identifier ?? '') ?? $identifier;
256+
}
257+
258+
private function normaliseIndexName(string $table, string $indexName): string
259+
{
260+
$indexName = $this->normaliseIdentifier($indexName);
261+
$prefix = $this->normaliseIdentifier($table) . '_';
262+
263+
if (str_starts_with($indexName, $prefix)) {
264+
return substr($indexName, strlen($prefix));
265+
}
266+
267+
return $indexName;
268+
}
269+
}

0 commit comments

Comments
 (0)