packages/sql-semantics/tests/Unit/Core/SchemaBuilderTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Core;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\Medium;
9use PHPUnit\Framework\Attributes\TestWith;
10use PHPUnit\Framework\TestCase;
11use RuntimeException;
12use SqlSemantics\Core\Binder;
13use SqlSemantics\Core\Dialect;
14use SqlSemantics\Core\SchemaBuilder;
15use SqlSemantics\Core\SemanticException;
16use SqlSemantics\Platform\MySql\Dialect as MySqlDialect;
17use SqlSemantics\Platform\PostgreSql\Dialect as PostgreSqlDialect;
18use SqlSemantics\Platform\Sqlite\Dialect as SqliteDialect;
19
20#[CoversClass(Binder::class)]
21#[CoversClass(SchemaBuilder::class)]
22#[CoversClass(\SqlSemantics\Core\Ast\DialectParser::class)]
23#[CoversClass(\SqlSemantics\Core\Binding\ExpressionBinder::class)]
24#[CoversClass(\SqlSemantics\Core\Binding\ExpressionRules::class)]
25#[CoversClass(\SqlSemantics\Core\Binding\FromBinder::class)]
26#[CoversClass(\SqlSemantics\Core\Binding\LiteralBinder::class)]
27#[CoversClass(\SqlSemantics\Core\Binding\NullFacts::class)]
28#[CoversClass(\SqlSemantics\Core\Binding\ProjectionBinder::class)]
29#[CoversClass(\SqlSemantics\Core\Binding\SelectBinder::class)]
30#[CoversClass(\SqlSemantics\Core\Binding\SyntaxGuard::class)]
31#[CoversClass(\SqlSemantics\Core\Binding\SelectModifiersBinder::class)]
32#[CoversClass(\SqlSemantics\Core\Binding\TypeResolution::class)]
33#[CoversClass(\SqlSemantics\Core\Ast\ColumnReader::class)]
34#[CoversClass(\SqlSemantics\Core\Ast\ConstraintReader::class)]
35#[CoversClass(\SqlSemantics\Core\Ast\Identifiers::class)]
36#[CoversClass(\SqlSemantics\Core\Ast\SchemaReader::class)]
37#[CoversClass(\SqlSemantics\Core\Ast\StatementList::class)]
38#[CoversClass(\SqlSemantics\Core\Ast\TokenGroups::class)]
39#[CoversClass(\SqlSemantics\Core\Ast\Tree::class)]
40#[CoversClass(\SqlSemantics\Core\Ast\TypeReader::class)]
41#[CoversClass(\SqlSemantics\Core\Binding\BoundRelation::class)]
42#[CoversClass(\SqlSemantics\Core\Binding\IdentitySequence::class)]
43#[CoversClass(\SqlSemantics\Core\Binding\Scope::class)]
44#[CoversClass(\SqlSemantics\Core\Binding\TableResolver::class)]
45#[CoversClass(\SqlSemantics\Core\Model\ColumnBinding::class)]
46#[CoversClass(\SqlSemantics\Core\Model\Expression::class)]
47#[CoversClass(\SqlSemantics\Core\Model\Join::class)]
48#[CoversClass(\SqlSemantics\Core\Model\Ordering::class)]
49#[CoversClass(\SqlSemantics\Core\Model\OutputColumn::class)]
50#[CoversClass(\SqlSemantics\Core\Model\BoundSelect::class)]
51#[CoversClass(\SqlSemantics\Core\Model\TableUse::class)]
52#[CoversClass(\SqlSemantics\Core\Schema::class)]
53#[CoversClass(\SqlSemantics\Core\Schema\ColumnDefinition::class)]
54#[CoversClass(\SqlSemantics\Core\Schema\TableConstraint::class)]
55#[CoversClass(\SqlSemantics\Core\Schema\TableDefinition::class)]
56#[CoversClass(SemanticException::class)]
57#[CoversClass(\SqlSemantics\Core\Type\TypeDescriptor::class)]
58#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Core\Policy\SyntaxRules::class)]
59#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\PostgreSql\QueryRules::class)]
60#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\PostgreSql\Platform::class)]
61#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\PostgreSql\TypeRules::class)]
62#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\PostgreSql\NameRules::class)]
63#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\PostgreSql\SchemaRules::class)]
64#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\Sqlite\QueryRules::class)]
65#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\Sqlite\Platform::class)]
66#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\Sqlite\TypeRules::class)]
67#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\Sqlite\NameRules::class)]
68#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\Sqlite\SchemaRules::class)]
69#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\MySql\QueryRules::class)]
70#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\MySql\Platform::class)]
71#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\MySql\TypeRules::class)]
72#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\MySql\NameRules::class)]
73#[\PHPUnit\Framework\Attributes\UsesClass(\SqlSemantics\Platform\MySql\SchemaRules::class)]
74#[Medium]
75final class SchemaBuilderTest extends TestCase
76{
77 #[TestWith([PostgreSqlDialect::PostgreSql, 'public', 'pg-17.2'])]
78 #[TestWith([MySqlDialect::MySql, '', 'mysql-8.4.7'])]
79 #[TestWith([SqliteDialect::Sqlite, 'main', 'sqlite-3.47.2'])]
80 public function testBuildRetainsTheResolvedLanguageAndNamespace(Dialect $dialect, string $defaultSchema, string $grammarVersion): void
81 {
82 $schema = (new SchemaBuilder($dialect))->build('CREATE TABLE users (id INTEGER)');
83 self::assertSame($dialect, $schema->dialect);
84 self::assertSame($grammarVersion, $schema->grammarVersion);
85 self::assertSame($defaultSchema, $schema->defaultSchema);
86 self::assertSame($defaultSchema, $schema->tables[0]->schema);
87 }
88
89 #[TestWith([PostgreSqlDialect::PostgreSql])]
90 #[TestWith([MySqlDialect::MySql])]
91 #[TestWith([SqliteDialect::Sqlite])]
92 public function testBuildAcceptsSeparateSqlStringsInDeclarationOrder(Dialect $dialect): void
93 {
94 $schema = (new SchemaBuilder($dialect))->build('CREATE TABLE a (id INTEGER)', 'CREATE TABLE b (id INTEGER)');
95 self::assertSame(['a', 'b'], array_column($schema->tables, 'name'));
96 }
97
98 public function testBuildDoesNotAccumulatePreviousDeclarations(): void
99 {
100 $builder = new SchemaBuilder(PostgreSqlDialect::PostgreSql);
101 $first = $builder->build('CREATE TABLE users (id INTEGER)');
102 $empty = $builder->build();
103 self::assertCount(1, $first->tables);
104 self::assertSame([], $empty->tables);
105 self::assertSame('integer', (new Binder($empty))->bind('SELECT 1')->outputs[0]->expression->type->name);
106 }
107
108 public function testBuildRejectsConflictingDeclarationsAcrossSqlStrings(): void
109 {
110 $builder = new SchemaBuilder(PostgreSqlDialect::PostgreSql);
111 $this->expectException(SemanticException::class);
112 $this->expectExceptionMessage('Duplicate table');
113 $builder->build('CREATE TABLE users (id INTEGER)', 'CREATE TABLE users (name TEXT)');
114 }
115
116 public function testBuildKeepsExplicitlyQualifiedDeclarations(): void
117 {
118 $schema = (new SchemaBuilder(PostgreSqlDialect::PostgreSql, 'app', 'pg-17.2'))->build('CREATE TABLE public.users (id INTEGER)', 'CREATE TABLE users (id INTEGER)');
119 self::assertSame(['public', 'app'], array_column($schema->tables, 'schema'));
120 self::assertSame('pg-17.2', $schema->grammarVersion);
121 self::assertSame($schema->tables[1], (new Binder($schema))->bind('SELECT id FROM users')->relations[0]->declaration);
122 }
123
124 public function testBuildPropagatesInvalidDdlSyntax(): void
125 {
126 $builder = new SchemaBuilder(PostgreSqlDialect::PostgreSql);
127 $this->expectException(\SqlParser\Parser\SyntaxException::class);
128 $builder->build('CREATE TABLE');
129 }
130
131 #[TestWith([PostgreSqlDialect::PostgreSql])]
132 #[TestWith([MySqlDialect::MySql])]
133 #[TestWith([SqliteDialect::Sqlite])]
134 public function testRejectsAnUnavailableGrammarRelease(Dialect $dialect): void
135 {
136 $this->expectException(RuntimeException::class);
137 $this->expectExceptionMessage('Unsupported');
138 new SchemaBuilder($dialect, grammarVersion: 'unavailable-release');
139 }
140
141}
142