5

I'm creating a metacritic type of site where users link review posts (from external sources).

In the form, I allow users to choose the type of rating the source site uses:

rating

For example, the source post can have rating 50 out of 100.

To display the rating on my site, I want to convert the source rating to a simple 5 star rating. So, rating for above example becomes 2.5

My question is, what would be the best way in PHP to make these types of calculations when considering performance and efficiency?

I'm struggling to find a good ruote to go, particularly with A+ etc...

Henrik Petterson
  • 6,862
  • 20
  • 71
  • 155
  • 1
    I will bounty this question with 100 points when eligible. – Henrik Petterson Aug 22 '15 at 17:19
  • 1
    It'd be easy enough to interpolate the numeric score scales, but what about the others? What is the range of the `A` rating? How does `A+` differ exactly? And can you elaborate on `no score` - is this equivalent to a rating of `0`? If so, this case, I would expect that no user-inputted rating is accepted? – Darragh Enright Aug 22 '15 at 17:23
  • @Darragh Sure. **A+** means it's the highest score. So the user can input F, D-, D, D+, E... up to **A+**. If they choose **A** as the highest score, then it's the same, up to **A**. No score means simply that the review didn't have a score, so please ignore that option. Do you have a solution to this? Please feel free to post an answer. Thanks! – Henrik Petterson Aug 22 '15 at 17:26
  • 2
    Why not convert everything to "out of 100", and then divide it to get out of 5? Just assign a numeric value to A, B, C and you can do the same for those. – BananaMan Aug 22 '15 at 17:27
  • @BananaMan Can you kindly post an answer demonstrating this approach? – Henrik Petterson Aug 22 '15 at 17:28
  • Probably the easiest way; use an `if` statement to determine what type/number of maximum score was chosen. Then do some math and calculate it back. So if it would be `50 out of 100`, you would divide the `50` by `100`, and multiply it by `5`. This way you first calculate what the score would be `out of 1`, then multiple it by `5` to get to `out of 5`. – BananaMan Aug 22 '15 at 17:34
  • @BananaMan I was thinking something along those lines but can't think of the best approach to make the code as efficient as possible. Can you please post an answer with your solution and we can put it to test? Thanks! – Henrik Petterson Aug 22 '15 at 17:36
  • 1
    Ill write something, but I won't promise it's the most efficient way, but it will work :p – BananaMan Aug 22 '15 at 17:37
  • 1
    By "as efficient as possible", what are you actually looking for? It would be pretty hard to write code like this that took noticeable time or memory on a modern system, so do you just mean "neat and readable"? – IMSoP Aug 22 '15 at 18:00
  • @IMSoP I meant "neat and readable", sorry, English is not my native language. =) – Henrik Petterson Aug 22 '15 at 18:01
  • 1
    @IMSoP Please feel free to post a working answer, I've hit a wall. Will bounty 100 points when eligible with a *neat* solution ;) – Henrik Petterson Aug 22 '15 at 18:02
  • 2
    Hmm. This is a pretty intriguing one. – Darragh Enright Aug 22 '15 at 18:04

5 Answers5

6

This is an interesting question. Here's my naive attempt at a solution that's pretty mechanical, but attempts to be fairly uniform. I'm sure it could be improved/optimised significantly.

Edit

I wasn't satisfied with my previous answer (I rushed it so there were some errors - thanks for pointing those out!). I decided to remove that and attempt a different approach, which goes a bit crazy with regexes and filter_input to determine if user input is valid - and if so - cast it to the appropriate type.

This makes validating the rating against the chosen scale, (by type and value comparison) a lot more uniform. I sanity-checked this a bit and I think I am happier with this approach ;)

Once again... Assuming a HTML form like:

<form method="post">
    <label>rating</label>
    <input name="rating" type="text" autofocus>
    <label>out of</label>
    <select name="scale">
        <option value="100">100</option>
        <option value="10">10</option>
        <option value="6">6</option>
        <option value="5">5</option>
        <option value="4">4</option>
        <option value="A">A</option>
        <option value="A+">A+</option>
    </select>
    <input type="submit">
</form>

And the following PHP to user-submitted input:

<?php

const MAX_STARS    = 5;
const REGEX_RATING = '/^(?<char>[a-fA-F]{1}[-+]?)$|^(?<digit>[1-9]?[0-9](\.\d+)?|100)$/';
const REGEX_SCALE  = '/^(?<char>A\+?)$|^(?<digit>100|10|6|5|4)$/';

