How do I combine these two queries to calculate rank change? - php

Introduction
I have a highscore table for my game which uses ranks. The scores table represents current highscores and player info and the recent table represents all recently posted scores by a user which may or may not have been a new top score.
The rank drop is calculated by calculating the player's current rank minus their rank they had at the time of reaching their latest top score.
The rank increase is calculated by calculating the player's rank they had at the time of reaching their latest top score minus the rank they had at the time of reaching their previous top score.
Finally, as written in code: $change = ($drop > 0 ? -$drop : $increase);
Question
I am using the following two queries combined with a bit of PHP code to calculate rank change. It works perfectly fine, but is sometimes a bit slow.
Would there be a way to optimize or combine the two queries + PHP code?
I created an SQL Fiddle of the first query: http://sqlfiddle.com/#!9/30848/1
The tables are filled with content already, so their structures should not be altered.
This is the current working code:
$q = "
select
(
select
coalesce(
(
select count(distinct b.username)
from recent b
where
b.istopscore = 1 AND
(
(
b.score > a.score AND
b.time <= a.time
) OR
(
b.score = a.score AND
b.username != a.username AND
b.time < a.time
)
)
), 0) + 1 Rank
from scores a
where a.nickname = ?) as Rank,
t.time,
t.username,
t.score
from
scores t
WHERE t.nickname = ?
";
$r_time = 0;
if( $stmt = $mysqli->prepare( $q ) )
{
$stmt->bind_param( 'ss', $nick, $nick );
$stmt->execute();
$stmt->store_result();
$stmt->bind_result( $r_rank, $r_time, $r_username, $r_score );
$stmt->fetch();
if( intval($r_rank) > 99999 )
$r_rank = 99999;
$stmt->close();
}
// Previous Rank
$r_prevrank = -1;
if( $r_rank > -1 )
{
$q = "
select
coalesce(
(
select count(distinct b.username)
from recent b
where
b.istopscore = 1 AND
(
(
b.score > a.score AND
b.time <= a.time
) OR
(
b.score = a.score AND
b.username != a.username AND
b.time < a.time
)
)
), 0) + 1 Rank
from recent a
where a.username = ? and a.time < ? and a.score < ?
order by score desc limit 1";
if( $stmt = $mysqli->prepare( $q ) )
{
$time_minus_one = ( $r_time - 1 );
$stmt->bind_param( 'sii', $r_username, $time_minus_one, $r_score );
$stmt->execute();
$stmt->store_result();
$stmt->bind_result( $r_prevrank );
$stmt->fetch();
if( intval($r_prevrank) > 99999 )
$r_prevrank = 99999;
$stmt->close();
}
$drop = ($current_rank - $r_rank);
$drop = ($drop > 0 ? $drop : 0 );
$increase = $r_prevrank - $r_rank;
$increase = ($increase > 0 ? $increase : 0 );
//$change = $increase - $drop;
$change = ($drop > 0 ? -$drop : $increase);
}
return $change;

If you are separating out the current top score into a new table, while all the raw data is available in the recent scores.. you have effectively produced a summary table.
Why not continue to summarize and summarize all the data you need?
It's then just a case of what do you know and when you can know it:
Current rank - Depends on other rows
Rank on new top score - Can be calculated as current rank and stored at time of insert/update
Previous rank on top score - Can be transferred from old 'rank on new top score' when a new top score is recorded.
I'd change your scores table to include two new columns:
scores - id, score, username, nickname, time, rank_on_update, old_rank_on_update
And adjust these columns as you update/insert each row.
Looks like you already have queries that can be used to backfit this data for your first iteration.
Now your queries become a lot simpler
To get rank from score:
SELECT COUNT(*) + 1 rank
FROM scores
WHERE score > :score
From username:
SELECT COUNT(*) + 1 rank
FROM scores s1
JOIN scores s2
ON s2.score > s1.score
WHERE s1.username = :username
And rank change becomes:
$drop = max($current_rank - $rank_on_update, 0);
$increase = max($old_rank_on_update - $rank_on_update, 0);
$change = $drop ? -$drop : $increase;
UPDATE
Comment 1 + 3 - Oops, may have messed that up.. have changed above.
Comment 2 - Incorrect, if you keep the scores (all the latest high-scores) up to date on the fly (every time a new high-score is recorded) and assuming there is one row per user, at the time of calculation current rank should simply be a count of scores higher than the user's score (+1). Should hopefully be able to avoid that crazy query once the data is up to date!
If you insist on separating by time, this will work for a new row if you haven't updated the row yet:
SELECT COUNT(*) + 1 rank
FROM scores
WHERE score >= :score
The other query would become:
SELECT COUNT(*) + 1 rank
FROM scores s1
JOIN scores s2
ON s2.score > s1.score
OR (s2.score = s1.score AND s2.time < s1.time)
WHERE s1.username = :username
But I'd at least try union for performance:
SELECT SUM(count) + 1 rank
FROM (
SELECT COUNT(*) count
FROM scores s1
JOIN scores s2
ON s2.score > s1.score
WHERE s1.username = :username
UNION ALL
SELECT COUNT(*) count
FROM scores s1
JOIN scores s2
ON s2.score = s1.score
AND s2.time < s1.time
WHERE s1.username = :username
) counts
An index on (score, time) would help here.
Personally I'd save yourself a headache and keep same scores at the same rank (pretty standard I believe).. If you want people to be able to claim first bragging rights just make sure you order by time ASC on any score charts and include the time in the display.

