2

I am using a SqlDataProvider in Yii2 and here is the general example:

$count = Yii::$app->db->createCommand('
    SELECT COUNT(*) FROM user WHERE status=:status
', [':status' => 1])->queryScalar();

$dataProvider = new SqlDataProvider([
    'sql' => 'SELECT * FROM user WHERE status=:status',
    'params' => [':status' => 1],
    'totalCount' => $count,
    'sort' => [
        'attributes' => [
            'age',
            'name' => [
                'asc' => ['first_name' => SORT_ASC, 'last_name' => SORT_ASC],
                'desc' => ['first_name' => SORT_DESC, 'last_name' => SORT_DESC],
                'default' => SORT_DESC,
                'label' => 'Name',
            ],
        ],
    ],
    'pagination' => [
        'pageSize' => 20,
    ],
]);

You can see this does a COUNT in a query before the actual query within the SqlDataProvider that gets the actual data results.

However I would prefer to use SLC_CALC_FOUND_ROWS as this number is a more reliable method to get the correct number that matches the actual amount of rows returned by the query inside the DataProvider as it's possible that matching rows could get added or deleted between the COUNT query and the SqlDataProvider queries and hence I need something more reliable.

I could lock the tables, but I don't think that's such as wise idea, so I need to use SQL_CALC_FOUND_ROWS to get the correct amount but I am unsure how I can do it with a dataProvider.

This would be the code to do what I want:

$sql = $this->db->createCommand("SELECT FOUND_ROWS()");
$count = $sql->queryScalar();

$dataProvider->totalCount = $count;

...but that doesn't work, so as I said I am unsure how to implement the code to work with a SqlDataProvider.

Brett
  • 19,449
  • 54
  • 157
  • 290
  • 3
    SLC_CALC_FOUND_ROWS will slow down your script. count(*) uses indexes and works much faster, so you don't really need to think about "real count number" because chance that some query will inseret between this two queries is very very small. – Alexander R. Apr 18 '15 at 11:43
  • @AlexanderRavikovich That isn't necessarily true, it depends on your application. Some tests have shown it's slower, some have shown it's faster - however, if you are running a high volume website than I think it's best to go for accuracy over speed, no!? – Brett Apr 18 '15 at 12:22
  • I agree with @AlexanderRavikovich. You can use ArrayDataProvider for your goal without any problems. I tried this approach like year ago, and results were dissapointing. SQL_CALC_FOUND_ROWS works slow on InnoDB volumes and uses way more resources. If you running high volume website - best way to keep it fast and monitor resources usage, not the precision. And yes, chance is really smal to get insert in that time. All things were tested on grid for 40m records table. – ineersa Apr 20 '15 at 08:31
  • And 1 more thing, show me or explain where SQL_CALC_FOUND_ROWS would be faster then COUNT(). From my tests/experience on highload databases (managing site with ~500m records each db now on yii2), i never saw SQL_CALC_FOUND_ROWS fast, esp. on InnoDB, which fits more for big data then MyISAM. – ineersa Apr 20 '15 at 08:39
  • @ineersa I don't pretend to know for a fact which is faster, that's why I said it depends on the application. I did some research before posting this question as well and that's where I gained some of the info on where people stated their test results showed that sometimes `COUNT(*)` was faster and others said `SQL_CALC_FOUND_ROWS` was faster; so the indication from that is that it depends on the application. – Brett Apr 20 '15 at 17:21

4 Answers4

2

From my understanding, the way SqlDataProvider functions is as follows:

  • If no pagination is set, the dataprovider will query the DB and then count() the models generated by the result. This is the behavior you want.
  • If pagination is set it will either use the value provided by $totalCount, or if $totalCount == NULL will return the value of SqlDataProvider::prepareTotalCount() which is set to return 0. Not the behavior you want.

I don't think it is possible to both take advantage of pagination in your queries and get the exact total count without two queries. After all the whole point of pagination is to not have to handle all the returned elements.

I see two possibilities.

You either remove pagination and handle it separately. This is only really viable if you know your return set is going to be relatively small. In most practical situations this is not an option.

Which leads us to having to run two queries. If you're ok with the idea of two queries and you feel it is necessary to execute them as close together as possible, here's how you can proceed to get the best results:

  • Extend SqlDataProvider into a new class.. lets call it CustomSqlDataProvider
  • Set a new public $totalCountCommand property.
  • Write a prepareTotalCount() method to override the default behavior

Something along the lines of:

protected function prepareTotalCount()
{
    return $this->totalCountCommand->queryScalar();
}

Then you can simply create your dataprovider alongs the lines of :

$countCommand = Yii::$app->db->createCommand('
    SELECT COUNT(*) FROM user WHERE status=:status
', [':status' => 1]);

$dataProvider = new CustomSqlDataProvider([
    'sql' => 'SELECT * FROM user WHERE status=:status',
    'params' => [':status' => 1],
    'totalCountCommand' => $countCommand,
    'sort' => [
        'attributes' => [
            'age',
            'name' => [
                'asc' => ['first_name' => SORT_ASC, 'last_name' => SORT_ASC],
                'desc' => ['first_name' => SORT_DESC, 'last_name' => SORT_DESC],
                'default' => SORT_DESC,
                'label' => 'Name',
            ],
        ],
    ],
    'pagination' => [
        'pageSize' => 20,
    ],
]);

What this should do, is run your count query when the dataprovider gets the result set as opposed to what you initially had where the count was done when the dataprovider was set (well technically even before it was set)

PS: I have not tested this code, just read the yii2 code. It should, however, either work with minor tweaks, or set you on the correct path. Let me know if you need any extra info.

Pomme.Verte
  • 1,782
  • 1
  • 15
  • 32
  • That looks like a pretty good option and looks as though it should work fine. I will give it a run shortly. – Brett Apr 20 '15 at 11:20
  • Just got around to trying it out and no go; got a `1` returned, instead of the real number. Also tried my own version by leaving `SQL_CALC_FOUND_ROWS` in the main query and then putting `return Yii::$app->db->createCommand("SELECT FOUND_ROWS()")->queryScalar();` inside the `prepareTotalCount` method but got the same result. – Brett Apr 20 '15 at 16:10
  • Ok, I'll give this a try as well and play with it. I'll let you know if I find anything – Pomme.Verte Apr 20 '15 at 16:11
  • Ok, I've found a solution. There is actually no need to use `SQL_CALC_FOUND_ROWS` in your case since the count command is separated from the pagination logic and has no `LIMIT` clause. (ie: because we're making two separate queries in the end). Because of this you can achieve the results you want by using `COUNT(*)` and it should always return an accurate value **and be faster**. I've updated my answer to show this. If you're programmatically changing the queries so that the count command uses a variation of the dataprovider 'sql', then you just have to remove the `LIMIT` or set it to `NULL` – Pomme.Verte Apr 20 '15 at 17:01
  • Interesting; however how is this different to the Yii example in my original question? Like, both are running separate queries to get the actual amount of rows no!? Also, you say there is no `LIMIT` clause, but doesn't Yii automatically append that to the query when utilising pagination? – Brett Apr 20 '15 at 17:16
  • The difference is that in the first case you will run the count query before constructing then pass that number to the dataprovider. In the second case the dataprovider waits until it needs the information to run the query. If you're concerned with rows being added in between both queries, then the second option is your best bet for consistent data. – Pomme.Verte Apr 20 '15 at 17:20
  • As far as the provider adding the `LIMIT` this is true, but only on the 'sql' query. Not on our custom `$TotalCountCommand`. So it will work regardless of pagination. – Pomme.Verte Apr 20 '15 at 17:21
  • Alright cool - so basically it's a better method, but not bulletproof? haha...... also, how would I go about setting the same value to the pagination instances `totalCount` property? – Brett Apr 20 '15 at 17:24
  • The pagination `totalCount` should follow and be the same as that of the dataprovider :) – Pomme.Verte Apr 20 '15 at 17:40
  • As another option (and for full disclosure) you also have the option of doing what @crafter suggested and then putting `$queryResults` into an `ArrayDataProvider`. Though this may make filtering/sorting more complicated and/or less efficient. – Pomme.Verte Apr 20 '15 at 17:43
  • As for the pagination `totalCount` - do you mean don't set it specifically and it will take the count from that of the dataProvider? – Brett Apr 20 '15 at 18:11
  • I've tested the code above and it `$dataprovider->pagination->totalCount` displays the correct count so it seems like it should work out of the box. So yeah no need to set it specifically. – Pomme.Verte Apr 20 '15 at 18:13
  • Weird...... just tested it; `totalCount` displays fine, but I get `0` for the `totalCount` from the pagination instance. – Brett Apr 20 '15 at 18:24
  • Fixed it by setting `$this->pagination->totalCount` inside the `prepareTotalCount` method..... unless you can think of a cleaner way to do it? – Brett Apr 20 '15 at 18:28
  • That is weird.. I think what you did is fine, there certainly are no drawbacks. I think this might be a bug in yii. It looks like `getCount()` properly sets the pagination `totalCount`. But `getTotalCount()` doesn't. I might create an issue. – Pomme.Verte Apr 20 '15 at 18:43
  • 1
    That is interesting. Well guess we're done here. Thanks very much for the help! – Brett Apr 20 '15 at 18:54
  • 1
    As extra information this odd behavior was reported here: https://github.com/yiisoft/yii2/issues/8143 – Pomme.Verte Jun 01 '15 at 15:12
