Skip to content

Commit 0d964f0

Browse files
authored
Merge pull request #2562 from acelaya-forks/visits-export
Allow exporting visits in CSV format
2 parents a65c5c3 + 248e803 commit 0d964f0

15 files changed

+167
-57
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
1212
* `after-date`: matches when current date and time is later than the defined threshold.
1313

1414
* [#2513](https://github.com/shlinkio/shlink/issues/2513) Add support for redis connections via unix socket (e.g. `REDIS_SERVERS=unix:/path/to/redis.sock`).
15+
* Visits generated in the command line can now be formatted in CSV, via `--format=csv`.
1516

1617
### Changed
1718
* [#2522](https://github.com/shlinkio/shlink/issues/2522) Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address, as this is a potential security issue.

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"laminas/laminas-inputfilter": "^2.31",
3434
"laminas/laminas-servicemanager": "^3.23",
3535
"laminas/laminas-stdlib": "^3.20",
36+
"league/csv": "^9.28",
3637
"matomo/matomo-php-tracker": "^3.3",
3738
"mezzio/mezzio": "^3.20",
3839
"mezzio/mezzio-fastroute": "^3.12",

module/CLI/src/Command/Domain/GetDomainVisitsCommand.php

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
namespace Shlinkio\Shlink\CLI\Command\Domain;
66

77
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
8-
use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput;
9-
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
8+
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
109
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
1110
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
1211
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
@@ -34,12 +33,10 @@ public function __invoke(
3433
SymfonyStyle $io,
3534
#[Argument('The domain which visits we want to get'), Ask('For what domain do you want to get visits?')]
3635
string $domain,
37-
#[MapInput] VisitsDateRangeInput $dateRangeInput,
36+
#[MapInput] VisitsListInput $input,
3837
): int {
39-
$paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($dateRangeInput->toDateRange()));
40-
[$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...));
41-
42-
ShlinkTable::default($io)->render($headers, $rows);
38+
$paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($input->dateRange()));
39+
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
4340

4441
return self::SUCCESS;
4542
}

module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
66

77
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
8-
use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput;
9-
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
8+
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
109
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
1110
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
1211
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
@@ -32,16 +31,15 @@ public function __invoke(
3231
SymfonyStyle $io,
3332
#[Argument('The short code which visits we want to get'), Ask('Which short code do you want to use?')]
3433
string $shortCode,
35-
#[MapInput] VisitsDateRangeInput $dateRangeInput,
34+
#[MapInput] VisitsListInput $input,
3635
#[Option('The domain for the short code', shortcut: 'd')]
3736
string|null $domain = null,
3837
): int {
3938
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
40-
$dateRange = $dateRangeInput->toDateRange();
39+
$dateRange = $input->dateRange();
4140
$paginator = $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
42-
[$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, static fn () => []);
4341

44-
ShlinkTable::default($io)->render($headers, $rows);
42+
VisitsCommandUtils::renderOutput($io, $input, $paginator);
4543

4644
return self::SUCCESS;
4745
}

module/CLI/src/Command/Tag/GetTagVisitsCommand.php

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
namespace Shlinkio\Shlink\CLI\Command\Tag;
66

77
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
8-
use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput;
9-
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
8+
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
109
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
1110
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
1211
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
@@ -35,7 +34,7 @@ public function __construct(
3534
public function __invoke(
3635
SymfonyStyle $io,
3736
#[Argument('The tag which visits we want to get'), Ask('For what tag do you want to get visits')] string $tag,
38-
#[MapInput] VisitsDateRangeInput $dateRangeInput,
37+
#[MapInput] VisitsListInput $input,
3938
#[Option(
4039
'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits '
4140
. 'in default domain',
@@ -44,12 +43,11 @@ public function __invoke(
4443
string|null $domain = null,
4544
): int {
4645
$paginator = $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams(
47-
dateRange: $dateRangeInput->toDateRange(),
46+
dateRange: $input->dateRange(),
4847
domain: $domain,
4948
));
50-
[$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...));
5149

52-
ShlinkTable::default($io)->render($headers, $rows);
50+
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
5351

5452
return self::SUCCESS;
5553
}

