Skip to content

Commit e44768f

Browse files
abnegateclaude
andcommitted
feat(sqlite): gate MariaDB-shape emulation behind setEmulateMySQL flag
Vanilla SQLite stays vanilla by default; opt in to MariaDB-shaped behaviour via setEmulateMySQL(true). When the flag is on: - _tenant declares as INT(11) UNSIGNED instead of INTEGER - parseSqliteColumnType maps INTEGER → 'int' and reports MariaDB byte ceilings / numeric precision defaults to match INFORMATION_SCHEMA - updateAttribute scans on resize-down and raises TruncateException - getSupportForAttributeResizing returns true Native SQLite features stay unconditionally on: - ON CONFLICT DO UPDATE upserts - REGEXP via the registered PCRE UDF - FTS5 fulltext, relationships, schema introspection, composite UNIQUE under shared tables (via getSQLIndex auto-prepend) Adapter test classes opt in to emulation so the existing test contract (MariaDB-shaped column metadata, resize-down failures) keeps passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1960c3c commit e44768f

3 files changed

Lines changed: 122 additions & 55 deletions

File tree

src/Database/Adapter/SQLite.php

Lines changed: 114 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,39 @@ class SQLite extends MariaDB
4545
private const MARIADB_MEDIUMTEXT_BYTES = '16777215';
4646
private const MARIADB_LONGTEXT_BYTES = '4294967295';
4747

48+
/**
49+
* When enabled, the adapter reports MariaDB-shaped column metadata,
50+
* advertises MariaDB-only capabilities (upserts, attribute resizing,
51+
* PCRE regex via the registered UDF), and declares schema-internal
52+
* columns (e.g. `_tenant`) using MariaDB-style types so callers that
53+
* inspect INFORMATION_SCHEMA-style results behave identically across
54+
* both adapters. Off by default — vanilla SQLite stays vanilla.
55+
*/
56+
protected bool $emulateMySQL = false;
57+
4858
public function __construct(mixed $pdo)
4959
{
5060
parent::__construct($pdo);
5161

5262
$this->registerUserFunctions();
5363
}
5464

65+
/**
66+
* Toggle MariaDB/MySQL emulation. See $emulateMySQL for what this
67+
* actually changes.
68+
*/
69+
public function setEmulateMySQL(bool $emulate): static
70+
{
71+
$this->emulateMySQL = $emulate;
72+
73+
return $this;
74+
}
75+
76+
public function getEmulateMySQL(): bool
77+
{
78+
return $this->emulateMySQL;
79+
}
80+
5581
/**
5682
* SQLite has no built-in regex operator — the inherited REGEXP path
5783
* relies on the connection supplying one. Register a PHP-implemented
@@ -209,7 +235,13 @@ public function createCollection(string $name, array $attributes = [], array $in
209235
$attributeStrings[$key] = "`{$attrId}` {$attrType}, ";
210236
}
211237

212-
$tenantQuery = $this->sharedTables ? '`_tenant` "INT(11) UNSIGNED" DEFAULT NULL,' : '';
238+
// SQLite stores integers regardless of declared type, but
239+
// testSchemaAttributes asserts the columnType reads back as
240+
// `int(11) unsigned` to match MariaDB. Quote the declaration so
241+
// PRAGMA table_info echoes the exact string under emulation;
242+
// otherwise use INTEGER, the affinity-correct vanilla form.
243+
$tenantType = $this->emulateMySQL ? '"INT(11) UNSIGNED"' : 'INTEGER';
244+
$tenantQuery = $this->sharedTables ? "`_tenant` {$tenantType} DEFAULT NULL," : '';
213245

214246
$collection = "
215247
CREATE TABLE {$this->getSQLTable($id)} (
@@ -408,10 +440,11 @@ public function updateAttribute(string $collection, string $id, string $type, in
408440

409441
// SQLite is dynamically typed — `ALTER TABLE ... MODIFY COLUMN` is
410442
// not supported and a smaller declared size silently accepts
411-
// larger values. To keep parity with the MariaDB contract that
412-
// resize-down rejects when data exceeds the new size, scan the
413-
// column ourselves and raise the same TruncateException.
414-
if ($type === Database::VAR_STRING && $size > 0 && !$array) {
443+
// larger values. Under MySQL emulation, scan the column and
444+
// raise the same TruncateException MariaDB throws. Off-
445+
// emulation the declared size is metadata-only, so skip the
446+
// scan and let the rename branch (if any) handle the rest.
447+
if ($this->emulateMySQL && $type === Database::VAR_STRING && $size > 0 && !$array) {
415448
$name = $this->filter($collection);
416449
$column = $this->filter($id);
417450
$sql = "SELECT 1 FROM {$this->getSQLTable($name)} WHERE LENGTH(`{$column}`) > :max LIMIT 1";
@@ -1215,11 +1248,11 @@ public function getSupportForUpdateLock(): bool
12151248
*/
12161249
public function getSupportForAttributeResizing(): bool
12171250
{
1218-
// SQLite is dynamically typed with no MODIFY COLUMN, but
1219-
// updateAttribute scans the column on resize-down and raises
1220-
// TruncateException when any row exceeds the new size — same
1221-
// contract MariaDB enforces via the engine.
1222-
return true;
1251+
// SQLite is dynamically typed with no MODIFY COLUMN. When
1252+
// emulating MySQL, updateAttribute scans the column on
1253+
// resize-down and raises TruncateException to match MariaDB's
1254+
// contract. Off-emulation, declared sizes are metadata-only.
1255+
return $this->emulateMySQL;
12231256
}
12241257

