First, I apologize if this has been asked before – indeed I’m sure it has, but I can’t find it/can’t work out what to search for to find it.
I need to generate unique quick reference id’s, based on a company name. So for example:
Company Name Reference Smiths Joinery smit0001 Smith and Jones Consulting smit0002 Smithsons Carpets smit0003
These will all be stored in a varchar column in a MySQL table. The data will be collected, escaped and inserted like ‘HTML -> PHP -> MySQL’. The ID’s should be in the format depicted above, four letters, then four numerics (initially at least – when I reach
smit9999 it will just spill over into 5 digits).
I can deal with generating the 4 letters from the company name, I will simply step through the name until I have collected 4 alpha characters, and
strtolower() it – but then I need to get the next available number.
What is the best/easiest way to do this, so that the possibility of duplicates is eliminated?
At the moment I’m thinking:
$fourLetters = 'smit'; $query = "SELECT `company_ref` FROM `companies` WHERE `company_ref` LIKE '$fourLetters%' ORDER BY `company_ref` DESC LIMIT 1"; $last = mysqli_fetch_assoc(mysqli_query($link, $query)); $newNum = ((int) ltrim(substr($last['company_ref'],4),'0')) + 1; $newRef = $fourLetters.str_pad($newNum, 4, '0', STR_PAD_LEFT);
But I can see this causing a problem if two users try to enter company names that would result in the same ID at the same time. I will be using a unique index on the column, so it would not result in duplicates in the database, but it will still cause a problem.
Can anyone think of a way to have MySQL work this out for me when I do the insert, rather than calculating it in PHP beforehand?
Note that actual code will be OO and will handle errors etc – I’m just looking for thoughts on whether there is a better way to do this specific task, it’s more about the SQL than anything else.
I think that @EmmanuelN’s suggestion of using a MySQL trigger may be the way to handle this, but:
- I am not good enough with MySQL, particularly triggers, to get this to work, and would like a step-by-step example of creating, adding and using a trigger.
- I am still not sure whether this will will eliminate the possibility of two identical ID’s being generated. See
what happens if two rows are inserted at the same time that result in the trigger running simultaneously, and produce the same reference? Is there any way to lock the trigger (or a UDF) in such a way that it can only have one concurrent instance?.
Or I would be open to any other suggested approaches to this problem.
If you are using MyISAM, then you can create a compound primary key on a text field + auto increment field. MySQL will handle incrementing the number automatically. They are separate fields, but you can get the same effect.
CREATE TABLE example ( company_name varchar(100), key_prefix char(4) not null, key_increment int unsigned auto_increment, primary key co_key (key_prefix,key_increment) ) ENGINE=MYISAM;
When you do an insert into the table, the
key_increment field will increment based on the highest value based on
key_prefix. So insert with
key_prefix “smit” will start with 1 in
key_prefix “jone” will start with 1 in
- You don’t have to do anything with calculating numbers.
- You do have a key split across 2 columns.
- It doesn’t work with InnoDB.
How about this solution with a trigger and a table to hold the company_ref’s uniquely. Made a correction – the reference table has to be MyISAM if you want the numbering to begin at 1 for each unique 4char sequence.
DROP TABLE IF EXISTS company; CREATE TABLE company ( company_name varchar(100) DEFAULT NULL, company_ref char(8) DEFAULT NULL ) ENGINE=InnoDB DELIMITER ;; CREATE TRIGGER company_reference BEFORE INSERT ON company FOR EACH ROW BEGIN INSERT INTO reference SET company_ref=SUBSTRING(LOWER(NEW.company_name), 1, 4), numeric_ref=NULL; SET NEW.company_ref=CONCAT(SUBSTRING(LOWER(NEW.company_name), 1, 4), LPAD(CAST(LAST_INSERT_ID() AS CHAR(10)), 4, '0')); END ;; DELIMITER ; DROP TABLE IF EXISTS reference; CREATE TABLE reference ( company_ref char(4) NOT NULL DEFAULT '', numeric_ref int(11) NOT NULL AUTO_INCREMENT, PRIMARY KEY (company_ref, numeric_ref) ) ENGINE=MyISAM;
And for completeness here is a trigger that will create a new reference if the company name is altered.
DROP TRIGGER IF EXISTS company_reference_up; DELIMITER ;; CREATE TRIGGER company_reference_up BEFORE UPDATE ON company FOR EACH ROW BEGIN IF NEW.company_name <> OLD.company_name THEN DELETE FROM reference WHERE company_ref=SUBSTRING(LOWER(OLD.company_ref), 1, 4) AND numeric_ref=SUBSTRING(OLD.company_ref, 5, 4); INSERT INTO reference SET company_ref=SUBSTRING(LOWER(NEW.company_name), 1, 4), numeric_ref=NULL; SET NEW.company_ref=CONCAT(SUBSTRING(LOWER(NEW.company_name), 1, 4), LPAD(CAST(LAST_INSERT_ID() AS CHAR(10)), 4, '0')); END IF; END; ;; DELIMITER ;
Given you’re using InnoDB, why not use an explicit transaction to grab an exclusive row lock and prevent another connection from reading the same row before you’re done setting a new ID based on it?
(Naturally, doing the calculation in a trigger would hold the lock for less time.)
mysqli_query($link, "BEGIN TRANSACTION"); $query = "SELECT `company_ref` FROM `companies` WHERE `company_ref` LIKE '$fourLetters%' ORDER BY `company_ref` DESC LIMIT 1 FOR UPDATE"; $last = mysqli_fetch_assoc(mysqli_query($link, $query)); $newNum = ((int) ltrim(substr($last['company_ref'],4),'0')) + 1; $newRef = $fourLetters.str_pad($newNum, 4, '0', STR_PAD_LEFT); mysqli_query($link, "INSERT INTO companies . . . (new row using $newref)"); mysqli_commit($link);
Edit: Just to be 100% sure I ran a test by hand to confirm that the second transaction will return the newly inserted row after waiting rather than the original locked row.
Edit2: Also tested the case where there is no initial row returned (Where you would think there is no initial row to put a lock on) and that works as well.
- Ensure you have an unique constraint on the Reference column.
- Fetch the current max sequential reference the same way you do it in your sample code. You don’t actually need to trim the zeroes before you cast to (int), ‘0001’ is a valid integer.
- Roll a loop and do your insert inside.
- Check affected rows after the insert. You can also check the SQL state for a duplicate key error, but having zero affected rows is a good indication that your insert failed due to inserting an existing Reference value.
- If you have zero affected rows, increment the sequential number, and roll the loop again. If you have non-zero affected rows, you’re done and have an unique identifier inserted.
Easiest way to avoid duplicate values for the reference column is to add a unique constraint. So if multiple processes try to set to the same value, MySQL will reject the second attempt and throw an error.
ALTER TABLE table_name ADD UNIQUE KEY (`company_ref`);
If I were faced with your situation, I would handle the company reference id generation within the application layer, triggers can get messy if not setup correctly.
A hacky version that works for InnoDB as well.
Replace the insert to
companies with two inserts in a transaction:
INSERT INTO __keys VALUES (LEFT(LOWER('Smiths Joinery'),4), LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE num = LAST_INSERT_ID(num+1); INSERT INTO __companies (comp_name, reference) VALUES ('Smiths Joinery', CONCAT(LEFT(LOWER(comp_name),4), LPAD(LAST_INSERT_ID(), 4, '0')));
CREATE TABLE `__keys` ( `prefix` char(4) NOT NULL, `num` smallint(5) unsigned NOT NULL, PRIMARY KEY (`prefix`) ) ENGINE=InnoDB COLLATE latin1_general_ci; CREATE TABLE `__companies` ( `comp_id` int(10) unsigned NOT NULL AUTO_INCREMENT, `comp_name` varchar(45) NOT NULL, `reference` char(8) NOT NULL, PRIMARY KEY (`comp_id`) ) ENGINE=InnoDB COLLATE latin1_general_ci;
latin1_general_cican be replaced with
LEFT(LOWER('Smiths Joinery'),4)would better become a function in PHP