diff --git a/.editorconfig b/.editorconfig index 7cc1bfc03..6a1471c08 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,4 +12,4 @@ trim_trailing_whitespace = true max_line_length = 120 [*.md] -trim_trailing_whitespace = false +trim_trailing_whitespace = false \ No newline at end of file diff --git a/app/Controllers/Attributes.php b/app/Controllers/Attributes.php index e032563d8..c885e4b90 100644 --- a/app/Controllers/Attributes.php +++ b/app/Controllers/Attributes.php @@ -78,9 +78,9 @@ class Attributes extends Secure_Controller * @return void * @noinspection PhpUnused */ - public function postDelete_attribute_value(): void + public function postDeleteDropdownAttributeValue(): void { - $success = $this->attribute->delete_value( + $success = $this->attribute->deleteDropdownAttributeValue( html_entity_decode($this->request->getPost('attribute_value')), $this->request->getPost('definition_id', FILTER_SANITIZE_NUMBER_INT) ); @@ -215,18 +215,6 @@ class Attributes extends Secure_Controller echo view('attributes/form', $data); } - /** - * AJAX called function to delete an attribute value. This is called when a dropdown item is removed. - * - * @param string $attribute_value - * @return bool - * @noinspection PhpUnused - */ - public function delete_value(string $attribute_value): bool - { - return $this->attribute->delete_value($attribute_value, NO_DEFINITION_ID); - } - /** * Deletes an attribute definition * @return void @@ -235,7 +223,7 @@ class Attributes extends Secure_Controller { $attributes_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS); - if ($this->attribute->delete_definition_list($attributes_to_delete)) { + if($this->attribute->deleteDefinitionList($attributes_to_delete)) { $message = lang('Attributes.definition_successful_deleted') . ' ' . count($attributes_to_delete) . ' ' . lang('Attributes.definition_one_or_multiple'); echo json_encode(['success' => true, 'message' => $message]); } else { diff --git a/app/Controllers/Config.php b/app/Controllers/Config.php index b54cee54c..9f1da31f3 100644 --- a/app/Controllers/Config.php +++ b/app/Controllers/Config.php @@ -402,7 +402,7 @@ class Config extends Secure_Controller $this->attribute->save_definition($definition_data, CATEGORY_DEFINITION_ID); } elseif ($batch_save_data['category_dropdown'] == NO_DEFINITION_ID) { - $this->attribute->delete_definition(CATEGORY_DEFINITION_ID); + $this->attribute->deleteDefinition(CATEGORY_DEFINITION_ID); } $success = $this->appconfig->batch_save($batch_save_data); diff --git a/app/Database/Migrations/20210422000001_remove_duplicate_links.php b/app/Database/Migrations/20210422000001_remove_duplicate_links.php index ba3fc7d07..df4d016f4 100644 --- a/app/Database/Migrations/20210422000001_remove_duplicate_links.php +++ b/app/Database/Migrations/20210422000001_remove_duplicate_links.php @@ -35,6 +35,7 @@ class Migration_remove_duplicate_links extends Migration $builder->select('item_id, definition_id, attribute_id, COUNT(*) as count'); $builder->where('sale_id', null); $builder->where('receiving_id', null); + $builder->where('item_id IS NOT NULL'); $builder->groupBy('item_id'); $builder->groupBy('definition_id'); $builder->groupBy('attribute_id'); diff --git a/app/Database/Migrations/20240630000001_fix_keys_for_db_upgrade.php b/app/Database/Migrations/20240630000001_fix_keys_for_db_upgrade.php index fb215c324..b25676aa0 100644 --- a/app/Database/Migrations/20240630000001_fix_keys_for_db_upgrade.php +++ b/app/Database/Migrations/20240630000001_fix_keys_for_db_upgrade.php @@ -12,10 +12,22 @@ class Migration_fix_keys_for_db_upgrade extends Migration */ public function up(): void { - $this->db->query("ALTER TABLE `ospos_tax_codes` MODIFY `deleted` tinyint(1) DEFAULT 0 NOT NULL;"); + helper('migration'); - if (!$this->index_exists('ospos_customers', 'company_name')) { - $this->db->query("ALTER TABLE `ospos_customers` ADD INDEX(`company_name`)"); + $forge = Database::forge(); + $fields = [ + 'deleted' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'null' => false, + ], + ]; + $forge->modifyColumn('tax_codes', $fields); + + if (!indexExists('customers', 'company_name')) { + $forge->addKey('company_name', false, false, 'company_name'); + $forge->processIndexes('customers'); } $checkSql = "SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = '" . $this->db->prefixTable('sales_items_taxes') . "' AND CONSTRAINT_NAME = 'ospos_sales_items_taxes_ibfk_1'"; @@ -29,10 +41,9 @@ class Migration_fix_keys_for_db_upgrade extends Migration . ' ADD CONSTRAINT ospos_sales_items_taxes_ibfk_1 FOREIGN KEY (sale_id, item_id, line) ' . ' REFERENCES ' . $this->db->prefixTable('sales_items') . ' (sale_id, item_id, line)'); - - $this->create_primary_key('customers', 'person_id'); - $this->create_primary_key('employees', 'person_id'); - $this->create_primary_key('suppliers', 'person_id'); + createPrimaryKey('customers', 'person_id'); + createPrimaryKey('employees', 'person_id'); + createPrimaryKey('suppliers', 'person_id'); } /** @@ -51,31 +62,4 @@ class Migration_fix_keys_for_db_upgrade extends Migration . ' ADD CONSTRAINT ospos_sales_items_taxes_ibfk_1 FOREIGN KEY (sale_id) ' . ' REFERENCES ' . $this->db->prefixTable('sales_items') . ' (sale_id)'); } - - private function create_primary_key(string $table, string $index): void - { - $result = $this->db->query('SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name= \'' . $this->db->getPrefix() . "$table' AND column_key = '$index'"); - - if (! $result->getRowArray()) { - $this->delete_index($table, $index); - $forge = Database::forge(); - $forge->addPrimaryKey($table, ''); - } - } - - private function index_exists(string $table, string $index): bool - { - $result = $this->db->query('SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = \'' . $this->db->getPrefix() . "$table' AND index_name = '$index'"); - $row_array = $result->getRowArray(); - return $row_array && $row_array['COUNT(*)'] > 0; - } - - private function delete_index(string $table, string $index): void - { - - if ($this->index_exists($table, $index)) { - $forge = Database::forge(); - $forge->dropKey($table, $index, FALSE); - } - } } diff --git a/app/Database/Migrations/20240826000000_fix_duplicate_attributes.php b/app/Database/Migrations/20240826000000_fix_duplicate_attributes.php index 4f714de80..04c86fb3d 100644 --- a/app/Database/Migrations/20240826000000_fix_duplicate_attributes.php +++ b/app/Database/Migrations/20240826000000_fix_duplicate_attributes.php @@ -26,7 +26,7 @@ class fix_duplicate_attributes extends Migration 'ospos_attribute_links_ibfk_5' ]; - drop_foreign_key_constraints($foreignKeys, 'ospos_attribute_links'); + dropForeignKeyConstraints($foreignKeys, 'attribute_links'); execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.0_attribute_links_unique_constraint.sql'); } diff --git a/app/Database/Migrations/20250213000000_fix_attributes_cascading_delete.php b/app/Database/Migrations/20250213000000_fix_attributes_cascading_delete.php index 01d15a7b4..b95bd47e7 100644 --- a/app/Database/Migrations/20250213000000_fix_attributes_cascading_delete.php +++ b/app/Database/Migrations/20250213000000_fix_attributes_cascading_delete.php @@ -14,7 +14,12 @@ class Migration_Attributes_fix_cascading_delete extends Migration public function up(): void { helper('migration'); - drop_foreign_key_constraints(['ospos_attribute_links_ibfk_1', 'ospos_attribute_links_ibfk_2'], 'ospos_attribute_links'); + + $this->db->query("ALTER TABLE `ospos_attribute_links` DROP INDEX `attribute_links_uq3`"); + $this->db->query("ALTER TABLE `ospos_attribute_links` DROP COLUMN `generated_unique_column`"); + + dropForeignKeyConstraints(['ospos_attribute_links_ibfk_1', 'ospos_attribute_links_ibfk_2'], 'attribute_links'); + $this->db->query("ALTER TABLE `ospos_attribute_links` ADD CONSTRAINT `ospos_attribute_links_ibfk_1` FOREIGN KEY (`definition_id`) REFERENCES `ospos_attribute_definitions` (`definition_id`) ON DELETE CASCADE;"); $this->db->query("ALTER TABLE `ospos_attribute_links` ADD CONSTRAINT `ospos_attribute_links_ibfk_2` FOREIGN KEY (`attribute_id`) REFERENCES `ospos_attribute_values` (`attribute_id`) ON DELETE CASCADE;"); } diff --git a/app/Database/Migrations/20250425000000_sessions_migration.php b/app/Database/Migrations/20250425000000_sessions_migration.php new file mode 100644 index 000000000..93cb48edc --- /dev/null +++ b/app/Database/Migrations/20250425000000_sessions_migration.php @@ -0,0 +1,28 @@ +addKey($columns, false, true, 'attribute_links_uq2'); + $forge->processIndexes('attribute_links'); + } + + if (!indexExists('inventory', 'trans_items_trans_date')) { + $forge->addKey(['trans_items', 'trans_date'], false, false, 'trans_items_trans_date'); + $forge->processIndexes('inventory'); + } + + error_log('Migrating Optimization Indices'); + } + + /** + * Revert a migration step. + */ + public function down(): void + { + } +} diff --git a/app/Database/Migrations/20250522000000_AttributeLinksUniqueConstraint.php b/app/Database/Migrations/20250522000000_AttributeLinksUniqueConstraint.php new file mode 100644 index 000000000..16b179df5 --- /dev/null +++ b/app/Database/Migrations/20250522000000_AttributeLinksUniqueConstraint.php @@ -0,0 +1,26 @@ +getPrefix(); - $db->setPrefix(''); - $database_name = $db->database; + $forge = Database::forge(); foreach ($foreignKeys as $fk) { - $builder = $db->table('INFORMATION_SCHEMA.TABLE_CONSTRAINTS'); - $builder->select('CONSTRAINT_NAME'); - $builder->where('TABLE_SCHEMA', $database_name); - $builder->where('TABLE_NAME', $table); - $builder->where('CONSTRAINT_TYPE', 'FOREIGN KEY'); - $builder->where('CONSTRAINT_NAME', $fk); - $query = $builder->get(); + if(foreignKeyExists($fk, $table)) { + $forge->dropForeignKey($table, $fk); + } + } +} - if ($query->getNumRows() > 0) { - $db->query("ALTER TABLE `$table` DROP FOREIGN KEY `$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(); + $forge->addPrimaryKey($index,'PRIMARY'); + $forge->processIndexes($table); + recreateForeignKeyConstraints($constraints); + } +} + +/** + * Drops all foreign key constraints that reference the provided table and column. + * + * @param string $table + * @param string $column + * @return array containing the deleted constraints in case they need to be recreated after. + */ + +function dropAllForeignKeyConstraints(string $table, string $column): 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')) + "); + + $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']); } } - $db->setPrefix($current_prefix); + 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 + * @return bool + */ +function indexExists(string $table, string $index): 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'"); + $row_array = $result->getRowArray(); + + return $row_array && $row_array['COUNT(*)'] > 0; +} + +function primaryKeyExists(string $table): 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'"); + $row_array = $result->getRowArray(); + + return $row_array && $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; } diff --git a/app/Models/Attribute.php b/app/Models/Attribute.php index c36b21f4b..2787d7288 100644 --- a/app/Models/Attribute.php +++ b/app/Models/Attribute.php @@ -40,6 +40,21 @@ class Attribute extends Model public const SHOW_IN_ITEMS = 1; // TODO: These need to be moved to constants.php public const SHOW_IN_SALES = 2; public const SHOW_IN_RECEIVINGS = 4; + public function deleteDropdownAttributeValue(string $attribute_value, int $definition_id): bool + { + $attribute_id = $this->getAttributeIdByValue($attribute_value); + $this->deleteAttributeLinksByDefinitionIdAndAttributeId($definition_id, $attribute_id); + + //Delete attribute value if not linked other attributes + $subQuery = $this->db->table('attribute_links'); + $subQuery->select('attribute_id'); + + $builder = $this->db->table('attribute_values'); + $builder->where('attribute_value', $attribute_value); + $builder->whereNotIn('attribute_id', $subQuery); + + return $builder->delete(); + } /** * @return array @@ -60,7 +75,7 @@ class Attribute extends Model $builder->where('definition_id', $definition_id); $builder->where('deleted', $deleted); - return ($builder->get()->getNumRows() == 1); // TODO: === + return ($builder->get()->getNumRows() === 1); } /** @@ -842,29 +857,16 @@ class Attribute extends Model return $attribute_id; } - /** - * @param string $attribute_value - * @param int $definition_id - * @return bool - */ - public function delete_value(string $attribute_value, int $definition_id): bool - { - // QueryBuilder does not support multi-table delete. - $query = 'DELETE atrv, atrl '; - $query .= 'FROM ' . $this->db->prefixTable('attribute_values') . ' atrv, ' . $this->db->prefixTable('attribute_links') . ' atrl '; - $query .= 'WHERE atrl.attribute_id = atrv.attribute_id AND atrv.attribute_value = ' . $this->db->escape($attribute_value); - $query .= ' AND atrl.definition_id = ' . $this->db->escape($definition_id); - return $this->db->query($query); - } - /** * Deletes an Attribute definition from the database and associated column in the items_import.csv * * @param int $definition_id Attribute definition ID to remove. * @return boolean true if successful and false if there is a failure */ - public function delete_definition(int $definition_id): bool + public function deleteDefinition(int $definition_id): bool { + $this->deleteAttributeLinksByDefinitionId($definition_id); + $builder = $this->db->table('attribute_definitions'); $builder->where('definition_id', $definition_id); @@ -875,14 +877,33 @@ class Attribute extends Model * @param array $definition_ids * @return bool */ - public function delete_definition_list(array $definition_ids): bool + public function deleteDefinitionList(array $definition_ids): bool { + $this->deleteAttributeLinksByDefinitionId($definition_ids); + $builder = $this->db->table('attribute_definitions'); $builder->whereIn('definition_id', $definition_ids); return $builder->update(['deleted' => DELETED]); } + /** + * Deletes attribute links by definition ID + * + * @param int|array $definition_id + */ + public function deleteAttributeLinksByDefinitionId(int|array $definition_id): void + { + if(!is_array($definition_id)) + { + $definition_id = [$definition_id]; + } + + $builder = $this->db->table('attribute_links'); + $builder->whereIn('definition_id', $definition_id); + $builder->delete(); + } + /** * Deletes any attribute_links for a specific definition that do not have an item_id associated with them and are not DROPDOWN types * @@ -987,4 +1008,34 @@ class Attribute extends Model return $builder->get()->getResultArray(); } + + /** + * @param string $attribute_value + * @return int + */ + private function getAttributeIdByValue(string $attribute_value): int + { + $builder = $this->db->table('attribute_values'); + $builder->select('attribute_id'); + $builder->where('attribute_value', $attribute_value); + return $builder->get()->getRow('attribute_id'); + } + + /** + * Deletes Attribute Links associated with a specific definition ID and attribute ID. + * Does not delete rows where sale_id or receiving_id has a value to retain records. + * + * @param int $definitionId + * @param int $attributeId + * @return \CodeIgniter\Database\BaseBuilder + */ + private function deleteAttributeLinksByDefinitionIdAndAttributeId(int $definitionId, int $attributeId): void + { + $builder = $this->db->table('attribute_links'); + $builder->where('sale_id', null); + $builder->where('receiving_id', null); + $builder->where('definition_id', $definitionId); + $builder->where('attribute_id', $attributeId); + $builder->delete(); + } } diff --git a/app/Views/attributes/form.php b/app/Views/attributes/form.php index 552d59b7f..8e216af8e 100644 --- a/app/Views/attributes/form.php +++ b/app/Views/attributes/form.php @@ -160,7 +160,7 @@ if (is_new) { values.splice($.inArray(value, values), 1); } else { - $.post('', { + $.post('', { definition_id: definition_id, attribute_value: value });