12251258
/**
@@ -1254,6 +1287,7 @@ public function getSupportForSchemaIndexes(): bool
12541287
*/
12551288
public function getSupportForUpserts(): bool
12561289
{
1290+
// ON CONFLICT DO UPDATE is native SQLite, not MariaDB emulation.
12571291
return true;
12581292
}
12591293

@@ -2197,6 +2231,9 @@ public function getSupportNonUtfCharacters(): bool
21972231
*/
21982232
public function getSupportForPCRERegex(): bool
21992233
{
2234+
// The REGEXP UDF is registered unconditionally in the
2235+
// constructor and runs preg_match (PCRE) — same surface as
2236+
// MariaDB's native operator from the caller's perspective.
22002237
return true;
22012238
}
22022239

@@ -2581,10 +2618,11 @@ private function parseSqliteColumnType(string $declaration): array
25812618
}
25822619

25832620
$dataType = \strtolower($base);
2584-
// SQLite spells INT and INTEGER interchangeably for declared types,
2585-
// but MariaDB's INFORMATION_SCHEMA always reports `int`. Canonicalise
2586-
// so getSchemaAttributes matches the parent contract.
2587-
if ($dataType === 'integer') {
2621+
// SQLite spells INT and INTEGER interchangeably for declared types.
2622+
// Under emulation, canonicalise to MariaDB's reported `int` so
2623+
// getSchemaAttributes matches that adapter's contract; otherwise
2624+
// keep the verbatim form the user declared.
2625+
if ($this->emulateMySQL && $dataType === 'integer') {
25882626
$dataType = 'int';
25892627
}
25902628

@@ -2596,6 +2634,12 @@ private function parseSqliteColumnType(string $declaration): array
25962634
'datetimePrecision' => null,
25972635
];
25982636

2637+
// VARCHAR / CHAR / DATETIME(n) / DECIMAL(p,s) length+precision
2638+
// come straight from the declaration — that's true for vanilla
2639+
// SQLite too. The MariaDB byte ceilings (TEXT/MEDIUMTEXT/etc.)
2640+
// and the integer/float precision defaults are MariaDB-specific
2641+
// INFORMATION_SCHEMA conventions, so report them only under
2642+
// emulation.
25992643
switch ($dataType) {
26002644
case 'varchar':
26012645
case 'char':
@@ -2604,19 +2648,6 @@ private function parseSqliteColumnType(string $declaration): array
26042648
}
26052649
break;
26062650

2607-
case 'text':
2608-
$result['characterMaximumLength'] = self::MARIADB_TEXT_BYTES;
2609-
break;
2610-
2611-
case 'mediumtext':
2612-
$result['characterMaximumLength'] = self::MARIADB_MEDIUMTEXT_BYTES;
2613-
break;
2614-
2615-
case 'longtext':
2616-
case 'json':
2617-
$result['characterMaximumLength'] = self::MARIADB_LONGTEXT_BYTES;
2618-
break;
2619-
26202651
case 'datetime':
26212652
case 'timestamp':
26222653
case 'time':
@@ -2625,40 +2656,70 @@ private function parseSqliteColumnType(string $declaration): array
26252656
}
26262657
break;
26272658