module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44

55
namespace Shlinkio\Shlink\CLI\Command\Visit;
66

7-
use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput;
8-
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
7+
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
98
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
109
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
1110
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
@@ -31,7 +30,7 @@ public function __construct(
3130

3231
public function __invoke(
3332
SymfonyStyle $io,
34-
#[MapInput] VisitsDateRangeInput $dateRangeInput,
33+
#[MapInput] VisitsListInput $input,
3534
#[Option(
3635
'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits '
3736
. 'in default domain',
@@ -40,12 +39,10 @@ public function __invoke(
4039
string|null $domain = null,
4140
): int {
4241
$paginator = $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams(
43-
dateRange: $dateRangeInput->toDateRange(),
42+
dateRange: $input->dateRange(),
4443
domain: $domain,
4544
));
46-
[$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...));
47-
48-
ShlinkTable::default($io)->render($headers, $rows);
45+
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
4946

5047
return self::SUCCESS;
5148
}

module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44

55
namespace Shlinkio\Shlink\CLI\Command\Visit;
66

7-
use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput;
8-
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
7+
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
98
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
109
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
1110
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
@@ -29,7 +28,7 @@ public function __construct(private readonly VisitsStatsHelperInterface $visitsH
2928

3029
public function __invoke(
3130
SymfonyStyle $io,
32-
#[MapInput] VisitsDateRangeInput $dateRangeInput,
31+
#[MapInput] VisitsListInput $input,
3332
#[Option(
3433
'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits '
3534
. 'in default domain',
@@ -39,13 +38,11 @@ public function __invoke(
3938
#[Option('Return visits only with this type', shortcut: 't')] OrphanVisitType|null $type = null,
4039
): int {
4140
$paginator = $this->visitsHelper->orphanVisits(new OrphanVisitsParams(
42-
dateRange: $dateRangeInput->toDateRange(),
41+
dateRange: $input->dateRange(),
4342
domain: $domain,
4443
type: $type,
4544
));
46-
[$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...));
47-
48-
ShlinkTable::default($io)->render($headers, $rows);
45+
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
4946

5047
return self::SUCCESS;
5148
}

module/CLI/src/Command/Visit/VisitsCommandUtils.php

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@
44

55
namespace Shlinkio\Shlink\CLI\Command\Visit;
66

7+
use League\Csv\Writer;
8+
use Shlinkio\Shlink\CLI\Input\VisitsListFormat;
9+
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
10+
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
711
use Shlinkio\Shlink\Common\Paginator\Paginator;
12+
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
813
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
14+
use Symfony\Component\Console\Output\OutputInterface;
915

1016
use function array_keys;
1117
use function array_map;
@@ -16,14 +22,86 @@ class VisitsCommandUtils
1622
{
1723
/**
1824
* @param Paginator<Visit> $paginator
19-
* @param callable(Visit $visits): array<string, string> $mapExtraFields
25+
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
2026
*/
21-
public static function resolveRowsAndHeaders(Paginator $paginator, callable $mapExtraFields): array
27+
public static function renderOutput(
28+
OutputInterface $output,
29+
VisitsListInput $inputData,
30+
Paginator $paginator,
31+
callable|null $mapExtraFields = null,
32+
): void {
33+
if ($inputData->format !== VisitsListFormat::FULL) {
34+
// Avoid running out of memory by loading visits in chunks
35+
$paginator->setMaxPerPage(1000);
36+
}
37+
38+
match ($inputData->format) {
39+
VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator, $mapExtraFields),
40+
default => self::renderHumanFriendlyOutput($output, $paginator, $mapExtraFields),
41+
};
42+
}
43+
44+
/**
45+
* @param Paginator<Visit> $paginator
46+
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
47+
*/
48+
private static function renderCSVOutput(
49+
OutputInterface $output,
50+
Paginator $paginator,
51+
callable|null $mapExtraFields,
52+
): void {
53+
$page = 1;
54+
do {
55+
$paginator->setCurrentPage($page);
56+
57+
[$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields);
58+
$csv = Writer::fromString();
59+
if ($page === 1) {
60+
$csv->insertOne($headers);
61+
}
62+
63+
$csv->insertAll($rows);
64+
$output->write($csv->toString());
65+
66+
$page++;
67+
} while ($paginator->hasNextPage());
68+
}
69+
70+
/**
71+
* @param Paginator<Visit> $paginator
72+
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
73+
*/
74+
private static function renderHumanFriendlyOutput(
75+
OutputInterface $output,
76+
Paginator $paginator,
77+
callable|null $mapExtraFields,
78+
): void {
79+
$page = 1;
80+
do {
81+
$paginator->setCurrentPage($page);
82+
$page++;
83+
84+
[$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields);
85+
ShlinkTable::default($output)->render(
86+
$headers,
87+
$rows,
88+
footerTitle: PagerfantaUtils::formatCurrentPageMessage($paginator, 'Page %s of %s'),
89+
);
90+
} while ($paginator->hasNextPage());
91+
}
92+
93+
/**
94+
* @param Paginator<Visit> $paginator
95+
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
96+
*/
97+
private static function resolveRowsAndHeaders(Paginator $paginator, callable|null $mapExtraFields): array
2298
{
23-
$extraKeys = [];
99+
$extraKeys = null;
100+
$mapExtraFields ??= static fn (Visit $_) => [];
101+
24102
$rows = array_map(function (Visit $visit) use (&$extraKeys, $mapExtraFields) {
25103
$extraFields = $mapExtraFields($visit);
26-
$extraKeys = array_keys($extraFields);
104+
$extraKeys ??= array_keys($extraFields);
27105

28106
$rowData = [
29107
'referer' => $visit->referer,
@@ -38,7 +116,7 @@ public static function resolveRowsAndHeaders(Paginator $paginator, callable $map
38116
// Filter out unknown keys
39117
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]);
40118
}, [...$paginator->getCurrentPageResults()]);
41-
$extra = array_map(camelCaseToHumanFriendly(...), $extraKeys);
119+
$extra = array_map(camelCaseToHumanFriendly(...), $extraKeys ?? []);
42120

43121
return [
44122
$rows,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace Shlinkio\Shlink\CLI\Input;
4+
5+
enum VisitsListFormat: string
6+
{
7+
/** Load and dump all visits at once, in a human-friendly format */
8+
case FULL = 'full';
9+
10+
/**
11+
* Load and dump visits in 1000-visit chunks, in a human-friendly format.
12+
* This format is recommended over `default` for large number of visits, to avoid running out of memory.
13+
*/
14+
case PAGINATED = 'paginated';
15+
16+
/** Load and dump visits in chunks, in CSV format */
17+
case CSV = 'csv';
18+
}

module/CLI/src/Input/VisitsDateRangeInput.php renamed to module/CLI/src/Input/VisitsListInput.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,22 @@
1010
use function Shlinkio\Shlink\Common\buildDateRange;
1111
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
1212

13-
class VisitsDateRangeInput
13+
class VisitsListInput
1414
{
1515
#[Option('Only return visits older than this date', shortcut: 's')]
1616
public string|null $startDate = null;
1717

1818
#[Option('Only return visits newer than this date', shortcut: 'e')]
1919
public string|null $endDate = null;
2020

21-
public function toDateRange(): DateRange
21+
#[Option(
22+
'Output format ("' . VisitsListFormat::FULL->value . '", "' . VisitsListFormat::PAGINATED->value . '" or "'
23+
. VisitsListFormat::CSV->value . '")',
24+
shortcut: 'f',
25+
)]
26+
public VisitsListFormat $format = VisitsListFormat::FULL;
27+
28+
public function dateRange(): DateRange
2229
{
2330
return buildDateRange(
2431
startDate: normalizeOptionalDate($this->startDate),

0 commit comments

Comments
 (0)