Files
opensourcepos/app/Helpers/migration_helper.php
T
jekkosandobjecttothis 9fef0bd88f fix(models): handle query() returning false in result-consuming methods (#4734)
* fix(models): handle query() returning false in result-consuming methods

As of CI 4.1.2, Connection::query() returns false when a query fails
(previously an empty result object). Guard the model/helper methods that
consume the result so a failed query degrades to an empty result instead
of throwing "Call to member function getResultArray() on false".

- Tax::get_taxes()
- Sale::get_all_suspended()
- Reports\Inventory_low::getData()
- migration_helper: dropAllForeignKeyConstraints(), indexExists(),
  primaryKeyExists()

Adds QueryFalseGuardTest forcing query() to return false. Closes #3634.

* test(query-false): mock concrete driver instead of abstract BaseConnection

onlyMethods() does not stub the 16 abstract methods on BaseConnection, so
mocking it produced an invalid double. Mock the concrete driver class
(resolved from the live test connection) instead.

* fix(3634): fail loudly when migration metadata queries return false

Model read methods degrade to an empty result on a failed query, but the
migration metadata helpers (indexExists, primaryKeyExists,
dropAllForeignKeyConstraints) must not mistake a failed query for 'not
found' and proceed with schema changes. They now throw a DatabaseException
so the migration aborts loudly.

- Add an optional injectable BaseConnection param for testability.
- Add missing docstrings (docstring-coverage check).
- Add regression tests asserting the helpers throw on a failed query.

* fix(3634): use supplied connection for FK drop; stub error() in tests

Address CodeRabbit review: dropAllForeignKeyConstraints now builds its
Forge from the supplied connection (consistent with the lookup) instead of
always the default one. Test mock also stubs error() to avoid a warning on
the constructor-disabled connection.

* fix(3634): revert FK drop to Database::forge() (driver-specific forge)

new Forge($db) creates the base Forge which does not implement
dropForeignKey for the driver ('This feature is not available'), breaking
migrations. Restore Database::forge(), which loads the driver-specific
Forge via the factory. The optional connection param is retained solely
for testing the fail-loud behavior on the lookup path.

* test(3634): add regression test for Inventory_low::getData query-failure guard

Covers the low-stock report's degrade-to-empty behavior on a failed query,
addressing the CodeRabbit note that it lacked a focused test.

* test(3634): adopt test{Method}_{Purpose} naming convention

Align QueryFalseGuardTest method names with the convention from #4730
(testGetTaxes_ReturnsEmptyArrayWhenQueryFails, etc.), per review.

* test: split QueryFalseGuardTest into per-component test files

Apply CodeRabbit review: extract the shared failing-connection helpers
into tests/Support/FailedQueryConnectionTrait.php and move each test to
its component's test file (TaxTest, SaleTest, Inventory_lowTest,
migration_helperTest). Use fresh model instances instead of the shared
model() singleton so the injected failing connection does not leak into
other tests.

* style(tests): remove redundant docblocks from query-failure regression tests

---------

Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-10-08 22:30:16 +02:00

361 lines
11 KiB
PHP