$letters = [
    'F-', 'F', 'F+',
    'G-', 'G', 'G+',
    'D-', 'D', 'D+',
    'C-', 'C', 'C+',
    'B-', 'B', 'B+',
    'A-', 'A', 'A+',
];

if ('POST' === $_SERVER['REQUEST_METHOD']) {

    // validate user-submitted `rating`
    $rating = filter_input(INPUT_POST, 'rating', FILTER_CALLBACK, [
        'options' => function($input) {
            if (preg_match(REGEX_RATING, $input, $matches)) {
                return isset($matches['digit']) 
                       ? (float) $matches['digit'] 
                       : strtoupper($matches['char']);
            }
            return false; // no match on regex
        },
    ]);

    // validate user-submitted `scale`
    $scale = filter_input(INPUT_POST, 'scale', FILTER_CALLBACK, [
        'options' => function($input) {
            if (preg_match(REGEX_SCALE, $input, $matches)) {
                return isset($matches['digit']) 
                       ? (float) $matches['digit'] 
                       : strtoupper($matches['char']);
            }
            return false; // no match on regex
        }
    ]);

    // if a valid letter rating, convert to calculable values
    if (in_array($scale, ['A+', 'A']) && in_array($rating, $letters)) {
        $scale  = array_search($scale,  $letters);
        $rating = array_search($rating, $letters);
    }

    // error! types don't match
    if (gettype($rating) !== gettype($scale)) {
        $error = 'rating %s and scale %s do not match';
        exit(sprintf($error, $_POST['rating'], $_POST['scale']));
    }

    // error! rating is higher than scale
    if ($rating > $scale) {
        $error = 'rating %s is greater than scale %s';
        exit(sprintf($error, $_POST['rating'], $_POST['scale']));
    }

    // done! print our rating...
    $stars = round(($rating / $scale) * MAX_STARS, 2);
    printf('%s stars out of %s (rating: %s scale: %s)', $stars, MAX_STARS, $_POST['rating'], $_POST['scale']);
}

?>

It's probably worth explaining what the hell is going on with the regexes and callbacks ;)

For example, take the following regex:

/^(?<char>A\+?)$|^(?<digit>100|10|6|5|4)$/'

This regex defines two named subpatterns. One, named <char>, captures A and A+; the other, named <digit> captures 100, 10, 6 etc.

preg_match() returns 0 if there is no match (or false on error) so we can return false in that case, because this means the user input (or the scale) POSTed was not valid.

Otherwise, the $match array will contain any captured values, with char and (optionally) digit as keys. If the digit key exists, we know the match is a digit and we can cast it to a float and return it. Otherwise, we must have matched on a char, so we can strtoupper() that value and return it:

return isset($matches['digit']) 
       ? (float) $matches['digit']    
       : strtoupper($matches['char']);

Both callbacks are identical (apart from the regexes themselves) so you could create a callable there and maybe save some duplication.

I hope it's not starting to feel a bit convoluted at this stage! Hope this helps :)

