8

I have a function for creating unique slug for a page title. It checks if the slug is available in the pages table then creates a unique slug by adding a '-int' accordingly. The function works fine for the first three entries eg for 'test slug' entered three time will create 'test-slug-1', 'test-slug-2' and 'test-slug-3'. Then after that I get an error "Fatal error: Maximum execution time of 30 seconds exceeded" for the fourth entry. There should be some problem with the logic, can anyone help me find it please.Below is the code:

function createSlug($title, $table_name, $field_name) {

global $db_connect;

$slug = preg_replace("/-$/","",preg_replace('/[^a-z0-9]+/i', "-", strtolower($title)));

$counter = 1;

do{

  $query = "SELECT * FROM $table_name WHERE  $field_name  = '".$slug."'";
  $result = mysqli_query($db_connect, $query) or die(mysqli_error($db_connect));


  if(mysqli_num_rows($result) > 0){
      $count = strrchr($slug , "-"); 
      $count = str_replace("-", "", $count);
      if($count > 0){

          $length = count($count) + 1;
          $newSlug = str_replace(strrchr($slug , "-"), '',$slug);
          $slug = $newSlug.'-'.$length;

          $count++;

      }else{
          $slug = $slug.'-'.$counter;
      }  

  }

  $counter++; 
  $row = mysqli_fetch_assoc($result);

}while(mysqli_num_rows($result) > 0);

return $slug;

}

bStaq
  • 121
  • 1
  • 2
  • 7
  • 1
    Instead of repeatedly hitting the database, you can just use LIKE to return all matching results then if the number of results is greater than 0 append the count of the number of the results to the slug. – Yellowledbet Apr 12 '13 at 12:46
  • Append count+1 is not the best idea as a record #3 could have been deleted but record 4 was not, that leaves 3 records, the next one you add is going to conflict – Hailwood Apr 12 '13 at 13:18

8 Answers8

33

Just hit the database once, grab everything at once, chances are that's the biggest bottleneck.

$query = "SELECT * FROM $table_name WHERE  $field_name  LIKE '".$slug."%'";

Then put your results in an array (let's say $slugs)

//we only bother doing this if there is a conflicting slug already
if(mysqli_num_rows($result) !== 0 && in_array($slug, $slugs)){
    $max = 0;

    //keep incrementing $max until a space is found
    while(in_array( ($slug . '-' . ++$max ), $slugs) );

    //update $slug with the appendage
    $slug .= '-' . $max;
}

We use the in_array() checks as if the slug was my-slug the LIKE would also return rows such as

my-slug-is-awesome
my-slug-is-awesome-1
my-slug-rules

etc which would cause issues, the in_array() checks ensure that we are only checking against the exact slug that was entered.

Why don't we just count the results and +1?

This is because if you had multiple results, and deleted a few, your next slug could well conflict.

E.g.

my-slug
my-slug-2
my-slug-3
my-slug-4
my-slug-5

Delete -3 and -5 leaves us with

my-slug
my-slug-2
my-slug-4

So, that gives us 3 results, the next insert would be my-slug-4 which already exists.

Why don't we just use ORDER BY and LIMIT 1?

We can't just do an order by in the query because the lack of natural sorting would make my-slug-10 rank lower than my-slug-4 as it compares character by character and 4 is higher than 1

E.g.

m = m
y = y
- = -
s = s
l = l
u = u
g = g
- = -
4 > 1 !!!
  < 0 (But the previous number was higher, so from here onwards is not compared)
Hailwood
  • 89,623
  • 107
  • 270
  • 423
  • 4
    You can order by length of field, then by field itself to achieve natural sorting :) sort of. – Ja͢ck Apr 12 '13 at 13:33
  • This is not working when below case. 1) My post -> my-post 2) My post -> my-post-1 3) My -> my-3 This conversion is going to wrong. – Bhavin Thummar Jul 18 '19 at 12:02
  • Hello, Hailwood how you going? could you take a look in my answer its similar than that one.. https://stackoverflow.com/questions/70906354/unique-profile-slug-with-php-and-pdo Thank you soo much – Sophie Jan 29 '22 at 17:44
15

Just use a single query to do all the heavy lifting for you...

$slug = preg_replace("/-$/","",preg_replace('/[^a-z0-9]+/i', "-", strtolower($title)));

$query = "SELECT COUNT(*) AS NumHits FROM $table_name WHERE  $field_name  LIKE '$slug%'";
$result = mysqli_query($db_connect, $query) or die(mysqli_error($db_connect));
$row = $result->fetch_assoc();
$numHits = $row['NumHits'];

