Skip to content

Commit 6ce5fb3

Browse files
authored
Merge pull request #65 from webrium/fix/schema-create-duplicate-foreign-keys
fix(schema): stop double-emitting foreign keys in Schema::create()
2 parents f6e9147 + 095517c commit 6ce5fb3

2 files changed

Lines changed: 138 additions & 5 deletions

File tree

src/Schema.php

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,15 @@ public static function create(string $table, callable $callback, ?string $connec
7474
$conn->statement($idx);
7575
}
7676

77-
// Foreign keys already inlined in CREATE TABLE for MySQL.
78-
// For PostgreSQL emit separately if any.
79-
foreach ($grammar->compileForeignKeys($blueprint) as $fk) {
80-
$conn->statement($fk);
81-
}
77+
// Foreign keys are already inlined as column-level CONSTRAINTs by
78+
// compileCreate() above (see SchemaGrammar::compileCreate()), so
79+
// they must NOT be re-emitted here via compileForeignKeys() — doing
80+
// so duplicates every constraint as a separate ALTER TABLE ADD
81+
// CONSTRAINT statement, which MySQL rejects (errno 121, duplicate
82+
// constraint) and SQLite rejects outright (ALTER TABLE ADD
83+
// CONSTRAINT isn't valid SQLite DDL). compileForeignKeys() is still
84+
// used correctly by Schema::table() below, where the table (and
85+
// its inline constraints) already exist.
8286

8387
// PostgreSQL column comments (separate COMMENT ON COLUMN statements)
8488
if ($grammar instanceof PostgresSchemaGrammar) {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Foxdb\Tests\Integration;
6+
7+
use Foxdb\Schema;
8+
use Foxdb\Schema\Blueprint;
9+
10+
/**
11+
* Regression coverage for Schema::create() double-emitting foreign key
12+
* constraints.
13+
*
14+
* Schema::create() compiles the CREATE TABLE statement (which already
15+
* inlines every `$table->foreign()` as a column-level CONSTRAINT, see
16+
* SchemaGrammar::compileCreate()) and then, unconditionally, ALSO runs
17+
* SchemaGrammar::compileForeignKeys() — which re-emits the very same
18+
* constraints as separate `ALTER TABLE ... ADD CONSTRAINT` statements.
19+
* No grammar overrides compileForeignKeys() to suppress this for the
20+
* "already inlined at CREATE TABLE time" case, so every driver executes
21+
* a duplicate constraint statement:
22+
*
23+
* - MySQL: rejects the duplicate constraint name (errno 121,
24+
* "Duplicate key on write or update").
25+
* - SQLite: has no `ALTER TABLE ... ADD CONSTRAINT` syntax at all, so
26+
* the statement is a hard syntax error.
27+
*
28+
* A table defined with `$table->foreign()` therefore cannot be created
29+
* via Schema::create() on any driver.
30+
*/
31+
class SchemaForeignKeyTest extends IntegrationTestCase
32+
{
33+
public function testCreateWithForeignKeyDoesNotThrow(): void
34+
{
35+
$parent = 'sfk_test_parents';
36+
$child = 'sfk_test_children';
37+
38+
try {
39+
Schema::create($parent, function (Blueprint $t) {
40+
$t->id();
41+
$t->string('name')->nullable();
42+
});
43+
44+
Schema::create($child, function (Blueprint $t) use ($parent) {
45+
$t->id();
46+
$t->bigInteger('parent_id')->unsigned();
47+
$t->foreign('parent_id')->references('id')->on($parent)->cascadeOnDelete();
48+
});
49+
50+
$this->assertTrue(Schema::hasTable($child));
51+
} finally {
52+
Schema::dropIfExists($child);
53+
Schema::dropIfExists($parent);
54+
}
55+
}
56+
57+
/**
58+
* SQLite never enforces foreign keys unless the connection issues
59+
* `PRAGMA foreign_keys = ON` — this library does not do so, which is a
60+
* separate, pre-existing gap unrelated to the double-emission bug this
61+
* file targets. Skipped here rather than silently asserting something
62+
* false for that driver.
63+
*/
64+
public function testCreateWithForeignKeyActuallyEnforcesTheConstraint(): void
65+
{
66+
if (strtolower((string) (getenv('DB_DRIVER') ?: 'sqlite')) === 'sqlite') {
67+
$this->markTestSkipped('SQLite FK enforcement requires PRAGMA foreign_keys=ON, which this library does not set (separate gap).');
68+
}
69+
70+
$parent = 'sfk_test_parents2';
71+
$child = 'sfk_test_children2';
72+
73+
try {
74+
Schema::create($parent, function (Blueprint $t) {
75+
$t->id();
76+
});
77+
78+
Schema::create($child, function (Blueprint $t) use ($parent) {
79+
$t->id();
80+
$t->bigInteger('parent_id')->unsigned();
81+
$t->foreign('parent_id')->references('id')->on($parent);
82+
});
83+
84+
// A reference to a non-existent parent row must be rejected —
85+
// proof the constraint is really enforced by the DB, not just
86+
// that CREATE TABLE happened to succeed some other way.
87+
$threw = false;
88+
try {
89+
\Foxdb\DB::table($child)->insert(['parent_id' => 999999]);
90+
} catch (\Throwable $e) {
91+
$threw = true;
92+
}
93+
$this->assertTrue($threw, 'Expected the foreign key constraint to reject an orphan reference.');
94+
} finally {
95+
Schema::dropIfExists($child);
96+
Schema::dropIfExists($parent);
97+
}
98+
}
99+
100+
public function testCreateWithMultipleForeignKeysOnSameTableDoesNotThrow(): void
101+
{
102+
$a = 'sfk_test_a';
103+
$b = 'sfk_test_b';
104+
$c = 'sfk_test_c';
105+
106+
try {
107+
Schema::create($a, function (Blueprint $t) {
108+
$t->id();
109+
});
110+
Schema::create($b, function (Blueprint $t) {
111+
$t->id();
112+
});
113+
114+
Schema::create($c, function (Blueprint $t) use ($a, $b) {
115+
$t->id();
116+
$t->bigInteger('a_id')->unsigned();
117+
$t->bigInteger('b_id')->unsigned();
118+
$t->foreign('a_id')->references('id')->on($a);
119+
$t->foreign('b_id')->references('id')->on($b);
120+
});
121+
122+
$this->assertTrue(Schema::hasTable($c));
123+
} finally {
124+
Schema::dropIfExists($c);
125+
Schema::dropIfExists($b);
126+
Schema::dropIfExists($a);
127+
}
128+
}
129+
}

0 commit comments

Comments
 (0)