Darragh Enright
  • 13,676
  • 7
  • 41
  • 48
  • 1
    Firstly, this is a lovely piece of code you have here, thank you very much. Before I put it into test, can you please adjust the code slightly so that it approves lowercase characters too, like for example: **b+** -- Maybe going with strtoupper() is appropriate? Not sure. Also can you please change round() to add 2 decimals, so stars could be: **2.45** – Henrik Petterson Aug 23 '15 at 14:43
  • Also, it looks like numbers with decimals will not work for $rating. For example, if the user adds **2.65** for rating, it will exit at 'The rating 2.65 is not valid for the...' – Henrik Petterson Aug 23 '15 at 15:29
  • The $needle check should be updated to resolve this... maybe by changing ctype_digit() to is_numeric() --- edit: the more I look into this code, the more I realise how brilliant it actually is. You write some really clean readable code. Great job, definitely deserving of a bounty. – Henrik Petterson Aug 23 '15 at 15:35
  • Finally, if user selects scale 'A' and rating 'C-', it will not work when it should. So, '+/-' options for 'A' scale is not working because of range('F', 'A') – Henrik Petterson Aug 23 '15 at 15:55
  • Hi there. Yeah, I can certainly add case-insensitivity - I'll update my answer to reflect that. Ditto for the rounding. I made the assumption that the numeric value would be an integer. Do you want to allow someone to give a review with that level of granularity; e.g: `12.5` out of `100`? – Darragh Enright Aug 23 '15 at 16:30
  • Yes. Mainly because scales like '5' normally have '2.5' rating. – Henrik Petterson Aug 23 '15 at 16:32
  • 1
    Re: I read the `A` scale as a straight `[A,B,C,D,E,F]` scale, not allowing `+/-`. I just re-read the question comments and I see that you meant differently. Again, it should be easy to tweak. – Darragh Enright Aug 23 '15 at 16:33
  • Thank you very much, look forward to the update indeed! – Henrik Petterson Aug 23 '15 at 16:33
  • Everything else is pretty easy to implement but allowing for floats actually completely breaks this approach :) – Darragh Enright Aug 23 '15 at 16:56
  • Sorry what do you mean with "floats"? – Henrik Petterson Aug 23 '15 at 16:58
  • Allowing non-integers in numeric scales; e.g: `1.1` out of `100` etc. I'll have to rethink a few things (or if you were a client, suggest that integers are "good enough" ;) ) – Darragh Enright Aug 23 '15 at 17:12
  • Ahaaa I see the problem now. I tried **$needle = is_numeric($rating)**... and thought it was working until I noticed that the $stars value turned out incorrect. =( Do you have any potential solutions to this madness that wouldn't require too much of a rewrite? EDIT: Here is an example of the very latest submissions we have had, so you can see an average of rating/scale choices: http://i.imgur.com/Al0iMwX.png – Henrik Petterson Aug 23 '15 at 17:16
  • 1
    Posted an update there - again it's proof-of-concept, and obviously only sanity-checked (so it's not fully tested), but it should work to your specs, and it's probably a bit neater too. – Darragh Enright Aug 23 '15 at 18:50
  • Wow, thank you very much indeed! I have put this to test and I noticed that none of the **A** or **A+** scales work. I am guessing that in **gettype($rating) === gettype($scale) && $rating <= $scale;** -- the **$rating <= $scale** part doesn't work with characters and only numbers, right? – Henrik Petterson Aug 24 '15 at 14:40
  • 1
    Ahhh... I completely rushed my second answer. I added a third iteration... actually it's not an iteration, since I changed it a lot. However, I think this should work better, and (I think) it's shorter. – Darragh Enright Aug 25 '15 at 04:15
  • You basically saved me a month of headache with this lovely piece of code. Thank you very much. I learn a lot by reading your code which is what I appreciate most. Honestly, I am still piecing things together, great stuff!! I will leave this bounty open for a couple of days to bring some attention to this answer and then reward it. Thanks again. – Henrik Petterson Aug 25 '15 at 17:29
2

I got a totaly diffrent point of view. in PHP, you can dynamicly addres variables.

My PHP side (I am very very lazy):

<?php
const REGEX_INPUT = '/^(?<char>[a-fA-F]{1}[-+]?)$|^(?<digit>[1-9]?[0-9](\.\d+)?|100)$/';

