Merge pull request #1076 from acelaya-forks/feature/import-from-shlink

Feature/import from shlink
This commit is contained in:
Alejandro Celaya 2021-04-18 17:43:48 +02:00 committed by GitHub
commit 8a8e3c3fc8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 376 additions and 146 deletions

View File

@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
Also, when using swoole, the file is now updated **after** tracking a visit, which means it will not apply until the next one.
* [#1059](https://github.com/shlinkio/shlink/issues/1059) Added ability to optionally display author API key and its name when listing short URLs from the command line.
* [#1066](https://github.com/shlinkio/shlink/issues/1066) Added support to import short URLs and their visits from another Shlink instance using its API.
### Changed
* [#1036](https://github.com/shlinkio/shlink/issues/1036) Updated to `happyr/doctrine-specification` 2.0.

View File

@ -49,7 +49,7 @@
"shlinkio/shlink-common": "dev-main#554e370 as 3.7",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^2.1",
"shlinkio/shlink-importer": "^2.2",
"shlinkio/shlink-importer": "dev-main#39928b6 as 2.3",
"shlinkio/shlink-installer": "dev-develop#aa50ea9 as 5.5",
"shlinkio/shlink-ip-geolocation": "^1.5",
"symfony/console": "^5.1",
@ -124,6 +124,7 @@
],
"test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
"test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml",
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
@ -132,7 +133,6 @@
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",

View File

@ -2,14 +2,6 @@
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
// When running tests, any mysql-specific option can interfere with other drivers
$driverOptions = env('APP_ENV') === 'test' ? [] : [
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
];
return [
'entity_manager' => [
@ -18,7 +10,6 @@ return [
'password' => 'root',
'driver' => 'pdo_mysql',
'host' => 'shlink_db',
'driverOptions' => $driverOptions,
],
],

View File

@ -11,7 +11,6 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
use Laminas\Stdlib\Glob;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use PDO;
use PHPUnit\Runner\Version;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\Selector;
@ -55,10 +54,6 @@ $buildDbConnection = function (): array {
'password' => 'root',
'dbname' => 'shlink_test',
'charset' => 'utf8',
'driverOptions' => [
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
],
],
'postgres' => [
'driver' => 'pdo_pgsql',

View File

@ -42,12 +42,6 @@ $helper = new class {
];
}
$driverOptions = ! $isMysql ? [] : [
// 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND
1002 => 'SET NAMES utf8',
// 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY
1000 => true,
];
return [
'driver' => self::DB_DRIVERS_MAP[$driver],
'dbname' => env('DB_NAME', 'shlink'),
@ -55,7 +49,6 @@ $helper = new class {
'password' => env('DB_PASSWORD'),
'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null),
'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]),
'driverOptions' => $driverOptions,
'unix_socket' => $isMysql ? env('DB_UNIX_SOCKET') : null,
];
}

View File

@ -103,7 +103,7 @@ class GetVisitsCommandTest extends TestCase
$this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
new Paginator(new ArrayAdapter([
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate(
new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')),
VisitLocation::fromGeolocation(new Location('', 'Spain', '', '', 0, 0, '')),
),
])),
)->shouldBeCalledOnce();

View File

@ -76,7 +76,7 @@ class LocateVisitsCommandTest extends TestCase
array $args
): void {
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
$location = new VisitLocation(Location::emptyInstance());
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will($mockMethodBehavior);
@ -120,7 +120,7 @@ class LocateVisitsCommandTest extends TestCase
public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $address, ''));
$location = new VisitLocation(Location::emptyInstance());
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
$this->invokeHelperMethods($visit, $location),
@ -153,7 +153,7 @@ class LocateVisitsCommandTest extends TestCase
public function errorWhileLocatingIpIsDisplayed(): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
$location = new VisitLocation(Location::emptyInstance());
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
$this->invokeHelperMethods($visit, $location),

View File

