Skip to content

Commit

Permalink
MDL-44251 DB helper to update rows without violating a unique index.
Browse files Browse the repository at this point in the history
  • Loading branch information
timhunt committed Mar 2, 2014
1 parent 76e4de3 commit c185726
Show file tree
Hide file tree
Showing 2 changed files with 244 additions and 0 deletions.
123 changes: 123 additions & 0 deletions lib/datalib.php
Original file line number Diff line number Diff line change
Expand Up @@ -1995,3 +1995,126 @@ function user_can_create_courses() {
$catsrs->close();
return false;
}

/**
* This method can update the values in mulitple database rows for a colum with
* a unique index, without violating that constraint.
*
* Suppose we have a table with a unique index on (otherid, sortorder), and
* for a particular value of otherid, we want to change all the sort orders.
* You have to do this carefully or you will violate the unique index at some time.
* This method takes care of the details for you.
*
* Note that, it is the responsibility of the caller to make sure that the
* requested rename is legal. For example, if you ask for [1 => 2, 2 => 2]
* then you will get a unique key violation error from the database.
*
* @param string $table The database table to modify.
* @param string $field the field that contains the values we are going to change.
* @param array $newvalues oldvalue => newvalue how to change the values.
* E.g. [1 => 4, 2 => 1, 3 => 3, 4 => 2].
* @param array $otherconditions array fieldname => requestedvalue extra WHERE clause
* conditions to restrict which rows are affected. E.g. array('otherid' => 123).
* @param int $unusedvalue (defaults to -1) a value that is never used in $ordercol.
*/
function update_field_with_unique_index($table, $field, array $newvalues,
array $otherconditions, $unusedvalue = -1) {
global $DB;
$safechanges = decompose_update_into_safe_changes($newvalues, $unusedvalue);

$transaction = $DB->start_delegated_transaction();
foreach ($safechanges as $change) {
list($from, $to) = $change;
$otherconditions[$field] = $from;
$DB->set_field($table, $field, $to, $otherconditions);
}
$transaction->allow_commit();
}

/**
* Helper used by {@link update_field_with_unique_index()}. Given a desired
* set of changes, break them down into single udpates that can be done one at
* a time without breaking any unique index constraints.
*
* Suppose the input is array(1 => 2, 2 => 1) and -1. Then the output will be
* array (array(1, -1), array(2, 1), array(-1, 2)). This function solves this
* problem in the general case, not just for simple swaps. The unit tests give
* more examples.
*
* Note that, it is the responsibility of the caller to make sure that the
* requested rename is legal. For example, if you ask for something impossible
* like array(1 => 2, 2 => 2) then the results are undefined. (You will probably
* get a unique key violation error from the database later.)
*
* @param array $newvalues The desired re-ordering.
* E.g. array(1 => 4, 2 => 1, 3 => 3, 4 => 2).
* @param int $unusedvalue A value that is not currently used.
* @return array A safe way to perform the re-order. An array of two-element
* arrays array($from, $to).
* E.g. array(array(1, -1), array(2, 1), array(4, 2), array(-1, 4)).
*/
function decompose_update_into_safe_changes(array $newvalues, $unusedvalue) {
$nontrivialmap = array();
foreach ($newvalues as $from => $to) {
if ($from == $unusedvalue || $to == $unusedvalue) {
throw new \coding_exception('Supposedly unused value ' . $unusedvalue . ' is actually used!');
}
if ($from != $to) {
$nontrivialmap[$from] = $to;
}
}

if (empty($nontrivialmap)) {
return array();
}

// First we deal with all renames that are not part of cycles.
// This bit is O(n^2) and it ought to be possible to do better,
// but it does not seem worth the effort.
$safechanges = array();
$nontrivialmapchanged = true;
while ($nontrivialmapchanged) {
$nontrivialmapchanged = false;

foreach ($nontrivialmap as $from => $to) {
if (array_key_exists($to, $nontrivialmap)) {
continue; // Cannot currenly do this rename.
}
// Is safe to do this rename now.
$safechanges[] = array($from, $to);
unset($nontrivialmap[$from]);
$nontrivialmapchanged = true;
}
}

// Are we done?
if (empty($nontrivialmap)) {
return $safechanges;
}

// Now what is left in $nontrivialmap must be a permutation,
// which must be a combination of disjoint cycles. We need to break them.
while (!empty($nontrivialmap)) {
// Extract the first cycle.
reset($nontrivialmap);
$current = $cyclestart = key($nontrivialmap);
$cycle = array();
do {
$cycle[] = $current;
$next = $nontrivialmap[$current];
unset($nontrivialmap[$current]);
$current = $next;
} while ($current !== $cyclestart);

// Now convert it to a sequence of safe renames by using a temp.
$safechanges[] = array($cyclestart, $unusedvalue);
$cycle[0] = $unusedvalue;
$to = $cyclestart;
while ($from = array_pop($cycle)) {
$safechanges[] = array($from, $to);
$to = $from;
}
}

return $safechanges;
}
121 changes: 121 additions & 0 deletions lib/tests/datalib_update_with_unique_index_test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.