1

UPDATE: I have modified my answer to allow page validation.


I arrive over one year late, but I wanted to share that it is actually possible to use SQL_CALC_FOUND_ROWS within a SqlDataProvider in Yii2.

You need to extend the SqlDataProvider class to:

  • get the total count from SELECT FOUND_ROWS().
  • modify how the validatePage property of the pagination object works.

What is the problem?

If you have a look to the last lines of the prepareModels() method of the SqlDataProvider class (Yii 2.0.10)...

if ($pagination !== false) {
    $pagination->totalCount = $this->getTotalCount();
    $limit = $pagination->getLimit();
    $offset = $pagination->getOffset();
}

$sql = $this->db->getQueryBuilder()->buildOrderByAndLimit($sql, $orders, $limit, $offset);

return $this->db->createCommand($sql, $this->params)->queryAll();

...you will see that $this->getTotalCount() is called before running the query. Obviously, that is a problem if you want to use SELECT FOUND_ROWS() for the total count.

But, why does it need to call it in advance? After all, at that point it hasn't started to build the pager yet. Well, the pagination object needs the total count just for validating the current page index.

The getOffset() method calls getPage() for the calculation, which calls getQueryParam() to obtain the current requested page. After doing so, getPage() calls setPage($page, true). And here is when the total count is necessary: setPage() will call getPageCount() to ensure that the requested page is within the boundaries.