@ -7,6 +7,8 @@ namespace Shlinkio\Shlink\Core\Entity;
use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Selectable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
@ -86,17 +88,29 @@ class ShortUrl extends AbstractEntity
?ShortUrlRelationResolverInterface $relationResolver = null
): self {
$meta = [
ShortUrlInputFilter::VALIDATE_URL => false,
ShortUrlInputFilter::LONG_URL => $url->longUrl(),
ShortUrlInputFilter::DOMAIN => $url->domain(),
ShortUrlInputFilter::TAGS => $url->tags(),
ShortUrlInputFilter::TITLE => $url->title(),
ShortUrlInputFilter::VALIDATE_URL => false,
ShortUrlInputFilter::MAX_VISITS => $url->meta()->maxVisits(),
];
if ($importShortCode) {
$meta[ShortUrlInputFilter::CUSTOM_SLUG] = $url->shortCode();
}
$instance = self::fromMeta(ShortUrlMeta::fromRawData($meta), $relationResolver);
$validSince = $url->meta()->validSince();
if ($validSince !== null) {
$instance->validSince = Chronos::instance($validSince);
}
$validUntil = $url->meta()->validUntil();
if ($validUntil !== null) {
$instance->validUntil = Chronos::instance($validUntil);
}
$instance->importSource = $url->source();
$instance->importOriginalShortCode = $url->shortCode();
$instance->dateCreated = Chronos::instance($url->createdAt());
@ -152,6 +166,20 @@ class ShortUrl extends AbstractEntity
return count($this->visits);
}
public function mostRecentImportedVisitDate(): ?Chronos
{
/** @var Selectable $visits */
$visits = $this->visits;
$criteria = Criteria::create()->where(Criteria::expr()->eq('type', Visit::TYPE_IMPORTED))
->orderBy(['id' => 'DESC'])
->setMaxResults(1);
/** @var Visit|false $visit */
$visit = $visits->matching($criteria)->last();
return $visit === false ? null : $visit->getDate();
}
/**
* @param Collection|Visit[] $visits
* @internal
@ -167,7 +195,7 @@ class ShortUrl extends AbstractEntity
return $this->maxVisits;
}
public function getTitle(): ?string
public function title(): ?string
{
return $this->title;
}

View File

@ -11,32 +11,83 @@ use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
class Visit extends AbstractEntity implements JsonSerializable
{
public const TYPE_VALID_SHORT_URL = 'valid_short_url';
public const TYPE_IMPORTED = 'imported';
public const TYPE_INVALID_SHORT_URL = 'invalid_short_url';
public const TYPE_BASE_URL = 'base_url';
public const TYPE_REGULAR_404 = 'regular_404';
private string $referer;
private Chronos $date;
private ?string $remoteAddr;
private ?string $visitedUrl;
private ?string $remoteAddr = null;
private ?string $visitedUrl = null;
private string $userAgent;
private string $type;
private ?ShortUrl $shortUrl;
private ?VisitLocation $visitLocation = null;
private function __construct(?ShortUrl $shortUrl, Visitor $visitor, string $type, bool $anonymize = true)
private function __construct(?ShortUrl $shortUrl, string $type)
{
$this->shortUrl = $shortUrl;
$this->date = Chronos::now();
$this->type = $type;
}
public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self
{
$instance = new self($shortUrl, self::TYPE_VALID_SHORT_URL);
$instance->hydrateFromVisitor($visitor, $anonymize);
return $instance;
}
public static function fromImport(ShortUrl $shortUrl, ImportedShlinkVisit $importedVisit): self
{
$instance = new self($shortUrl, self::TYPE_IMPORTED);
$instance->userAgent = $importedVisit->userAgent();
$instance->referer = $importedVisit->referer();
$instance->date = Chronos::instance($importedVisit->date());
$importedLocation = $importedVisit->location();
$instance->visitLocation = $importedLocation !== null ? VisitLocation::fromImport($importedLocation) : null;
return $instance;
}
public static function forBasePath(Visitor $visitor, bool $anonymize = true): self
{
$instance = new self(null, self::TYPE_BASE_URL);
$instance->hydrateFromVisitor($visitor, $anonymize);
return $instance;
}
public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self
{
$instance = new self(null, self::TYPE_INVALID_SHORT_URL);
$instance->hydrateFromVisitor($visitor, $anonymize);
return $instance;
}
public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self
{
$instance = new self(null, self::TYPE_REGULAR_404);
$instance->hydrateFromVisitor($visitor, $anonymize);
return $instance;
}
private function hydrateFromVisitor(Visitor $visitor, bool $anonymize = true): void
{
$this->userAgent = $visitor->getUserAgent();
$this->referer = $visitor->getReferer();
$this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress());
$this->visitedUrl = $visitor->getVisitedUrl();
$this->type = $type;
}
private function processAddress(bool $anonymize, ?string $address): ?string
@ -53,26 +104,6 @@ class Visit extends AbstractEntity implements JsonSerializable
}
}
public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self
{
return new self($shortUrl, $visitor, self::TYPE_VALID_SHORT_URL, $anonymize);
}
public static function forBasePath(Visitor $visitor, bool $anonymize = true): self
{
return new self(null, $visitor, self::TYPE_BASE_URL, $anonymize);
}
public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self
{
return new self(null, $visitor, self::TYPE_INVALID_SHORT_URL, $anonymize);
}
public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self
{
return new self(null, $visitor, self::TYPE_REGULAR_404, $anonymize);
}
public function getRemoteAddr(): ?string
{
return $this->remoteAddr;
@ -119,6 +150,15 @@ class Visit extends AbstractEntity implements JsonSerializable
return $this->type;
}
/**
* Needed only for ArrayCollections to be able to apply criteria filtering
* @internal
*/
public function getType(): string
{
return $this->type();
}
public function jsonSerialize(): array
{
return [

View File

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Entity;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisitLocation;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
class VisitLocation extends AbstractEntity implements VisitLocationInterface
@ -19,9 +20,53 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
private string $timezone;
private bool $isEmpty;
public function __construct(Location $location)
private function __construct()
{
$this->exchangeLocationInfo($location);
}
public static function fromGeolocation(Location $location): self
{
$instance = new self();
$instance->countryCode = $location->countryCode();
$instance->countryName = $location->countryName();
$instance->regionName = $location->regionName();
$instance->cityName = $location->city();
$instance->latitude = $location->latitude();
$instance->longitude = $location->longitude();
$instance->timezone = $location->timeZone();
$instance->computeIsEmpty();
return $instance;
}
public static function fromImport(ImportedShlinkVisitLocation $location): self
{
$instance = new self();
$instance->countryCode = $location->countryCode();
$instance->countryName = $location->countryName();
$instance->regionName = $location->regionName();
$instance->cityName = $location->cityName();
$instance->latitude = $location->latitude();
$instance->longitude = $location->longitude();
$instance->timezone = $location->timeZone();
$instance->computeIsEmpty();
return $instance;
}
private function computeIsEmpty(): void
{
$this->isEmpty = (
$this->countryCode === '' &&
$this->countryName === '' &&
$this->regionName === '' &&
$this->cityName === '' &&
$this->latitude === 0.0 &&
$this->longitude === 0.0 &&
$this->timezone === ''
);
}
public function getCountryName(): string
@ -49,26 +94,6 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
return $this->isEmpty;
}
private function exchangeLocationInfo(Location $info): void
{
$this->countryCode = $info->countryCode();
$this->countryName = $info->countryName();
$this->regionName = $info->regionName();
$this->cityName = $info->city();
$this->latitude = $info->latitude();
$this->longitude = $info->longitude();
$this->timezone = $info->timeZone();
$this->isEmpty = (
$this->countryCode === '' &&
$this->countryName === '' &&
$this->regionName === '' &&
$this->cityName === '' &&
$this->latitude === 0.0 &&
$this->longitude === 0.0 &&
$this->timezone === ''
);
}
public function jsonSerialize(): array
{
return [

View File

@ -71,7 +71,7 @@ class LocateVisit
try {
$location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance();
$visit->locate(new VisitLocation($location));
$visit->locate(VisitLocation::fromGeolocation($location));
$this->em->flush();
} catch (WrongIpException $e) {
$this->logger->warning(

View File

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use function sprintf;
@ -34,4 +35,9 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem
return $e;
}
public static function fromImport(ImportedShlinkUrl $importedUrl): self
{
return self::fromSlug($importedUrl->shortCode(), $importedUrl->domain());
}
}

View File

@ -6,12 +6,14 @@ namespace Shlinkio\Shlink\Core\Importer;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Sources\ImportSources;
use Symfony\Component\Console\Style\StyleInterface;
use function sprintf;
@ -22,6 +24,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
private ShortUrlRelationResolverInterface $relationResolver;
private ShortCodeHelperInterface $shortCodeHelper;
private DoctrineBatchHelperInterface $batchHelper;
private ShortUrlRepositoryInterface $shortUrlRepo;
public function __construct(
EntityManagerInterface $em,
@ -33,6 +36,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
$this->relationResolver = $relationResolver;
$this->shortCodeHelper = $shortCodeHelper;
$this->batchHelper = $batchHelper;
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); // @phpstan-ignore-line
}
/**
@ -40,51 +44,65 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
*/
public function process(StyleInterface $io, iterable $shlinkUrls, array $params): void
{
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$importShortCodes = $params['import_short_codes'];
$iterable = $this->batchHelper->wrapIterable($shlinkUrls, 100);
$source = $params['source'];
$iterable = $this->batchHelper->wrapIterable($shlinkUrls, $source === ImportSources::SHLINK ? 10 : 100);
/** @var ImportedShlinkUrl $url */
foreach ($iterable as $url) {
$longUrl = $url->longUrl();
/** @var ImportedShlinkUrl $importedUrl */
foreach ($iterable as $importedUrl) {
$skipOnShortCodeConflict = static function () use ($io, $importedUrl): bool {
$action = $io->choice(sprintf(
'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate '
. 'a new one or skip it?',
$importedUrl->longUrl(),
$importedUrl->shortCode(),
), ['Generate new short-code', 'Skip'], 1);
// Skip already imported URLs
if ($shortUrlRepo->importedUrlExists($url)) {
$io->text(sprintf('%s: <comment>Skipped</comment>', $longUrl));
return $action === 'Skip';
};
$longUrl = $importedUrl->longUrl();
try {
$shortUrlImporting = $this->resolveShortUrl($importedUrl, $importShortCodes, $skipOnShortCodeConflict);
} catch (NonUniqueSlugException $e) {
$io->text(sprintf('%s: <fg=red>Error</>', $longUrl));
continue;
}
$shortUrl = ShortUrl::fromImport($url, $importShortCodes, $this->relationResolver);
if (! $this->handleShortCodeUniqueness($url, $shortUrl, $io, $importShortCodes)) {
continue;
}
$this->em->persist($shortUrl);
$io->text(sprintf('%s: <info>Imported</info>', $longUrl));
$resultMessage = $shortUrlImporting->importVisits($importedUrl->visits(), $this->em);
$io->text(sprintf('%s: %s', $longUrl, $resultMessage));
}
}
private function resolveShortUrl(
ImportedShlinkUrl $importedUrl,
bool $importShortCodes,
callable $skipOnShortCodeConflict
): ShortUrlImporting {
$alreadyImportedShortUrl = $this->shortUrlRepo->findOneByImportedUrl($importedUrl);
if ($alreadyImportedShortUrl !== null) {
return ShortUrlImporting::fromExistingShortUrl($alreadyImportedShortUrl);
}
$shortUrl = ShortUrl::fromImport($importedUrl, $importShortCodes, $this->relationResolver);
if (! $this->handleShortCodeUniqueness($shortUrl, $importShortCodes, $skipOnShortCodeConflict)) {
throw NonUniqueSlugException::fromImport($importedUrl);
}
$this->em->persist($shortUrl);
return ShortUrlImporting::fromNewShortUrl($shortUrl);
}
private function handleShortCodeUniqueness(
ImportedShlinkUrl $url,
ShortUrl $shortUrl,
StyleInterface $io,
bool $importShortCodes
bool $importShortCodes,
callable $skipOnShortCodeConflict
): bool {
if ($this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, $importShortCodes)) {
return true;
}
$longUrl = $url->longUrl();
$action = $io->choice(sprintf(
'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate a new '
. 'one or skip it?',
$longUrl,
$url->shortCode(),
), ['Generate new short-code', 'Skip'], 1);
if ($action === 'Skip') {
$io->text(sprintf('%s: <comment>Skipped</comment>', $longUrl));
if ($skipOnShortCodeConflict()) {
return false;
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Importer;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
use function sprintf;
final class ShortUrlImporting
{
private ShortUrl $shortUrl;
private bool $isNew;
private function __construct(ShortUrl $shortUrl, bool $isNew)
{
$this->shortUrl = $shortUrl;
$this->isNew = $isNew;
}
public static function fromExistingShortUrl(ShortUrl $shortUrl): self
{
return new self($shortUrl, false);
}
public static function fromNewShortUrl(ShortUrl $shortUrl): self
{
return new self($shortUrl, true);
}
/**
* @param iterable|ImportedShlinkVisit[] $visits
*/
public function importVisits(iterable $visits, EntityManagerInterface $em): string
{
$mostRecentImportedDate = $this->shortUrl->mostRecentImportedVisitDate();
$importedVisits = 0;
foreach ($visits as $importedVisit) {
// Skip visits which are older than the most recent already imported visit's date
if (
$mostRecentImportedDate !== null
&& $mostRecentImportedDate->gte(Chronos::instance($importedVisit->date()))
) {
continue;
}
$em->persist(Visit::fromImport($this->shortUrl, $importedVisit));
$importedVisits++;
}
if ($importedVisits === 0) {
return $this->isNew ? '<info>Imported</info>' : '<comment>Skipped</comment>';
}
return $this->isNew
? sprintf('<info>Imported</info> with <info>%s</info> visits', $importedVisits)
: sprintf('<comment>Skipped</comment>. Imported <info>%s</info> visits', $importedVisits);
}
}

View File

@ -264,12 +264,10 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
return $qb->getQuery()->getOneOrNullResult();
}
public function importedUrlExists(ImportedShlinkUrl $url): bool
public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('COUNT(DISTINCT s.id)')
->from(ShortUrl::class, 's')
->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode'))
$qb = $this->createQueryBuilder('s');
$qb->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode'))
->setParameter('shortCode', $url->shortCode())
->andWhere($qb->expr()->eq('s.importSource', ':importSource'))
->setParameter('importSource', $url->source())
@ -277,8 +275,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
$this->whereDomainIs($qb, $url->domain());
$result = (int) $qb->getQuery()->getSingleScalarResult();
return $result > 0;
return $qb->getQuery()->getOneOrNullResult();
}
private function whereDomainIs(QueryBuilder $qb, ?string $domain): void

View File

@ -40,5 +40,5 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat
public function findOneMatching(ShortUrlMeta $meta): ?ShortUrl;
public function importedUrlExists(ImportedShlinkUrl $url): bool;
public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl;
}

View File

@ -203,6 +203,9 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
private function resolveVisitsWithNativeQuery(QueryBuilder $qb, ?int $limit, ?int $offset): array
{
// TODO Order by date and ID, not just by ID (order by date DESC, id DESC).
// That ensures imported visits are properly ordered even if inserted in wrong chronological order.
$qb->select('v.id')
->orderBy('v.id', 'DESC')
// Falling back to values that will behave as no limit/offset, but will workaround MS SQL not allowing

View File

@ -8,7 +8,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
class ShortCodeHelper implements ShortCodeHelperInterface
class ShortCodeHelper implements ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelper
{
private EntityManagerInterface $em;

View File

@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
interface ShortCodeHelperInterface
interface ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelperInterface
{
public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool;
}

View File

@ -34,7 +34,7 @@ class ShortUrlDataTransformer implements DataTransformerInterface
'tags' => invoke($shortUrl->getTags(), '__toString'),
'meta' => $this->buildMeta($shortUrl),
'domain' => $shortUrl->getDomain(),
'title' => $shortUrl->getTitle(),
'title' => $shortUrl->title(),
];
}

View File

@ -63,8 +63,7 @@ class VisitLocator implements VisitLocatorInterface
$location = Location::emptyInstance();
}
$location = new VisitLocation($location);
$this->locateVisit($visit, $location, $helper);
$this->locateVisit($visit, VisitLocation::fromGeolocation($location), $helper);
// Flush and clear after X iterations
if ($count % $persistBlock === 0) {

View File

@ -416,7 +416,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
}
/** @test */
public function importedShortUrlsAreSearchedAsExpected(): void
public function importedShortUrlsAreFoundWhenExpected(): void
{
$buildImported = static fn (string $shortCode, ?String $domain = null) =>
new ImportedShlinkUrl('', 'foo', [], Chronos::now(), $domain, $shortCode, null);
@ -429,11 +429,11 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
self::assertTrue($this->repo->importedUrlExists($buildImported('my-cool-slug')));
self::assertTrue($this->repo->importedUrlExists($buildImported('another-slug', 'doma.in')));
self::assertFalse($this->repo->importedUrlExists($buildImported('non-existing-slug')));
self::assertFalse($this->repo->importedUrlExists($buildImported('non-existing-slug', 'doma.in')));
self::assertFalse($this->repo->importedUrlExists($buildImported('my-cool-slug', 'doma.in')));
self::assertFalse($this->repo->importedUrlExists($buildImported('another-slug')));
self::assertNotNull($this->repo->findOneByImportedUrl($buildImported('my-cool-slug')));
self::assertNotNull($this->repo->findOneByImportedUrl($buildImported('another-slug', 'doma.in')));
self::assertNull($this->repo->findOneByImportedUrl($buildImported('non-existing-slug')));
self::assertNull($this->repo->findOneByImportedUrl($buildImported('non-existing-slug', 'doma.in')));
self::assertNull($this->repo->findOneByImportedUrl($buildImported('my-cool-slug', 'doma.in')));
self::assertNull($this->repo->findOneByImportedUrl($buildImported('another-slug')));
}
}

View File

@ -57,7 +57,7 @@ class VisitRepositoryTest extends DatabaseTestCase
$visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance());
if ($i >= 2) {
$location = new VisitLocation(Location::emptyInstance());
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$this->getEntityManager()->persist($location);
$visit->locate($location);
}