return ($numHits > 0) ? ($slug . '-' . $numHits) : $slug;
Nerdwood
  • 3,947
  • 1
  • 21
  • 20
  • Much Thanks, that works perfect. Thanks to everyone who contributed, you have all helped in some way. Much appreciated. – bStaq Apr 12 '13 at 14:15
  • 2
    Excellent! Don't forget to mark one of the answers as accepted as this turned into a huge list of code-heavy answers! – Nerdwood Apr 12 '13 at 15:52
  • 6
    bad example. it will add double slugs after a row has been deleted – Frank B Sep 19 '15 at 07:57
  • I dont thik this is not good idea. assume there is slug_2 in a 2nd row, but it never check for it. if there is same title used for 3 times there will be 1 title and 2 title_2 s – Rashod Chamikara Bandara Apr 17 '18 at 16:19
  • This is not working when below case. 1) My post -> my-post 2) My post -> my-post-1 3) My -> my-3 This conversion is going to wrong. – Bhavin Thummar Jul 18 '19 at 12:03
3

You can just select slug with the biggest number and increase it with 1:

$query = "SELECT $field_name FROM $table_name WHERE  $field_name LIKE '".$slug."-[0-9]*' ORDER BY $field_name DESC LIMIT 1";

[0-9]* in query means any count of numbers.

This query will select row with $slug at start and the bigest number.

After that you can parse result get number and increase it.

In this case you will have only one query and lot of unused performance.

UPDATE

This will not work, because slug-8 will be "bigger" than slug-11. But no idea how to fix it. maybe ORDER BYidDESC ?

UPDATE 2

Query can be ordered by length too and it will work right. Thanks to Jack:

$query = "SELECT $field_name FROM $table_name WHERE  $field_name LIKE '".$slug."-[0-9]*' ORDER BY LENGTH($field_name), $field_name DESC LIMIT 1";

UPDATE 3

Also added check for original slug. Thanks to Hailwood.

$query = "SELECT $field_name FROM $table_name WHERE $field_name = '".$slug."' OR $field_name LIKE '".$slug."-[0-9]*' ORDER BY LENGTH($field_name), $field_name DESC LIMIT 1";
Narek
  • 3,813
  • 4
  • 42
  • 58
  • Have you tried `ORDER BY LENGTH($field_name) DESC, $field_name DESC`? – Ja͢ck Apr 12 '13 at 13:06
  • @Jack hmm, probably will work. Thanks, will update my answer. – Narek Apr 12 '13 at 13:08
  • Although this doesn't check for the existence of the original slug (aka no `-int`) so all slugs will end up having a `-int` – Hailwood Apr 12 '13 at 13:26
  • Two more things, 1) you have two sets or ORDER BY, 2) you want OR not AND, no string could ever be itself and itself + `-[digit]` – Hailwood Apr 12 '13 at 13:47
  • @Hailwood made more updates on this answer than in my rest answers :) – Narek Apr 12 '13 at 13:48
  • Actually, I had to make a couple changes to get this to work for me: `$query = "SELECT $field_name FROM $table_name WHERE $field_name = '".$slug."' OR $field_name REGEXP '".$slug."-[0-9]*' ORDER BY LENGTH($field_name) DESC, $field_name DESC LIMIT 1";` – Ben Y Feb 20 '14 at 23:45
2

Why don't you just create a slug and leave the rest of the job that involves indexing to MySQL. Here is a slugify function ( it is a slightly modified version used by Symfony framework ).

function slugify( $text ) {
    $text = preg_replace('~[^\\pL\d]+~u', '-', $text);  
    $text = trim($text, '-');
    $text = iconv('utf-8', 'ASCII//IGNORE//TRANSLIT', $text);   
    $text = strtolower(trim($text));
    $text = preg_replace('~[^-\w]+~', '', $text);

    return empty($text) ? substr( md5( time() ), 0, 8 ) : $text;
}

And the MySQL part can be solved with trigger ( change the table and column names ).

BEGIN
     declare original_slug varchar(255);
     declare slug_counter int;
     set original_slug = new.slug;
     set slug_counter = 1;
     while exists (select true from post where slug = new.slug) do
        set new.slug = concat(original_slug, '-', slug_counter); 
        set slug_counter = slug_counter + 1;
     end while;
END

MySQL Insert row, on duplicate: add suffix and re-insert

Community
  • 1
  • 1
Danijel
  • 12,408
  • 5
  • 38
  • 54
1

For the one part I would create an object that is dealing with the part creating the slug and handling the number:

// generate new slug:
$slug = new NumberedSlug('Creating Unique Page Title Slugs in PHP');
echo $slug, "\n", $slug->increase(), "\n";

// read existing slug:
$slug = new NumberedSlug('creating-unique-page-title-slugs-in-php-44');
echo $slug->getNumber(), "\n";

Output:

creating-unique-page-title-slugs-in-php
creating-unique-page-title-slugs-in-php-1
44