if (preg_match (REGEX_INPUT , $_POST['rating'] )
    && preg_match (REGEX_INPUT , $_POST['scale'] )){//filter that input through given regex

$f- = 0;
$f  = 1;
//add all of the non numeric possibilities you allow (lowercase)
$a = 16;
$a+ = 17;

$rating = strtolower($_POST['rating']);
$scale  = strtolower($_POST['scale']) ;
try{
  $yourstars = 5.0 * $$rating/$$scale; //here is the magic
} catch (Exception $e) {


}else{
//not allowed inputs
}

You will get a warning on the numbers, supress them or define every allowed posibility like with the letters.

Explenation:

If the post is F, it gets saved as f in the $rating. now if I call ${$rating}, it calls ${f}.

On numerical posts the same: if the post is 10, I finally call $10. $10 is not defined, so php throws a warning and says that it assumes $10 = 10.

The versions of the others are good as well, this is just an other aspect for the lazy ones of you.

Community
  • 1
  • 1
inetphantom
  • 2,498
  • 4
  • 38
  • 61
0

This is how it would work for the numeric value's.

How to do alphabetic score's: simply make an array holding A-F and assign those to numeric value's. Then assign a new variable to the maximum numeric value (so if an A+ would be 100, then the maximum would be 100), and do the same math again to calculate the score out of 5.

<?php
// Assign your $_POST / $_GET data to these variables
$maximum = 100;
$rating = 50;

// Checks wether the entries are a number, if true then automatically calculates score out of 5
if (ctype_digit($maximum) && ctype_digit($rating)) {
  $score = ($rating/$maximum)*5;
}

echo $score;
?>

I'm not familiar with the A-F scoring system and I can't find any conversion. Perhaps somebody with this knowledge can finish the answer for op.

BananaMan
  • 168
  • 1
  • 10
  • 1
    `is_int` won't do anything useful with user input - *all* supplied values are of variable type string; you want `ctype_digit`, which checks if all the characters in a string are digits. – IMSoP Aug 22 '15 at 18:01
  • @IMSoP ctype_digit(2.5) will return false (note the dot). Therefore, I suggest going with is_numeric() instead... – Henrik Petterson Aug 22 '15 at 23:58
  • @HenrikPatterson I was correcting the use of is_int, but you're right that in this case we might want fractions. Unfortunately, is_numeric is rather *too* flexible (do you want "0.549E-5" to validate?) so this might be a case for a custom regex to allow either integer or single decimal place. – IMSoP Aug 23 '15 at 09:36
0

@Darrgh Answer almost solve your problem, but it's too much validate in it. So I make it much simple enough and still cover your need.

Here's my code :

const MAX_STARS = 5;

$letters = [
    'F-', 'F', 'F+',
    'G-', 'G', 'G+',
    'D-', 'D', 'D+',
    'C-', 'C', 'C+',
    'B-', 'B', 'B+',
    'A-', 'A', 'A+',
];

$scales = [
       4 => 4,
       5 => 5,
       6 => 6,
      10 => 10,
     100 => 100,
     'A' => 16,
    'A+' => 17,
];

$rating = is_numeric($_POST['rating']) ? (float) $_POST['rating'] : strtoupper($_POST['rating']);
$scale  = is_numeric($_POST['scale']) ? (float) $_POST['scale'] : $_POST['scale'];

if (false === $rating) {
    echo 'please supply a valid rating';
} elseif (!(gettype($rating) === gettype($scale))) {
    echo 'rating does not match scale';
} else {    
    $scale = $scales[$scale];

    if (!is_float($rating)) {
        $haystack = 'A+' === $scale ? $letters : array_slice($letters, 0, -1);
        $rating = array_search($rating, $haystack, true);
    }

    $stars = round(($rating / $scale) * MAX_STARS, 2);
    printf('rating: %s. scale: %s. stars: %s', $_POST['rating'], $_POST['scale'], $stars);
}

May be this will help you a little. :D

Eko Junaidi Salam
  • 1,663
  • 1
  • 18
  • 26
0

Am I not getting it or is my mathless approach not a simple solution? Just copy and paste the below into a PHP document and it works? Or to easy? Probaly as I just merge alle post and get vars to 1 array as strings and then I just substitue the letters or their corresponding numeric value. Then I treat all values as numbers again to get to the same 100 point scale. The I have a string containing the rating as a percentage of 100 percent where 100 is the highest. A star then would be easy like awarding 0,05 star per 1% extra rate (100 * 0,05 stars makes 5 starts).

  <form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
    <label>rating</label>
    <input name="rating" id="rating" type="text" autofocus>
    <label>out of</label>
    <select name="scale">
        <option value="100">100</option>
        <option value="10">10</option>
        <option value="6">6</option>
        <option value="5">5</option>
        <option value="4">4</option>
        <option value="A">A</option>
        <option value="A+">A+</option>
    </select>
    <input type="submit">
</form>
<?php    
    if(!empty($_SERVER['REQUEST_METHOD']))
    {
        // Patterns 
        $patterns = array();
        $patterns[0] = '/A$/';
        $patterns[1] = '/A\+/';
        // Replace with
        $replacements = array();
        $replacements[0] = '80';
        $replacements[1] = '100';

        // Merge $_GET & $_POST
        foreach (array_merge($_GET, $_POST) AS $name => $value) {
            // If string matches pattern then replace A or A+ with value
            if (is_string($value) && !empty($value) && !is_numeric($value)) {
               $value = preg_replace($patterns, $replacements, $value);
            }
            // All others are either valid or should be multiplied by 10 
            $value = ($value < 10 ? ($value*10) : $value);
        }?>
        <!-- write the value of $_POST['rate'] to text input if post is set -->
        <script>
           document.getElementById("rating").value = "<?php echo $value; ?>";
        </script>
     <?php
    }
?>