diff --git a/plugins/Live/Model.php b/plugins/Live/Model.php index 44930dccb8b..072724fa2f7 100644 --- a/plugins/Live/Model.php +++ b/plugins/Live/Model.php @@ -69,33 +69,31 @@ public function queryLogVisits($idSite, $period, $date, $segment, $offset, $limi $queries = $this->splitDatesIntoMultipleQueries($dateStart, $dateEnd, $limit, $offset, $filterSortOrder); $foundVisits = array(); + $remainingOffset = $offset; foreach ($queries as $queryRange) { $updatedLimit = $limit; if (!empty($limit) && (int)$limit > -1) { $updatedLimit = $limit - count($foundVisits); + if ($updatedLimit <= 0) { + break; + } } - $updatedOffset = $offset; - if (!empty($offset) && !empty($foundVisits)) { - $updatedOffset = 0; // we've already skipped enough rows - } - - [$sql, $bind] = $this->makeLogVisitsQueryString($idSite, $queryRange[0], $queryRange[1], $segment, $updatedOffset, $updatedLimit, $visitorId, $minTimestamp, $filterSortOrder); - + [$sql, $bind] = $this->makeLogVisitsQueryString($idSite, $queryRange[0], $queryRange[1], $segment, $remainingOffset, $updatedLimit, $visitorId, $minTimestamp, $filterSortOrder); $visits = $this->executeLogVisitsQuery($sql, $bind, $segment, $dateStart, $dateEnd, $minTimestamp, $limit); - if (!empty($offset) && empty($visits)) { - // find out if there are any matches - $updatedOffset = 0; - [$sql, $bind] = $this->makeLogVisitsQueryString($idSite, $queryRange[0], $queryRange[1], $segment, $updatedOffset, $updatedLimit, $visitorId, $minTimestamp, $filterSortOrder); - - $visits = $this->executeLogVisitsQuery($sql, $bind, $segment, $dateStart, $dateEnd, $minTimestamp, $limit); - if (!empty($visits)) { - // found out the number of visits that we skipped in this query - $offset = $offset - count($visits); + if (!empty($remainingOffset)) { + if (empty($visits)) { + // No visits returned - need to count total in range to adjust offset + $totalInRange = $this->countLogVisitsInRange($idSite, $queryRange[0], $queryRange[1], $segment, $visitorId, $minTimestamp); + $remainingOffset = max(0, $remainingOffset - $totalInRange); + continue; + } else { + // Visits returned - these are already AFTER the offset was applied by SQL + // So the offset is now fulfilled + $remainingOffset = 0; } - continue; } if (!empty($visits)) { @@ -122,6 +120,53 @@ public function queryLogVisits($idSite, $period, $date, $segment, $offset, $limi return $foundVisits; } + /** + * Count visits in a time range without loading all data into memory + * Uses SQL COUNT(*) for efficiency + * + * @param int|array $idSite + * @param Date $dateStart + * @param Date $dateEnd + * @param string $segment + * @param string $visitorId + * @param int $minTimestamp + * @return int + * @throws Exception + */ + private function countLogVisitsInRange($idSite, $dateStart, $dateEnd, $segment, $visitorId, $minTimestamp) + { + [$whereClause, $bindIdSites] = $this->getIdSitesWhereClause($idSite); + [$whereBind, $where] = $this->getWhereClauseAndBind($whereClause, $bindIdSites, $dateStart, $dateEnd, $visitorId, $minTimestamp); + + $segment = new Segment($segment, $idSite, $dateStart, $dateEnd); + + // Use COUNT(*), do not load all data + $select = "COUNT(*) as count"; + $from = "log_visit"; + + if ($segment->isEmpty()) { + $groupBy = false; + } else { + // When segment is used, we need to count distinct visits + $select = "COUNT(DISTINCT log_visit.idvisit) as count"; + $groupBy = false; // No GROUP BY needed when using COUNT(DISTINCT) + } + + $query = $segment->getSelectQuery($select, $from, $where, $whereBind, $orderBy = '', $groupBy); + + $query['sql'] = DbHelper::addMaxExecutionTimeHintToQuery($query['sql'], $this->getLiveQueryMaxExecutionTime()); + + $readerDb = Db::getReader(); + try { + $result = $readerDb->fetchOne($query['sql'], $query['bind']); + } catch (Exception $e) { + $this->handleMaxExecutionTimeError($readerDb, $e, $segment->getOriginalString(), $dateStart, $dateEnd, $minTimestamp, 0, $query); + throw $e; + } + + return (int)$result; + } + /** * Return the most recent date time of any visit for the given idSite * If period / date are provided the method return the most recent date time within that period @@ -262,7 +307,7 @@ public function splitDatesIntoMultipleQueries($dateStart, $dateEnd, $limit, $off { $virtualDateEnd = $dateEnd; if (empty($dateEnd)) { - $virtualDateEnd = Date::now()->addDay(1); // matomo always adds one day for some reason + $virtualDateEnd = Date::now()->addDay(1); } $virtualDateStart = $dateStart; @@ -272,72 +317,85 @@ public function splitDatesIntoMultipleQueries($dateStart, $dateEnd, $limit, $off $queries = []; $hasStartEndDateMoreThanOneDayInBetween = $virtualDateStart && $virtualDateStart->addDay(1)->isEarlier($virtualDateEnd); - if ( - $limit - && $hasStartEndDateMoreThanOneDayInBetween - ) { - if (strtolower($filterSortOrder) !== 'asc') { - $virtualDateEnd = $virtualDateEnd->subDay(1); - $queries[] = [$virtualDateEnd, $dateEnd]; // need to use ",endDate" in case endDate is not set - if ($virtualDateStart->addDay(7)->isEarlier($virtualDateEnd)) { - $queries[] = [$virtualDateEnd->subDay(7), $virtualDateEnd->subSeconds(1)]; - $virtualDateEnd = $virtualDateEnd->subDay(7); + if ($limit && $hasStartEndDateMoreThanOneDayInBetween) { + if (strtolower($filterSortOrder) !== 'asc') { + // DESC: From newest to oldest + $currentEnd = $virtualDateEnd; + + // First query: last day + $blockStart = $currentEnd->subDay(1); + $queries[] = [$blockStart, $dateEnd]; + $currentEnd = $blockStart; + + // 7-day block - only if enough space + if ($virtualDateStart->addDay(7)->isEarlier($currentEnd)) { + $blockStart = $currentEnd->subDay(7); + $queries[] = [$blockStart, $currentEnd->subSeconds(1)]; + $currentEnd = $blockStart; } if (!$offset) { - // only when no offset - // we would in worst case - if not enough visits are found to bypass the offset - execute below queries too often. - // like we would need to execute each of the queries twice just to find out if there are some visits that - // need to be skipped... - - if ($virtualDateStart->addDay(30)->isEarlier($virtualDateEnd)) { - $queries[] = [$virtualDateEnd->subDay(30), $virtualDateEnd->subSeconds(1)]; - $virtualDateEnd = $virtualDateEnd->subDay(30); + // 30-day block - only if enough space + if ($virtualDateStart->addDay(30)->isEarlier($currentEnd)) { + $blockStart = $currentEnd->subDay(30); + $queries[] = [$blockStart, $currentEnd->subSeconds(1)]; + $currentEnd = $blockStart; } - if ($virtualDateStart->addPeriod(1, 'year')->isEarlier($virtualDateEnd)) { - $queries[] = [$virtualDateEnd->subYear(1), $virtualDateEnd->subSeconds(1)]; - $virtualDateEnd = $virtualDateEnd->subYear(1); + + // 1-year block - only if enough space + if ($virtualDateStart->addPeriod(1, 'year')->isEarlier($currentEnd)) { + $blockStart = $currentEnd->subYear(1); + $queries[] = [$blockStart, $currentEnd->subSeconds(1)]; + $currentEnd = $blockStart; } } - if ($virtualDateStart->isEarlier($virtualDateEnd)) { - // need to use ",endDate" in case startDate is not set in which case we do not want to have any limit - $queries[] = [$dateStart, $virtualDateEnd->subSeconds(1)]; + // Rest + if ($virtualDateStart->isEarlier($currentEnd)) { + $queries[] = [$dateStart, $currentEnd->subSeconds(1)]; } } else { - $queries[] = [$virtualDateStart, $virtualDateStart->addDay(1)->subSeconds(1)]; - $virtualDateStart = $virtualDateStart->addDay(1); - - if ($virtualDateStart->addDay(7)->isEarlier($virtualDateEnd)) { - $queries[] = [$virtualDateStart, $virtualDateStart->addDay(7)->subSeconds(1)]; - $virtualDateStart = $virtualDateStart->addDay(7); + // ASC: From oldest to newest + $currentStart = $virtualDateStart; + + // First query: first day + $blockEnd = $currentStart->addDay(1); + $queries[] = [$currentStart, $blockEnd->subSeconds(1)]; + $currentStart = $blockEnd; + + // 7-day block - only if enough space + if ($currentStart->addDay(7)->isEarlier($virtualDateEnd)) { + $blockEnd = $currentStart->addDay(7); + $queries[] = [$currentStart, $blockEnd->subSeconds(1)]; + $currentStart = $blockEnd; } if (!$offset) { - // only when no offset - // we would in worst case - if not enough visits are found to bypass the offset - execute below queries too often. - // like we would need to execute each of the queries twice just to find out if there are some visits that - // need to be skipped... - - if ($virtualDateStart->addDay(30)->isEarlier($virtualDateEnd)) { - $queries[] = [$virtualDateStart, $virtualDateStart->addDay(30)->subSeconds(1)]; - $virtualDateStart = $virtualDateStart->addDay(30); + // 30-day block - only if enough space + if ($currentStart->addDay(30)->isEarlier($virtualDateEnd)) { + $blockEnd = $currentStart->addDay(30); + $queries[] = [$currentStart, $blockEnd->subSeconds(1)]; + $currentStart = $blockEnd; } - if ($virtualDateStart->addPeriod(1, 'year')->isEarlier($virtualDateEnd)) { - $queries[] = [$virtualDateStart, $virtualDateStart->addPeriod(1, 'year')->subSeconds(1)]; - $virtualDateStart = $virtualDateStart->addPeriod(1, 'year'); + + // 1-year block - only if enough space + if ($currentStart->addPeriod(1, 'year')->isEarlier($virtualDateEnd)) { + $blockEnd = $currentStart->addPeriod(1, 'year'); + $queries[] = [$currentStart, $blockEnd->subSeconds(1)]; + $currentStart = $blockEnd; } } - if ($virtualDateStart->isEarlier($virtualDateEnd)) { - // need to use ",endDate" in case startDate is not set in which case we do not want to have any limit - $queries[] = [$virtualDateStart, $dateEnd]; + // Rest + if ($currentStart->isEarlier($virtualDateEnd)) { + $queries[] = [$currentStart, $dateEnd]; } } } else { $queries[] = array($dateStart, $dateEnd); } + return $queries; } diff --git a/plugins/Live/tests/Integration/ModelTest.php b/plugins/Live/tests/Integration/ModelTest.php index ee9ad9e3b74..b9d6c67b07e 100644 --- a/plugins/Live/tests/Integration/ModelTest.php +++ b/plugins/Live/tests/Integration/ModelTest.php @@ -33,6 +33,7 @@ public function setUp(): void { parent::setUp(); + Fixture::createSuperUser(); $this->setSuperUser(); Fixture::createWebsite('2010-01-01'); } @@ -40,7 +41,7 @@ public function setUp(): void public function testGetStandAndEndDateUsesNowWhenDateOutOfRange() { $model = new Model(); - list($dateStart, $dateEnd) = $model->getStartAndEndDate($idSite = 1, 'year', (date('Y') + 1) . '-01-01'); + [$dateStart, $dateEnd] = $model->getStartAndEndDate($idSite = 1, 'year', (date('Y') + 1) . '-01-01'); $validDates = $this->getValidNowDates(); @@ -52,7 +53,7 @@ public function testGetStandAndEndDateUsesNowWhenDateOutOfRange() public function testGetStandAndEndDateUsesNowWhenEndDateOutOfRange() { $model = new Model(); - list($dateStart, $dateEnd) = $model->getStartAndEndDate($idSite = 1, 'year', date('Y') . '-01-01'); + [$dateStart, $dateEnd] = $model->getStartAndEndDate($idSite = 1, 'year', date('Y') . '-01-01'); $validDates = $this->getValidNowDates(); @@ -157,7 +158,7 @@ public function testQueryAdjacentVisitorIdMaxExecutionTime() public function testGetStandAndEndDate() { $model = new Model(); - list($dateStart, $dateEnd) = $model->getStartAndEndDate($idSite = 1, 'year', '2018-02-01'); + [$dateStart, $dateEnd] = $model->getStartAndEndDate($idSite = 1, 'year', '2018-02-01'); $this->assertEquals('2018-01-01 00:00:00', $dateStart->getDatetime()); $this->assertEquals('2019-01-01 00:00:00', $dateEnd->getDatetime()); @@ -214,8 +215,8 @@ public function testIsLookingAtMoreThanOneDayWhenStartAndEndDateIsSetMoreThanOne public function testMakeLogVisitsQueryString() { $model = new Model(); - list($dateStart, $dateEnd) = $model->getStartAndEndDate($idSite = 1, 'month', '2010-01-01'); - list($sql, $bind) = $model->makeLogVisitsQueryString( + [$dateStart, $dateEnd] = $model->getStartAndEndDate($idSite = 1, 'month', '2010-01-01'); + [$sql, $bind] = $model->makeLogVisitsQueryString( $idSite = 1, $dateStart, $dateEnd, @@ -249,8 +250,8 @@ public function testMakeLogVisitsQueryStringWithMultipleIdSites() }); $model = new Model(); - list($dateStart, $dateEnd) = $model->getStartAndEndDate($idSite = 1, 'month', '2010-01-01'); - list($sql, $bind) = $model->makeLogVisitsQueryString( + [$dateStart, $dateEnd] = $model->getStartAndEndDate($idSite = 1, 'month', '2010-01-01'); + [$sql, $bind] = $model->makeLogVisitsQueryString( $idSite = 1, $dateStart, $dateEnd, @@ -283,8 +284,8 @@ public function testMakeLogVisitsQueryStringWithOffset() { $model = new Model(); - list($dateStart, $dateEnd) = $model->getStartAndEndDate($idSite = 1, 'month', '2010-01-01'); - list($sql, $bind) = $model->makeLogVisitsQueryString( + [$dateStart, $dateEnd] = $model->getStartAndEndDate($idSite = 1, 'month', '2010-01-01'); + [$sql, $bind] = $model->makeLogVisitsQueryString( $idSite = 1, $dateStart, $dateEnd, @@ -315,8 +316,8 @@ public function testMakeLogVisitsQueryStringWithOffset() public function testMakeLogVisitsQueryStringWhenSegment() { $model = new Model(); - list($dateStart, $dateEnd) = $model->getStartAndEndDate($idSite = 1, 'month', '2010-01-01'); - list($sql, $bind) = $model->makeLogVisitsQueryString( + [$dateStart, $dateEnd] = $model->getStartAndEndDate($idSite = 1, 'month', '2010-01-01'); + [$sql, $bind] = $model->makeLogVisitsQueryString( $idSite = 1, $dateStart, $dateEnd, @@ -357,8 +358,8 @@ public function testMakeLogVisitsQueryStringAddsMaxExecutionHintIfConfigured() Db\Schema::unsetInstance(); $model = new Model(); - list($dateStart, $dateEnd) = $model->getStartAndEndDate($idSite = 1, 'month', '2010-01-01'); - list($sql, $bind) = $model->makeLogVisitsQueryString( + [$dateStart, $dateEnd] = $model->getStartAndEndDate($idSite = 1, 'month', '2010-01-01'); + [$sql, $bind] = $model->makeLogVisitsQueryString( $idSite = 1, $dateStart, $dateEnd, @@ -382,8 +383,8 @@ public function testMakeLogVisitsQueryStringDoesNotAddsMaxExecutionHintForVisito $this->setMaxExecutionTime(30); $model = new Model(); - list($dateStart, $dateEnd) = $model->getStartAndEndDate($idSite = 1, 'month', '2010-01-01'); - list($sql, $bind) = $model->makeLogVisitsQueryString( + [$dateStart, $dateEnd] = $model->getStartAndEndDate($idSite = 1, 'month', '2010-01-01'); + [$sql, $bind] = $model->makeLogVisitsQueryString( $idSite = 1, $dateStart, $dateEnd, @@ -523,6 +524,230 @@ private function splitDatesIntoMultipleQueries($startDate, $endDate, $limit, $of }, $queries); } + public function testQueryLogVisitsSkipsOffsetAcrossSplitDateRanges() + { + // build visits across 3 days so the query will be split into multiple ranges + $this->trackVisitAtTime('2010-01-01 03:00:00'); + $this->trackVisitAtTime('2010-01-01 06:10:00'); + $this->trackVisitAtTime('2010-01-01 09:20:00'); + $this->trackVisitAtTime('2010-01-02 03:00:00'); + $this->trackVisitAtTime('2010-01-02 06:10:00'); + $this->trackVisitAtTime('2010-01-03 03:00:00'); + $this->trackVisitAtTime('2010-01-03 06:10:00'); + $this->trackVisitAtTime('2010-01-03 09:20:00'); + + $this->assertEquals(8, $this->countVisitsBetween('2010-01-01 00:00:00', '2010-01-04 00:00:00')); + + $model = new Model(); + + // sanity check: without offset we see all visits + $allVisits = $model->queryLogVisits( + 1, + 'range', + '2010-01-01,2010-01-03', + $segment = '', + $offset = 0, + $limit = 10, + $visitorId = false, + $minTimestamp = false, + $filterSortOrder = 'desc' + ); + $this->assertCount(8, $allVisits); + + $visits = $model->queryLogVisits( + 1, + 'range', + '2010-01-01,2010-01-03', + $segment = '', + $offset = 6, + $limit = 2, + $visitorId = false, + $minTimestamp = false, + $filterSortOrder = 'desc' + ); + + $this->assertCount(2, $visits); + $this->assertEquals('2010-01-01 06:10:00', $visits[0]['visit_last_action_time']); + $this->assertEquals('2010-01-01 03:00:00', $visits[1]['visit_last_action_time']); + } + + public function testQueryLogVisitsOffsetAcrossSplitDateRangesAscending() + { + $this->trackVisitAtTime('2010-02-01 03:00:00'); + $this->trackVisitAtTime('2010-02-01 06:10:00'); + $this->trackVisitAtTime('2010-02-01 09:20:00'); + $this->trackVisitAtTime('2010-02-02 03:00:00'); + $this->trackVisitAtTime('2010-02-02 06:10:00'); + $this->trackVisitAtTime('2010-02-03 03:00:00'); + $this->trackVisitAtTime('2010-02-03 06:10:00'); + $this->trackVisitAtTime('2010-02-03 09:20:00'); + + $this->assertEquals(8, $this->countVisitsBetween('2010-02-01 00:00:00', '2010-02-04 00:00:00')); + + $model = new Model(); + + $visits = $model->queryLogVisits( + 1, + 'range', + '2010-02-01,2010-02-03', + $segment = '', + $offset = 6, + $limit = 2, + $visitorId = false, + $minTimestamp = false, + $filterSortOrder = 'asc' + ); + + $this->assertCount(2, $visits); + $this->assertEquals('2010-02-03 06:10:00', $visits[0]['visit_last_action_time']); + $this->assertEquals('2010-02-03 09:20:00', $visits[1]['visit_last_action_time']); + } + + public function testQueryLogVisitsOffsetAcrossYearRange() + { + $dates = [ + '2010-01-01 03:00:00', + '2010-02-01 03:00:00', + '2010-03-01 03:00:00', + '2010-04-01 03:00:00', + '2010-05-01 03:00:00', + '2010-06-01 03:00:00', + '2010-07-01 03:00:00', + '2010-08-01 03:00:00', + '2010-09-01 03:00:00', + '2010-10-01 03:00:00', + '2010-11-01 03:00:00', + '2010-12-01 03:00:00', + '2011-01-01 03:00:00', + ]; + + foreach ($dates as $dateTime) { + $this->trackVisitAtTime($dateTime); + } + + $this->assertEquals(13, $this->countVisitsBetween('2010-01-01 00:00:00', '2011-02-01 00:00:00')); + + $model = new Model(); + $visits = $model->queryLogVisits( + 1, + 'range', + '2010-01-01,2011-01-01', + $segment = '', + $offset = 10, + $limit = 2, + $visitorId = false, + $minTimestamp = false, + $filterSortOrder = 'desc' + ); + + $this->assertCount(2, $visits); + $this->assertEquals('2010-03-01 03:00:00', $visits[0]['visit_last_action_time']); + $this->assertEquals('2010-02-01 03:00:00', $visits[1]['visit_last_action_time']); + } + + public function testQueryLogVisitsOffsetBeyondTotalReturnsEmpty() + { + $this->trackVisitAtTime('2010-04-01 03:00:00'); + $this->trackVisitAtTime('2010-04-01 06:10:00'); + $this->trackVisitAtTime('2010-04-02 03:00:00'); + + $this->assertEquals(3, $this->countVisitsBetween('2010-04-01 00:00:00', '2010-04-03 00:00:00')); + + $model = new Model(); + $visits = $model->queryLogVisits( + 1, + 'range', + '2010-04-01,2010-04-02', + $segment = '', + $offset = 10, + $limit = 5, + $visitorId = false, + $minTimestamp = false, + $filterSortOrder = 'desc' + ); + + $this->assertSame([], $visits); + } + + public function testQueryLogVisitsOffsetWeekPeriod() + { + $this->trackVisitAtTime('2010-01-05 01:00:00'); + $this->trackVisitAtTime('2010-01-06 01:00:00'); + $this->trackVisitAtTime('2010-01-07 01:00:00'); + + $this->assertEquals(3, $this->countVisitsBetween('2010-01-04 00:00:00', '2010-01-08 00:00:00')); + + $model = new Model(); + $visits = $model->queryLogVisits( + 1, + 'week', + '2010-01-05', + $segment = '', + $offset = 1, + $limit = 1, + $visitorId = false, + $minTimestamp = false, + $filterSortOrder = 'desc' + ); + + $this->assertCount(1, $visits); + $this->assertEquals('2010-01-06 01:00:00', $visits[0]['visit_last_action_time']); + } + + public function testQueryLogVisitsOffsetMonthPeriod() + { + $this->trackVisitAtTime('2010-06-05 01:00:00'); + $this->trackVisitAtTime('2010-06-10 01:00:00'); + $this->trackVisitAtTime('2010-06-20 01:00:00'); + $this->trackVisitAtTime('2010-06-25 01:00:00'); + + $this->assertEquals(4, $this->countVisitsBetween('2010-06-01 00:00:00', '2010-07-01 00:00:00')); + + $model = new Model(); + $visits = $model->queryLogVisits( + 1, + 'month', + '2010-06-15', + $segment = '', + $offset = 2, + $limit = 1, + $visitorId = false, + $minTimestamp = false, + $filterSortOrder = 'desc' + ); + + $this->assertCount(1, $visits); + $this->assertEquals('2010-06-10 01:00:00', $visits[0]['visit_last_action_time']); + } + + public function testQueryLogVisitsOffsetYearPeriod() + { + $this->trackVisitAtTime('2011-01-01 03:00:00'); + $this->trackVisitAtTime('2011-03-01 03:00:00'); + $this->trackVisitAtTime('2011-06-01 03:00:00'); + $this->trackVisitAtTime('2011-09-01 03:00:00'); + $this->trackVisitAtTime('2011-12-01 03:00:00'); + + $this->assertEquals(5, $this->countVisitsBetween('2011-01-01 00:00:00', '2012-01-01 00:00:00')); + + $model = new Model(); + $visits = $model->queryLogVisits( + 1, + 'year', + '2011-01-01', + $segment = '', + $offset = 3, + $limit = 2, + $visitorId = false, + $minTimestamp = false, + $filterSortOrder = 'desc' + ); + + $this->assertCount(2, $visits); + $this->assertEquals('2011-03-01 03:00:00', $visits[0]['visit_last_action_time']); + $this->assertEquals('2011-01-01 03:00:00', $visits[1]['visit_last_action_time']); + } + protected function setSuperUser() { FakeAccess::$superUser = true; @@ -557,4 +782,22 @@ private function trackPageView(): void $t->setVisitorId(substr(sha1('X4F66G776HGI'), 0, 16)); $t->doTrackPageView('foo'); } + + private function trackVisitAtTime(string $dateTime): void + { + $t = Fixture::getTracker(1, $dateTime, $defaultInit = true); + $t->setTokenAuth(Fixture::getTokenAuth()); + $t->setNewVisitorId(); + $t->setForceVisitDateTime($dateTime); + $t->setUrl('http://example.org/' . str_replace([' ', ':'], '-', $dateTime)); + Fixture::checkResponse($t->doTrackPageView('Visit at ' . $dateTime)); + } + + private function countVisitsBetween(string $startDate, string $endDate): int + { + return (int) Db::fetchOne( + 'SELECT COUNT(*) FROM ' . Common::prefixTable('log_visit') . ' WHERE visit_last_action_time >= ? AND visit_last_action_time <= ? AND idsite = ?', + [$startDate, $endDate, 1] + ); + } }