/**
* Tests for {@link decompose_update_into_safe_changes()} and
* {@link update_field_with_unique_index()}.
*
* @package core
* @copyright 2014 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/

defined('MOODLE_INTERNAL') || die();


/**
* Tests for {@link decompose_update_into_safe_changes()} and
* {@link update_field_with_unique_index()}.
*
* @copyright 2014 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class datalib_update_with_unique_index_testcase extends advanced_testcase {

public function test_decompose_update_into_safe_changes_identity() {
$this->assertEquals(array(), decompose_update_into_safe_changes(
array(1 => 1, 2 => 2), -1));
}

public function test_decompose_update_into_safe_changes_no_overlap() {
$this->assertEquals(array(array(1, 3), array(2, 4)), decompose_update_into_safe_changes(
array(1 => 3, 2 => 4), -1));
}

public function test_decompose_update_into_safe_changes_shift() {
$this->assertSame(array(array(3, 4), array(2, 3), array(1, 2)), decompose_update_into_safe_changes(
array(1 => 2, 2 => 3, 3 => 4), -1));
}

public function test_decompose_decompose_update_into_safe_changes_simple_swap() {
$this->assertEquals(array(array(1, -1), array(2, 1), array(-1, 2)), decompose_update_into_safe_changes(
array(1 => 2, 2 => 1), -1));
}

public function test_decompose_update_into_safe_changes_cycle() {
$this->assertEquals(array(array(1, -2), array(3, 1), array(2, 3), array(-2, 2)),
decompose_update_into_safe_changes(
array(1 => 2, 2 => 3 , 3 => 1), -2));
}

public function test_decompose_update_into_safe_changes_complex() {
$this->assertEquals(array(array(9, 10), array(8, 9),
array(1, -1), array(5, 1), array(7, 5), array(-1, 7),
array(4, -1), array(6, 4), array(-1, 6)), decompose_update_into_safe_changes(
array(1 => 7, 2 => 2, 3 => 3, 4 => 6, 5 => 1, 6 => 4, 7 => 5, 8 => 9, 9 => 10), -1));
}

public function test_decompose_update_into_safe_changes_unused_value_id_used() {
try {
decompose_update_into_safe_changes(array(1 => 1), 1);
$this->fail('Expected exception was not thrown');
} catch (coding_exception $e) {
$this->assertEquals('Supposedly unused value 1 is actually used!', $e->a);
}
}

public function test_reorder_rows() {
global $DB;
$dbman = $DB->get_manager();
$this->resetAfterTest();

$table = new xmldb_table('test_table');
$table->setComment("This is a test'n drop table. You can drop it safely");
$tablename = $table->getName();

$table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
$table->add_field('otherid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
$table->add_field('sortorder', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
$table->add_field('otherdata', XMLDB_TYPE_TEXT, 'big', null, null, null);
$table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
$table->add_key('unique', XMLDB_KEY_UNIQUE, array('otherid', 'sortorder'));
$dbman->create_table($table);

// Rows intentionally added in a slightly 'random' order.
// Note we are testing hat the otherid = 1 rows don't get messed up,
// as well as testing that the otherid = 2 rows are updated correctly.
$DB->insert_record($tablename, array('otherid' => 2, 'sortorder' => 1, 'otherdata' => 'To become 4'));
$DB->insert_record($tablename, array('otherid' => 2, 'sortorder' => 2, 'otherdata' => 'To become 1'));
$DB->insert_record($tablename, array('otherid' => 1, 'sortorder' => 1, 'otherdata' => 'Other 1'));
$DB->insert_record($tablename, array('otherid' => 1, 'sortorder' => 2, 'otherdata' => 'Other 2'));
$DB->insert_record($tablename, array('otherid' => 2, 'sortorder' => 3, 'otherdata' => 'To stay at 3'));
$DB->insert_record($tablename, array('otherid' => 2, 'sortorder' => 4, 'otherdata' => 'To become 2'));

update_field_with_unique_index($tablename, 'sortorder',
array(1 => 4, 2 => 1, 3 => 3, 4 => 2), array('otherid' => 2));

$this->assertEquals(array(
3 => (object) array('id' => 3, 'otherid' => 1, 'sortorder' => 1, 'otherdata' => 'Other 1'),
4 => (object) array('id' => 4, 'otherid' => 1, 'sortorder' => 2, 'otherdata' => 'Other 2'),
), $DB->get_records($tablename, array('otherid' => 1), 'sortorder'));
$this->assertEquals(array(
2 => (object) array('id' => 2, 'otherid' => 2, 'sortorder' => 1, 'otherdata' => 'To become 1'),
6 => (object) array('id' => 6, 'otherid' => 2, 'sortorder' => 2, 'otherdata' => 'To become 2'),
5 => (object) array('id' => 5, 'otherid' => 2, 'sortorder' => 3, 'otherdata' => 'To stay at 3'),
1 => (object) array('id' => 1, 'otherid' => 2, 'sortorder' => 4, 'otherdata' => 'To become 4'),
), $DB->get_records($tablename, array('otherid' => 2), 'sortorder'));
}
}

0 comments on commit c185726

Please sign in to comment.