<?php
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Database\Exceptions\DatabaseException;
use Config\Database;
/**
* Executes a SQL migration script.
* @param string $path Path to migration script.
* @param bool $withTransaction Whether to wrap execution in a transaction.
* @return bool Whether the migration executed successfully.
*/
function executeScript(string $path, bool $withTransaction = false): bool
{
$version = explode('_', basename($path, '.sql'), 2)[0];
log_message('info', "Migrating to $version (file: $path)");
$sqls = explode(';', file_get_contents($path));
array_pop($sqls);
$db = Database::connect();
if ($withTransaction) {
$db->transStart();
}
$success = true; // whether *all* queries succeeded
try {
foreach ($sqls as $statement) {
$statement = "$statement;";
$hadError = $withTransaction ? !$db->query($statement) : !$db->simpleQuery($statement);
if ($hadError) {
$success = false;
foreach ($db->error() as $error) {
log_message('error', "error: $error");
}
}
}
} catch (Exception $e) {
log_message('error', "Could not migrate to $version: " . $e->getMessage());
if ($withTransaction) {
$db->transRollback();
}
return false;
}
if ($success) {
log_message('info', "Successfully migrated to $version");
} else {
log_message('info', "Could not migrate to $version.");
}
if ($withTransaction) {
$db->transComplete();
}
return $success;
}
/**
* Drops provided foreign key constraints from a given table if the constraint exists.
* This is required to successfully create the generated unique constraint.
*
* @param array $foreignKeys names of the foreign key constraints to drop
* @param string $table name of the table the foreign key constraints are on
* @return void
*/
function dropForeignKeyConstraints(array $foreignKeys, string $table): void
{
$db = Database::connect();
$forge = Database::forge();
foreach ($foreignKeys as $fk) {
if(foreignKeyExists($fk, $table)) {
$forge->dropForeignKey($table, $fk);
}
}
}
/**
* Removes the database prefix from the current database connection.
* TODO: This function should be moved to a more global location since it may be needed outside of migrations.
* @return string The prefix before overriding.
*/
function overridePrefix(string $prefix = ''): string {
$db = Database::connect();
$originalPrefix = $db->getPrefix();
$db->setPrefix($prefix);
return $originalPrefix;
}
/**
* Creates a primary key on the specified table and index column.
*
* @param string $table
* @param string $index
* @return void
*/
function createPrimaryKey(string $table, string $index): void {
if (! primaryKeyExists($table)) {
$constraints = dropAllForeignKeyConstraints($table, $index);
deleteIndex($table, $index);
$forge = Database::forge();
if (isMariaDb()) {
$forge->addPrimaryKey($index);
} else {
$forge->addPrimaryKey($index, 'PRIMARY');
}
$forge->processIndexes($table);
recreateForeignKeyConstraints($constraints);
}
}
/**
* Drops all foreign key constraints that reference the provided table and column.
*
* A failed metadata query is treated as an error (thrown) rather than mistaken
* for "no constraints found", so callers never proceed with schema changes on a
* wrong assumption.
*
* @param string $table
* @param string $column
* @param BaseConnection|null $db Connection to use (defaults to the default connection).
*
* @return array containing the deleted constraints in case they need to be recreated after.
*
* @throws DatabaseException If the metadata query fails.
*/
function dropAllForeignKeyConstraints(string $table, string $column, ?BaseConnection $db = null): array {
$db ??= Database::connect();
$result = $db->query("
SELECT DISTINCT
kcu.CONSTRAINT_NAME,
kcu.TABLE_NAME,
kcu.COLUMN_NAME,
kcu.REFERENCED_TABLE_NAME,
kcu.REFERENCED_COLUMN_NAME,
rc.DELETE_RULE,
rc.UPDATE_RULE
FROM information_schema.KEY_COLUMN_USAGE kcu
LEFT JOIN information_schema.REFERENTIAL_CONSTRAINTS rc
ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
AND kcu.TABLE_NAME = rc.TABLE_NAME
WHERE kcu.TABLE_SCHEMA = DATABASE()
AND ((kcu.REFERENCED_TABLE_NAME = '" . $db->getPrefix() . "$table' AND kcu.REFERENCED_COLUMN_NAME = '$column')
OR (kcu.TABLE_NAME = '" . $db->getPrefix() . "$table' AND kcu.COLUMN_NAME = '$column'))
AND rc.CONSTRAINT_NAME IS NOT NULL
");
if ($result === false) {
$detail = 'unknown error';
try {
$error = $db->error();
if (is_array($error) && !empty($error['message'])) {
$detail = (string) $error['message'];
}
} catch (\Throwable $e) {
// A disconnected or mocked connection may not support error(); keep the default.
}
throw new DatabaseException(sprintf(
'Failed to look up foreign key constraints referencing table "%s" column "%s": %s',
$table, $column, $detail
));
}
$deletedConstraints = [];
foreach ($result->getResultArray() as $constraint) {
$deletedConstraints[] = [
'constraintName' => $constraint['CONSTRAINT_NAME'],
'tableName' => str_replace($db->DBPrefix, '', $constraint['TABLE_NAME']),
'columnName' => $constraint['COLUMN_NAME'],
'referencedTable' => str_replace($db->DBPrefix, '', $constraint['REFERENCED_TABLE_NAME']),
'referencedColumn' => $constraint['REFERENCED_COLUMN_NAME'],
'onDelete' => $constraint['DELETE_RULE'],
'onUpdate' => $constraint['UPDATE_RULE'],
];
}
if ($deletedConstraints) {
$forge = Database::forge();
foreach ($deletedConstraints as $foreignKey) {
$forge->dropForeignKey($foreignKey['tableName'], $foreignKey['constraintName']);
}
}
return $deletedConstraints;
}
/**
* Deletes the specified index from the specified table.
*
* @param string $table
* @param string $index
* @return void
*/
function deleteIndex(string $table, string $index): void {
if (indexExists($table, $index)) {
$forge = Database::forge();
$forge->dropKey($table, $index, FALSE);
}
}
/**
* Checks if the specified index exists on the specified table.
*
* @param string $table
* @param string $index
* @param BaseConnection|null $db Connection to use (defaults to the default connection).
*
* @return bool
*
* @throws DatabaseException If the metadata query fails.
*/
function indexExists(string $table, string $index, ?BaseConnection $db = null): bool {
$db ??= Database::connect();
$result = $db->query('SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = \'' . $db->getPrefix() . "$table' AND index_name = '$index'");
if ($result === false) {
$detail = 'unknown error';
try {
$error = $db->error();
if (is_array($error) && !empty($error['message'])) {
$detail = (string) $error['message'];
}
} catch (\Throwable $e) {
// A disconnected or mocked connection may not support error(); keep the default.
}
throw new DatabaseException(sprintf(
'Failed to check whether index "%s" exists on table "%s": %s',
$index, $table, $detail
));
}
$row_array = $result->getRowArray();
return $row_array !== null && $row_array['COUNT(*)'] > 0;
}
/**
* Checks if the specified table has a primary key.
*
* @param string $table
* @param BaseConnection|null $db Connection to use (defaults to the default connection).
*
* @return bool
*
* @throws DatabaseException If the metadata query fails.
*/
function primaryKeyExists(string $table, ?BaseConnection $db = null): bool {
$db ??= Database::connect();
$result = $db->query('SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema = DATABASE() AND table_name = \'' . $db->getPrefix() . "$table' AND constraint_type = 'PRIMARY KEY'");
if ($result === false) {
$detail = 'unknown error';
try {
$error = $db->error();
if (is_array($error) && !empty($error['message'])) {
$detail = (string) $error['message'];
}
} catch (\Throwable $e) {
// A disconnected or mocked connection may not support error(); keep the default.
}
throw new DatabaseException(sprintf(
'Failed to check whether table "%s" has a primary key: %s',
$table, $detail
));
}
$row_array = $result->getRowArray();
return $row_array !== null && $row_array['COUNT(*)'] > 0;
}
function recreateForeignKeyConstraints(array $constraints): void {
if ($constraints) {
$forge = Database::forge();
foreach ($constraints as $constraint) {
$forge->addForeignKey($constraint['columnName'], $constraint['referencedTable'], $constraint['referencedColumn'], $constraint['onUpdate'], $constraint['onDelete'], $constraint['constraintName']);
$forge->processIndexes($constraint['tableName']);
}
}
}
/**
* Checks if a foreign key constraint exists in the specified table.
*
* @param string $constraintName
* @param string $tableName
* @return bool true when the constraint exists, false otherwise.
*/
function foreignKeyExists(string $constraintName, string $tableName): bool {
$prefix = overridePrefix();
$db = Database::connect();
$builder = $db->table('INFORMATION_SCHEMA.TABLE_CONSTRAINTS');
$builder->select('CONSTRAINT_NAME');
$builder->where('TABLE_SCHEMA', $db->database);
$builder->where('TABLE_NAME', $prefix . $tableName);
$builder->where('CONSTRAINT_TYPE', 'FOREIGN KEY');
$builder->where('CONSTRAINT_NAME', $constraintName);
$query = $builder->get();
overridePrefix($prefix);
return $query->getNumRows() > 0;
}
/**
* Drops a column from a table if it exists.
*
* @param string $table The name of the table.
* @param string $column The name of the column to drop.
* @return void
*/
function dropColumnIfExists(string $table, string $column): void
{
$prefix = overridePrefix();
$db = Database::connect();
$builder = $db->table('information_schema.COLUMNS');
// Check if the column exists in the table
$builder->select('COLUMN_NAME')
->where('TABLE_SCHEMA', $db->database)
->where('TABLE_NAME', $prefix . $table)
->where('COLUMN_NAME', $column);
$query = $builder->get();
if ($query->getNumRows() > 0)
{
// Drop the column if it exists
$db->query("ALTER TABLE `" . $prefix . "$table` DROP COLUMN `$column`");
}
overridePrefix($prefix);
}
/**
* Checks if the current database is MariaDB.
*
* @return bool true if the database is MariaDB, false otherwise.
*/
function isMariaDb(): bool
{
$db = Database::connect();
$version = $db->getVersion();
return stripos($version, 'mariadb') !== false;
}