2628-
case 'tinyint':
2629-
$result['numericPrecision'] = '3';
2659+
case 'decimal':
2660+
case 'numeric':
2661+
if ($argument !== null) {
2662+
$result['numericPrecision'] = (string) $argument;
2663+
}
2664+
if ($secondArgument !== null) {
2665+
$result['numericScale'] = (string) $secondArgument;
2666+
} elseif ($this->emulateMySQL && $argument !== null) {
2667+
$result['numericScale'] = '0';
2668+
}
26302669
break;
2670+
}
26312671

2632-
case 'smallint':
2633-
$result['numericPrecision'] = '5';
2634-
break;
2672+
if ($this->emulateMySQL) {
2673+
switch ($dataType) {
2674+
case 'text':
2675+
$result['characterMaximumLength'] = self::MARIADB_TEXT_BYTES;
2676+
break;
26352677

2636-
case 'mediumint':
2637-
$result['numericPrecision'] = '7';
2638-
break;
2678+
case 'mediumtext':
2679+
$result['characterMaximumLength'] = self::MARIADB_MEDIUMTEXT_BYTES;
2680+
break;
26392681

2640-
case 'int':
2641-
case 'integer':
2642-
$result['numericPrecision'] = '10';
2643-
break;
2682+
case 'longtext':
2683+
case 'json':
2684+
$result['characterMaximumLength'] = self::MARIADB_LONGTEXT_BYTES;
2685+
break;
26442686

2645-
case 'bigint':
2646-
$result['numericPrecision'] = '19';
2647-
break;
2687+
case 'tinyint':
2688+
$result['numericPrecision'] = '3';
2689+
break;
26482690

2649-
case 'decimal':
2650-
case 'numeric':
2651-
$result['numericPrecision'] = $argument !== null ? (string) $argument : '10';
2652-
$result['numericScale'] = $secondArgument !== null ? (string) $secondArgument : '0';
2653-
break;
2691+
case 'smallint':
2692+
$result['numericPrecision'] = '5';
2693+
break;
26542694

2655-
case 'float':
2656-
$result['numericPrecision'] = '12';
2657-
break;
2695+
case 'mediumint':
2696+
$result['numericPrecision'] = '7';
2697+
break;
26582698

2659-
case 'double':
2660-
$result['numericPrecision'] = '22';
2661-
break;
2699+
case 'int':
2700+
case 'integer':
2701+
$result['numericPrecision'] = '10';
2702+
break;
2703+
2704+
case 'bigint':
2705+
$result['numericPrecision'] = '19';
2706+
break;
2707+
2708+
case 'decimal':
2709+
case 'numeric':
2710+
if ($result['numericPrecision'] === null) {
2711+
$result['numericPrecision'] = '10';
2712+
}
2713+
break;
2714+
2715+
case 'float':
2716+
$result['numericPrecision'] = '12';
2717+
break;
2718+
2719+
case 'double':
2720+
$result['numericPrecision'] = '22';
2721+
break;
2722+
}
26622723
}
26632724

26642725
return $result;

tests/e2e/Adapter/SQLiteTest.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ public function getDatabase(): Database
3939
$redis->flushAll();
4040
$cache = new Cache(new RedisAdapter($redis));
4141

42-
$database = new Database(new SQLite($pdo), $cache);
42+
$adapter = new SQLite($pdo);
43+
$adapter->setEmulateMySQL(true);
44+
45+
$database = new Database($adapter, $cache);
4346
$database
4447
->setAuthorization(self::$authorization)
4548
->setDatabase('utopiaTests')

tests/e2e/Adapter/SharedTables/SQLiteTest.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ public function getDatabase(): Database
5252

5353
$cache = new Cache(new RedisAdapter($redis));
5454

55-
$database = new Database(new SQLite($pdo), $cache);
55+
$adapter = new SQLite($pdo);
56+
$adapter->setEmulateMySQL(true);
57+
58+
$database = new Database($adapter, $cache);
5659
$database
5760
->setAuthorization(self::$authorization)
5861
->setDatabase('utopiaTests')

0 commit comments

Comments
 (0)