For the other part, the database, this already greatly simplifies your code (please double check, I've done this quick). Also see how you can benefit from the Mysqli object you actually have (but not use as is):

function createSlug($title, $table_name, $field_name, Mysqli $mysqli = NULL)
{
    $mysqli || $mysqli = $GLOBALS['db_connect'];

    $slug = new NumberedSlug($title);

    do
    {
        $query = "SELECT 1 FROM $table_name WHERE  $field_name  = '" . $slug . "'";

        if (!$result = $mysqli->query($query)) {
            throw new RuntimeException(var_export($mysqli->error_list, true));
        }

        if ($result->num_rows) {
            $slug->increase();
        }

    } while ($result->num_rows);

    return $slug;
}

But as others have already written you should first get all slugs that are numbered at once from the database and then pick a unique one if necessary. This will reduce the number of database calls. Also the code is much more compact:

function createSlug2($title, $table_name, $field_name, Mysqli $mysqli = NULL)
{
    $mysqli || $mysqli = $GLOBALS['db_connect'];

    $slug = new NumberedSlug($title);

    $query = "SELECT $field_name FROM $table_name WHERE $field_name LIKE '$slug-_%'";

    if (!$result = $mysqli->query($query)) {
        throw new RuntimeException(var_export($mysqli->error_list, true));
    }

    $existing = array_flip(call_user_func_array('array_merge', $result->fetch_all()));

    $slug->increase();

    while (isset($existing[$slug])) 
    {
        $slug->increase();
    }

    return $slug;
}

See it in action.

M8R-1jmw5r
  • 4,896
  • 2
  • 18
  • 26
  • Whoops, I broke it :D http://eval.in/private/2759b55f18fd2f There is nothing to stop your users from manually creating a page with the slugs `'php-5', 'php-4', 'php-3', 'php-1'` Which causes the code to fall over as it is expecting the numbers to be ordered ascendingly in the array, which nothing says they have to be. – Hailwood Apr 12 '13 at 14:25
  • @Hailwood: oh right. just array_merge the result via call_user_func_array and then sort with natsort. -- http://eval.in/private/3c8c6b27c5414d -- or even just use `in_array` or `array_flip` and `isset` – M8R-1jmw5r Apr 12 '13 at 14:27
  • I think I must be tired, I'm a nit picking bastard tonight ;) – Hailwood Apr 12 '13 at 14:30
  • I'll upvote, but I broke it again :D http://eval.in/private/03780cbbf5d041 If `$slug-[digit]` exists, even if just `$slug` doesn't it's going to throw it's toys and assume that `$slug` is taken :D – Hailwood Apr 12 '13 at 14:37
  • 1
    I did understood OP wanted to have slugs numbered, always, that's also why I $slug->increase() before the check once to give it that number. If you don't want that, just remove the first increase. – M8R-1jmw5r Apr 12 '13 at 14:40
  • Not one mentioned the length a slug can be. if a database field can store 255 bytes and the slug is > 254 bytes, the added number will not be stored into the database which results again in a slug that did exist before. – Frank B Sep 19 '15 at 08:00
1

I wasn't fully satisfied with the answers, so I came up with a slightly different approach.

(SELECT CONCAT({$slug}, '-', counter) FROM (
  SELECT (@row_number:=@row_number + 1) AS counter, ev.*
  FROM (
    SELECT REPLACE(slug, {$slug}-, '') AS remainder
    FROM products, (SELECT @row_number:=0) AS t
    WHERE slug LIKE '{$slug}%'
  ) ev
  ORDER BY LENGTH(remainder), remainder
) sr
WHERE counter <> remainder)
LIMIT 1

What this basically does, it checks all the existing values in the DB that are similar to the new slug, and matches it against the row number to check for gaps, and if none is found, it uses the maximum number that's generated by the first identical slug that gets pushed to the end (note: we replace slug- and not slug)

Inc33
  • 1,747
  • 1
  • 20
  • 26
0
$query = "SELECT * FROM $table_name WHERE  $field_name  LIKE '".$slug."%'";
$result = mysqli_query($db_connect, $query) or die(mysqli_error($db_connect));
//EDITED BASED ON COMMENT SUGGESTIONS
//create array of all matching slug names currently in database
$slugs = array();
while($row = $result->fetch_row()) {
    $slugs[] = $row['field_name'];
}
//test if slug is in database, append - '1,2,..n' until available slug is found
if(in_array($slug, $slugs)){
    $count = 1;
    do{
       $testSlug = $slug . '-' . $count;
       $count++;
    } while(in_array($testSlug, $slugs));
    $slug = $testSlug;
}
//insert slug 

You should be able to do this in a single database call with the LIKE keyword that will reduce your execution time.

Yellowledbet
  • 147
  • 3
  • 9
0

You could use the Fbeen/UniqueSlugBundle. This Bundle is lightweight and does what it needs to do.

Frank B
  • 3,667
  • 1
  • 16
  • 22