View File

@ -17,7 +17,7 @@ class VisitLocationTest extends TestCase
public function isEmptyReturnsTrueWhenAllValuesAreEmpty(array $args, bool $isEmpty): void
{
$payload = new Location(...$args);
$location = new VisitLocation($payload);
$location = VisitLocation::fromGeolocation($payload);
self::assertEquals($isEmpty, $location->isEmpty());
}

View File

@ -168,7 +168,7 @@ class LocateVisitTest extends TestCase
($this->locateVisit)($event);
self::assertEquals($visit->getVisitLocation(), new VisitLocation(Location::emptyInstance()));
self::assertEquals($visit->getVisitLocation(), VisitLocation::fromGeolocation(Location::emptyInstance()));
$findVisit->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce();
$resolveIp->shouldNotHaveBeenCalled();
@ -204,7 +204,7 @@ class LocateVisitTest extends TestCase
($this->locateVisit)($event);
self::assertEquals($visit->getVisitLocation(), new VisitLocation($location));
self::assertEquals($visit->getVisitLocation(), VisitLocation::fromGeolocation($location));
$findVisit->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce();
$resolveIp->shouldHaveBeenCalledOnce();

View File

@ -5,18 +5,22 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Importer;
use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Importer\ImportedLinksProcessor;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
use Shlinkio\Shlink\Importer\Sources\ImportSources;
use Symfony\Component\Console\Style\StyleInterface;
use function count;
@ -28,6 +32,8 @@ class ImportedLinksProcessorTest extends TestCase
{
use ProphecyTrait;
private const PARAMS = ['import_short_codes' => true, 'source' => ImportSources::BITLY];
private ImportedLinksProcessor $processor;
private ObjectProphecy $em;
private ObjectProphecy $shortCodeHelper;
@ -64,11 +70,11 @@ class ImportedLinksProcessorTest extends TestCase
];
$expectedCalls = count($urls);
$importedUrlExists = $this->repo->importedUrlExists(Argument::cetera())->willReturn(false);
$importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn(null);
$ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true);
$persist = $this->em->persist(Argument::type(ShortUrl::class));
$this->processor->process($this->io->reveal(), $urls, ['import_short_codes' => true]);
$this->processor->process($this->io->reveal(), $urls, self::PARAMS);
$importedUrlExists->shouldHaveBeenCalledTimes($expectedCalls);
$ensureUniqueness->shouldHaveBeenCalledTimes($expectedCalls);
@ -86,24 +92,25 @@ class ImportedLinksProcessorTest extends TestCase
new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2', null),
new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3', null),
];
$contains = fn (string $needle) => fn (string $text) => str_contains($text, $needle);
$importedUrlExists = $this->repo->importedUrlExists(Argument::cetera())->will(function (array $args): bool {
/** @var ImportedShlinkUrl $url */
[$url] = $args;
$importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->will(
function (array $args): ?ShortUrl {
/** @var ImportedShlinkUrl $url */
[$url] = $args;
return contains(['foo', 'baz2', 'baz3'], $url->longUrl());
});
return contains(['foo', 'baz2', 'baz3'], $url->longUrl()) ? ShortUrl::fromImport($url, true) : null;
},
);
$ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true);
$persist = $this->em->persist(Argument::type(ShortUrl::class));
$this->processor->process($this->io->reveal(), $urls, ['import_short_codes' => true]);
$this->processor->process($this->io->reveal(), $urls, self::PARAMS);
$importedUrlExists->shouldHaveBeenCalledTimes(count($urls));
$ensureUniqueness->shouldHaveBeenCalledTimes(2);
$persist->shouldHaveBeenCalledTimes(2);
$this->io->text(Argument::that($contains('Skipped')))->shouldHaveBeenCalledTimes(3);
$this->io->text(Argument::that($contains('Imported')))->shouldHaveBeenCalledTimes(2);
$this->io->text(Argument::containingString('Skipped'))->shouldHaveBeenCalledTimes(3);
$this->io->text(Argument::containingString('Imported'))->shouldHaveBeenCalledTimes(2);
}
/** @test */
@ -116,9 +123,8 @@ class ImportedLinksProcessorTest extends TestCase
new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2', null),
new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3', 'bar'),
];
$contains = fn (string $needle) => fn (string $text) => str_contains($text, $needle);
$importedUrlExists = $this->repo->importedUrlExists(Argument::cetera())->willReturn(false);
$importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn(null);
$failingEnsureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(
Argument::any(),
true,
@ -135,14 +141,77 @@ class ImportedLinksProcessorTest extends TestCase
});
$persist = $this->em->persist(Argument::type(ShortUrl::class));
$this->processor->process($this->io->reveal(), $urls, ['import_short_codes' => true]);
$this->processor->process($this->io->reveal(), $urls, self::PARAMS);
$importedUrlExists->shouldHaveBeenCalledTimes(count($urls));
$failingEnsureUniqueness->shouldHaveBeenCalledTimes(5);
$successEnsureUniqueness->shouldHaveBeenCalledTimes(2);
$choice->shouldHaveBeenCalledTimes(5);
$persist->shouldHaveBeenCalledTimes(2);
$this->io->text(Argument::that($contains('Skipped')))->shouldHaveBeenCalledTimes(3);
$this->io->text(Argument::that($contains('Imported')))->shouldHaveBeenCalledTimes(2);
$this->io->text(Argument::containingString('Error'))->shouldHaveBeenCalledTimes(3);
$this->io->text(Argument::containingString('Imported'))->shouldHaveBeenCalledTimes(2);
}
/**
* @test
* @dataProvider provideUrlsWithVisits
*/
public function properAmountOfVisitsIsImported(
ImportedShlinkUrl $importedUrl,
string $expectedOutput,
int $amountOfPersistedVisits,
?ShortUrl $foundShortUrl
): void {
$findExisting = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn($foundShortUrl);
$ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true);
$persistUrl = $this->em->persist(Argument::type(ShortUrl::class));
$persistVisits = $this->em->persist(Argument::type(Visit::class));
$this->processor->process($this->io->reveal(), [$importedUrl], self::PARAMS);
$findExisting->shouldHaveBeenCalledOnce();
$ensureUniqueness->shouldHaveBeenCalledTimes($foundShortUrl === null ? 1 : 0);
$persistUrl->shouldHaveBeenCalledTimes($foundShortUrl === null ? 1 : 0);
$persistVisits->shouldHaveBeenCalledTimes($amountOfPersistedVisits);
$this->io->text(Argument::containingString($expectedOutput))->shouldHaveBeenCalledOnce();
}
public function provideUrlsWithVisits(): iterable
{
$now = Chronos::now();
$createImportedUrl = fn (array $visits) => new ImportedShlinkUrl('', 's', [], $now, null, 's', null, $visits);
yield 'new short URL' => [$createImportedUrl([
new ImportedShlinkVisit('', '', $now, null),
new ImportedShlinkVisit('', '', $now, null),
new ImportedShlinkVisit('', '', $now, null),
new ImportedShlinkVisit('', '', $now, null),
new ImportedShlinkVisit('', '', $now, null),
]), '<info>Imported</info> with <info>5</info> visits', 5, null];
yield 'existing short URL without previous imported visits' => [
$createImportedUrl([
new ImportedShlinkVisit('', '', $now, null),
new ImportedShlinkVisit('', '', $now, null),
new ImportedShlinkVisit('', '', $now->addDays(3), null),
new ImportedShlinkVisit('', '', $now->addDays(3), null),
]),
'<comment>Skipped</comment>. Imported <info>4</info> visits',
4,
ShortUrl::createEmpty(),
];
yield 'existing short URL with previous imported visits' => [
$createImportedUrl([
new ImportedShlinkVisit('', '', $now, null),
new ImportedShlinkVisit('', '', $now, null),
new ImportedShlinkVisit('', '', $now, null),
new ImportedShlinkVisit('', '', $now->addDays(3), null),
new ImportedShlinkVisit('', '', $now->addDays(3), null),
]),
'<comment>Skipped</comment>. Imported <info>2</info> visits',
2,
ShortUrl::createEmpty()->setVisits(new ArrayCollection([
Visit::fromImport(ShortUrl::createEmpty(), new ImportedShlinkVisit('', '', $now, null)),
])),
];
}
}

View File

@ -68,7 +68,7 @@ class OrphanVisitDataTransformerTest extends TestCase
->withHeader('Referer', 'referer')
->withUri(new Uri('https://doma.in/foo/bar')),
),
)->locate($location = new VisitLocation(Location::emptyInstance())),
)->locate($location = VisitLocation::fromGeolocation(Location::emptyInstance())),
[
'referer' => 'referer',
'date' => $visit->getDate()->toAtomString(),