What is the solution?

To extend the SqlDataProvider class, setting the validatePage property of the pagination object to false until we have executed our query. Then we can get the total count from SELECT FOUND_ROWS() and enable a custom page validation.

Our new custom data provider could be something like this:

use Yii;
use yii\data\SqlDataProvider;

class CustomDataProvider extends SqlDataProvider
{
    protected function prepareModels()
    {
        // we set the validatePage property to false temporarily 
        $pagination = $this->getPagination();
        $validatePage = $pagination->validatePage;
        $pagination->validatePage = false;

        // call parent method 
        $dataModels = parent::prepareModels();

        // get total count
        $count = Yii::$app->db->createCommand( 'SELECT FOUND_ROWS()' )->queryScalar();

        // both the data provider and the pagination object need to know the total count 
        $this->setTotalCount( $count );
        $pagination->totalCount = $count;

        // custom page validation       
        $pagination->validatePage = $validatePage;
        // getPage(true) returns a validated page index if $validatePage is also true
        if ( $pagination->getPage(false) != $pagination->getPage(true) ) { 
            return $this->prepareModels();
            // or if you favor performance over precision (and fear recursion) *maybe* is better:
            //$this->sql = str_replace( 'SQL_CALC_FOUND_ROWS ', '', $this->sql);
            //return parent::prepareModels();
        }

        return $dataModels;
    }
}

