Skip to content

Commit 222f469

Browse files
authored
fix(install): add menusitems FK during fresh install for schema parity (#9) (#27)
* fix(install): add menusitems FK during fresh install for schema parity (#9) Fresh installs previously omitted the menusitems -> menuscategory FK because SqlUtility::prefixQuery() does not rewrite REFERENCES targets inside CREATE TABLE bodies. PR #8 kept install-time integrity by seeding categories before items and relied on the runtime system module upgrade path to add the FK later. That left a parity gap: freshly installed sites that never ran the system module upgrade had no FK. Add system_menu_install_add_category_fk() in install/include/makedata.php that runs a prefixed ALTER TABLE ADD CONSTRAINT after menu seeding succeeds. The helper is idempotent (INFORMATION_SCHEMA lookup) and non-fatal (trigger_error on failure; the next system module upgrade retries via the same FK name). Implementation stays local to the installer — SqlUtility::prefixQuery() and install SQL are unchanged any regressions. * Remove the unused function parameter "$sql".
1 parent 565b251 commit 222f469

2 files changed

Lines changed: 241 additions & 0 deletions

File tree

htdocs/install/include/makedata.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,56 @@ function system_menu_install_seed_defaults($dbm, array $groups, int $moduleId):
143143
return true;
144144
}
145145

146+
/**
147+
* Add the menusitems -> menuscategory foreign key during fresh install.
148+
*
149+
* The install SQL in sql/mysql.structure.sql cannot carry this FK inline
150+
* because SqlUtility::prefixQuery() only rewrites the leading table name
151+
* and does not rewrite REFERENCES targets inside CREATE TABLE bodies.
152+
* Running a prefixed ALTER TABLE ADD CONSTRAINT after the menu tables are
153+
* created and seeded keeps fresh-install schema in parity with the runtime
154+
* system-module upgrade path in modules/system/include/update.php
155+
* (see system_menu_create_tables()).
156+
*
157+
* Idempotent: returns true early if the FK already exists. Non-fatal on
158+
* failure — the caller continues and the next system module upgrade
159+
* retries the add via the same constraint name.
160+
*
161+
* @param Db_manager $dbm
162+
*
163+
* @return bool true on success or already-present, false if the ALTER failed
164+
*/
165+
function system_menu_install_add_category_fk($dbm): bool
166+
{
167+
$db = $dbm->db;
168+
$itemTable = $db->prefix('menusitems');
169+
$catTable = $db->prefix('menuscategory');
170+
$fkName = $db->prefix('fk_items_category');
171+
172+
$result = $db->query(
173+
'SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS'
174+
. ' WHERE CONSTRAINT_NAME = ' . $db->quote($fkName)
175+
. ' AND TABLE_NAME = ' . $db->quote($itemTable)
176+
. ' AND TABLE_SCHEMA = DATABASE()'
177+
);
178+
if ($db->isResultSet($result) && ($result instanceof \mysqli_result) && $db->getRowsNum($result) > 0) {
179+
return true;
180+
}
181+
182+
$sql = 'ALTER TABLE `' . $itemTable . '` ADD CONSTRAINT `' . $fkName . '`'
183+
. ' FOREIGN KEY (`items_cid`) REFERENCES `' . $catTable . '` (`category_id`)'
184+
. ' ON DELETE CASCADE';
185+
if (false === $db->exec($sql)) {
186+
trigger_error(
187+
'Failed to add menusitems foreign key during install; next system module upgrade will retry.',
188+
E_USER_WARNING
189+
);
190+
return false;
191+
}
192+
193+
return true;
194+
}
195+
146196
/**
147197
* @param $dbm
148198
* @param $adminname
@@ -200,6 +250,9 @@ function make_data($dbm, $adminname, $hashedAdminPass, $adminmail, $language, $g
200250
if (!system_menu_install_seed_defaults($dbm, $groups, 1)) {
201251
return false;
202252
}
253+
// Best-effort: add the menusitems FK after seeding completes. Non-fatal on
254+
// failure — install continues and the next system module upgrade retries.
255+
system_menu_install_add_category_fk($dbm);
203256

204257
foreach ($modversion['templates'] as $tplfile) {
205258
$templateType = isset($tplfile['type']) && 'admin' === $tplfile['type'] ? 'admin' : 'module';

tests/unit/htdocs/modules/system/SystemMenuInstallationTest.php

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,194 @@ public function installerUsesSharedSeedDefinitionsAndSeedsMenusAfterSystemModule
173173
);
174174
}
175175

176+
#[Test]
177+
public function installerInvokesForeignKeyHelperAfterSuccessfulMenuSeeding(): void
178+
{
179+
$source = $this->readSourceFile('install/include/makedata.php');
180+
181+
$this->assertMatchesRegularExpression(
182+
'/if\\s*\\(\\s*!system_menu_install_seed_defaults\\(\\$dbm, \\$groups, 1\\)\\s*\\).*?'
183+
. 'system_menu_install_add_category_fk\\(\\$dbm\\);/s',
184+
$source,
185+
'make_data() must call system_menu_install_add_category_fk() after the seed-success guard'
186+
);
187+
}
188+
189+
#[Test]
190+
public function installerForeignKeyHelperBuildsPrefixedAlterTableStatement(): void
191+
{
192+
$source = $this->readSourceFile('install/include/makedata.php');
193+
194+
$this->assertStringContainsString(
195+
'function system_menu_install_add_category_fk($dbm): bool',
196+
$source,
197+
'Helper must be defined in install/include/makedata.php'
198+
);
199+
$this->assertStringContainsString(
200+
"\$db->prefix('menusitems')",
201+
$source
202+
);
203+
$this->assertStringContainsString(
204+
"\$db->prefix('menuscategory')",
205+
$source
206+
);
207+
$this->assertStringContainsString(
208+
"\$db->prefix('fk_items_category')",
209+
$source
210+
);
211+
$this->assertMatchesRegularExpression(
212+
'/ALTER TABLE `[^`]*` ADD CONSTRAINT `[^`]*`/',
213+
$source
214+
);
215+
$this->assertStringContainsString(
216+
'FOREIGN KEY (`items_cid`) REFERENCES',
217+
$source
218+
);
219+
$this->assertStringContainsString(
220+
'ON DELETE CASCADE',
221+
$source
222+
);
223+
}
224+
225+
#[Test]
226+
public function installerForeignKeyHelperIsIdempotentAndNonFatal(): void
227+
{
228+
$source = $this->readSourceFile('install/include/makedata.php');
229+
230+
$this->assertStringContainsString(
231+
'INFORMATION_SCHEMA.TABLE_CONSTRAINTS',
232+
$source,
233+
'Helper must check INFORMATION_SCHEMA to stay idempotent'
234+
);
235+
$this->assertMatchesRegularExpression(
236+
'/getRowsNum\\(\\$result\\)\\s*>\\s*0\\s*\\)\\s*\\{\\s*return true;/',
237+
$source,
238+
'Helper must short-circuit when the FK already exists'
239+
);
240+
$this->assertMatchesRegularExpression(
241+
'/trigger_error\\(\\s*[\'"][^\'"]*foreign key[^\'"]*[\'"]\\s*,\\s*E_USER_WARNING\\s*\\)/i',
242+
$source,
243+
'Helper must surface exec failure via trigger_error(E_USER_WARNING)'
244+
);
245+
$this->assertMatchesRegularExpression(
246+
'/false === \\$db->exec\\(\\$sql\\)\\)\\s*\\{[^}]*return false;/s',
247+
$source,
248+
'Helper must return false on exec failure without aborting install'
249+
);
250+
}
251+
252+
#[Test]
253+
public function foreignKeyHelperEmitsPrefixedAlterTableWhenFkMissing(): void
254+
{
255+
$dbm = new class {
256+
public object $db;
257+
public function __construct()
258+
{
259+
// Stubs declare no parameters; PHP accepts extra positional args silently.
260+
// This keeps the helper's $db->query($sql) etc. call sites working while
261+
// not declaring (therefore not leaving unused) the args the stub ignores.
262+
$this->db = new class {
263+
public array $execCalls = [];
264+
public function prefix(string $name): string
265+
{
266+
return 'xo_' . $name;
267+
}
268+
public function quote(string $value): string
269+
{
270+
return "'" . addslashes($value) . "'";
271+
}
272+
public function query(): bool
273+
{
274+
// Returning false makes the existence-short-circuit fall through
275+
// to the ALTER branch (the path being tested).
276+
return false;
277+
}
278+
public function isResultSet(): bool
279+
{
280+
return false;
281+
}
282+
public function getRowsNum(): int
283+
{
284+
return 0;
285+
}
286+
public function exec(string $sql)
287+
{
288+
$this->execCalls[] = $sql;
289+
return 1;
290+
}
291+
};
292+
}
293+
};
294+
295+
$result = system_menu_install_add_category_fk($dbm);
296+
297+
$this->assertTrue($result, 'Helper must return true when the ALTER succeeds');
298+
$this->assertCount(1, $dbm->db->execCalls, 'Helper must issue exactly one ALTER');
299+
$alter = $dbm->db->execCalls[0];
300+
$this->assertStringContainsString('ALTER TABLE `xo_menusitems`', $alter);
301+
$this->assertStringContainsString('ADD CONSTRAINT `xo_fk_items_category`', $alter);
302+
$this->assertStringContainsString('FOREIGN KEY (`items_cid`)', $alter);
303+
$this->assertStringContainsString('REFERENCES `xo_menuscategory` (`category_id`)', $alter);
304+
$this->assertStringContainsString('ON DELETE CASCADE', $alter);
305+
}
306+
307+
#[Test]
308+
public function foreignKeyHelperWarnsAndReturnsFalseWhenAlterFails(): void
309+
{
310+
$dbm = new class {
311+
public object $db;
312+
public function __construct()
313+
{
314+
$this->db = new class {
315+
public int $execCallCount = 0;
316+
public function prefix(string $name): string
317+
{
318+
return 'xo_' . $name;
319+
}
320+
public function quote(string $value): string
321+
{
322+
return "'" . addslashes($value) . "'";
323+
}
324+
public function query(): bool
325+
{
326+
return false;
327+
}
328+
public function isResultSet(): bool
329+
{
330+
return false;
331+
}
332+
public function getRowsNum(): int
333+
{
334+
return 0;
335+
}
336+
public function exec(): bool
337+
{
338+
$this->execCallCount++;
339+
return false;
340+
}
341+
};
342+
}
343+
};
344+
345+
$warnings = [];
346+
set_error_handler(static function (int $errno, string $errstr) use (&$warnings): bool {
347+
$warnings[] = [$errno, $errstr];
348+
return true;
349+
});
350+
351+
try {
352+
$result = system_menu_install_add_category_fk($dbm);
353+
} finally {
354+
restore_error_handler();
355+
}
356+
357+
$this->assertFalse($result, 'Helper must return false when the ALTER fails');
358+
$this->assertSame(1, $dbm->db->execCallCount, 'Helper must attempt the ALTER exactly once');
359+
$this->assertCount(1, $warnings, 'Helper must emit exactly one E_USER_WARNING on failure');
360+
$this->assertSame(E_USER_WARNING, $warnings[0][0]);
361+
$this->assertStringContainsString('foreign key', $warnings[0][1]);
362+
}
363+
176364
#[Test]
177365
public function systemModuleRegistersInstallAndUpdateHooksToSameScript(): void
178366
{

0 commit comments

Comments
 (0)