Repository: opensourcepos/opensourcepos
Pull request #4230: Bugfix constraint migration fix
Author: login: objecttothis
Created: 2025-04-21T07:46:05Z | Merged: 2025-05-29T11:24:09Z

- Migrate Sessions table to prevent problems with migrating populated databases.
- Refactor a few function names for PSR-12 compliance.
- Fix bug in migration causing failure due to trying to drop indexes with associated foreign key constraints. This bug was causing 3.3.2 databases to fail migration. #4202 
- migration to change constraint back to ON DELETE RESTRICT
20250213000000_fix_attributes_cascading_delete.php reverted a change on the ospos_attribute_links_ibfk_1 and ospos_attribute_links_ibfk_2 constraints on the ospos_attribute_links table to fix a bug with not being able to delete a dropdown value. The problem is that these need to be ON DELETE RESTRICT in order to use generated_unique_column and attribute_links_uq3 to enforce no duplication of attribute links.  The solution to this (in this PR) is to change them back to ON DELETE RESTRICT and programmatically cascade delete before trying to remove an entry.  This way we can enforce uniqueness AND deleting dropdown rows.
- Add indices to ospos_inventory and ospos_attribute_links which were causing the items view queries to time out when attribute_links was large and the hardware wasn't fast.

closing #4237
closing #4251