And we can use it in this way:

$dataProvider = new CustomDataProvider([
    'sql' => 'SELECT SQL_CALC_FOUND_ROWS ...';
    //'totalCount' => $count, // this is not necessary any longer!
    // more properties like 'pagination', 'sort', 'params', etc.
]);

Is there any drawback?

Well, our new custom page validation is less efficient: if the page doesn't pass the validation it will need one extra query.

How does the page validation work?

Imaging that you have 100 items and you use a data provider with pageSize => 20 to display data in a ListView. The pager will show links to navigate through 5 pages but, in some cases, the user can try to access page 6: because he manually modifies the URL, because the number of records changes since he last loaded the page (like in @Brett's example) or because he follows an old link.

How does the data provider manage the situation? If validatePage is set to...

  • false (whatever provider): it will try to query for the page 6 with a SQL like: SELECT ... LIMIT 20 OFFSET 100. It will get an empty dataset and the widget will output 'No results found.'
  • true (SqlDataProvider): it will detect beforehand that the last available page is the number 5 and it will query for it with SELECT ... LIMIT 20 OFFSET 80.
  • true (CustomDataProvider): it will try to query for the page 6, get an empty dataset, realize afterwards that page 6 doesn't exist and query again for page 5.

IMO, it is not a big deal because getting into a non-existing page will happen very rarely.

Is this actually necessary?

The OP wanted this approach to ensure that both the counting and the actual query are executed as close as possible. Maybe you want it for performance.

In any case, you should read the comments to the question from @AlexanderRavikovich and @ineersa. It is counterintuitive, but in a lot of cases a second count(*) query can be faster than using SQL_CALC_FOUND_ROWS.

There is a lot written about it, don't bother too much: it depends a lot on your query and the database version. The best you can do is to test both ways before implementing a custom data provider.

Final notes:

If you really care about precision, consider this scenario:

  • If count(*) fails, it will normally fail for a few records.
  • If SELECT FOUND_ROWS() fails... well, it can be an epic fail!

And if you really care about performance, there are some nice suggestions in the answers to this other question (take it with a grain of salt, it is very old), I like this one specially.

Community
  • 1
  • 1
David
  • 6,695
  • 3
  • 29
  • 46
  • Thanks for the extra information David. It's been so long since I worked on this particular situation I would need to go over the code again to revisit it's inner workings and understand how it all works again lol..... if I ever decide to revisit it I will definitely take a look at if it could be improved with this method. – Brett Nov 18 '16 at 09:01
  • Though my only concern is the lack of page validation; no I'm not worried about users manually modifying the page number and getting a not results page, but I guess there could be a situation where a user lets a page sit idle for a while, in that time an admin deletes some content which then makes the last page "page 4", user then clicks "page 5" and gets no results page and they are left wondering what happened lol........ with page validation they would end up back on page 4 but seeing new content. – Brett Nov 18 '16 at 09:05
  • 1
    @Brett. Good point! I have updated my answer. It seems that you always aware about a possible modification in the number of matching records between two actions :) – David Nov 18 '16 at 22:10
  • Haha I have to be :) Thanks for the modifications to your answer though, I'm sure it will come in handy to many and is something I will be considering implementing if I decide the current solution should be changed :) – Brett Nov 19 '16 at 07:47
0

Create your query like this :

$queryResults  = Yii::app()->db->createCommand()
    ->select('SQL_CALC_FOUND_ROWS (0), ' .
             'table1.column_1, table1.column_n')
    ->from('table1')
    ->where('status=1')
    ->queryAll();

