Skip to content

Commit 98db48e

Browse files
committed
Add live database schema introspection
1 parent a093f98 commit 98db48e

11 files changed

Lines changed: 420 additions & 4 deletions

File tree

.github/workflows/test-phpunit.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ jobs:
4444
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
4545
with:
4646
php-version: ${{ matrix.php-version }}
47+
extensions: sqlite3
4748
coverage: xdebug
4849

4950
- name: Get composer cache directory
@@ -68,4 +69,6 @@ jobs:
6869
composer audit --ansi --abandoned=report
6970
7071
- name: Run Extension Tests
71-
run: composer test:stan
72+
run: |
73+
composer test:stan
74+
composer test:unit

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
.vscode/
22
.idea/
33
build/
4+
tmp/*
5+
!tmp/.gitkeep
46
vendor/
57
composer.lock
68
.php-cs-fixer.php

composer.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,16 +83,19 @@
8383
"test:all": [
8484
"@cs:check",
8585
"@phpstan:check",
86-
"@test:stan"
86+
"@test:stan",
87+
"@test:unit"
8788
],
88-
"test:stan": "phpunit --colors=always --group=static-analysis"
89+
"test:stan": "phpunit --colors=always --group=static-analysis",
90+
"test:unit": "phpunit --colors=always --group=unit"
8991
},
9092
"scripts-descriptions": {
9193
"cs:check": "Checks for coding style violations",
9294
"cs:fix": "Fixes coding style violations",
9395
"phpstan:baseline": "Runs PHPStan and dumps resulting errors to baseline",
9496
"phpstan:check": "Runs PHPStan with identifiers support",
9597
"test:all": "Runs all tests",
96-
"test:stan": "Runs the Static Analysis Tests"
98+
"test:stan": "Runs the Static Analysis Tests",
99+
"test:unit": "Runs the Unit Tests"
97100
}
98101
}

src/Database/Schema/Column.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Database\Schema;
15+
16+
/**
17+
* A single column of a database table, as introspected from the live schema.
18+
*/
19+
final readonly class Column
20+
{
21+
public function __construct(
22+
public string $name,
23+
public string $type,
24+
public bool $nullable,
25+
public bool $primaryKey,
26+
public ?string $default,
27+
) {}
28+
29+
/**
30+
* @param array{name: string, type: string, nullable: bool, primaryKey: bool, default: string|null} $state
31+
*/
32+
public static function __set_state(array $state): self
33+
{
34+
return new self(
35+
name: $state['name'],
36+
type: $state['type'],
37+
nullable: $state['nullable'],
38+
primaryKey: $state['primaryKey'],
39+
default: $state['default'],
40+
);
41+
}
42+
}

src/Database/Schema/Schema.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Database\Schema;
15+
16+
/**
17+
* The database schema, as introspected from the live SQLite connection.
18+
*/
19+
final readonly class Schema
20+
{
21+
/**
22+
* @param string $hash Fingerprint of the migration set that produced this schema
23+
* @param array<string, Table> $tables Keyed by table name
24+
*/
25+
public function __construct(
26+
public string $hash,
27+
public array $tables,
28+
) {}
29+
30+
/**
31+
* @param array{hash: string, tables: array<string, Table>} $state
32+
*/
33+
public static function __set_state(array $state): self
34+
{
35+
return new self(
36+
hash: $state['hash'],
37+
tables: $state['tables'],
38+
);
39+
}
40+
41+
public function getTable(string $name): ?Table
42+
{
43+
return $this->tables[$name] ?? null;
44+
}
45+
}

