1

This is a really obvious data problem, but I can't find a simple solution anywhere.

Using TYPO3 QueryBuilder, how do you select the most recent entry for each user from a table that has multiple entries per user?

uid  user_id  value  crdate
1    1        0      123456
2    1        1      123400
3    2        1      123356
4    2        0      123300

I have tried loads of raw SQL approaches and eventually found a method that works, based on this solution - How can I SELECT rows with MAX(Column value), DISTINCT by another column in SQL?

SELECT * 
FROM `tx_tablename` AS `tt` 
INNER JOIN (
    SELECT `uid`, `user_id`, MAX(`crdate`) AS `MaxDateTime` 
    FROM `tx_tablename` 
    GROUP BY `user_id`
) AS `groupedtt` 
ON `tt`.`user_id` = `groupedtt`.`user_id` 
AND `tt`.`crdate` = `groupedtt`.`MaxDateTime` 
WHERE `tt`.`consent_content` = 3

But I can't see how to reproduce this in QueryBuilder, as the ->join() statement will only accept table names as parameters, not SQL, and ->join() will only accept one joining condition, not two.

Has anyone else found a solution that works in QueryBuilder? Many thanks

Oliver Hader
  • 4,093
  • 1
  • 25
  • 47
David Green
  • 103
  • 9

3 Answers3

2

The quoting is done in the TYPO3 QueryBuilder. You can bypass it by using the ConcreteQueryBuilder directly.

But doing this, you have to quote identifier yourself, or exceptions would be thrown.

This should do the trick in your pseudo code:

use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
...
$subQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
  ->getQueryBuilderForTable('tx_tablename');

$subQuery = $subQueryBuilder
  ->select('uid', 'user_id')
  ->from('tx_tablename')
  ->addSelectLiteral(
    $subQueryBuilder->expr()->max('crdate', 'max_crdate')
  )
  ->groupBy('user_id');

$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
  ->getQueryBuilderForTable('tx_tablename');

$queryResult = $queryBuilder
  ->select('a.*')
  ->from('tx_tablename', 'a')
;

$queryBuilder
    ->getConcreteQueryBuilder()
        ->innerJoin(
            $queryBuilder->quoteIdentifier('a'), // !!! important, quote identifier yourself
            '(' . $subQuery->getSQL() . ')',
            $queryBuilder->quoteIdentifier('b'), // !!! important, quote identifier yourself
            $queryBuilder->expr()->andX(
                $queryBuilder->expr()->eq('a.user_id', $queryBuilder->quoteIdentifier('b.user_id')),
                $queryBuilder->expr()->eq('a.crdate', $queryBuilder->quoteIdentifier('b.max_crdate'))
            ) // andX()
        ) // innerJoin()
;

$queryResult = $queryBuilder->execute();

edit 1

Fixed code example. Need quoteIdentifier() instead of createNamedParam().

Note

If you use nested selects/subselects AND using named parameters, you have to use the outermost queryBuilder instance for named parameter creation, not the current level queryBuilder.

Sybille Peters
  • 2,832
  • 1
  • 27
  • 49
Stefan Bürk
  • 524
  • 4
  • 8
0

You likely need a sub-query for this. Try the following.

use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
...
$subQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
  ->getQueryBuilderForTable('tx_tablename');

$subQuery = $subQueryBuilder
  ->select('uid', 'user_id')
  ->from('tx_tablename')
  ->addSelectLiteral(
    $subQueryBuilder->expr()->max('crdate', 'max_crdate')
  )
  ->groupBy('user_id');

$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
  ->getQueryBuilderForTable('tx_tablename');

$queryResult = $queryBuilder
  ->select('a.*')
  ->from('tx_tablename', 'a')
  ->innerJoin(
    'a',
    '(' . $subQuery->getSQL() . ')',
    'b',
    $queryBuilder->expr()->andX(
      $queryBuilder->expr()->eq('a.user_id', $queryBuilder->createNamedParameter('b.user_id', \PDO::PARAM_STR)),
      $queryBuilder->expr()->eq('a.crdate', $queryBuilder->createNamedParameter('b.max_crdate', \PDO::PARAM_STR))
    )
  )
  ->execute();

However, the code — as it stands now — produces double backticks (`) inside the innerJoin() query. I am not sure how to get rid off them, but the code shows the concept.

Michael
  • 645
  • 5
  • 12
  • 1
    Hi Michael, thank you so much for your answer. Apols for the delay in responding - Xmas family shut-down. I tried your approach last week and as you point out, the subquery gets double quoted - because the join-> parameter is expecting a table name and so adds quotes to anything supplied. This is what lead me to ask here, because a subquery didn't work. But thanks very much for trying it :-) – David Green Dec 26 '19 at 09:54
0

You're right — you cannot use sub-queries as arguments in join(), innerJoin(), leftJoin(), and rightJoin() in TYPO3 as these values are escaped using quoteIdentifier() (see TYPO3 v10.2 source code at GitHub) and backticks added.

I wonder if the following SQL query returns the result you are after:

SELECT `uid`, `user_id`, value, MAX(`crdate`)
FROM `tx_tablename`
GROUP BY `user_id`
HAVING MAX(`crdate`);

In this case the Doctrine code would look like the following:

use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
...
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
  ->getQueryBuilderForTable('tx_tablename');

$queryResult = $queryBuilder
  ->select('uid', 'user_id', 'value')
  ->from('tx_tablename')
  ->addSelectLiteral(
    $queryBuilder->expr()->max('crdate', 'crdate')
  )
  ->add('having', 'MAX(`crdate`)')
  ->groupBy('user_id')
  ->execute();
Michael
  • 645
  • 5
  • 12
  • Back from visiting relatives. Thanks again Michael - I've also previously tried this but it produces the wrong result. The GROUP BY approach splits the data into subgroups by user, but then outputs only the first record from each subgroup (not the most recent), with a crdate field showing the highest crdate from the whole subgroup. I attempted "ORDER BY `crdate` DESC" to make the newest records come first, but mySQL rejects this - it expects you to group first and then order. But that way when you reach the ORDER BY bit, the result is already reduced to 1 record per user - too late. Hmmm. – David Green Dec 30 '19 at 12:28