$totalRecords =  Yii::app()->db
    ->createCommand('SELECT FOUND_ROWS()')
    ->queryScalar();
$totalFetched =  count($queryResults);

echo 'Fetched '.$totalFetched.' of '.$totalRecords.' records.';
crafter
  • 6,246
  • 1
  • 34
  • 46
  • I'm not sure I understand. Could you provide an example whilst using the `SqlDataProvider`? Also, your example uses `Yii1`, I'm using `Yii2`. – Brett Apr 17 '15 at 07:25
  • Using SQL_CALC_FOUND_ROWS will not bring you benefit is using SqlDataProvider. Instead, use 'totalCount' => $this->db->createCommand("SELECT * FROM user")->where("status=:status", array(":status"=>1)->queryScalar(), – crafter Apr 18 '15 at 11:34
  • @crafter I hope you mean SELECT COUNT(*) FROM user – Alexander R. Apr 18 '15 at 11:46
  • Yes, and no. If you want to show data in a grid for example with 50% active users , and want to show only active users, you might want to say "showing 1-10 of 1000 (active) users", or showing 1-10 of 2000 (all users)". In the latter part, the grid will show 1000 users, show the value 2000 might not be appropriate here. You decide. – crafter Apr 18 '15 at 11:56
  • 1
    In any scenario, few COUNT(*) queries will work much faster and will use much lesser memory then using SELECT * FROM only for counting! If I have 100k user table even with 10-20 fields, and say 50% is active, your SELECT * FROM user will kill the server. This is wrong to SELECT data for counting, instead od doing COUNT thatwas developed to do that in efficient way. – Alexander R. Apr 18 '15 at 17:49
  • My bad, @AlexanderRavikovich. I did mean "select count(). ...". I didn't read your comment properly, and was focused on the WHERE clause. Cannot edit the comment now. – crafter Apr 18 '15 at 21:24
0

You need 2 queries in order to get total rows if you want to use SQL_CALC_FOUND_ROWS instead of COUNT. Here SELECT SQL_CALC_FOUND_ROWS * FROM tbl_name WHERE status = 1 LIMIT 10; will return to you result of the query which is limited by 10 rows, but because you are using SQL_CALC_FOUND_ROWS it will count total number of items which fit query conditins and remember it. After that you use SELECT FOUND_ROWS(); to get this number from dbms.

ADDED

Code that i wrote to test it on one of my projects:

Yii::$app->db->createCommand('SELECT SQL_CALC_FOUND_ROWS * FROM {{%articles}} LIMIT 1')->queryScalar();
$count = Yii::$app->db->createCommand('SELECT FOUND_ROWS()')->queryScalar();
$dataProvider = new \yii\data\SqlDataProvider([
    'sql' => 'SELECT * FROM {{%articles}}',
    'totalCount' => $count,
    'pagination' => [
        'pageSize' => 2,
    ],
]);
echo $count . ' ' . count($dataProvider->getModels());

It output me 5 2 where 5 is the total number of items and 2 is number of items fetched for page

Tony
  • 5,797
  • 3
  • 26
  • 22
  • Please read my whole question, I have already indicated I know how to get the value from `SQL_CALC_FOUND_ROWS` in *normal instances*, but I am trying to get it to work with the `SqlDataProvider`. – Brett Apr 18 '15 at 12:26
  • I'm not sure how that is any different than doing a `COUNT(*)` beforehand; the `SQL_CALC_FOUND_ROWS` has to come *after* the query inside the `SqlDataProvider` is run. – Brett Apr 18 '15 at 13:45
  • maybe you should check yii2 debugger and see queries log. if you want to do `SELECT FOUND_ROWS()` _after_ the query in `SqlDataProvider` make sure that it goes _exactly_ after that query. – Tony Apr 18 '15 at 15:43
  • you can create your own `dataprovider` class inherited from `SqlDataProvider` and override `prepareTotalCount()` method – Tony Apr 19 '15 at 06:15
  • I suppose that's something to look at :) – Brett Apr 19 '15 at 06:31