* @git Joomla Component Builder * @copyright Copyright (C) 2015 Vast Development Method. All rights reserved. * @license GNU General Public License version 2 or later; see LICENSE.txt */ namespace VDM\Joomla\Abstraction; use Joomla\CMS\Factory; use Joomla\CMS\Version; use VDM\Joomla\Interfaces\TableInterface as Table; use VDM\Joomla\Interfaces\SchemaInterface; /** * Schema Checking * * @since 3.2.1 */ abstract class Schema implements SchemaInterface { /** * The Table Class. * * @var Table * @since 3.2.1 */ protected Table $table; /** * The Database Class * * @since 3.2.1 */ protected $db; /** * The local tables * * @var array * @since 3.2.1 */ private array $tables; /** * The component table prefix * * @var string * @since 3.2.1 */ private string $prefix; /** * The field unique keys * * @var array * @since 3.2.1 */ private array $uniqueKeys; /** * The field keys * * @var array * @since 3.2.1 */ private array $keys; /** * The current table columns * * @var array * @since 3.2.1 */ private array $columns; /** * The success messages of the action * * @var array * @since 3.2.1 */ private array $success; /** * Current Joomla Version We are IN * * @var int * @since 3.2.1 **/ protected $currentVersion; /** * Current DB Version We are IN * * @var string * @since 5.0.4 **/ protected string $dbVersion; /** * Current DB Type We are IN * * @var string * @since 5.0.4 **/ protected string $dbType; /** * Constructor. * * @param Table $table The Table Class. * * @since 3.2.1 * @throws \Exception If the database fails */ public function __construct(Table $table) { $this->table = $table; try { // set the database object $this->db = Factory::getDbo(); // current DB version $this->dbVersion = $this->db->getVersion(); // current DB type $this->dbType = $this->db->getServerType(); // get current website tables $this->tables = $this->db->getTableList(); // set the component table prefix $this->prefix = $this->db->getPrefix() . $this->getCode(); // set the current version $this->currentVersion = Version::MAJOR_VERSION; } catch (\Exception $e) { throw new \Exception("Error: failed to initialize schema class due to a database error."); } } /** * Check and update database schema for missing fields or tables. * * @return array The array of successful updates/actions, if empty no update/action was taken. * @since 3.2.1 * @throws \Exception If there is an error during the update process. */ public function update(): array { try { $this->success = [ "Success: scan of the component tables started." ]; foreach ($this->table->tables() as $table) { $this->uniqueKeys = []; $this->keys = []; if ($this->tableExists($table)) { $this->updateSchema($table); } else { $this->createTable($table); } } } catch (\Exception $e) { throw new \Exception("Error: updating database schema. " . $e->getMessage()); } if (count($this->success) == 1) { $this->success[] = "Success: scan of the component tables completed with no update needed."; } else { $this->success[] = "Success: scan of the component tables completed."; } return $this->success; } /** * Get the targeted component code * * @return string * @since 3.2.1 */ abstract protected function getCode(): string; /** * Check if a table exists in the database. * * @param string $table The name of the table to check. * * @return bool True if table exists, False otherwise. * @since 3.2.1 */ protected function tableExists(string $table): bool { return in_array($this->getTable($table), $this->tables); } /** * Update the schema of an existing table. * * @param string $table The table to update. * * @return void * @since 3.2.1 * @throws \Exception If there is an error while updating the schema. */ public function updateSchema(string $table): void { try { $existingColumns = $this->getExistingColumns($table); $expectedColumns = $this->table->fields($table, true); $missingColumns = array_diff($expectedColumns, $existingColumns); if (!empty($missingColumns)) { $this->addMissingColumns($table, $missingColumns); } $this->checkColumnsDataType($table, $expectedColumns); } catch (\Exception $e) { throw new \Exception("Error: updating schema for $table table. " . $e->getMessage()); } if (!empty($missingColumns)) { $column_s = (count($missingColumns) == 1) ? 'column' : 'columns'; $missingColumns = implode(', ', $missingColumns); $this->success[] = "Success: added missing ($missingColumns) $column_s to $table table."; } } /** * Create a table with all necessary fields. * * @param string $table The name of the table to create. * * @return void * @since 3.2.1 * @throws \Exception If there is an error creating the table. */ public function createTable(string $table): void { try { $columns = []; $fields = $this->table->fields($table, true); $createTable = 'CREATE TABLE IF NOT EXISTS ' . $this->db->quoteName($this->getTable($table)); foreach ($fields as $field) { if (($def = $this->getColumnDefinition($table, $field)) !== null) { $columns[] = $def; } } $columnDefinitions = implode(', ', $columns); $keys = $this->getTableKeys(); $createTableSql = "$createTable ($columnDefinitions, $keys)"; $this->db->setQuery($createTableSql); $this->db->execute(); } catch (\Exception $e) { throw new \Exception("Error: failed to create missing $table table. " . $e->getMessage()); } $this->success[] = "Success: created missing $table table."; } /** * Fetch existing columns from a database table. * * @param string $table The name of the table. * * @return array An array of column names. * @since 3.2.1 */ protected function getExistingColumns(string $table): array { $this->columns = $this->db->getTableColumns($this->getTable($table), false); return array_keys($this->columns); } /** * Add missing columns to a table. * * @param string $table The table to update. * @param array $columns List of missing columns/fields. * * @return void * @since 3.2.1 * @throws \Exception If there is an error adding columns. */ protected function addMissingColumns(string $table, array $columns): void { try { $query = $this->db->getQuery(true); $alterTable = 'ALTER TABLE ' . $this->db->quoteName($this->getTable($table)) . ' '; // Start an ALTER TABLE query $alterQueries = []; foreach ($columns as $column) { if (($def = $this->getColumnDefinition($table, $column)) !== null) { $alterQueries[] = " ADD " . $def; } } $this->db->setQuery($alterTable . implode(', ', $alterQueries)); $this->db->execute(); } catch (\Exception $e) { $column_s = (count($columns) == 1) ? 'column' : 'columns'; $columns = implode(', ', $columns); throw new \Exception("Error: failed to add ($columns) $column_s to $table table. " . $e->getMessage()); } } /** * Validate and update the data type of existing fields/columns * * @param string $table The table to update. * @param array $columns List of columns/fields to check. * * @return void * @since 3.2.1 */ protected function checkColumnsDataType(string $table, array $columns): void { $requireUpdate = []; foreach ($columns as $column) { $current = $this->columns[$column] ?? null; if ($current === null || ($expected = $this->table->get($table, $column, 'db')) === null) { continue; } // check if the data type and size match if ($this->isDataTypeChangeSignificant($current->Type, $expected['type'])) { $requireUpdate[$column] = [ 'column' => $column, 'current' => $current->Type, 'expected' => $expected['type'] ]; } // check if update of default values is needed if ($this->checkDefault($table, $column)) { $requireUpdate[$column] = [ 'column' => $column, 'current' => $current->Type, 'expected' => $expected['type'] ]; } // check if update of null is needed if ($this->checkNull($table, $column)) { $requireUpdate[$column] = [ 'column' => $column, 'current' => $current->Type, 'expected' => $expected['type'] ]; } } if (!empty($requireUpdate)) { $this->updateColumnsDataType($table, $requireUpdate); } } /** * Generates a SQL snippet for defining a table column, incorporating column type, * default value, nullability, and auto-increment properties. * * @param string $table The table name to be used. * @param string $field The field name in the table to generate SQL for. * * @return string|null The SQL snippet for the column definition. * @since 3.2.1 * @throws \Exception If the schema details cannot be retrieved or the SQL statement cannot be constructed properly. */ protected function getColumnDefinition(string $table, string $field): ?string { try { // Retrieve the database schema details for the specified table and field if (($db = $this->table->get($table, $field, 'db')) === null) { return null; } // Prepare the column name $column_name = $this->db->quoteName($field); $db['name'] = $field; // Prepare the type and default value SQL statement $type = $db['type'] ?? 'TEXT'; $db_default = isset($db['default']) ? $db['default'] : null; $default = $this->getDefaultValue($type, $db_default); // Prepare the null switch, and auto increment statement $null_switch = !empty($db['null_switch']) ? ' ' . $db['null_switch'] : ''; // Prepare the auto increment statement $auto_increment = !empty($db['auto_increment']) ? " AUTO_INCREMENT" : ''; // If there's a default value, the column should not be nullable if ($default !== '') { $null_switch = ''; } $this->setKeys($db); // Assemble the SQL snippet for the column definition return "{$column_name} {$type}{$null_switch}{$auto_increment}{$default}"; } catch (\Exception $e) { throw new \Exception("Error: failed to generate column definition for ($table.$field). " . $e->getMessage()); } } /** * Check and Update the default values if needed, including existing data adjustments * * @param string $table The table to update. * @param string $column The column/field to check. * * @return bool * @since 3.2.1 */ protected function checkDefault(string $table, string $column): bool { // Retrieve the expected column configuration $expected = $this->table->get($table, $column, 'db'); // Skip updates if the column is auto_increment if (isset($expected['auto_increment']) && $expected['auto_increment'] === true) { return false; } // Retrieve the current column configuration $current = $this->columns[$column]; // Determine the new default value based on the expected settings $type = $expected['type'] ?? 'TEXT'; $db_default = isset($expected['default']) ? $expected['default'] : null; $newDefault = $this->getDefaultValue($type, $db_default, true); // First, adjust existing rows to conform to the new default if necessary if (is_numeric($newDefault) && $this->adjustExistingDefaults($table, $column, $current->Default, $newDefault)) { $this->success[] = "Success: updated the ($column) defaults in $table table."; return true; } if (isset($expected['default']) && is_string($expected['default']) && strtoupper($expected['default']) === 'EMPTY' && isset($current->Default) && is_string($current->Default) && strpos($current->Default, 'EMPTY') !== false) { return true; // little fix } return false; } /** * Check and Update the null value if needed, including existing data adjustments * * @param string $table The table to update. * @param string $column The column/field to check. * * @return bool * @since 3.2.2 */ protected function checkNull(string $table, string $column): bool { // Retrieve the expected column configuration $expected = $this->table->get($table, $column, 'db'); // Skip updates if the null_switch is not set if (!isset($expected['null_switch'])) { return false; } // Retrieve the current column configuration $current = $this->columns[$column]; // Skip updates if the Null is not set if (!isset($current->Null)) { return false; } // set the expected NULL $expected_null = 'NO'; if ($expected['null_switch'] === "NULL") { $expected_null = 'YES'; } // set the current NULL $current_null = $current->Null; // Prepare the type and default value SQL statement $type = $expected['type'] ?? 'TEXT'; $db_default = isset($expected['default']) ? $expected['default'] : null; $default = $this->getDefaultValue($type, $db_default, true); // check the null values if they match if ($current_null !== $expected_null && $current_null === 'NO' && empty($default)) { $this->success[] = "Success: updated the ($column) null state in $table table."; return true; } return false; } /** * Update the data type of the given fields. * * @param string $table The table to update. * @param array $columns List of columns/fields that must be updated. * * @return void * @since 3.2.1 */ protected function updateColumnsDataType(string $table, array $columns): void { $alterTable = 'ALTER TABLE ' . $this->db->quoteName($this->getTable($table)); foreach ($columns as $column => $types) { if (($def = $this->getColumnDefinition($table, $column)) === null) { continue; } $dbField = $this->db->quoteName($column); $alterQuery = "$alterTable CHANGE $dbField ". $def; if ($this->updateColumnDataType($alterQuery, $table, $column)) { $current = $types['current'] ?? 'error'; $expected = $types['expected'] ?? 'error'; $this->success[] = "Success: updated ($column) column datatype $current to $expected in $table table."; } } } /** * Add the component name to get the full table name. * * @param string $table The table name. * * @return void * @since 3.2.1 */ protected function getTable(string $table): string { return $this->prefix . '_' . $table; } /** * Determines if the change in data type between two definitions is significant. * * This function checks if there's a significant difference between the current * data type and the expected data type that would require updating the database schema. * It ignores display width for numeric types where MySQL considers these attributes * irrelevant for storage but considers size and other modifiers for types like VARCHAR. * * @param string $currentType The current data type from the database schema. * @param string $expectedType The expected data type to validate against. * * @return bool Returns true if the data type change is significant, otherwise false. * @since 3.2.1 */ protected function isDataTypeChangeSignificant(string $currentType, string $expectedType): bool { // Normalize both input types to lowercase and remove extra spaces for comparison $currentType = strtolower(trim($currentType)); $expectedType = strtolower(trim($expectedType)); // Regex to extract the base data type and numeric parameters (size and precision) with named groups $typePattern = '/^(?\w+)(\((?\s*\d+\s*(,\s*\d+\s*)?)\))?/'; // Match types and parameters preg_match($typePattern, $currentType, $currentMatches); preg_match($typePattern, $expectedType, $expectedMatches); // Compare base types (datatype without size/precision) if ($currentMatches['datatype'] !== $expectedMatches['datatype']) { return true; // Base types differ } // Define numeric types where display width is irrelevant but precision (for DECIMAL) matters $sizeIrrelevantTypes = [ 'int', 'tinyint', 'smallint', 'mediumint', 'bigint', 'float', 'double' // Numeric types where display width is irrelevant ]; // Handle DECIMAL and NUMERIC types explicitly (precision and scale are relevant) if (in_array($currentMatches['datatype'], ['decimal', 'numeric'])) { // Extract precision and scale (if present) if ($currentMatches['params'] !== $expectedMatches['params']) { return true; // Precision or scale has changed } } // Check if the type is in the list of size-irrelevant types if (in_array($currentMatches['datatype'], $sizeIrrelevantTypes)) { // Remove irrelevant parts like display width and "unsigned" for size-irrelevant types, including extra spaces $currentBaseType = preg_replace('/\s*\(\s*\d+(\s*,\s*\d+)?\s*\)\s*|\s*unsigned\s*/', '', $currentType); $expectedBaseType = preg_replace('/\s*\(\s*\d+(\s*,\s*\d+)?\s*\)\s*|\s*unsigned\s*/', '', $expectedType); // Compare base types after normalization return $currentBaseType !== $expectedBaseType; } // For types where size is relevant (e.g., VARCHAR, CHAR, etc.), compare the full definitions // Normalize size parameters by removing extra spaces around commas, e.g., "decimal(5 , 2)" -> "decimal(5,2)" $normalizedCurrentType = preg_replace('/\s*,\s*/', ',', $currentType); $normalizedExpectedType = preg_replace('/\s*,\s*/', ',', $expectedType); // Perform a full comparison for types where size matters return $normalizedCurrentType !== $normalizedExpectedType; } /** * Updates existing rows in a column to a new default value * * @param string $table The table to update. * @param string $column The column to update. * @param mixed $currentDefault Current default value. * @param mixed $newDefault The new default value to be set. * * @return bool True if update was successful, false if no update was needed. * @since 3.2.1 * @throws \Exception If there is an error updating column defaults. */ protected function adjustExistingDefaults(string $table, string $column, $currentDefault, $newDefault): bool { // Determine if adjustment is needed based on new and current defaults if ($newDefault !== $currentDefault) { try { // Format the new default for SQL use $sqlDefault = $this->db->quote($newDefault); $updateTable = 'UPDATE ' . $this->db->quoteName($this->getTable($table)); $dbField = $this->db->quoteName($column); if (isset($this->columns[$column])) { $fieldType = strtoupper($this->columns[$column]->Type); // If the field is numeric, avoid comparing with empty string if (strpos($fieldType, 'INT') !== false || strpos($fieldType, 'FLOAT') !== false || strpos($fieldType, 'DOUBLE') !== false || strpos($fieldType, 'DECIMAL') !== false) { $whereCondition = "$dbField IS NULL OR $dbField = 0"; } else { // Default condition for non-numeric fields $whereCondition = "$dbField IS NULL OR $dbField = ''"; } // Update SQL to set new default on existing rows where the default is currently the old default $sql = $updateTable . " SET $dbField = $sqlDefault WHERE $whereCondition"; // Execute the update $this->db->setQuery($sql); return $this->db->execute(); } else { throw new \Exception("Error: Column $column does not exist in table $table."); } } catch (\Exception $e) { throw new \Exception("Error: failed to update ($column) column defaults in $table table. " . $e->getMessage()); } } return false; } /** * Update the data type of the given field. * * @param string $updateString The SQL command to update the column data type * @param string $table The table to update. * @param string $field Column/field that must be updated. * * @return bool true on succes * @since 3.2.1 * @throws \Exception If there is an error adding columns. */ protected function updateColumnDataType(string $updateString, string $table, string $field): bool { try { $this->db->setQuery($updateString); return $this->db->execute(); } catch (\Exception $e) { throw new \Exception("Error: failed to update the datatype of ($field) column in $table table. " . $e->getMessage()); } } /** * Key all needed keys for this table * * @return string of keys * @since 3.2.1 */ protected function getTableKeys(): string { $keys = []; $keys[] = 'PRIMARY KEY (`id`)'; // TODO (we may want this to be dynamicly set) if (!empty($this->uniqueKeys)) { $keys[] = implode(', ', $this->uniqueKeys); } if (!empty($this->keys)) { $keys[] = implode(', ', $this->keys); } return implode(', ', $keys); } /** * Function to set the view keys * * @param string $column The field column database array values * * @return void * @since 3.2.1 */ protected function setKeys(array $column): void { $this->setUniqueKey($column); $this->setKey($column); } /** * Function to set the unique key * * @param string $column The field column database array values * * @return void * @since 3.2.1 */ protected function setUniqueKey(array $column): void { if (isset($column['unique_key']) && $column['unique_key']) { $key = $column['unique_key_name'] ?? $column['name']; $this->uniqueKeys[] = "UNIQUE KEY `idx_" . $key . "` (`" . $column['name'] . "`)"; } } /** * Function to set the key * * @param string $column The field column database array values * * @return void * @since 3.2.1 */ protected function setKey(array $column): void { if (isset($column['key']) && $column['key']) { $key = $column['key_name'] ?? $column['name']; $this->keys[] = "KEY `idx_" . $key . "` (`" . $column['name'] . "`)"; } } /** * Adjusts the default value SQL fragment for a database field based on its type and specific rules. * * If the field is of type DATETIME and the Joomla version is not 3, it sets the default to CURRENT_TIMESTAMP * if not explicitly specified otherwise. For all other types it handles defaults by either leaving them unset or applying * the provided default, properly quoted for SQL safety. When a 'EMPTY' default is specified, it returns no default at all. (:) * * @param string $type The type of the database field (e.g., 'DATETIME'). * @param string|null $defaultValue Optional default value for the field, null if not provided. * @param bool $pure Optional to add the 'DEFAULT' string or not. * * @return string The SQL fragment to set the default value for a field. * @since 3.2.1 * @throws \RuntimeException If the database unsupported */ protected function getDefaultValue(string $type, ?string $defaultValue, bool $pure = false): string { if ($defaultValue === null || strtoupper($defaultValue) === 'EMPTY') { return ''; } // Logic to handle DATETIME default values based on database type if (strtoupper($type) === 'DATETIME') { if ($this->dbType === 'mysql') { // MySQL-specific logic if (version_compare($this->dbVersion, '5.6', '>=')) { return $pure ? "CURRENT_TIMESTAMP" : " DEFAULT CURRENT_TIMESTAMP"; } else { return $pure ? "'0000-00-00 00:00:00'" : " DEFAULT '0000-00-00 00:00:00'"; } } elseif ($this->dbType === 'pgsql') { // PostgreSQL supports CURRENT_TIMESTAMP universally return $pure ? "CURRENT_TIMESTAMP" : " DEFAULT CURRENT_TIMESTAMP"; } else { // Unsupported database type (at this point... we can grow this area) throw new \RuntimeException("Unsupported database type: {$dbType}"); } } // Apply and quote the default value $sql_default = $this->quote($defaultValue); return $pure ? $defaultValue : " DEFAULT $sql_default"; } /** * Set a value based on data type * * @param mixed $value The value to set * * @return mixed * @since 3.2.0 **/ protected function quote($value) { if ($value === null) // hmm the null does pose an issue (will keep an eye on this) { return 'NULL'; } if (is_numeric($value)) { if (filter_var($value, FILTER_VALIDATE_INT)) { return (int) $value; } elseif (filter_var($value, FILTER_VALIDATE_FLOAT)) { return (float) $value; } } elseif (is_bool($value)) // not sure if this will work well (but its correct) { return $value ? 'TRUE' : 'FALSE'; } // For date and datetime values elseif ($value instanceof \DateTime) { return $this->db->quote($value->format('Y-m-d H:i:s')); } // For other data types, just escape it return $this->db->quote($value); } }