I spent a lot of time trying to figure out what the rank logic is and put in a comment about it. In the meantime, here is a join query that you can run on your data - I think your solution will something something to this effect:
SELECT s.username, count(*) rank
FROM scores s LEFT JOIN recent r ON s.username != r.username
WHERE r.istopscore
AND r.score >= s.score
AND r.time <= s.time
AND (r.score-s.score + s.time-r.time)
GROUP BY s.username
ORDER BY rank ASC;
+----------+------+
| username | rank |
+----------+------+
| Beta | 1 |
| Alpha | 2 |
| Echo | 3 |
+----------+------+
(note that last AND is just to ensure you don't account for r.score==s.score && r.time==s.time - which i guess would be a "tie" game?)

I am not a MySQL guy, but I think that using self-join for ranking is a bad practice in any RDBMS. You should consider using of ranking functions. But there are no ranking functionality in MySQL. But there are workarounds.

There are some assumptions that have to be made here in order to move forward with this. I assume that the scores table has only one entry per 'username' which is somehow equivalent to a nickname.
Try this,
If I had a working db, this would be quick to figure out and test, but basically you are taking the 'sub query' you are running in the selected field and you are building a temp table with ALL of the records and filtering them out.
select a.nickname
, count(distinct b.username) as rank
, t.time
, t.username
, t.score
from
(
select
a.nickname
, b.username
from (select * from scores where nickname=? ) a
left join (select * from recent where istopscore = 1) as b
on (
b.score > a.score and b.time <= a.time -- include the b record if the b score is higher
or
b.score = a.score and b.time < a.time and a.username != b.username -- include b if the score is the same, b got the score before a got the score
)
) tmp
join scores t on (t.nickname = tmp.nickname)
where t.nickname = ?
I did not attempt to address your later logic, you can use the same theory, but it is not worth trying unless you can confirm that this method returns the correct rows.
If you would like to get deeper, you should create some data sets and fully setup the SQL Fiddle.

Related

Select Nth record from MySQL query from Millions of rows

I have a MySQL query as below; I would like to select the top record for each range of 600 records in a table with 1.8M records. So far I have to loop 3,000 times to accomplish this which is not an efficient solution.
Database Schema;
Table: bet_perm_13predict
id bet_id perm_id avg_odd avg_odd2 avg_odd3
1 23 1 43.29 28.82 28.82
2 23 2 42.86 28.59 28.59
3 23 3 43.13 28.73 28.73
Table: bet_permute_13games
perm_id perm_code
1 0000000000000
2 0000000000001
3 0000000000002
4 0000000000010
Sample MySQL Query in PHP
$totRange = 0; //Used as starting point in rang
$range = 600; //Used as range
$stop = 0;//Used as endPoint of range
while($totRange < 1800000){
$stop = $totRange+$range;
$sql = "SELECT (tb1.avg_odd2 + tb1.avg_odd3) AS totAvg_odd ,
tb1.perm_id , tb1.avg_odd, tb1.avg_odd2, tb1.avg_odd3, tb2.perm_code
FROM bet_perm_13predict tb1
INNER JOIN bet_permute_13games tb2 ON tb2.perm_id = tb1.perm_id
WHERE tb1.bet_id = '$bet_id' && tb1.perm_id
BETWEEN $startRange AND $stop ORDER BY totAvg_odd ASC LIMIT 1"
$q1 = $this->db->query($sql);
$totRange = $stop;
}
In other words I want to select a sample of the data that will represent the entire table with the sample not being random but predefined using the top record in range of 600. So far I have no idea how to proceed. There is no clear online material on this subject.
You can use integer division to create groups.
DEMO
SELECT ID, ID DIV 600 as grp
FROM Table1
Then find the max value on each group. Some options here
Get records with max value for each group of grouped SQL results
For those who might encounter the same issue, this is how I solved it. I used #Juan Carlos suggestion and added a way to pick top record of group using Subquery.
SELECT * FROM
(SELECT * , perm_id DIV $limit as grp , (avg_odd2 + avg_odd3) AS totAvg_odd
FROM bet_perm_13predict WHERE bet_id = '$bet_id' ORDER BY grp ASC ) tb1
INNER JOIN bet_permute_13games tb2 ON tb2.perm_id = tb1.perm_id
INNER JOIN bet_entry tb3 ON tb3.bet_id = tb1.bet_id
WHERE tb1.avg_odd2 < (SELECT AVG(avg_odd2) FROM bet_perm_13predict WHERE bet_id = '$bet_id' )
&& tb1.avg_odd3 < (SELECT AVG(avg_odd3) FROM bet_perm_13predict WHERE bet_id = '$bet_id' )
GROUP BY grp ORDER BY totAvg_odd ASC
LIMIT 100

Single query that allows alias with it's own limit

I would like to better optimize my code. I'd like to have a single query that allows an alias name to have it's own limit and also include a result with no limit.
Currently I'm using two queries like this:
// ALL TIME //
$mikep = mysqli_query($link, "SELECT tasks.EID, reports.how_did_gig_go FROM tasks INNER JOIN reports ON tasks.EID=reports.eid WHERE `priority` IS NOT NULL AND `partners_name` IS NOT NULL AND mike IS NOT NULL GROUP BY EID ORDER BY tasks.show_date DESC;");
$num_rows_mikep = mysqli_num_rows($mikep);
$rating_sum_mikep = 0;
while ($row = mysqli_fetch_assoc($mikep)) {
$rating_mikep = $row['how_did_gig_go'];
$rating_sum_mikep += $rating_mikep;
}
$average_mikep = $rating_sum_mikep/$num_rows_mikep;
// AND NOW WITH A LIMIT 10 //
$mikep_limit = mysqli_query($link, "SELECT tasks.EID, reports.how_did_gig_go FROM tasks INNER JOIN reports ON tasks.EID=reports.eid WHERE `priority` IS NOT NULL AND `partners_name` IS NOT NULL AND mike IS NOT NULL GROUP BY EID ORDER BY tasks.show_date DESC LIMIT 10;");
$num_rows_mikep_limit = mysqli_num_rows($mikep_limit);
$rating_sum_mikep_limit = 0;
while ($row = mysqli_fetch_assoc($mikep_limit)) {
$rating_mikep_limit = $row['how_did_gig_go'];
$rating_sum_mikep_limit += $rating_mikep_limit;
}
$average_mikep_limit = $rating_sum_mikep_limit/$num_rows_mikep_limit;
This allows me to show an all-time average and also an average over the last 10 reviews. Is it really necessary for me to set up two queries?
Also, I understand I could get the sum in the query, but not all the values are numbers, so I've actually converted them in PHP, but left out that code in order to try and simplify what is displayed in the code.
All-time average and average over the last 10 reviews
In the best case scenario, where your column how_did_gig_go was 100% numeric, a single query like this could work like so:
SELECT
AVG(how_did_gig_go) AS avg_how_did_gig_go
, SUM(CASE
WHEN rn <= 10 THEN how_did_gig_go
ELSE 0
END) / 10 AS latest10_avg
FROM (
SELECT
#num + 1 AS rn
, tasks.show_date
, reports.how_did_gig_go
FROM tasks
INNER JOIN reports ON tasks.EID = reports.eid
CROSS JOIN ( SELECT #num := 0 AS n ) AS v
WHERE priority IS NOT NULL
AND partners_name IS NOT NULL
AND mike IS NOT NULL
ORDER BY tasks.show_date DESC
) AS d
But; Unless all the "numbers" are in fact numeric you are doomed to sending every row back from the server for php to process unless you can clean-up the data in MySQL somehow.
You might avoid sending all that data twice if you establish a way for your php to use only the top 10 from the whole list. There are probably way of doing that in PHP.
If you wanted assistance in SQL to do that, then maybe having 2 columns would help, it would reduce the number of table scans.
SELECT
EID
, how_did_gig_go
, CASE
WHEN rn <= 10 THEN how_did_gig_go
ELSE 0
END AS latest10_how_did_gig_go
FROM (
SELECT
#num + 1 AS rn
, tasks.EID
, reports.how_did_gig_go
FROM tasks
INNER JOIN reports ON tasks.EID = reports.eid
CROSS JOIN ( SELECT #num := 0 AS n ) AS v
WHERE priority IS NOT NULL
AND partners_name IS NOT NULL
AND mike IS NOT NULL
ORDER BY tasks.show_date DESC
) AS d
In future (MySQL 8.x) ROW_NUMBER() OVER(order by tasks.show_date DESC) would be a better method than the "roll your own" row numbering (using #num+1) shown before.

What would be a good query to find my previous rank in this score table?

I am currently using this query to find the rank of a player:
select
coalesce(
(
select count(1)
from scores b
where
b.top > a.top OR
(
b.top = a.top AND
b.time < a.time
)
), 0
) + 1 Rank
from
Scores a
where
user = ?
I have a score table like this:
id int
user varchar(100)
time int (timestamp)
top int
And a recent table like this:
id int
user varchar(100)
time int (timestamp)
score int
istopscore int (boolean 1/0)
The database is already filled with data, so I cannot simply change the structure of the database. There are over 200.000 rows in the recent table, so sorting takes a lot of time. I am trying to find a way to do this as quickly as possible.
How would I find the previous rank of a player? Here is what I have tried:
select
coalesce(
(
select count(1)
from recent b
where
b.istopscore = 1 AND
(
(
b.score > a.top AND
b.time <= a.time
) OR
(
b.score = a.top AND
b.time < a.time
)
)
), 0) + 1 Rank
from scores a
where user = ?
The problem with this query is that if a user has scored multiple new top scores, it counts all of them, so it does not give the correct result.
Any help would be greatly appreciated.
I think your query is almost right. To overcome the problem with multiple top scores you can use count(distinct username), like this:
select
coalesce(
(
select count(distinct username)
from recent b
where
b.istopscore = 1 AND
(
(
b.score > a.top AND
b.time <= a.time
) OR
(
b.score = a.top AND
b.time < a.time
)
)
), 0) + 1 Rank
from scores a
where username = 'Echo'

Is there any way to find the value of a query?

I recently made a system that ranks each players depending on their points. Well the way the system gets the points is rather confusing. After using this system for over 24 hours, I have found out that it is not organizing it according to the points. But then it suddenly occurred to me, that I could be calculating the points wrong in a way that does not represent the SQL query. Here is my SQL query that my rankings uses:
SELECT * , playeruid AS player_id, (
(
SELECT COALESCE(sum(player1points),0)
FROM `mybb_matches`
WHERE player1uid = player_id AND gid = $id AND timestamp < $time AND winneruid is NOT NULL AND dispute != 3 )
+
(
SELECT COALESCE(sum(player2points),0)
FROM `mybb_matches`
WHERE player2uid = player_id AND gid = $id AND timestamp < $time AND winneruid is NOT NULL AND dispute != 3 )
+
(
SELECT SUM( rank )
FROM `mybb_matchesgame`
WHERE playeruid = player_id AND gid = $id )
)
AS points
FROM mybb_matchesgame WHERE gid = $id
ORDER BY points DESC
Now that this is shown, I was wondering if there's any way to grab the value of "points" and display it somehow so I can verify the number. Is this possible?
There are no group by statements in the queries, so the SUM is most likely not over the expected set. Also COALESCE can be replaced with IFNULL, which might be a bit more efficient.
SELECT q.* , playeruid AS player_id, a.points+b.points+c.points AS points
FROM mybb_matchesgame q
LEFT JOIN (
SELECT IFNULL(SUM(player1points),0) as points,player_id
FROM `mybb_matches`
WHERE timestamp < $time AND winneruid is NOT NULL AND dispute != 3
GROUP BY player_id) a ON player1uid = a.player_id
LEFT JOIN (
SELECT IFNULL(sum(player2points),0) as points,player_id
FROM `mybb_matches`
WHERE timestamp < $time AND winneruid is NOT NULL AND dispute != 3
GROUP BY player_id) b ON player2uid = b.player_id
LEFT JOIN (
SELECT IFNULL(SUM( rank ),0) as points,player_id
FROM `mybb_matchesgame`
GROUP BY player_id) c ON playeruid = c.player_id
WHERE gid = $id
ORDER BY a.points+b.points+c.points DESC;

How to find neighbour order by another colum mysql

My problem is,
I have a table in mysql
which colums are
id Student score
1 A 55
2 B 86
3 C 65
4 D 23
5 E 84
6 F 45
7 G 80
I want to find rank of any student in whole class based on score, with the student who scored just greater them him and an another student who scored just less them him.
for example if I am searching for student E
then output should be
id User score rank_in_classs
2 B 86 1
5 E 84 2
7 G 80 3
An another example can be that if I am looking for student A
id User score rank_in_classs
3 c 65 4
1 A 55 5
6 F 45 6
How can I find it using mysql query.
Thanks
Query
SELECT id, Student, score,
FIND_IN_SET( score, (
SELECT GROUP_CONCAT( score
ORDER BY score DESC )
FROM tbl )
) AS rank_in_class
FROM tbl
ORDER BY rank_in_class
LIMIT 3;
DEMO
Using the FIND_IN_SET() based solution proposed by Ullas you can locate a particular student +/- 1 rank this way:
set #this_rank := (
SELECT
FIND_IN_SET( score, (
SELECT GROUP_CONCAT( score
ORDER BY score DESC )
FROM tbl
)
) AS rank_in_class
FROM tbl
where student = 'A'
);
select
*
from (
SELECT
id
, Student
, score
, FIND_IN_SET( score, (
SELECT GROUP_CONCAT( score
ORDER BY score DESC )
FROM tbl
)
) AS rank_in_class
FROM tbl
) ric
where rank_in_class between #this_rank-1 AND #this_rank+1
ORDER BY rank_in_class
LIMIT 3;
Note: IF you stored the rank value in the table, then this would not be so cumbersome and could perform way better.
The best way to do that is probably with a mix of two queries and a little of PHP.
The first query retrieves the rank of the "focused" student you want;
The second query retrieves the range of all the students around the focused "student" with a dynamic LIMIT statement (this is where the use of PHP is unavoidable);
So something like this would probably do the job :
$user = 'C'; // Student user we want to "focus"
$range = 1; // Range around the "focus" : 1 before and 1 after (could be changed to anything else)
// First query : retrieving the rank of the "focused" student
$stmt = $mysqli->prepare('SELECT COUNT(*) AS Rank FROM Student AS Focused INNER JOIN Student as Others ON Others.Score > Focused.Score OR (Others.Score = Focused.Score AND Others.Id > Focused.Id) WHERE Focused.user = ?');
$stmt->bind_param('s',$user);
$stmt->execute();
$res = $stmt->get_result()->fetch_assoc();
$startRank = $res['Rank'];
// Computing the dynamic LIMIT
if (($startRank - $range) < 1) {
$offset = 0;
$rowCount = $startRank + $range + 1;
} else {
$offset = $startRank - $range;
$rowCount = ($range * 2)+1;
}
// Second query : retrieving the rank of all the students around the "focused" student
$stmt = $mysqli->prepare('SELECT id, user, score, #curRank := #curRank + 1 AS rank FROM Student, (SELECT #curRank := ?) Rank ORDER BY Score DESC, id DESC LIMIT ?, ?');
$stmt->bind_param('iii',$offset,$offset,$rowCount);
$stmt->execute();
This is probably the most optimized way to query the database to get what you want. As a bonus, you can change the range to whatever you like.

Categories