src/Database/Schema/Table.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Database\Schema;
15+
16+
/**
17+
* A database table and its columns, as introspected from the live schema.
18+
*/
19+
final readonly class Table
20+
{
21+
/**
22+
* @param array<string, Column> $columns Keyed by column name
23+
*/
24+
public function __construct(
25+
public string $name,
26+
public array $columns,
27+
) {}
28+
29+
/**
30+
* @param array{name: string, columns: array<string, Column>} $state
31+
*/
32+
public static function __set_state(array $state): self
33+
{
34+
return new self(
35+
name: $state['name'],
36+
columns: $state['columns'],
37+
);
38+
}
39+
40+
public function getColumn(string $name): ?Column
41+
{
42+
return $this->columns[$name] ?? null;
43+
}
44+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Database;
15+
16+
use CodeIgniter\Database\SQLite3\Connection;
17+
use Config\Database;
18+
use RuntimeException;
19+
20+
/**
21+
* Creates an isolated SQLite3 connection used to materialize the user's schema,
22+
* leaving the user's own database connections untouched.
23+
*/
24+
final class SchemaConnectionFactory
25+
{
26+
/**
27+
* @throws RuntimeException
28+
*/
29+
public function create(string $databasePath): Connection
30+
{
31+
$directory = dirname($databasePath);
32+
33+
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
34+
throw new RuntimeException(sprintf('Unable to create database directory "%s".', $directory));
35+
}
36+
37+
$connection = Database::connect([
38+
'DBDriver' => 'SQLite3',
39+
'database' => $databasePath,
40+
'foreignKeys' => true,
41+
], false);
42+
43+
assert($connection instanceof Connection);
44+
45+
return $connection;
46+
}
47+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Database;
15+
16+
use CodeIgniter\Database\SQLite3\Connection;
17+
use CodeIgniter\PHPStan\Database\Schema\Column;
18+
use CodeIgniter\PHPStan\Database\Schema\Schema;
19+
use CodeIgniter\PHPStan\Database\Schema\Table;
20+
21+
/**
22+
* Reads the tables and columns of a live database connection into a `Schema`.
23+
*/
24+
final class SchemaIntrospector
25+
{
26+
/**
27+
* Tables created by the framework that are not part of the user's domain schema.
28+
*/
29+
private const IGNORED_TABLES = [
30+
'migrations',
31+
];
32+
33+
public function introspect(Connection $db, string $hash): Schema
34+
{
35+
$tableNames = $db->listTables();
36+
37+
if ($tableNames === false) {
38+
return new Schema(hash: $hash, tables: []);
39+
}
40+
41+
$tables = [];
42+
43+
foreach ($tableNames as $tableName) {
44+
if (in_array($tableName, self::IGNORED_TABLES, true)) {
45+
continue;
46+
}
47+
48+
$columns = [];
49+
50+
foreach ($db->getFieldData($tableName) as $field) {
51+
$name = $field->name;
52+
53+
if (! is_string($name)) {
54+
continue;
55+
}
56+
57+
$type = $field->type;
58+
$default = $field->default;
59+
60+
$columns[$name] = new Column(
61+
name: $name,
62+
type: is_string($type) ? $type : '',
63+
nullable: (bool) $field->nullable,
64+
primaryKey: (bool) $field->primary_key,
65+
default: is_string($default) ? $default : null,
66+
);
67+
}
68+
69+
$tables[$tableName] = new Table(name: $tableName, columns: $columns);
70+
}
71+
72+
return new Schema(hash: $hash, tables: $tables);
73+
}
74+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Tests\Database\Schema;
15+
16+
use CodeIgniter\PHPStan\Database\Schema\Column;
17+
use CodeIgniter\PHPStan\Database\Schema\Schema;
18+
use CodeIgniter\PHPStan\Database\Schema\Table;
19+
use PHPUnit\Framework\Attributes\Group;
20+
use PHPUnit\Framework\TestCase;
21+
22+
/**
23+
* @internal
24+
*/
25+
#[Group('unit')]
26+
final class SchemaSetStateTest extends TestCase
27+
{
28+
public function testSurvivesVarExportFileCacheRoundTrip(): void
29+
{
30+
$schema = new Schema(hash: 'fingerprint', tables: [
31+
'users' => new Table(name: 'users', columns: [
32+
'id' => new Column(name: 'id', type: 'INTEGER', nullable: false, primaryKey: true, default: null),
33+
'name' => new Column(name: 'name', type: 'VARCHAR', nullable: false, primaryKey: false, default: 'guest'),
34+
]),
35+
]);
36+
37+
$file = dirname(__DIR__, 3) . '/tmp/' . uniqid('schema-cache-', true) . '.php';
38+
file_put_contents($file, '<?php return ' . var_export($schema, true) . ';');
39+
40+
try {
41+
$restored = include $file;
42+
} finally {
43+
unlink($file);
44+
}
45+
46+
self::assertSame(var_export($schema, true), var_export($restored, true));
47+
}
48+
}

0 commit comments

Comments
 (0)