Diff:
diff --git a/.editorconfig b/.editorconfig
index 7cc1bfc031..6a1471c08c 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 e032563d86..c885e4b909 100644
--- a/app/Controllers/Attributes.php
+++ b/app/Controllers/Attributes.php
@@ -78,9 +78,9 @@ public function postSaveAttributeValue(): void
      * @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 @@ public function getView(int $definition_id = NO_DEFINITION_ID): void
         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 @@ public function postDelete(): void
     {
         $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 b54cee54c3..9f1da31f39 100644
--- a/app/Controllers/Config.php
+++ b/app/Controllers/Config.php
@@ -402,7 +402,7 @@ public function postSaveGeneral(): void
 
             $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 ba3fc7d07f..df4d016f45 100644
--- a/app/Database/Migrations/20210422000001_remove_duplicate_links.php
+++ b/app/Database/Migrations/20210422000001_remove_duplicate_links.php
@@ -35,6 +35,7 @@ private function migrate_duplicate_attribute_links(): void
         $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 fb215c3242..b25676aa01 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;");
-
-        if (!$this->index_exists('ospos_customers', 'company_name')) {
-            $this->db->query("ALTER TABLE `ospos_customers` ADD INDEX(`company_name`)");
+        helper('migration');
+
+        $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 @@ public function up(): void
             . ' 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 @@ public function down(): void
             . ' 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 4f714de80c..04c86fb3dd 100644
--- a/app/Database/Migrations/20240826000000_fix_duplicate_attributes.php
+++ b/app/Database/Migrations/20240826000000_fix_duplicate_attributes.php
@@ -26,7 +26,7 @@ public function up(): void
             '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 01d15a7b4a..b95bd47e79 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 0000000000..93cb48edc7
--- /dev/null
+++ b/app/Database/Migrations/20250425000000_sessions_migration.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Database\Migrations;
+
+use CodeIgniter\Database\Migration;
+
+class Migration_sessions_migration extends Migration
+{
+    /**
+     * Perform a migration step.
+     */
+    public function up(): void
+    {
+        error_log('Migrating sessions table');
+
+        helper('migration');
+        execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.1_migrate_sessions_table.sql');
+
+        error_log('Migrating sessions table');
+    }
+
+    /**
+     * Revert a migration step.
+     */
+    public function down(): void
+    {
+    }
+}
diff --git a/app/Database/Migrations/20250519000000_MigrationOptimizationIndices.php b/app/Database/Migrations/20250519000000_MigrationOptimizationIndices.php
new file mode 100644
index 0000000000..8e4762be78
--- /dev/null
+++ b/app/Database/Migrations/20250519000000_MigrationOptimizationIndices.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Database\Migrations;
+
+use CodeIgniter\Database\Migration;
+use Config\Database;
+
+class MigrationOptimizationIndices extends Migration
+{
+    /**
+     * Perform a migration step.
+     */
+    public function up(): void
+    {
+        error_log('Migrating Optimization Indices');
+
+        helper('migration');
+        $forge = Database::forge();
+
+        if (!indexExists('attribute_links', 'attribute_links_uq2')) {
+            $columns = [
+                'item_id',
+                'receiving_id',
+                'sale_id',
+                'definition_id',
+                'attribute_id'
+            ];
+            $forge->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 0000000000..16b179df5d
--- /dev/null
+++ b/app/Database/Migrations/20250522000000_AttributeLinksUniqueConstraint.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace App\Database\Migrations;
+
+use CodeIgniter\Database\Migration;
+use Config\Database;
+
+class AttributeLinksUniqueConstraint extends Migration
+{
+    /**
+     * Perform a migration step.
+     */
+    public function up(): void
+    {
+        error_log('Migrating attribute_links unique constraint started');
+
+        execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.1_attribute_links_unique_constraint.sql');
+    }
+
+    /**
+     * Revert a migration step.
+     */
+    public function down(): void
+    {
+    }
+}
diff --git a/app/Database/Migrations/sqlscripts/3.4.1_attribute_links_unique_constraint.sql b/app/Database/Migrations/sqlscripts/3.4.1_attribute_links_unique_constraint.sql
new file mode 100644
index 0000000000..d573c65852
--- /dev/null
+++ b/app/Database/Migrations/sqlscripts/3.4.1_attribute_links_unique_constraint.sql
@@ -0,0 +1,16 @@
+ALTER TABLE `ospos_attribute_links` DROP CONSTRAINT `ospos_attribute_links_ibfk_1`;
+ALTER TABLE `ospos_attribute_links` DROP CONSTRAINT `ospos_attribute_links_ibfk_2`;
+
+# Prevents duplicate attribute links with the same definition_id and item_id.
+# This accounts for dropdown rows (null item_id) and rows associated with sales or receivings.
+ALTER TABLE `ospos_attribute_links`
+    ADD COLUMN `generated_unique_column` VARCHAR(255) GENERATED ALWAYS AS (
+        CASE
+            WHEN `sale_id` IS NULL AND `receiving_id` IS NULL AND `item_id` IS NOT NULL THEN CONCAT(`definition_id`, '-', `item_id`)
+            ELSE NULL
+        END
+        ) STORED,
+    ADD UNIQUE INDEX `attribute_links_uq3` (`generated_unique_column`);
+
+ALTER TABLE `ospos_attribute_links` ADD CONSTRAINT `ospos_attribute_links_ibfk_1` FOREIGN KEY (`definition_id`) REFERENCES `ospos_attribute_definitions` (`definition_id`) ON DELETE RESTRICT;
+ALTER TABLE `ospos_attribute_links` ADD CONSTRAINT `ospos_attribute_links_ibfk_2` FOREIGN KEY (`attribute_id`) REFERENCES `ospos_attribute_values` (`attribute_id`) ON DELETE RESTRICT;
diff --git a/app/Database/Migrations/sqlscripts/3.4.1_migrate_sessions_table.sql b/app/Database/Migrations/sqlscripts/3.4.1_migrate_sessions_table.sql
new file mode 100644
index 0000000000..d1b78f8e1c
--- /dev/null
+++ b/app/Database/Migrations/sqlscripts/3.4.1_migrate_sessions_table.sql
@@ -0,0 +1,10 @@
+DROP TABLE IF EXISTS `ospos_sessions`;
+CREATE TABLE IF NOT EXISTS `ospos_sessions` (
+    `id` varchar(128) NOT null,
+    `ip_address` varchar(45) NOT null,
+    `timestamp` timestamp DEFAULT CURRENT_TIMESTAMP NOT null,
+    `data` blob NOT null,
+    KEY `ospos_sessions_timestamp` (`timestamp`)
+);
+
+ALTER TABLE ospos_sessions ADD PRIMARY KEY (id, ip_address);
diff --git a/app/Helpers/migration_helper.php b/app/Helpers/migration_helper.php
index d929926e4f..025f7a2681 100644
--- a/app/Helpers/migration_helper.php
+++ b/app/Helpers/migration_helper.php
@@ -30,32 +30,178 @@ function execute_script(string $path): void
 }
 
 /**
- * Drops the foreign key constraints from the attribute_links table.
+ * Drops provided foreign key constraints from given table.
  * 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 drop_foreign_key_constraints(array $foreignKeys, string $table): void
+function dropForeignKeyConstraints(array $foreignKeys, string $table): void
 {
     $db = Database::connect();
-
-    $current_prefix = $db->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 ($query->getNumRows() > 0) {
-            $db->query("ALTER TABLE `$table` DROP FOREIGN KEY `$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();
+        $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']);
+        }
+    }
+
+    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);
 
-    $db->setPrefix($current_prefix);
+    return $query->getNumRows() > 0;
 }
diff --git a/app/Models/Attribute.php b/app/Models/Attribute.php
index c36b21f4bb..2787d72885 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 @@ public function exists(int $definition_id, bool $deleted = false): bool
         $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 @@ public function saveAttributeValue(string $attribute_value, int $definition_id,
         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 @@ public function delete_definition(int $definition_id): bool
      * @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 @@ public function get_attributes_by_definition(int $definition_id): array
 
         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 552d59b7fa..8e216af8ed 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('<?= esc("$controller_name/delete_attribute_value/") ?>', {
+                $.post('<?= esc("$controller_name/DeleteDropdownAttributeValue/") ?>', {
                     definition_id: definition_id,
                     attribute_value: value
                 });


Reviews:
user: login: jekkos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T21:18:30Z
user: login: jekkos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T21:18:53Z
user: login: jekkos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T21:42:33Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T21:57:36Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T21:58:27Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T21:58:53Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T13:05:21Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T13:06:49Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T13:07:33Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T13:08:26Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T13:19:55Z
user: login: jekkos
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-01T13:46:18Z
user: login: BudsieBuds
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T18:10:13Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T18:16:22Z
user: login: jekkos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T20:16:03Z
user: login: jekkos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T20:16:32Z
user: login: jekkos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T21:27:51Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T08:06:53Z
user: login: jekkos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T08:19:50Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T11:15:39Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T11:17:48Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T11:18:21Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T11:21:04Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T11:46:08Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T11:51:11Z
user: login: jekkos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-24T07:58:36Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-24T09:34:59Z
user: login: jekkos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-27T21:28:36Z
user: login: objecttothis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-28T04:09:05Z
user: login: jekkos
body: I'm not going to block the PR here at this point, I know these things take time to test. But I do have some minor remarks on the usage of comments and eventual removal of a duplicated log line. Beside that I think it looks good.
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-28T22:17:33Z
user: login: jekkos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-28T22:19:21Z
user: login: jekkos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-28T22:25:21Z

Review comments:
diff_hunk: @@ -54,33 +54,91 @@ public function down(): void
             . ' REFERENCES ' . $this->db->prefixTable('sales_items') . ' (sale_id)');
     }
 
-    private function create_primary_key(string $table, string $index): void
+    private function createPrimaryKey(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'");
path: app/Database/Migrations/20240630000001_fix_keys_for_db_upgrade.php
user: login: jekkos
body: this method looks very generic, could it be moved to the db helper?
created_at: 2025-04-29T21:18:30Z
author_association: MEMBER
diff_hunk: @@ -54,33 +54,91 @@ public function down(): void
             . ' REFERENCES ' . $this->db->prefixTable('sales_items') . ' (sale_id)');
     }
 
-    private function create_primary_key(string $table, string $index): void
+    private function createPrimaryKey(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);
+        if (! $result->getRowArray()) {
+            $constraints = $this->deleteForeignKeyConstraints($table, $index);
+            $this->deleteIndex($table, $index);
             $forge = Database::forge();
-            $forge->addPrimaryKey($table, '');
+            $forge->addPrimaryKey($index,'PRIMARY');
+            $forge->processIndexes($table);
+            $this->recreateForeignKeyConstraints($constraints);
+        }
+    }
 
+    private function deleteForeignKeyConstraints(string $table, string $column): array
+    {
+        $result = $this->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 = '" . $this->db->getPrefix() . "$table' AND kcu.REFERENCED_COLUMN_NAME = '$column') 
+                OR (kcu.TABLE_NAME = '" . $this->db->getPrefix() . "$table' AND kcu.COLUMN_NAME = '$column'))
+        ");
+
+        $deletedConstraints = [];
+
+        foreach ($result->getResultArray() as $constraint) {
+            $deletedConstraints[] = [
+                'constraintName' => $constraint['CONSTRAINT_NAME'],
+                'tableName' => str_starts_with($constraint['TABLE_NAME'], $this->db->DBPrefix)
+                    ? substr($constraint['TABLE_NAME'], strlen($this->db->DBPrefix))
+                    : $constraint['TABLE_NAME'],
+                'columnName' => $constraint['COLUMN_NAME'],
+                'referencedTable' => str_starts_with($constraint['REFERENCED_TABLE_NAME'], $this->db->DBPrefix)
+                    ? substr($constraint['REFERENCED_TABLE_NAME'], strlen($this->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;
     }
 
-    private function index_exists(string $table, string $index): bool
+    private function deleteIndex(string $table, string $index): void
+    {
+        if ($this->indexExists($table, $index)) {
path: app/Database/Migrations/20240630000001_fix_keys_for_db_upgrade.php
user: login: jekkos
body: same with this one, I bet we can reuse those in other migrations
created_at: 2025-04-29T21:18:53Z
author_association: MEMBER
diff_hunk: @@ -14,7 +14,12 @@ class Migration_Attributes_fix_cascading_delete extends Migration
     public function up(): void
     {
         helper('migration');
+
+        $this->db->query("ALTER TABLE `ospos_attribute_links` DROP INDEX `attribute_links_uq3`");
path: app/Database/Migrations/20250213000000_fix_attributes_cascading_delete.php
user: login: jekkos
body: @objecttothis We can not modify migrations that were already in a release version. People with MariaDB now have a DB which had these statements executed. 
created_at: 2025-04-29T21:42:32Z
author_association: MEMBER
diff_hunk: @@ -14,7 +14,12 @@ class Migration_Attributes_fix_cascading_delete extends Migration
     public function up(): void
     {
         helper('migration');
+
+        $this->db->query("ALTER TABLE `ospos_attribute_links` DROP INDEX `attribute_links_uq3`");
path: app/Database/Migrations/20250213000000_fix_attributes_cascading_delete.php
user: login: objecttothis
body: I hear you, but anyone with MySQL would be permanently unable to upgrade their version of OSPOS because this migration fails on those systems without this statement. I suppose the way to make sure that everyone ends up with the same database schema would be another migration which either drops it for everyone or adds it back in for people who migrated through this change.  I am working on a code change which will add the the index back in and revert the constraint changes made here to ON DELETE RESTRICT but without the bug of not being able to delete a dropdown selection item.  The short version of this is that ON DELETE RESTRICT is needed on the constraints in order to enforce uniqueness to attribute_links, so to still get the effects of ON DELETE CASCADE we have to programmatically implement ON DELETE CASCADE.
created_at: 2025-04-29T21:57:36Z
author_association: MEMBER
diff_hunk: @@ -54,33 +54,91 @@ public function down(): void
             . ' REFERENCES ' . $this->db->prefixTable('sales_items') . ' (sale_id)');
     }
 
-    private function create_primary_key(string $table, string $index): void
+    private function createPrimaryKey(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'");
path: app/Database/Migrations/20240630000001_fix_keys_for_db_upgrade.php
user: login: objecttothis
body: Possibly.  I'll look into it.
created_at: 2025-04-29T21:58:27Z
author_association: MEMBER
diff_hunk: @@ -54,33 +54,91 @@ public function down(): void
             . ' REFERENCES ' . $this->db->prefixTable('sales_items') . ' (sale_id)');
     }
 
-    private function create_primary_key(string $table, string $index): void
+    private function createPrimaryKey(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);
+        if (! $result->getRowArray()) {
+            $constraints = $this->deleteForeignKeyConstraints($table, $index);
+            $this->deleteIndex($table, $index);
             $forge = Database::forge();
-            $forge->addPrimaryKey($table, '');
+            $forge->addPrimaryKey($index,'PRIMARY');
+            $forge->processIndexes($table);
+            $this->recreateForeignKeyConstraints($constraints);
+        }
+    }
 
+    private function deleteForeignKeyConstraints(string $table, string $column): array
+    {
+        $result = $this->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 = '" . $this->db->getPrefix() . "$table' AND kcu.REFERENCED_COLUMN_NAME = '$column') 
+                OR (kcu.TABLE_NAME = '" . $this->db->getPrefix() . "$table' AND kcu.COLUMN_NAME = '$column'))
+        ");
+
+        $deletedConstraints = [];
+
+        foreach ($result->getResultArray() as $constraint) {
+            $deletedConstraints[] = [
+                'constraintName' => $constraint['CONSTRAINT_NAME'],
+                'tableName' => str_starts_with($constraint['TABLE_NAME'], $this->db->DBPrefix)
+                    ? substr($constraint['TABLE_NAME'], strlen($this->db->DBPrefix))
+                    : $constraint['TABLE_NAME'],
+                'columnName' => $constraint['COLUMN_NAME'],
+                'referencedTable' => str_starts_with($constraint['REFERENCED_TABLE_NAME'], $this->db->DBPrefix)
+                    ? substr($constraint['REFERENCED_TABLE_NAME'], strlen($this->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;
     }
 
-    private function index_exists(string $table, string $index): bool
+    private function deleteIndex(string $table, string $index): void
+    {
+        if ($this->indexExists($table, $index)) {
path: app/Database/Migrations/20240630000001_fix_keys_for_db_upgrade.php
user: login: objecttothis
body: Sounds good.  I'll look into it.
created_at: 2025-04-29T21:58:53Z
author_association: MEMBER
diff_hunk: @@ -230,18 +230,6 @@ public function getView(int $definition_id = NO_DEFINITION_ID): void
         echo view('attributes/form', $data);
     }
 
-    /**
path: app/Controllers/Attributes.php
user: login: objecttothis
body: This method wasn't actually called anywhere.
created_at: 2025-05-01T13:05:21Z
author_association: MEMBER
diff_hunk: @@ -35,6 +35,7 @@ private function migrate_duplicate_attribute_links(): void
         $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');
path: app/Database/Migrations/20210422000001_remove_duplicate_links.php
user: login: objecttothis
body: This is a retroactive fix because existing OSPOS MySQL databases can't get past it otherwise.
created_at: 2025-05-01T13:06:49Z
author_association: MEMBER
diff_hunk: @@ -54,33 +54,91 @@ public function down(): void
             . ' REFERENCES ' . $this->db->prefixTable('sales_items') . ' (sale_id)');
     }
 
-    private function create_primary_key(string $table, string $index): void
+    private function createPrimaryKey(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'");
path: app/Database/Migrations/20240630000001_fix_keys_for_db_upgrade.php
user: login: objecttothis
body: I'm still going to extract this.  The rest of the code is ready for review.
created_at: 2025-05-01T13:07:33Z
author_association: MEMBER
diff_hunk: @@ -54,33 +54,91 @@ public function down(): void
             . ' REFERENCES ' . $this->db->prefixTable('sales_items') . ' (sale_id)');
     }
 
-    private function create_primary_key(string $table, string $index): void
+    private function createPrimaryKey(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);
+        if (! $result->getRowArray()) {
+            $constraints = $this->deleteForeignKeyConstraints($table, $index);
+            $this->deleteIndex($table, $index);
             $forge = Database::forge();
-            $forge->addPrimaryKey($table, '');
+            $forge->addPrimaryKey($index,'PRIMARY');
+            $forge->processIndexes($table);
+            $this->recreateForeignKeyConstraints($constraints);
+        }
+    }
 
+    private function deleteForeignKeyConstraints(string $table, string $column): array
+    {
+        $result = $this->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 = '" . $this->db->getPrefix() . "$table' AND kcu.REFERENCED_COLUMN_NAME = '$column') 
+                OR (kcu.TABLE_NAME = '" . $this->db->getPrefix() . "$table' AND kcu.COLUMN_NAME = '$column'))
+        ");
+
+        $deletedConstraints = [];
+
+        foreach ($result->getResultArray() as $constraint) {
+            $deletedConstraints[] = [
+                'constraintName' => $constraint['CONSTRAINT_NAME'],
+                'tableName' => str_starts_with($constraint['TABLE_NAME'], $this->db->DBPrefix)
+                    ? substr($constraint['TABLE_NAME'], strlen($this->db->DBPrefix))
+                    : $constraint['TABLE_NAME'],
+                'columnName' => $constraint['COLUMN_NAME'],
+                'referencedTable' => str_starts_with($constraint['REFERENCED_TABLE_NAME'], $this->db->DBPrefix)
+                    ? substr($constraint['REFERENCED_TABLE_NAME'], strlen($this->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;
     }
 
-    private function index_exists(string $table, string $index): bool
+    private function deleteIndex(string $table, string $index): void
+    {
+        if ($this->indexExists($table, $index)) {
path: app/Database/Migrations/20240630000001_fix_keys_for_db_upgrade.php
user: login: objecttothis
body: I'll do this one also.  I believe we already have a migration helper where it can go.
created_at: 2025-05-01T13:08:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,16 @@
+ALTER TABLE `ospos_attribute_links` DROP CONSTRAINT `ospos_attribute_links_ibfk_1`;
path: app/Database/Migrations/sqlscripts/3.4.1_attribute_links_unique_constraint.sql
user: login: objecttothis
body: @jekkos regarding your objection above about retroactively changing the migration to drop attribute_links_uq3 and how MariaDB users will have already run that, the way to fix it is by this migration.
There are two scenarios: MySQL DBMS and MariaDB DBMS.  Those on the MySQL DBMS will not have generated_unique_column when it hits line 6 in this migration and people with MariaDB DBMS will have it.  I haven't written it yet, but I need to write the php up() function that calls this sql to instead programmatically drop the generated_unique_column if it exists so that we don't get an error on MariaDB databases that the column exists.
created_at: 2025-05-01T13:19:55Z
author_association: MEMBER
diff_hunk: @@ -230,18 +230,6 @@ public function getView(int $definition_id = NO_DEFINITION_ID): void
         echo view('attributes/form', $data);
     }
 
-    /**
path: app/Controllers/Attributes.php
user: login: jekkos
body: Beware it might be in use but called from the frontend. I might have come across it when I fixed the bug where we were not able to delete an attribute dropdown value.
created_at: 2025-05-01T13:46:10Z
author_association: MEMBER
path: .editorconfig
user: login: BudsieBuds
body: These changes should probably be removed, superseded by #4201.
created_at: 2025-05-02T18:10:13Z
author_association: MEMBER
path: .editorconfig
user: login: objecttothis
body: Yeah, once you merge that PR into the master I am planning to rebase this branch on the master and it will be overridden by the master.
created_at: 2025-05-02T18:16:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,16 @@
+ALTER TABLE `ospos_attribute_links` DROP CONSTRAINT `ospos_attribute_links_ibfk_1`;
path: app/Database/Migrations/sqlscripts/3.4.1_attribute_links_unique_constraint.sql
user: login: jekkos
body: That sounds good
created_at: 2025-05-12T20:16:03Z
author_association: MEMBER
diff_hunk: @@ -35,6 +35,7 @@ private function migrate_duplicate_attribute_links(): void
         $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');
path: app/Database/Migrations/20210422000001_remove_duplicate_links.php
user: login: jekkos
body: Thanks for that
created_at: 2025-05-12T20:16:31Z
author_association: MEMBER
diff_hunk: @@ -181,7 +181,7 @@
         }
         else
         {
-            $.post('<?= esc("$controller_name/delete_attribute_value/") ?>', {definition_id: definition_id, attribute_value: value});
+            $.post('<?= esc("$controller_name/DeleteDropdownAttributeValue/") ?>', {definition_id: definition_id, attribute_value: value});
path: app/Views/attributes/form.php
user: login: jekkos
body: Does it work with the Delete first char uppercase?
created_at: 2025-05-12T21:27:51Z
author_association: MEMBER
diff_hunk: @@ -181,7 +181,7 @@
         }
         else
         {
-            $.post('<?= esc("$controller_name/delete_attribute_value/") ?>', {definition_id: definition_id, attribute_value: value});
+            $.post('<?= esc("$controller_name/DeleteDropdownAttributeValue/") ?>', {definition_id: definition_id, attribute_value: value});
path: app/Views/attributes/form.php
user: login: objecttothis
body: I believe so.  I'll test it.  I was following the capitalization of the function name which is I believe is PostDeleteDropdownAttributeValue().  It's possible the router may not care.  In this case should we just make it all lowercase?
created_at: 2025-05-13T08:06:53Z
author_association: MEMBER
diff_hunk: @@ -181,7 +181,7 @@
         }
         else
         {
-            $.post('<?= esc("$controller_name/delete_attribute_value/") ?>', {definition_id: definition_id, attribute_value: value});
+            $.post('<?= esc("$controller_name/DeleteDropdownAttributeValue/") ?>', {definition_id: definition_id, attribute_value: value});
path: app/Views/attributes/form.php
user: login: jekkos
body: I though it was camelcase from the view but I could be wrong
created_at: 2025-05-13T08:19:50Z
author_association: MEMBER
diff_hunk: @@ -54,33 +54,91 @@ public function down(): void
             . ' REFERENCES ' . $this->db->prefixTable('sales_items') . ' (sale_id)');
     }
 
-    private function create_primary_key(string $table, string $index): void
+    private function createPrimaryKey(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'");
path: app/Database/Migrations/20240630000001_fix_keys_for_db_upgrade.php
user: login: objecttothis
body: Done
created_at: 2025-05-22T11:15:38Z
author_association: MEMBER
diff_hunk: @@ -230,18 +230,6 @@ public function getView(int $definition_id = NO_DEFINITION_ID): void
         echo view('attributes/form', $data);
     }
 
-    /**
path: app/Controllers/Attributes.php
user: login: objecttothis
body: I checked again in the code and could not find it called anywhere.
created_at: 2025-05-22T11:17:47Z
author_association: MEMBER
diff_hunk: @@ -230,18 +230,6 @@ public function getView(int $definition_id = NO_DEFINITION_ID): void
         echo view('attributes/form', $data);
     }
 
-    /**
path: app/Controllers/Attributes.php
user: login: objecttothis
body: PHPStorm also doesn't think it's called anywhere but it wouldn't find AJAX calls anyway.  String Searches don't find it.
created_at: 2025-05-22T11:18:21Z
author_association: MEMBER
diff_hunk: @@ -181,7 +181,7 @@
         }
         else
         {
-            $.post('<?= esc("$controller_name/delete_attribute_value/") ?>', {definition_id: definition_id, attribute_value: value});
+            $.post('<?= esc("$controller_name/DeleteDropdownAttributeValue/") ?>', {definition_id: definition_id, attribute_value: value});
path: app/Views/attributes/form.php
user: login: objecttothis
body: PascalCase from the view is working as it is.  It might be case insensitive, but IMO PascalCase matches the actual function name most precisely.
created_at: 2025-05-22T11:21:04Z
author_association: MEMBER
diff_hunk: @@ -41,6 +41,38 @@ class Attribute extends Model
     public const SHOW_IN_SALES = 2;
     public const SHOW_IN_RECEIVINGS = 4;
 
+    // ==============
path: app/Models/Attribute.php
user: login: objecttothis
body: @jekkos I would like to move toward organizing the models with these section headings.  Right now the models are pretty disorganized.  IDEs like PHPStorm can help but I'd still like to move things around.  C# has `#region foo` but PHP doesn't have anything like that.  What do you think?
created_at: 2025-05-22T11:46:08Z
author_association: MEMBER
diff_hunk: @@ -987,4 +1025,38 @@ public function get_attributes_by_definition(int $definition_id): array
 
         return $builder->get()->getResultArray();
     }
+
+// ==============
path: app/Models/Attribute.php
user: login: objecttothis
body: This heading would be for private Model functions.
created_at: 2025-05-22T11:50:52Z
author_association: MEMBER
diff_hunk: @@ -230,18 +230,6 @@ public function getView(int $definition_id = NO_DEFINITION_ID): void
         echo view('attributes/form', $data);
     }
 
-    /**
path: app/Controllers/Attributes.php
user: login: jekkos
body: Ok but let's check if the dropdown delete still works then after
created_at: 2025-05-24T07:58:36Z
author_association: MEMBER
diff_hunk: @@ -230,18 +230,6 @@ public function getView(int $definition_id = NO_DEFINITION_ID): void
         echo view('attributes/form', $data);
     }
 
-    /**
path: app/Controllers/Attributes.php
user: login: objecttothis
body: I checked it in my testing and it still worked to delete a drop-down choice. I confirmed also that it did not delete attribute link rows attached to sales or receivings. The cascading delete of items with that drop-down choice also works.

I don't think I tested deleting an entire drop-down attribute so we need to test that.
created_at: 2025-05-24T09:34:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,28 @@
+<?php
+
+namespace App\Database\Migrations;
+
+use CodeIgniter\Database\Migration;
+use Config\Database;
+
+class AttributeLinksUniqueConstraint extends Migration
+{
+    /**
+     * Perform a migration step.
+     */
+    public function up(): void
+    {
+        error_log('Migrating Attribute Links Unique Constraint');
+
path: app/Database/Migrations/20250522000000_AttributeLinksUniqueConstraint.php
user: login: objecttothis
body: @jekkos this and the SQL file associated with it fix the problem reported in #4251
created_at: 2025-05-26T21:26:12Z
author_association: MEMBER
diff_hunk: @@ -41,6 +41,38 @@ class Attribute extends Model
     public const SHOW_IN_SALES = 2;
     public const SHOW_IN_RECEIVINGS = 4;
 
+    // ==============
path: app/Models/Attribute.php
user: login: jekkos
body: I'm not really a fan of using comments for this.. IDE should in fact give us this functionality instead in my opinion.
created_at: 2025-05-27T21:28:36Z
author_association: MEMBER
diff_hunk: @@ -41,6 +41,38 @@ class Attribute extends Model
     public const SHOW_IN_SALES = 2;
     public const SHOW_IN_RECEIVINGS = 4;
 
+    // ==============
path: app/Models/Attribute.php
user: login: objecttothis
body: I see your point.  PHPStorm does have a 'structure' window which shows the function signatures and this is very helpful, but as you can see from the sale model below that the list appears in the order that the function appears in the model, which is a hot mess.  This means that if I'm looking to see if we have a specific function, I often have to scan the entire list to find it.  I don't feel extremely strongly about using comments to separate sections, but it really would be useful if our classes all followed a similar ordering. Something like:
Fields
Constructors
Create
Read
Update
Delete
Private
Static
![image](https://github.com/user-attachments/assets/622136a3-590d-47f6-8b86-cd78cf53f7f7)

Thoughts?

created_at: 2025-05-28T04:07:30Z
author_association: MEMBER
diff_hunk: @@ -842,29 +874,16 @@ public function saveAttributeValue(string $attribute_value, int $definition_id,
         return $attribute_id;
     }
 
-    /**
-     * @param string $attribute_value
-     * @param int $definition_id
-     * @return bool
-     */
-    public function delete_value(string $attribute_value, int $definition_id): bool
path: app/Models/Attribute.php
user: login: jekkos
body: @objecttothis I double checked the code and found that this method is in use in the controller `Attributes.php:83` 
created_at: 2025-05-28T22:09:42Z
author_association: MEMBER
diff_hunk: @@ -987,4 +1025,38 @@ public function get_attributes_by_definition(int $definition_id): array
 
         return $builder->get()->getResultArray();
     }
+
+// ==============
path: app/Models/Attribute.php
user: login: jekkos
body: I'd try to code to structure things logically.. having to add comments might be a code smell (according to clean code guidelines). It might just indicate naming is not good or classes are not sticking to single resonsibility principle.
created_at: 2025-05-28T22:11:59Z
author_association: MEMBER
diff_hunk: @@ -160,7 +160,7 @@
             if (is_new) {
                 values.splice($.inArray(value, values), 1);
             } else {
-                $.post('<?= esc("$controller_name/delete_attribute_value/") ?>', {
+                $.post('<?= esc("$controller_name/DeleteDropdownAttributeValue/") ?>', {
path: app/Views/attributes/form.php
user: login: jekkos
body: It's this one that calls the method you removed here
created_at: 2025-05-28T22:13:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,28 @@
+<?php
+
+namespace App\Database\Migrations;
+
+use CodeIgniter\Database\Migration;
+use Config\Database;
+
+class AttributeLinksUniqueConstraint extends Migration
+{
+    /**
+     * Perform a migration step.
+     */
+    public function up(): void
+    {
+        error_log('Migrating Attribute Links Unique Constraint');
+
+        execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.1_attribute_links_unique_constraint.sql');
+
+        error_log('Migrating Attribute Links Unique Constraint');
path: app/Database/Migrations/20250522000000_AttributeLinksUniqueConstraint.php
user: login: jekkos
body: Maybe one message is suffiicient?
created_at: 2025-05-28T22:13:42Z
author_association: MEMBER
diff_hunk: @@ -842,29 +874,16 @@ public function saveAttributeValue(string $attribute_value, int $definition_id,
         return $attribute_id;
     }
 
-    /**
-     * @param string $attribute_value
-     * @param int $definition_id
-     * @return bool
-     */
-    public function delete_value(string $attribute_value, int $definition_id): bool
path: app/Models/Attribute.php
user: login: jekkos
body: Ah I see you replaced it with a another one, probably fine
created_at: 2025-05-28T22:16:00Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,28 @@
+<?php
+
+namespace App\Database\Migrations;
+
+use CodeIgniter\Database\Migration;
+use Config\Database;
+
+class AttributeLinksUniqueConstraint extends Migration
+{
+    /**
+     * Perform a migration step.
+     */
+    public function up(): void
+    {
+        error_log('Migrating Attribute Links Unique Constraint');
+
path: app/Database/Migrations/20250522000000_AttributeLinksUniqueConstraint.php
user: login: jekkos
body: Great thanks for resolving this!
created_at: 2025-05-28T22:19:21Z
author_association: MEMBER
diff_hunk: @@ -41,6 +41,38 @@ class Attribute extends Model
     public const SHOW_IN_SALES = 2;
     public const SHOW_IN_RECEIVINGS = 4;
 
+    // ==============
path: app/Models/Attribute.php
user: login: jekkos
body: Agree I would try to stick to an order in which we define method type or fields then instead of using comments. It's more consistent in our case and indeed, we have a lot of methods in a controller class. So better to deal with it this way
created_at: 2025-05-28T22:25:21Z
author_association: MEMBER

PR comments:
user: login: objecttothis
created_at: 2025-05-22T11:53:26Z
body: @jekkos, I think the commit history is a little rough after rebasing on the master.  When I merge, per usual I'm planning to do a squash and merge with a commit message of this to clean everything up:

```
- Refactored function names for PSR-12 compliance
- Programmatically cascade delete attribute_link rows when a dropdown attribute is deleted but leave attribute_link rows associated with transactions.
- Added `WHERE item_id IS NOT NULL` to migration to prevent failure on MySQL databases during migration
- Retroactive correction of migration to prevent MySQL databases from failing.
- Refactored generic functions to helper
- Reverted attribute_links foreign key to ON DELETE RESTRICT which is required for a unique constraint on this table. Cascading deletes are now handled programmatically.
- Migration Session table to match Code Igniter 4.6
- Add index to attribute_links to prevent query timeout in items view on large databases
- Added overridePrefix() function to the migration_helper. Any time QueryBuilder is adding a prefix to the query when we don't want it to, this query can be used to override the prefix then set it back after you're done.
- Added dropAllForeignKeyConstraints() helper function.
- Added deleteIndex() helper function.
- Added indexExists() helper function.
- Added primaryKeyExists() helper function.
- Added recreateForeignKeyConstraints() helper function.
- Added CRUD section headings to the Attribute model.
- Replaced `==` with `===` to prevent type juggling.
- Removed unused delete_value function.
- Reworked deleteDefinition() and deleteDefinitionList() functions to delete rows from the attribute_links table which are associated.
- Added deleteAttributeLinksByDefinitionId() function
```

This PR is now ready for review. 
author_association: MEMBER
user: login: objecttothis
created_at: 2025-05-23T10:57:27Z
body: @jekkos the PHPLinter is failing on PHP 8.4 deprecations (https://github.com/opensourcepos/opensourcepos/actions/runs/15184079188/job/42700305258).  I do think we need to make these changes to remove deprecated code, but that needs to be a separate PR from this one.  
author_association: MEMBER
user: login: jekkos
created_at: 2025-05-27T21:30:34Z
body: We can keep the full history in the squashed commit message body. So typically a good commit message has a short first line describing what is is about, and then more details can be added after the first newline. 

I find it very useful to add info to the history this way, it's a bit of an ongoing documentation with every change that is done.
author_association: MEMBER
user: login: objecttothis
created_at: 2025-05-28T22:25:05Z
body: @jekkos in the morning I'll remove the comments and extra log lines. I think I was just mimicking what was in other migrations.

I'm OK with just ordering class functions logically, but I think we should agree on an order and move functions to those locations as we work on the code. Doing it all here would make the commit pretty difficult to parse.

I'll squash and merge after pushing those commits.
author_association: MEMBER
user: login: objecttothis
created_at: 2025-05-28T22:36:37Z
body: @jekkos I was taught a class order of

Fields
Constructors 
Create
Read
Update
Delete
Private methods

Does that work or a variation of it? If not feel free to propose a different order. I just get tired of wasting time not knowing exactly where a function of a certain time would generally be in a model.
author_association: MEMBER