From 375f15256bde386caecb9310536b52350ff8c073 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 04:38:08 +0300 Subject: [PATCH 01/30] Added feed management via database --- README.md | 2 +- composer.json | 4 +- config/feeds.php | 16 +++-- .../2025_09_01_231655_create_feeds_table.php | 49 +++++++++++++ docs/snippets/advanced-nested.php | 4 +- docs/snippets/feeds-feed-item.php | 4 +- src/Casts/ExpressionCast.php | 33 +++++++++ src/Console/Commands/FeedGenerateCommand.php | 28 +++----- src/Console/Commands/FeedMakeCommand.php | 12 ++++ src/{Services => Converters}/ConvertToXml.php | 2 +- src/Enums/FeedFormatEnum.php | 10 +++ src/Exceptions/FeedNotFoundException.php | 15 ---- src/Exceptions/InvalidExpressionException.php | 15 ++++ src/Helpers/FeedHelper.php | 58 ---------------- src/Helpers/ScheduleFeedHelper.php | 46 +++++++++++++ src/LaravelFeedServiceProvider.php | 8 +++ src/Models/Feed.php | 51 ++++++++++++++ src/Queries/FeedQuery.php | 69 +++++++++++++++++++ src/Services/Generator.php | 13 ++++ stubs/feed_activate_migration.stub | 0 stubs/feed_activate_operation.stub | 0 testbench.yaml | 5 +- .../Console/Generation/DefaultTest.php | 15 ++-- .../Console/Generation/DisabledTest.php | 27 ++++---- .../Generation/IncorrectParameterTest.php | 45 ++++++------ .../Console/Generation/SpecifiedTest.php | 31 ++++----- .../Console/Generation/UnknownClassTest.php | 26 ------- tests/Helpers/expects.php | 11 ++- tests/Helpers/feeds.php | 33 +++++++++ tests/Pest.php | 19 ++--- .../Providers/WorkbenchServiceProvider.php | 30 -------- workbench/database/seeders/DatabaseSeeder.php | 15 ++++ workbench/database/seeders/FeedSeeder.php | 42 +++++++++++ 33 files changed, 497 insertions(+), 241 deletions(-) create mode 100644 database/migrations/2025_09_01_231655_create_feeds_table.php create mode 100644 src/Casts/ExpressionCast.php rename src/{Services => Converters}/ConvertToXml.php (99%) create mode 100644 src/Enums/FeedFormatEnum.php delete mode 100644 src/Exceptions/FeedNotFoundException.php create mode 100644 src/Exceptions/InvalidExpressionException.php delete mode 100644 src/Helpers/FeedHelper.php create mode 100644 src/Helpers/ScheduleFeedHelper.php create mode 100644 src/Models/Feed.php create mode 100644 src/Queries/FeedQuery.php create mode 100644 stubs/feed_activate_migration.stub create mode 100644 stubs/feed_activate_operation.stub delete mode 100644 tests/Feature/Console/Generation/UnknownClassTest.php create mode 100644 tests/Helpers/feeds.php delete mode 100644 workbench/app/Providers/WorkbenchServiceProvider.php create mode 100644 workbench/database/seeders/DatabaseSeeder.php create mode 100644 workbench/database/seeders/FeedSeeder.php diff --git a/README.md b/README.md index f4612ef..5d67da2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ consumers. > - Chunked queries to database > - Draft mode for a process > - Easy property mapping -> - Generation of any XML (feeds, sitemaps, etc.) +> - Generation of any feeds, sitemaps, etc. ## Installation diff --git a/composer.json b/composer.json index 95bc30b..6dae35f 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "php": "^8.2", "ext-dom": "*", "ext-libxml": "*", + "dragonmantank/cron-expression": "^3.4", "illuminate/database": "^11.0 || ^12.0", "illuminate/filesystem": "^11.0 || ^12.0", "illuminate/support": "^11.0 || ^12.0", @@ -36,7 +37,8 @@ "psr-4": { "Tests\\": "tests/", "Workbench\\App\\": "workbench/app/", - "Workbench\\Database\\Factories\\": "workbench/database/factories/" + "Workbench\\Database\\Factories\\": "workbench/database/factories/", + "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" } }, "config": { diff --git a/config/feeds.php b/config/feeds.php index 0a2945c..0ef1516 100644 --- a/config/feeds.php +++ b/config/feeds.php @@ -3,11 +3,17 @@ declare(strict_types=1); return [ - 'channels' => [ - // App\Feeds\FooFeed::class => (bool) env('FEED_FOO_ENABLED', true), - // App\Feeds\BarFeed::class => (bool) env('FEED_BAR_ENABLED', true), - // App\Feeds\BazFeed::class => (bool) env('FEED_BAZ_ENABLED', false), + 'pretty' => (bool) env('FEED_PRETTY', false), + + 'table' => [ + 'connection' => env('DB_CONNECTION', 'sqlite'), + + 'table' => env('FEED_TABLE', 'feeds'), ], - 'pretty' => (bool) env('FEED_PRETTY', false), + 'schedule' => [ + 'ttl' => (int) env('FEED_SCHEDULE_TTL', 1440), + + 'background' => (bool) env('FEED_SCHEDULE_RUN_BACKGROUND', true), + ], ]; diff --git a/database/migrations/2025_09_01_231655_create_feeds_table.php b/database/migrations/2025_09_01_231655_create_feeds_table.php new file mode 100644 index 0000000..a0222c3 --- /dev/null +++ b/database/migrations/2025_09_01_231655_create_feeds_table.php @@ -0,0 +1,49 @@ +schema()->create($this->table(), function (Blueprint $table) { + $table->id(); + + $table->string('class')->unique(); + $table->string('title'); + + $table->string('expression'); + $table->string('format'); + + $table->boolean('is_active'); + + $table->timestamp('last_activity')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + $this->schema()->dropIfExists($this->table()); + } + + protected function schema(): Builder + { + return Schema::connection($this->connection()); + } + + protected function connection(): ?string + { + return config('feeds.table.connection'); + } + + protected function table(): string + { + return config('feeds.table.table'); + } +}; diff --git a/docs/snippets/advanced-nested.php b/docs/snippets/advanced-nested.php index 02b98a9..b0963a4 100644 --- a/docs/snippets/advanced-nested.php +++ b/docs/snippets/advanced-nested.php @@ -11,11 +11,11 @@ class UserFeedItem extends FeedItem public function toArray(): array { return [ - 'name' => $this->model->name, + 'name' => $this->model->class, 'email' => $this->model->email, 'header' => [ - '@cdata' => '

' . $this->model->name . '

', + '@cdata' => '

' . $this->model->class . '

', ], 'names' => [ diff --git a/docs/snippets/feeds-feed-item.php b/docs/snippets/feeds-feed-item.php index db0176a..28e3311 100644 --- a/docs/snippets/feeds-feed-item.php +++ b/docs/snippets/feeds-feed-item.php @@ -12,7 +12,7 @@ class UserFeedItem extends FeedItem public function toArray(): array { return [ - 'name' => $this->model->name, + 'name' => $this->model->class, 'email' => $this->model->email, 'header' => [ @@ -20,7 +20,7 @@ public function toArray(): array 'my-key-1' => 'my value 1', 'my-key-2' => 'my value 2', ], - '@cdata' => '

' . $this->model->name . '

', + '@cdata' => '

' . $this->model->class . '

', ], ]; } diff --git a/src/Casts/ExpressionCast.php b/src/Casts/ExpressionCast.php new file mode 100644 index 0000000..e7e6cf9 --- /dev/null +++ b/src/Casts/ExpressionCast.php @@ -0,0 +1,33 @@ +isValid($value)) { + throw new InvalidExpressionException($value); + } + + return Str::of($value)->squish()->trim()->toString(); + } + + protected function isValid(string $value): bool + { + return CronExpression::isValidExpression($value); + } +} diff --git a/src/Console/Commands/FeedGenerateCommand.php b/src/Console/Commands/FeedGenerateCommand.php index 5d81bb6..b76c992 100644 --- a/src/Console/Commands/FeedGenerateCommand.php +++ b/src/Console/Commands/FeedGenerateCommand.php @@ -4,7 +4,7 @@ namespace DragonCode\LaravelFeed\Console\Commands; -use DragonCode\LaravelFeed\Helpers\FeedHelper; +use DragonCode\LaravelFeed\Queries\FeedQuery; use DragonCode\LaravelFeed\Services\Generator; use Illuminate\Console\Command; use Laravel\Prompts\Concerns\Colors; @@ -12,38 +12,32 @@ use Symfony\Component\Console\Input\InputArgument; use function app; -use function config; #[AsCommand('feed:generate', 'Generate XML feeds')] class FeedGenerateCommand extends Command { use Colors; - public function handle(Generator $generator, FeedHelper $helper): void + public function handle(Generator $generator, FeedQuery $query): void { - foreach ($this->feedable($helper) as $feed => $enabled) { + foreach ($this->feedable($query) as $feed => $enabled) { $enabled ? $this->components->task($feed, fn () => $generator->feed(app($feed))) : $this->components->twoColumnDetail($feed, $this->messageYellow('SKIP')); } } - protected function feedable(FeedHelper $helper): array + protected function feedable(FeedQuery $feeds): array { - if ($feed = $this->resolveFeedClass($helper)) { - return [$feed => true]; - } - - return config('feeds.channels'); - } + if ($id = $this->argument('feed')) { + $feed = $feeds->find((int) $id); - protected function resolveFeedClass(FeedHelper $helper): ?string - { - if (! $class = $this->argument('class')) { - return null; + return [$feed->class => true]; } - return $helper->find((string) $class); + return $feeds->all() + ->pluck('is_active', 'class') + ->all(); } protected function messageYellow(string $message): string @@ -58,7 +52,7 @@ protected function messageYellow(string $message): string protected function getArguments(): array { return [ - ['class', InputArgument::OPTIONAL, 'The feed class for generation'], + ['feed', InputArgument::OPTIONAL, 'The Feed ID for generation (from the database)'], ]; } } diff --git a/src/Console/Commands/FeedMakeCommand.php b/src/Console/Commands/FeedMakeCommand.php index 7173a5a..1591532 100644 --- a/src/Console/Commands/FeedMakeCommand.php +++ b/src/Console/Commands/FeedMakeCommand.php @@ -33,6 +33,18 @@ public function handle(): void (bool) $this->option('force') ); } + + $this->makeOperation(); + } + + protected function makeOperation(): void + { + // TODO: Make operation or migration for putting record to database + + $type = true ? 'Operation' : 'Migration'; + $path = base_path('operation/now.php'); + + $this->components->info(sprintf('%s [%s] created successfully.', $type, $path)); } protected function makeFeedItem(string $name, bool $force): void diff --git a/src/Services/ConvertToXml.php b/src/Converters/ConvertToXml.php similarity index 99% rename from src/Services/ConvertToXml.php rename to src/Converters/ConvertToXml.php index c0bd13a..ed6bc74 100644 --- a/src/Services/ConvertToXml.php +++ b/src/Converters/ConvertToXml.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace DragonCode\LaravelFeed\Services; +namespace DragonCode\LaravelFeed\Converters; use DOMDocument; use DOMNode; diff --git a/src/Enums/FeedFormatEnum.php b/src/Enums/FeedFormatEnum.php new file mode 100644 index 0000000..e8c0673 --- /dev/null +++ b/src/Enums/FeedFormatEnum.php @@ -0,0 +1,10 @@ +ensure($class); - } - - if (class_exists($class = $this->resolve($class))) { - return $this->ensure($class); - } - - throw new FeedNotFoundException($class); - } - - protected function resolve(string $class): string - { - return Str::of($class) - ->replace('/', '\\') - ->ltrim('\\') - ->start($this->rootNamespace() . 'Feeds\\') - ->finish('Feed') - ->toString(); - } - - protected function ensure(string $class): string - { - if (! is_a($class, Feed::class, true)) { - throw new UnexpectedFeedException($class); - } - - return $class; - } - - protected function rootNamespace(): string - { - return $this->laravel->getNamespace(); - } -} diff --git a/src/Helpers/ScheduleFeedHelper.php b/src/Helpers/ScheduleFeedHelper.php new file mode 100644 index 0000000..dfdfad0 --- /dev/null +++ b/src/Helpers/ScheduleFeedHelper.php @@ -0,0 +1,46 @@ +query->active()->each( + fn (Feed $feed) => $this->register($feed) + ); + } + + protected function register(Feed $feed): void + { + $event = $this->event($feed); + + if ($this->canBackground) { + $event->runInBackground(); + } + } + + protected function event(Feed $feed): Event + { + return Schedule::command(FeedGenerateCommand::class, [$feed->id]) + ->withoutOverlapping($this->ttl) + ->cron($feed->expression); + } +} diff --git a/src/LaravelFeedServiceProvider.php b/src/LaravelFeedServiceProvider.php index d0ae2b8..bd5154a 100644 --- a/src/LaravelFeedServiceProvider.php +++ b/src/LaravelFeedServiceProvider.php @@ -25,6 +25,7 @@ public function boot(): void $this->registerCommands(); $this->publishConfig(); + $this->migrations(); } protected function publishConfig(): void @@ -34,6 +35,13 @@ protected function publishConfig(): void ], ['config', 'feeds']); } + protected function migrations(): void + { + $this->publishesMigrations([ + __DIR__ . '/../database/migrations' => $this->app->databasePath('migrations'), + ]); + } + protected function registerCommands(): void { $this->commands([ diff --git a/src/Models/Feed.php b/src/Models/Feed.php new file mode 100644 index 0000000..425b76e --- /dev/null +++ b/src/Models/Feed.php @@ -0,0 +1,51 @@ + ExpressionCast::class, + 'format' => FeedFormatEnum::class, + + 'is_active' => 'boolean', + + 'last_activity' => 'datetime', + ]; + } +} diff --git a/src/Queries/FeedQuery.php b/src/Queries/FeedQuery.php new file mode 100644 index 0000000..86b90f1 --- /dev/null +++ b/src/Queries/FeedQuery.php @@ -0,0 +1,69 @@ + $class, + 'title' => $title, + 'expression' => $expression, + 'format' => $format, + 'is_active' => $isActive, + ]); + } + + public function find(int $id): Feed + { + return Feed::findOrFail($id); + } + + public function all(): Collection + { + return Feed::query() + ->orderBy('id') + ->get(); + } + + public function active(): Collection + { + return Feed::query() + ->where('is_active', true) + ->orderBy('id') + ->get(); + } + + public function setLastActivity(string $class): void + { + Feed::query() + ->whereClass($class) + ->update(['last_activity' => now()]); + } + + public function delete(int $id): void + { + Feed::destroy($id); + } + + public function restore(int $id): void + { + Feed::query() + ->whereId($id) + ->restore(); + } +} diff --git a/src/Services/Generator.php b/src/Services/Generator.php index 962335a..283d171 100644 --- a/src/Services/Generator.php +++ b/src/Services/Generator.php @@ -4,12 +4,15 @@ namespace DragonCode\LaravelFeed\Services; +use DragonCode\LaravelFeed\Converters\ConvertToXml; use DragonCode\LaravelFeed\Data\ElementData; use DragonCode\LaravelFeed\Feeds\Feed; +use DragonCode\LaravelFeed\Queries\FeedQuery; use Illuminate\Database\Eloquent\Collection; use function blank; use function collect; +use function get_class; use function implode; use function sprintf; @@ -18,6 +21,7 @@ class Generator public function __construct( protected Filesystem $filesystem, protected ConvertToXml $converter, + protected FeedQuery $query, ) {} public function feed(Feed $feed): void @@ -33,6 +37,8 @@ public function feed(Feed $feed): void $this->performFooter($file, $feed); $this->release($file, $path); + + $this->setLastActivity($feed); } protected function performItem($file, Feed $feed): void @@ -111,4 +117,11 @@ protected function openFile(string $path) { return $this->filesystem->open($path); } + + protected function setLastActivity(Feed $feed): void + { + $this->query->setLastActivity( + get_class($feed) + ); + } } diff --git a/stubs/feed_activate_migration.stub b/stubs/feed_activate_migration.stub new file mode 100644 index 0000000..e69de29 diff --git a/stubs/feed_activate_operation.stub b/stubs/feed_activate_operation.stub new file mode 100644 index 0000000..e69de29 diff --git a/testbench.yaml b/testbench.yaml index ada5349..30dfe49 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -2,11 +2,14 @@ laravel: '@testbench' providers: - DragonCode\LaravelFeed\LaravelFeedServiceProvider - - Workbench\App\Providers\WorkbenchServiceProvider migrations: + - database/migrations - workbench/database/migrations +seeders: + - Workbench\Database\Seeders\DatabaseSeeder + workbench: install: false discovers: diff --git a/tests/Feature/Console/Generation/DefaultTest.php b/tests/Feature/Console/Generation/DefaultTest.php index 08dc4ed..b70f9cf 100644 --- a/tests/Feature/Console/Generation/DefaultTest.php +++ b/tests/Feature/Console/Generation/DefaultTest.php @@ -3,21 +3,20 @@ declare(strict_types=1); use DragonCode\LaravelFeed\Console\Commands\FeedGenerateCommand; +use DragonCode\LaravelFeed\Models\Feed; use function Pest\Laravel\artisan; test('generate', function () { $command = artisan(FeedGenerateCommand::class); - config() - ?->collection('feeds.channels') - ?->keys() - ?->each(fn (string $feed) => $command->expectsOutputToContain($feed)); + getAllFeeds()->each( + fn (Feed $feed) => $command->expectsOutputToContain($feed->class) + ); $command->assertSuccessful()->run(); - config() - ?->collection('feeds.channels') - ?->keys() - ?->each(fn (string $feed) => expect(app($feed)->path())->toBeReadableFile()); + getAllFeeds()->each( + fn (Feed $feed) => expect(app($feed)->path())->toBeReadableFile() + ); }); diff --git a/tests/Feature/Console/Generation/DisabledTest.php b/tests/Feature/Console/Generation/DisabledTest.php index 9fa0476..75817a1 100644 --- a/tests/Feature/Console/Generation/DisabledTest.php +++ b/tests/Feature/Console/Generation/DisabledTest.php @@ -3,30 +3,31 @@ declare(strict_types=1); use DragonCode\LaravelFeed\Console\Commands\FeedGenerateCommand; +use DragonCode\LaravelFeed\Models\Feed; use Workbench\App\Feeds\SitemapFeed; use Workbench\App\Feeds\YandexFeed; use function Pest\Laravel\artisan; test('generate', function () { - config()?->set('feeds.channels.' . SitemapFeed::class, false); - config()?->set('feeds.channels.' . YandexFeed::class, false); + disableFeeds([ + SitemapFeed::class, + YandexFeed::class, + ]); $command = artisan(FeedGenerateCommand::class); - config() - ?->collection('feeds.channels') - ?->keys() - ?->each(fn (string $feed) => $command->expectsOutputToContain($feed)); + getAllFeeds()->each( + fn (Feed $feed) => $command->expectsOutputToContain($feed->class) + ); $command->assertSuccessful()->run(); - config() - ?->collection('feeds.channels') - ?->keys() - ?->each(fn (string $feed) => match ($feed) { + getAllFeeds()->each( + fn (Feed $feed) => match ($feed->class) { SitemapFeed::class, - YandexFeed::class => expect(app($feed)->path())->not->toBeReadableFile(), - default => expect(app($feed)->path())->toBeReadableFile() - }); + YandexFeed::class => expect(app($feed->class)->path())->not->toBeReadableFile(), + default => expect(app($feed->class)->path())->toBeReadableFile() + } + ); }); diff --git a/tests/Feature/Console/Generation/IncorrectParameterTest.php b/tests/Feature/Console/Generation/IncorrectParameterTest.php index fe48160..90cbc14 100644 --- a/tests/Feature/Console/Generation/IncorrectParameterTest.php +++ b/tests/Feature/Console/Generation/IncorrectParameterTest.php @@ -3,42 +3,39 @@ declare(strict_types=1); use DragonCode\LaravelFeed\Console\Commands\FeedGenerateCommand; -use DragonCode\LaravelFeed\Exceptions\FeedNotFoundException; +use DragonCode\LaravelFeed\Models\Feed; use function Pest\Laravel\artisan; test('incorrect', function (mixed $name) { artisan(FeedGenerateCommand::class, [ - 'class' => $name, + 'feed' => $name, ])->run(); -}) - ->throws(FeedNotFoundException::class) - ->with([ - 'foo=bar', - 'foo+bar', - 'foo bar', - '123', - 123, - ]); +})->with([ + 'foo bar', + '123', + 123, +]); -test('may be correct', function (mixed $name) { +test('will be correct', function (int $id) { $command = artisan(FeedGenerateCommand::class, [ - 'class' => $name, + 'feed' => $id, ]); - config() - ?->collection('feeds.channels') - ?->keys() - ?->each(fn (string $feed) => $command->expectsOutputToContain($feed)); + getAllFeeds()->each( + fn (Feed $feed) => $id === $feed->id + ? $command->expectsOutputToContain($feed->class) + : $command->doesntExpectOutputToContain($feed->class) + ); $command->assertSuccessful()->run(); - config() - ?->collection('feeds.channels') - ?->keys() - ?->each(fn (string $feed) => expect(app($feed)->path())->toBeReadableFile()); + getAllFeeds()->each( + fn (Feed $feed) => $id === $feed->id + ? expect(app($feed->class)->path())->toBeReadableFile() + : expect(app($feed->class)->path())->not->toBeReadableFile() + ); })->with([ - '', - 0, - null, + fn () => Feed::query()->latest()->first()->id, + fn () => Feed::query()->oldest()->first()->id, ]); diff --git a/tests/Feature/Console/Generation/SpecifiedTest.php b/tests/Feature/Console/Generation/SpecifiedTest.php index 7649a00..eba7c7d 100644 --- a/tests/Feature/Console/Generation/SpecifiedTest.php +++ b/tests/Feature/Console/Generation/SpecifiedTest.php @@ -3,32 +3,29 @@ declare(strict_types=1); use DragonCode\LaravelFeed\Console\Commands\FeedGenerateCommand; +use DragonCode\LaravelFeed\Models\Feed; use Workbench\App\Feeds\SitemapFeed; use function Pest\Laravel\artisan; test('generate', function () { + $source = findFeed(SitemapFeed::class); + $command = artisan(FeedGenerateCommand::class, [ - 'class' => SitemapFeed::class, + 'feed' => SitemapFeed::class, ]); - config() - ?->collection('feeds.channels') - ?->keys() - ?->each( - fn (string $feed) => $feed === SitemapFeed::class - ? $command->expectsOutputToContain($feed) - : $command->doesntExpectOutputToContain($feed) - ); + getAllFeeds()->each( + fn (Feed $feed) => $source->id === $feed->id + ? $command->expectsOutputToContain($feed->class) + : $command->doesntExpectOutputToContain($feed->class) + ); $command->assertSuccessful()->run(); - config() - ?->collection('feeds.channels') - ?->keys() - ?->each( - fn (string $feed) => $feed === SitemapFeed::class - ? expect(app($feed)->path())->toBeReadableFile() - : expect(app($feed)->path())->not->toBeReadableFile() - ); + getAllFeeds()->each( + fn (Feed $feed) => $source->id === $feed->id + ? expect(app($feed->class)->path())->toBeReadableFile() + : expect(app($feed->class)->path())->not->toBeReadableFile() + ); }); diff --git a/tests/Feature/Console/Generation/UnknownClassTest.php b/tests/Feature/Console/Generation/UnknownClassTest.php deleted file mode 100644 index 5951a9a..0000000 --- a/tests/Feature/Console/Generation/UnknownClassTest.php +++ /dev/null @@ -1,26 +0,0 @@ - $feed, - ])->run(); -}) - ->throws(UnexpectedFeedException::class) - ->with([ - TestCase::class, - WorkbenchServiceProvider::class, - - YandexFeedItem::class, - YandexFeedInfo::class, - ]); diff --git a/tests/Helpers/expects.php b/tests/Helpers/expects.php index 0e9f63a..759ff0f 100644 --- a/tests/Helpers/expects.php +++ b/tests/Helpers/expects.php @@ -6,15 +6,14 @@ use function Pest\Laravel\artisan; -/** - * @param class-string $feed - */ -function expectFeed(string $feed): void +function expectFeed(string $class): void { - $instance = app($feed); + $feed = findFeed($class); + + $instance = app($feed->class); artisan(FeedGenerateCommand::class, [ - 'class' => $feed, + 'class' => $feed->id, ])->assertSuccessful()->run(); expect($instance->path())->toBeReadableFile(); diff --git a/tests/Helpers/feeds.php b/tests/Helpers/feeds.php new file mode 100644 index 0000000..de0dcf1 --- /dev/null +++ b/tests/Helpers/feeds.php @@ -0,0 +1,33 @@ +update([ + 'is_active' => true, + ]); +} + +function disableFeeds(array|string $classes): void +{ + Feed::query() + ->whereIn('class', Arr::wrap($classes)) + ->update(['is_active' => false]); +} + +function getAllFeeds(): Collection +{ + return Feed::get(); +} + +function findFeed(string $class): Feed +{ + return Feed::query() + ->whereClass($class) + ->firstOrFail(); +} diff --git a/tests/Pest.php b/tests/Pest.php index fa9a81d..124e2c1 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,12 +2,9 @@ declare(strict_types=1); +use DragonCode\LaravelFeed\Models\Feed; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; -use Workbench\App\Feeds\FullFeed; -use Workbench\App\Feeds\PartialFeed; -use Workbench\App\Feeds\SitemapFeed; -use Workbench\App\Feeds\YandexFeed; pest() ->printer() @@ -25,15 +22,9 @@ pest() ->in('Feature/Console/Generation') ->beforeEach(function () { - config()?->set('feeds.channels', [ - FullFeed::class => true, - PartialFeed::class => true, - SitemapFeed::class => true, - YandexFeed::class => true, - ]); + enableAllFeeds(); - config() - ?->collection('feeds.channels') - ?->keys() - ?->each(fn (string $feed) => deleteFeedResult($feed)); + getAllFeeds()->each( + fn (Feed $feed) => deleteFeedResult($feed->class) + ); }); diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php deleted file mode 100644 index 2682ce9..0000000 --- a/workbench/app/Providers/WorkbenchServiceProvider.php +++ /dev/null @@ -1,30 +0,0 @@ -isFreshLaravel()) { - return; - } - - Repository::macro('collection', function (string $key) { - return new Collection($this->get($key)); - }); - } - - protected function isFreshLaravel(): bool - { - return Str::of(Application::VERSION)->before('.')->toString() === '12'; - } -} diff --git a/workbench/database/seeders/DatabaseSeeder.php b/workbench/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..df3c3aa --- /dev/null +++ b/workbench/database/seeders/DatabaseSeeder.php @@ -0,0 +1,15 @@ +call(FeedSeeder::class); + } +} diff --git a/workbench/database/seeders/FeedSeeder.php b/workbench/database/seeders/FeedSeeder.php new file mode 100644 index 0000000..e5cc92a --- /dev/null +++ b/workbench/database/seeders/FeedSeeder.php @@ -0,0 +1,42 @@ +feeds as $feed) { + $this->store($feed); + } + } + + protected function store(string $name): void + { + Feed::create([ + 'class' => $name, + 'title' => Str::title($name), + + 'expression' => '* * * * *', + ]); + } +} From dd2867cce03f573024915d1ead10fd4fd49766e0 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 04:44:51 +0300 Subject: [PATCH 02/30] Set default attributes in Feed model, clean up FeedSeeder, and update composer scripts for testing flow --- composer.json | 11 +++++++++-- src/Models/Feed.php | 6 ++++++ workbench/database/seeders/FeedSeeder.php | 3 +-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 6dae35f..feb113c 100644 --- a/composer.json +++ b/composer.json @@ -65,10 +65,17 @@ "@clear", "@prepare" ], + "build": "@php vendor/bin/testbench workbench:build --ansi", "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", "prepare": "@php vendor/bin/testbench package:discover --ansi", "style": "vendor/bin/pint --parallel --ansi", - "test": "@php vendor/bin/pest --colors=always", - "test:update": "@php vendor/bin/pest --colors=always --update-snapshots" + "test": [ + "@build", + "@php vendor/bin/pest --colors=always" + ], + "test:update": [ + "@build", + "@php vendor/bin/pest --colors=always --update-snapshots" + ] } } diff --git a/src/Models/Feed.php b/src/Models/Feed.php index 425b76e..ff98e5c 100644 --- a/src/Models/Feed.php +++ b/src/Models/Feed.php @@ -27,6 +27,12 @@ class Feed extends Model 'last_activity', ]; + protected $attributes = [ + 'format' => FeedFormatEnum::Xml, + + 'is_active' => true, + ]; + public function getConnectionName(): ?string { return config('feeds.table.connection'); diff --git a/workbench/database/seeders/FeedSeeder.php b/workbench/database/seeders/FeedSeeder.php index e5cc92a..c1f0e8e 100644 --- a/workbench/database/seeders/FeedSeeder.php +++ b/workbench/database/seeders/FeedSeeder.php @@ -6,7 +6,6 @@ use DragonCode\LaravelFeed\Models\Feed; use Illuminate\Database\Seeder; -use Illuminate\Support\Str; use Workbench\App\Feeds\EmptyFeed; use Workbench\App\Feeds\FullFeed; use Workbench\App\Feeds\PartialFeed; @@ -34,7 +33,7 @@ protected function store(string $name): void { Feed::create([ 'class' => $name, - 'title' => Str::title($name), + 'title' => $name, 'expression' => '* * * * *', ]); From f70731d2c2c56cf1c5c6978793da9edfd7091307 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 04:52:04 +0300 Subject: [PATCH 03/30] Set default value for `expression` attribute in Feed model --- src/Models/Feed.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Models/Feed.php b/src/Models/Feed.php index ff98e5c..223df54 100644 --- a/src/Models/Feed.php +++ b/src/Models/Feed.php @@ -28,6 +28,8 @@ class Feed extends Model ]; protected $attributes = [ + 'expression' => '* * * * *', + 'format' => FeedFormatEnum::Xml, 'is_active' => true, From b5891b52f1fefd3213515edf134dba5de319e199 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 04:52:18 +0300 Subject: [PATCH 04/30] Enable database seeding by default in TestCase --- tests/TestCase.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/TestCase.php b/tests/TestCase.php index 42bf8ab..2011754 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -10,4 +10,6 @@ class TestCase extends BaseTestCase { use WithWorkbench; + + public bool $seed = true; } From 097840f84db1b924ef88e9e6bb4c4abedefaa9c2 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 04:52:27 +0300 Subject: [PATCH 05/30] Remove unused `expression` attribute from FeedSeeder --- workbench/database/seeders/FeedSeeder.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/workbench/database/seeders/FeedSeeder.php b/workbench/database/seeders/FeedSeeder.php index c1f0e8e..152c21e 100644 --- a/workbench/database/seeders/FeedSeeder.php +++ b/workbench/database/seeders/FeedSeeder.php @@ -34,8 +34,6 @@ protected function store(string $name): void Feed::create([ 'class' => $name, 'title' => $name, - - 'expression' => '* * * * *', ]); } } From 76a4e7c05c4c77441d2e55a6b344ebae7e8a796b Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 04:52:44 +0300 Subject: [PATCH 06/30] Update scripts in composer.json --- composer.json | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index feb113c..a8e60a2 100644 --- a/composer.json +++ b/composer.json @@ -69,13 +69,7 @@ "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", "prepare": "@php vendor/bin/testbench package:discover --ansi", "style": "vendor/bin/pint --parallel --ansi", - "test": [ - "@build", - "@php vendor/bin/pest --colors=always" - ], - "test:update": [ - "@build", - "@php vendor/bin/pest --colors=always --update-snapshots" - ] + "test": "@php vendor/bin/pest --colors=always", + "test:update": "@php vendor/bin/pest --colors=always --update-snapshots" } } From 72205a423bf5bd56f6139e0f794b25a17b071c26 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 04:54:46 +0300 Subject: [PATCH 07/30] Add TODO comment for parameter explanations in feeds config --- config/feeds.php | 1 + 1 file changed, 1 insertion(+) diff --git a/config/feeds.php b/config/feeds.php index 0ef1516..5698dea 100644 --- a/config/feeds.php +++ b/config/feeds.php @@ -2,6 +2,7 @@ declare(strict_types=1); +// TODO: Add comments explaining the meanings of the parameters return [ 'pretty' => (bool) env('FEED_PRETTY', false), From d3806f21de229b4a4c6d50d6fdeaa1f7ea16afd8 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 04:54:53 +0300 Subject: [PATCH 08/30] Add `dragon-code/laravel-deploy-operations` to dev dependencies in composer.json --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index a8e60a2..08f5c2f 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ }, "require-dev": { "dragon-code/codestyler": "^6.3", + "dragon-code/laravel-deploy-operations": "^7.1", "orchestra/testbench": "^9.0 || ^10.0", "pestphp/pest": "^3.0 || ^4.0", "pestphp/pest-plugin-laravel": "^3.0 || ^4.0", From 4aef5ade4181cbc467e96dca0e9fe9938b423c2c Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 11:51:51 +0300 Subject: [PATCH 09/30] Remove `format` attribute from Feed model and database structure, set default format in Feed class --- .../migrations/2025_09_01_231655_create_feeds_table.php | 1 - src/Feeds/Feed.php | 7 ++++++- src/Models/Feed.php | 5 ----- src/Queries/FeedQuery.php | 3 --- tests/Feature/Feeds/YandexTest.php | 7 +++++++ 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/database/migrations/2025_09_01_231655_create_feeds_table.php b/database/migrations/2025_09_01_231655_create_feeds_table.php index a0222c3..3f8cbf2 100644 --- a/database/migrations/2025_09_01_231655_create_feeds_table.php +++ b/database/migrations/2025_09_01_231655_create_feeds_table.php @@ -17,7 +17,6 @@ public function up(): void $table->string('title'); $table->string('expression'); - $table->string('format'); $table->boolean('is_active'); diff --git a/src/Feeds/Feed.php b/src/Feeds/Feed.php index 60ee526..f5758dc 100644 --- a/src/Feeds/Feed.php +++ b/src/Feeds/Feed.php @@ -5,6 +5,7 @@ namespace DragonCode\LaravelFeed\Feeds; use DragonCode\LaravelFeed\Data\ElementData; +use DragonCode\LaravelFeed\Enums\FeedFormatEnum; use DragonCode\LaravelFeed\Feeds\Info\FeedInfo; use DragonCode\LaravelFeed\Feeds\Items\FeedItem; use Illuminate\Contracts\Filesystem\Filesystem; @@ -17,6 +18,8 @@ abstract class Feed { + protected FeedFormatEnum $format = FeedFormatEnum::Xml; + protected string $storage = 'public'; protected ?string $filename = null; @@ -55,7 +58,9 @@ public function info(): FeedInfo public function filename(): string { - return $this->filename ??= Str::kebab(class_basename($this)) . '.xml'; + return $this->filename ??= Str::of(class_basename($this)) + ->append('.', $this->format->value) + ->toString(); } public function path(): string diff --git a/src/Models/Feed.php b/src/Models/Feed.php index 223df54..6f7add9 100644 --- a/src/Models/Feed.php +++ b/src/Models/Feed.php @@ -5,7 +5,6 @@ namespace DragonCode\LaravelFeed\Models; use DragonCode\LaravelFeed\Casts\ExpressionCast; -use DragonCode\LaravelFeed\Enums\FeedFormatEnum; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -20,7 +19,6 @@ class Feed extends Model 'title', 'expression', - 'format', 'is_active', @@ -30,8 +28,6 @@ class Feed extends Model protected $attributes = [ 'expression' => '* * * * *', - 'format' => FeedFormatEnum::Xml, - 'is_active' => true, ]; @@ -49,7 +45,6 @@ protected function casts(): array { return [ 'expression' => ExpressionCast::class, - 'format' => FeedFormatEnum::class, 'is_active' => 'boolean', diff --git a/src/Queries/FeedQuery.php b/src/Queries/FeedQuery.php index 86b90f1..f791476 100644 --- a/src/Queries/FeedQuery.php +++ b/src/Queries/FeedQuery.php @@ -4,7 +4,6 @@ namespace DragonCode\LaravelFeed\Queries; -use DragonCode\LaravelFeed\Enums\FeedFormatEnum; use DragonCode\LaravelFeed\Models\Feed; use Illuminate\Database\Eloquent\Collection; @@ -16,14 +15,12 @@ public function create( string $class, string $title, string $expression = '* * * * *', - FeedFormatEnum $format = FeedFormatEnum::Xml, bool $isActive = true, ): Feed { return Feed::create([ 'class' => $class, 'title' => $title, 'expression' => $expression, - 'format' => $format, 'is_active' => $isActive, ]); } diff --git a/tests/Feature/Feeds/YandexTest.php b/tests/Feature/Feeds/YandexTest.php index e7b77f9..89e39c5 100644 --- a/tests/Feature/Feeds/YandexTest.php +++ b/tests/Feature/Feeds/YandexTest.php @@ -2,10 +2,17 @@ declare(strict_types=1); +use DragonCode\LaravelFeed\Models\Feed; use Workbench\App\Feeds\YandexFeed; test('export', function () { createProducts(); + dd( + Feed::query()->pluck('class')->all(), + '---------------', + YandexFeed::class, + ); + expectFeed(YandexFeed::class); }); From ca33734097438d34217e91a5fe09f42d894266da Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 12:07:46 +0300 Subject: [PATCH 10/30] Set default cache driver --- phpunit.xml | 7 ++++++- testbench.yaml | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/phpunit.xml b/phpunit.xml index 0f2a031..7ca863e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -27,9 +27,14 @@ + + + + + + - diff --git a/testbench.yaml b/testbench.yaml index 30dfe49..07d17d9 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -2,6 +2,8 @@ laravel: '@testbench' providers: - DragonCode\LaravelFeed\LaravelFeedServiceProvider + - Spatie\LaravelData\LaravelDataServiceProvider + - DragonCode\LaravelDeployOperations\ServiceProvider migrations: - database/migrations @@ -23,3 +25,7 @@ workbench: - create-sqlite-db - db-wipe - migrate-fresh + +env: + CACHE_DRIVER: array + CACHE_STORE: array From 34d0bcdbf0e7db1a5c90ac63e3202cdf5762f8a5 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 22:00:05 +0300 Subject: [PATCH 11/30] Update test setup and composer scripts, remove unused code, and refine seeding configuration --- composer.json | 1 + testbench.yaml | 3 --- tests/Feature/Feeds/YandexTest.php | 7 ------- tests/Helpers/expects.php | 2 +- tests/TestCase.php | 3 ++- 5 files changed, 4 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index 08f5c2f..5e3bd10 100644 --- a/composer.json +++ b/composer.json @@ -66,6 +66,7 @@ "@clear", "@prepare" ], + "migrate": "@php vendor/bin/testbench migrate:fresh --seed --ansi", "build": "@php vendor/bin/testbench workbench:build --ansi", "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", "prepare": "@php vendor/bin/testbench package:discover --ansi", diff --git a/testbench.yaml b/testbench.yaml index 07d17d9..43aae98 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -9,9 +9,6 @@ migrations: - database/migrations - workbench/database/migrations -seeders: - - Workbench\Database\Seeders\DatabaseSeeder - workbench: install: false discovers: diff --git a/tests/Feature/Feeds/YandexTest.php b/tests/Feature/Feeds/YandexTest.php index 89e39c5..e7b77f9 100644 --- a/tests/Feature/Feeds/YandexTest.php +++ b/tests/Feature/Feeds/YandexTest.php @@ -2,17 +2,10 @@ declare(strict_types=1); -use DragonCode\LaravelFeed\Models\Feed; use Workbench\App\Feeds\YandexFeed; test('export', function () { createProducts(); - dd( - Feed::query()->pluck('class')->all(), - '---------------', - YandexFeed::class, - ); - expectFeed(YandexFeed::class); }); diff --git a/tests/Helpers/expects.php b/tests/Helpers/expects.php index 759ff0f..a00d961 100644 --- a/tests/Helpers/expects.php +++ b/tests/Helpers/expects.php @@ -13,7 +13,7 @@ function expectFeed(string $class): void $instance = app($feed->class); artisan(FeedGenerateCommand::class, [ - 'class' => $feed->id, + 'feed' => $feed->id, ])->assertSuccessful()->run(); expect($instance->path())->toBeReadableFile(); diff --git a/tests/TestCase.php b/tests/TestCase.php index 2011754..0f835b2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,10 +6,11 @@ use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase as BaseTestCase; +use Workbench\Database\Seeders\DatabaseSeeder; class TestCase extends BaseTestCase { use WithWorkbench; - public bool $seed = true; + public string $seeder = DatabaseSeeder::class; } From d57c5269b80c7cf7ab190003816bd90ce5d7f2c1 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 22:59:31 +0300 Subject: [PATCH 12/30] Refactor exceptions, enhance FeedQuery logic, and improve test coverage. --- composer.json | 2 +- src/Casts/ClassCast.php | 35 ++++++++++++++++ src/Console/Commands/FeedGenerateCommand.php | 18 +++++--- src/Exceptions/FeedNotFoundException.php | 15 +++++++ .../InvalidFeedArgumentException.php | 19 +++++++++ src/Exceptions/UnexpectedClassException.php | 15 +++++++ ...tion.php => UnknownFeedClassException.php} | 7 +++- src/Models/Feed.php | 2 + src/Queries/FeedQuery.php | 3 +- tests/Expectations.php | 8 ++++ .../Console/Generation/DefaultTest.php | 2 +- .../Console/Generation/DisabledTest.php | 4 +- .../Feature/Console/Generation/FoundTest.php | 31 ++++++++++++++ .../Generation/IncorrectParameterTest.php | 42 ++++++------------- .../Console/Generation/NotFoundTest.php | 14 +++++++ .../Console/Generation/SpecifiedTest.php | 6 +-- tests/Feature/Feeds/EmptyTest.php | 2 +- tests/Feature/Feeds/FullTest.php | 2 +- tests/Feature/Feeds/PartialTest.php | 2 +- tests/Feature/Feeds/SitemapTest.php | 2 +- tests/Feature/Feeds/YandexTest.php | 2 +- tests/Feature/Queries/Create/ClassTest.php | 23 ++++++++++ .../Feature/Queries/Create/ExpressionTest.php | 29 +++++++++++++ tests/Feature/Queries/Create/LastActivity.php | 35 ++++++++++++++++ tests/Feature/Queries/Create/SuccessTest.php | 24 +++++++++++ tests/Helpers/expects.php | 2 +- tests/Pest.php | 2 - 27 files changed, 294 insertions(+), 54 deletions(-) create mode 100644 src/Casts/ClassCast.php create mode 100644 src/Exceptions/FeedNotFoundException.php create mode 100644 src/Exceptions/InvalidFeedArgumentException.php create mode 100644 src/Exceptions/UnexpectedClassException.php rename src/Exceptions/{UnexpectedFeedException.php => UnknownFeedClassException.php} (58%) create mode 100644 tests/Feature/Console/Generation/FoundTest.php create mode 100644 tests/Feature/Console/Generation/NotFoundTest.php create mode 100644 tests/Feature/Queries/Create/ClassTest.php create mode 100644 tests/Feature/Queries/Create/ExpressionTest.php create mode 100644 tests/Feature/Queries/Create/LastActivity.php create mode 100644 tests/Feature/Queries/Create/SuccessTest.php diff --git a/composer.json b/composer.json index 5e3bd10..319d6fd 100644 --- a/composer.json +++ b/composer.json @@ -66,9 +66,9 @@ "@clear", "@prepare" ], - "migrate": "@php vendor/bin/testbench migrate:fresh --seed --ansi", "build": "@php vendor/bin/testbench workbench:build --ansi", "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "migrate": "@php vendor/bin/testbench migrate:fresh --seed --ansi", "prepare": "@php vendor/bin/testbench package:discover --ansi", "style": "vendor/bin/pint --parallel --ansi", "test": "@php vendor/bin/pest --colors=always", diff --git a/src/Casts/ClassCast.php b/src/Casts/ClassCast.php new file mode 100644 index 0000000..167ac31 --- /dev/null +++ b/src/Casts/ClassCast.php @@ -0,0 +1,35 @@ +argument('feed')) { - $feed = $feeds->find((int) $id); + if (! $id = $this->argument('feed')) { + return $feeds->all() + ->pluck('is_active', 'class') + ->all(); + } - return [$feed->class => true]; + if (! is_numeric($id)) { + throw new InvalidFeedArgumentException($id); } - return $feeds->all() - ->pluck('is_active', 'class') - ->all(); + $feed = $feeds->find((int) $id); + + return [$feed->class => true]; } protected function messageYellow(string $message): string diff --git a/src/Exceptions/FeedNotFoundException.php b/src/Exceptions/FeedNotFoundException.php new file mode 100644 index 0000000..84e8c50 --- /dev/null +++ b/src/Exceptions/FeedNotFoundException.php @@ -0,0 +1,15 @@ + ClassCast::class, 'expression' => ExpressionCast::class, 'is_active' => 'boolean', diff --git a/src/Queries/FeedQuery.php b/src/Queries/FeedQuery.php index f791476..8880b0d 100644 --- a/src/Queries/FeedQuery.php +++ b/src/Queries/FeedQuery.php @@ -4,6 +4,7 @@ namespace DragonCode\LaravelFeed\Queries; +use DragonCode\LaravelFeed\Exceptions\FeedNotFoundException; use DragonCode\LaravelFeed\Models\Feed; use Illuminate\Database\Eloquent\Collection; @@ -27,7 +28,7 @@ public function create( public function find(int $id): Feed { - return Feed::findOrFail($id); + return Feed::findOr($id, callback: static fn () => throw new FeedNotFoundException($id)); } public function all(): Collection diff --git a/tests/Expectations.php b/tests/Expectations.php index 2ff9cef..110b6ec 100644 --- a/tests/Expectations.php +++ b/tests/Expectations.php @@ -25,3 +25,11 @@ return $this; }); + +expect()->extend('toMatchGeneratedFeed', function () { + $path = app($this->value->class)->path(); + + expect($path)->toBeReadableFile(); + + return $this; +}); diff --git a/tests/Feature/Console/Generation/DefaultTest.php b/tests/Feature/Console/Generation/DefaultTest.php index b70f9cf..24aac4f 100644 --- a/tests/Feature/Console/Generation/DefaultTest.php +++ b/tests/Feature/Console/Generation/DefaultTest.php @@ -17,6 +17,6 @@ $command->assertSuccessful()->run(); getAllFeeds()->each( - fn (Feed $feed) => expect(app($feed)->path())->toBeReadableFile() + fn (Feed $feed) => expect($feed)->toMatchGeneratedFeed() ); }); diff --git a/tests/Feature/Console/Generation/DisabledTest.php b/tests/Feature/Console/Generation/DisabledTest.php index 75817a1..b48bb86 100644 --- a/tests/Feature/Console/Generation/DisabledTest.php +++ b/tests/Feature/Console/Generation/DisabledTest.php @@ -26,8 +26,8 @@ getAllFeeds()->each( fn (Feed $feed) => match ($feed->class) { SitemapFeed::class, - YandexFeed::class => expect(app($feed->class)->path())->not->toBeReadableFile(), - default => expect(app($feed->class)->path())->toBeReadableFile() + YandexFeed::class => expect($feed)->not->toMatchGeneratedFeed(), + default => expect($feed)->toMatchGeneratedFeed() } ); }); diff --git a/tests/Feature/Console/Generation/FoundTest.php b/tests/Feature/Console/Generation/FoundTest.php new file mode 100644 index 0000000..8b65b54 --- /dev/null +++ b/tests/Feature/Console/Generation/FoundTest.php @@ -0,0 +1,31 @@ + $id, + ]); + + getAllFeeds()->each( + fn (Feed $feed) => $id === $feed->id + ? $command->expectsOutputToContain($feed->class) + : $command->doesntExpectOutputToContain($feed->class) + ); + + $command->assertSuccessful()->run(); + + getAllFeeds()->each( + fn (Feed $feed) => $id === $feed->id + ? expect($feed)->toMatchGeneratedFeed() + : expect($feed)->not->toMatchGeneratedFeed() + ); +})->with([ + fn () => Feed::query()->latest()->first()->id, + fn () => Feed::query()->oldest()->first()->id, +]); diff --git a/tests/Feature/Console/Generation/IncorrectParameterTest.php b/tests/Feature/Console/Generation/IncorrectParameterTest.php index 90cbc14..a3937ee 100644 --- a/tests/Feature/Console/Generation/IncorrectParameterTest.php +++ b/tests/Feature/Console/Generation/IncorrectParameterTest.php @@ -3,39 +3,21 @@ declare(strict_types=1); use DragonCode\LaravelFeed\Console\Commands\FeedGenerateCommand; -use DragonCode\LaravelFeed\Models\Feed; +use DragonCode\LaravelFeed\Exceptions\InvalidFeedArgumentException; use function Pest\Laravel\artisan; -test('incorrect', function (mixed $name) { +test('incorrect', function (mixed $id) { artisan(FeedGenerateCommand::class, [ - 'feed' => $name, - ])->run(); -})->with([ - 'foo bar', - '123', - 123, -]); - -test('will be correct', function (int $id) { - $command = artisan(FeedGenerateCommand::class, [ 'feed' => $id, + ])->run(); +}) + ->throws(InvalidFeedArgumentException::class, 'Feed ID must be of type integer, [string] given.') + ->with([ + 'foo bar', + '+', + '-', + '/', + '\\', + '_', ]); - - getAllFeeds()->each( - fn (Feed $feed) => $id === $feed->id - ? $command->expectsOutputToContain($feed->class) - : $command->doesntExpectOutputToContain($feed->class) - ); - - $command->assertSuccessful()->run(); - - getAllFeeds()->each( - fn (Feed $feed) => $id === $feed->id - ? expect(app($feed->class)->path())->toBeReadableFile() - : expect(app($feed->class)->path())->not->toBeReadableFile() - ); -})->with([ - fn () => Feed::query()->latest()->first()->id, - fn () => Feed::query()->oldest()->first()->id, -]); diff --git a/tests/Feature/Console/Generation/NotFoundTest.php b/tests/Feature/Console/Generation/NotFoundTest.php new file mode 100644 index 0000000..58bc0d7 --- /dev/null +++ b/tests/Feature/Console/Generation/NotFoundTest.php @@ -0,0 +1,14 @@ + 123, + ])->run(); +})->throws(FeedNotFoundException::class, 'Feed [123] not found.'); diff --git a/tests/Feature/Console/Generation/SpecifiedTest.php b/tests/Feature/Console/Generation/SpecifiedTest.php index eba7c7d..205f707 100644 --- a/tests/Feature/Console/Generation/SpecifiedTest.php +++ b/tests/Feature/Console/Generation/SpecifiedTest.php @@ -12,7 +12,7 @@ $source = findFeed(SitemapFeed::class); $command = artisan(FeedGenerateCommand::class, [ - 'feed' => SitemapFeed::class, + 'feed' => $source->id, ]); getAllFeeds()->each( @@ -25,7 +25,7 @@ getAllFeeds()->each( fn (Feed $feed) => $source->id === $feed->id - ? expect(app($feed->class)->path())->toBeReadableFile() - : expect(app($feed->class)->path())->not->toBeReadableFile() + ? expect($feed)->toMatchGeneratedFeed() + : expect($feed)->not->toMatchGeneratedFeed() ); }); diff --git a/tests/Feature/Feeds/EmptyTest.php b/tests/Feature/Feeds/EmptyTest.php index 8d5e406..931f9f8 100644 --- a/tests/Feature/Feeds/EmptyTest.php +++ b/tests/Feature/Feeds/EmptyTest.php @@ -7,5 +7,5 @@ test('export', function (bool $pretty) { setPrettyXml($pretty); - expectFeed(EmptyFeed::class); + expectFeedSnapshot(EmptyFeed::class); })->with('boolean'); diff --git a/tests/Feature/Feeds/FullTest.php b/tests/Feature/Feeds/FullTest.php index d3df498..204dfef 100644 --- a/tests/Feature/Feeds/FullTest.php +++ b/tests/Feature/Feeds/FullTest.php @@ -10,5 +10,5 @@ createNews(...NewsFakeData::toArray()); - expectFeed(FullFeed::class); + expectFeedSnapshot(FullFeed::class); })->with('boolean'); diff --git a/tests/Feature/Feeds/PartialTest.php b/tests/Feature/Feeds/PartialTest.php index 27a2b6e..aa0b0ca 100644 --- a/tests/Feature/Feeds/PartialTest.php +++ b/tests/Feature/Feeds/PartialTest.php @@ -14,5 +14,5 @@ createNews(...NewsFakeData::toArray()); - expectFeed(PartialFeed::class); + expectFeedSnapshot(PartialFeed::class); })->with('boolean'); diff --git a/tests/Feature/Feeds/SitemapTest.php b/tests/Feature/Feeds/SitemapTest.php index 6cc3914..15e6a82 100644 --- a/tests/Feature/Feeds/SitemapTest.php +++ b/tests/Feature/Feeds/SitemapTest.php @@ -7,5 +7,5 @@ test('export', function () { createProducts(); - expectFeed(SitemapFeed::class); + expectFeedSnapshot(SitemapFeed::class); }); diff --git a/tests/Feature/Feeds/YandexTest.php b/tests/Feature/Feeds/YandexTest.php index e7b77f9..00ad370 100644 --- a/tests/Feature/Feeds/YandexTest.php +++ b/tests/Feature/Feeds/YandexTest.php @@ -7,5 +7,5 @@ test('export', function () { createProducts(); - expectFeed(YandexFeed::class); + expectFeedSnapshot(YandexFeed::class); }); diff --git a/tests/Feature/Queries/Create/ClassTest.php b/tests/Feature/Queries/Create/ClassTest.php new file mode 100644 index 0000000..d4068ba --- /dev/null +++ b/tests/Feature/Queries/Create/ClassTest.php @@ -0,0 +1,23 @@ +create( + class : EmptyFeed::class, + title : 'Some', + expression: $value + ); +})->throws( + exception: InvalidExpressionException::class, +)->with([ + 'foo', + '123', + 'foo 1 2 3', + '* * * * * *', + '* * *', +]); diff --git a/tests/Feature/Queries/Create/ExpressionTest.php b/tests/Feature/Queries/Create/ExpressionTest.php new file mode 100644 index 0000000..7c9dc20 --- /dev/null +++ b/tests/Feature/Queries/Create/ExpressionTest.php @@ -0,0 +1,29 @@ +create( + class: 'foo', + title: 'Some', + ); +})->throws( + exception : UnexpectedClassException::class, + exceptionMessage: 'Class [foo] does not exist.' +); + +test('not extending', function () { + app(FeedQuery::class)->create( + class: ProductFakeData::class, + title: 'Some', + ); +})->throws( + exception : UnknownFeedClassException::class, + exceptionMessage: sprintf('The [%s] class must extend from the %s class.', ProductFakeData::class, Feed::class) +); diff --git a/tests/Feature/Queries/Create/LastActivity.php b/tests/Feature/Queries/Create/LastActivity.php new file mode 100644 index 0000000..06fbe97 --- /dev/null +++ b/tests/Feature/Queries/Create/LastActivity.php @@ -0,0 +1,35 @@ + $feed->id, + + 'last_activity' => null, + ]); + + artisan(FeedGenerateCommand::class) + ->expectsOutputToContain($feed->class) + ->assertSuccessful() + ->run(); + + assertDatabaseMissing(Feed::class, [ + 'id' => $feed->id, + + 'last_activity' => null, + ]); + + $feed->refresh(); + + expect($feed->last_activity)->toDateTime(); +}); diff --git a/tests/Feature/Queries/Create/SuccessTest.php b/tests/Feature/Queries/Create/SuccessTest.php new file mode 100644 index 0000000..18fce60 --- /dev/null +++ b/tests/Feature/Queries/Create/SuccessTest.php @@ -0,0 +1,24 @@ +forceDelete(); + + $feed = app(FeedQuery::class)->create( + class : EmptyFeed::class, + title : 'Some', + expression: '*/15 */2 * 1 *' + ); + + expect($feed) + ->class->toBe(EmptyFeed::class) + ->title->toBe('Some') + ->expression->toBe('*/15 */2 * 1 *') + ->is_active->toBeTrue() + ->last_activity->toBeNull(); +}); diff --git a/tests/Helpers/expects.php b/tests/Helpers/expects.php index a00d961..33b5a7b 100644 --- a/tests/Helpers/expects.php +++ b/tests/Helpers/expects.php @@ -6,7 +6,7 @@ use function Pest\Laravel\artisan; -function expectFeed(string $class): void +function expectFeedSnapshot(string $class): void { $feed = findFeed($class); diff --git a/tests/Pest.php b/tests/Pest.php index 124e2c1..7344aeb 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -22,8 +22,6 @@ pest() ->in('Feature/Console/Generation') ->beforeEach(function () { - enableAllFeeds(); - getAllFeeds()->each( fn (Feed $feed) => deleteFeedResult($feed->class) ); From 33d3b269a51eb8394b2144a15d1b59aef443c870 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 23:04:00 +0300 Subject: [PATCH 13/30] Add detailed comments to feeds config for clarity and maintainability --- config/feeds.php | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/config/feeds.php b/config/feeds.php index 5698dea..dc9dc71 100644 --- a/config/feeds.php +++ b/config/feeds.php @@ -2,19 +2,60 @@ declare(strict_types=1); -// TODO: Add comments explaining the meanings of the parameters return [ + /** + * Pretty-print the generated feed output. + * + * When enabled, the resulting XML/JSON will include indentation and + * human‑friendly formatting. Disable for slightly smaller payload size. + * + * By default, false + */ + 'pretty' => (bool) env('FEED_PRETTY', false), + /** + * Database table settings used by the package (e.g., for generation logs or state). + */ + 'table' => [ + /** + * The database connection name to use. + * + * Should match a connection defined in config/database.php under + * the "connections" array. + */ + 'connection' => env('DB_CONNECTION', 'sqlite'), + /** + * The database table name used by the package. + */ + 'table' => env('FEED_TABLE', 'feeds'), ], + /** + * Scheduling options for feed generation/update tasks. + */ + 'schedule' => [ + /** + * Time To Live (in minutes) for the schedule lock or cache. + * + * Controls how frequently a scheduled job may be executed to avoid + * overlapping or excessively frequent runs. + */ + 'ttl' => (int) env('FEED_SCHEDULE_TTL', 1440), + /** + * Run scheduled jobs in the background. + * + * When true, tasks will be dispatched to run asynchronously so they do + * not block the current process. Set to false to run in the foreground. + */ + 'background' => (bool) env('FEED_SCHEDULE_RUN_BACKGROUND', true), ], ]; From 892fa6f9ddddef5d6531eb878c7d286e1fdda6dd Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 23:07:02 +0300 Subject: [PATCH 14/30] Add PHPDoc comment for `append` method in Filesystem service --- src/Services/Filesystem.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Services/Filesystem.php b/src/Services/Filesystem.php index bff317d..ef644b3 100644 --- a/src/Services/Filesystem.php +++ b/src/Services/Filesystem.php @@ -30,6 +30,9 @@ public function open(string $path) return fopen($path, 'ab'); } + /** + * @param resource $resource + */ public function append($resource, string $content): void { if (! empty($content)) { From 1c582f726cac3ba1f97991cfabcf153425d456c7 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 23:47:42 +0300 Subject: [PATCH 15/30] Refactoring Filesystem.php --- src/Exceptions/OpenFeedException.php | 15 +++++++++++++++ src/Exceptions/WriteFeedException.php | 15 +++++++++++++++ src/Services/Filesystem.php | 26 ++++++++++++++++++++++---- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 src/Exceptions/OpenFeedException.php create mode 100644 src/Exceptions/WriteFeedException.php diff --git a/src/Exceptions/OpenFeedException.php b/src/Exceptions/OpenFeedException.php new file mode 100644 index 0000000..0b08ba4 --- /dev/null +++ b/src/Exceptions/OpenFeedException.php @@ -0,0 +1,15 @@ +ensureFileDelete($path); $this->ensureDirectory($path); - return fopen($path, 'ab'); + $resource = fopen($path, 'ab'); + + if ($resource === false) { + throw new OpenFeedException($path); + } + + return $resource; } /** * @param resource $resource */ - public function append($resource, string $content): void + public function append($resource, string $content, string $path): void { - if (! empty($content)) { - fwrite($resource, $content); + if (blank($content)) { + return; + } + + if (fwrite($resource, $content) === false) { + throw new WriteFeedException($path); } } @@ -62,6 +76,10 @@ public function release($resource, string $path): void */ public function close($resource): void { + if (! is_resource($resource)) { + return; + } + fclose($resource); } From f693e063430fe42d7cad96ba374e97f15e991239 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Tue, 2 Sep 2025 23:47:48 +0300 Subject: [PATCH 16/30] Refactoring Generator.php --- src/Services/Generator.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Services/Generator.php b/src/Services/Generator.php index 283d171..25ebea6 100644 --- a/src/Services/Generator.php +++ b/src/Services/Generator.php @@ -52,13 +52,13 @@ protected function performItem($file, Feed $feed): void ); } - $this->append($file, implode(PHP_EOL, $content)); + $this->append($file, implode(PHP_EOL, $content), $feed->path()); }); } protected function performHeader($file, Feed $feed): void { - $this->append($file, $feed->header()); + $this->append($file, $feed->header(), $feed->path()); } protected function performInfo($file, Feed $feed): void @@ -69,7 +69,7 @@ protected function performInfo($file, Feed $feed): void $value = $this->converter->convertInfo($info); - $this->append($file, PHP_EOL . $value); + $this->append($file, PHP_EOL . $value, $feed->path()); } protected function performRoot($file, Feed $feed): void @@ -82,7 +82,7 @@ protected function performRoot($file, Feed $feed): void ? sprintf("\n<%s %s>\n", $name, $this->makeRootAttributes($feed->root())) : sprintf("\n<%s>\n", $name); - $this->append($file, $value); + $this->append($file, $value, $feed->path()); } protected function performFooter($file, Feed $feed): void @@ -93,7 +93,9 @@ protected function performFooter($file, Feed $feed): void $value .= "\n\n"; } - $this->append($file, $value . $feed->footer()); + $value .= $feed->footer(); + + $this->append($file, $value, $feed->path()); } protected function makeRootAttributes(ElementData $item): string @@ -103,9 +105,9 @@ protected function makeRootAttributes(ElementData $item): string ->implode(' '); } - protected function append($file, string $content): void + protected function append($file, string $content, string $path): void { - $this->filesystem->append($file, $content); + $this->filesystem->append($file, $content, $path); } protected function release($file, string $path): void From affa34a9fb3411fd9762d3ab98b705d4c7b3d7c8 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Wed, 3 Sep 2025 00:10:16 +0300 Subject: [PATCH 17/30] The tests were switched to parallel mode --- composer.json | 2 +- config/feeds.php | 7 ----- testbench.yaml | 1 + tests/Helpers/cleanup.php | 28 ------------------- tests/Pest.php | 9 ------ tests/Unit/Console/MakeInfoTest.php | 2 -- tests/Unit/Console/MakeItemTest.php | 2 -- tests/Unit/Console/MakeTest.php | 8 ------ .../Providers/WorkbenchServiceProvider.php | 26 +++++++++++++++++ 9 files changed, 28 insertions(+), 57 deletions(-) delete mode 100644 tests/Helpers/cleanup.php create mode 100644 workbench/app/Providers/WorkbenchServiceProvider.php diff --git a/composer.json b/composer.json index 319d6fd..35e8648 100644 --- a/composer.json +++ b/composer.json @@ -71,7 +71,7 @@ "migrate": "@php vendor/bin/testbench migrate:fresh --seed --ansi", "prepare": "@php vendor/bin/testbench package:discover --ansi", "style": "vendor/bin/pint --parallel --ansi", - "test": "@php vendor/bin/pest --colors=always", + "test": "@php vendor/bin/pest --parallel --colors=always", "test:update": "@php vendor/bin/pest --colors=always --update-snapshots" } } diff --git a/config/feeds.php b/config/feeds.php index dc9dc71..e51840f 100644 --- a/config/feeds.php +++ b/config/feeds.php @@ -11,13 +11,11 @@ * * By default, false */ - 'pretty' => (bool) env('FEED_PRETTY', false), /** * Database table settings used by the package (e.g., for generation logs or state). */ - 'table' => [ /** * The database connection name to use. @@ -25,20 +23,17 @@ * Should match a connection defined in config/database.php under * the "connections" array. */ - 'connection' => env('DB_CONNECTION', 'sqlite'), /** * The database table name used by the package. */ - 'table' => env('FEED_TABLE', 'feeds'), ], /** * Scheduling options for feed generation/update tasks. */ - 'schedule' => [ /** * Time To Live (in minutes) for the schedule lock or cache. @@ -46,7 +41,6 @@ * Controls how frequently a scheduled job may be executed to avoid * overlapping or excessively frequent runs. */ - 'ttl' => (int) env('FEED_SCHEDULE_TTL', 1440), /** @@ -55,7 +49,6 @@ * When true, tasks will be dispatched to run asynchronously so they do * not block the current process. Set to false to run in the foreground. */ - 'background' => (bool) env('FEED_SCHEDULE_RUN_BACKGROUND', true), ], ]; diff --git a/testbench.yaml b/testbench.yaml index 43aae98..e0ad071 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -1,6 +1,7 @@ laravel: '@testbench' providers: + - Workbench\App\Providers\WorkbenchServiceProvider - DragonCode\LaravelFeed\LaravelFeedServiceProvider - Spatie\LaravelData\LaravelDataServiceProvider - DragonCode\LaravelDeployOperations\ServiceProvider diff --git a/tests/Helpers/cleanup.php b/tests/Helpers/cleanup.php deleted file mode 100644 index e2216db..0000000 --- a/tests/Helpers/cleanup.php +++ /dev/null @@ -1,28 +0,0 @@ -delete( - $filename - ); -} - -function deleteFeed(string $feedName): void -{ - $path = app_path( - feedPath($feedName) - ); - - deleteFile($path); -} - -function deleteFeedResult(string $feedClass): void -{ - deleteFile( - app($feedClass)->path() - ); -} diff --git a/tests/Pest.php b/tests/Pest.php index 7344aeb..2e574ba 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use DragonCode\LaravelFeed\Models\Feed; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -18,11 +17,3 @@ pest() ->extend(TestCase::class) ->in('Unit'); - -pest() - ->in('Feature/Console/Generation') - ->beforeEach(function () { - getAllFeeds()->each( - fn (Feed $feed) => deleteFeedResult($feed->class) - ); - }); diff --git a/tests/Unit/Console/MakeInfoTest.php b/tests/Unit/Console/MakeInfoTest.php index 109dee2..51b4307 100644 --- a/tests/Unit/Console/MakeInfoTest.php +++ b/tests/Unit/Console/MakeInfoTest.php @@ -7,8 +7,6 @@ use function Pest\Laravel\artisan; test('make feed item', function () { - deleteFeed('Info/FooBar'); - artisan(FeedInfoMakeCommand::class, [ 'name' => 'FooBar', '--force' => true, diff --git a/tests/Unit/Console/MakeItemTest.php b/tests/Unit/Console/MakeItemTest.php index 0f9e3f5..12da122 100644 --- a/tests/Unit/Console/MakeItemTest.php +++ b/tests/Unit/Console/MakeItemTest.php @@ -7,8 +7,6 @@ use function Pest\Laravel\artisan; test('make feed item', function () { - deleteFeed('Items/FooBar'); - artisan(FeedItemMakeCommand::class, [ 'name' => 'FooBar', '--force' => true, diff --git a/tests/Unit/Console/MakeTest.php b/tests/Unit/Console/MakeTest.php index 2aaf173..1462ce7 100644 --- a/tests/Unit/Console/MakeTest.php +++ b/tests/Unit/Console/MakeTest.php @@ -7,8 +7,6 @@ use function Pest\Laravel\artisan; test('make feed', function () { - deleteFeed('FooBar'); - artisan(FeedMakeCommand::class, [ 'name' => 'FooBar', '--force' => true, @@ -23,9 +21,6 @@ }); test('make with item', function () { - deleteFeed('QweRty'); - deleteFeed('Items/QweRty'); - artisan(FeedMakeCommand::class, [ 'name' => 'QweRty', '--item' => true, @@ -43,9 +38,6 @@ }); test('make with info', function () { - deleteFeed('QweRty'); - deleteFeed('Items/QweRty'); - artisan(FeedMakeCommand::class, [ 'name' => 'QweRty', '--info' => true, diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php new file mode 100644 index 0000000..2f760f6 --- /dev/null +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -0,0 +1,26 @@ +disks() as $disk) { + Storage::fake($disk); + } + } + + protected function disks(): array + { + return array_keys(config('filesystems.disks')); + } +} From 68049245e92a5e8be61e2f08a264b05c354076ef Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Wed, 3 Sep 2025 03:36:13 +0300 Subject: [PATCH 18/30] Added generation of operations and migration --- composer.json | 1 + src/Console/Commands/FeedMakeCommand.php | 45 ++++++-- src/Publishers/MigrationPublisher.php | 20 ++++ src/Publishers/OperationPublisher.php | 20 ++++ src/Publishers/Publisher.php | 103 ++++++++++++++++++ stubs/migration.stub | 20 ++++ stubs/operation.stub | 23 ++++ .../FeedTest}/make_feed.snap | 0 .../InfoTest}/make_with_info.snap | 0 .../InfoTest}/make_with_info__2.snap | 0 .../ItemTest}/make_with_item.snap | 0 .../ItemTest}/make_with_item__2.snap | 0 tests/Expectations.php | 20 +++- tests/Helpers/cleanup.php | 22 ++++ tests/Helpers/mocks.php | 32 ++++++ tests/Pest.php | 21 +++- tests/Unit/Console/Make/FeedTest.php | 21 ++++ tests/Unit/Console/Make/InfoTest.php | 24 ++++ tests/Unit/Console/Make/ItemTest.php | 24 ++++ tests/Unit/Console/Make/MigrationTest.php | 27 +++++ tests/Unit/Console/Make/OperationTest.php | 25 +++++ tests/Unit/Console/MakeTest.php | 55 ---------- 22 files changed, 432 insertions(+), 71 deletions(-) create mode 100644 src/Publishers/MigrationPublisher.php create mode 100644 src/Publishers/OperationPublisher.php create mode 100644 src/Publishers/Publisher.php create mode 100644 stubs/migration.stub create mode 100644 stubs/operation.stub rename tests/.pest/snapshots/Unit/Console/{MakeTest => Make/FeedTest}/make_feed.snap (100%) rename tests/.pest/snapshots/Unit/Console/{MakeTest => Make/InfoTest}/make_with_info.snap (100%) rename tests/.pest/snapshots/Unit/Console/{MakeTest => Make/InfoTest}/make_with_info__2.snap (100%) rename tests/.pest/snapshots/Unit/Console/{MakeTest => Make/ItemTest}/make_with_item.snap (100%) rename tests/.pest/snapshots/Unit/Console/{MakeTest => Make/ItemTest}/make_with_item__2.snap (100%) create mode 100644 tests/Helpers/cleanup.php create mode 100644 tests/Helpers/mocks.php create mode 100644 tests/Unit/Console/Make/FeedTest.php create mode 100644 tests/Unit/Console/Make/InfoTest.php create mode 100644 tests/Unit/Console/Make/ItemTest.php create mode 100644 tests/Unit/Console/Make/MigrationTest.php create mode 100644 tests/Unit/Console/Make/OperationTest.php delete mode 100644 tests/Unit/Console/MakeTest.php diff --git a/composer.json b/composer.json index 35e8648..b1444c8 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "require-dev": { "dragon-code/codestyler": "^6.3", "dragon-code/laravel-deploy-operations": "^7.1", + "mockery/mockery": "^1.6", "orchestra/testbench": "^9.0 || ^10.0", "pestphp/pest": "^3.0 || ^4.0", "pestphp/pest-plugin-laravel": "^3.0 || ^4.0", diff --git a/src/Console/Commands/FeedMakeCommand.php b/src/Console/Commands/FeedMakeCommand.php index 1591532..da6bcb9 100644 --- a/src/Console/Commands/FeedMakeCommand.php +++ b/src/Console/Commands/FeedMakeCommand.php @@ -5,10 +5,17 @@ namespace DragonCode\LaravelFeed\Console\Commands; use DragonCode\LaravelFeed\Concerns\InteractsWithName; +use DragonCode\LaravelFeed\Publishers\MigrationPublisher; +use DragonCode\LaravelFeed\Publishers\OperationPublisher; use Illuminate\Console\GeneratorCommand; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Composer; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; +use function app; +use function vsprintf; + #[AsCommand('make:feed', 'Create a new feed')] class FeedMakeCommand extends GeneratorCommand { @@ -16,6 +23,13 @@ class FeedMakeCommand extends GeneratorCommand protected $type = 'Feed'; + public function __construct( + protected Composer $composer, + Filesystem $files + ) { + parent::__construct($files); + } + public function handle(): void { parent::handle(); @@ -34,17 +48,22 @@ public function handle(): void ); } - $this->makeOperation(); + $this->makeOperation( + $this->argument('name'), + $this->getQualifyClass() + ); } - protected function makeOperation(): void + protected function makeOperation(string $name, string $class): void { - // TODO: Make operation or migration for putting record to database - - $type = true ? 'Operation' : 'Migration'; - $path = base_path('operation/now.php'); - - $this->components->info(sprintf('%s [%s] created successfully.', $type, $path)); + $publisher = $this->hasOperations() + ? app(OperationPublisher::class, ['title' => $name, 'class' => $class]) + : app(MigrationPublisher::class, ['title' => $name, 'class' => $class]); + + $this->components->info(vsprintf('%s [%s] created successfully.', [ + $publisher->name(), + $publisher->publish(), + ])); } protected function makeFeedItem(string $name, bool $force): void @@ -73,6 +92,16 @@ protected function getDefaultNamespace($rootNamespace): string return $rootNamespace . '\Feeds'; } + protected function getQualifyClass(): string + { + return $this->qualifyClass($this->getNameInput()); + } + + protected function hasOperations(): bool + { + return $this->composer->hasPackage('dragon-code/laravel-deploy-operations'); + } + protected function getOptions(): array { return [ diff --git a/src/Publishers/MigrationPublisher.php b/src/Publishers/MigrationPublisher.php new file mode 100644 index 0000000..43a16af --- /dev/null +++ b/src/Publishers/MigrationPublisher.php @@ -0,0 +1,20 @@ +classBasename() + ->before('Publisher') + ->toString(); + } + + public function publish(): string + { + return $this->store( + $this->path(), + $this->replace() + ); + } + + protected function store(string $path, string $contents): string + { + $this->filesystem->ensureDirectoryExists(dirname($path)); + $this->filesystem->put($path, $contents); + + return $path; + } + + protected function replace(): string + { + return str_replace( + ['DummyClass', 'DummyBaseClass', 'DummyTitle'], + [$this->class, $this->baseClass(), $this->title()], + $this->load() + ); + } + + protected function baseClass(): string + { + return class_basename($this->class); + } + + protected function title(): string + { + return Str::of($this->title) + ->replace(['\\', '/'], ': ') + ->snake(' ') + ->title() + ->toString(); + } + + protected function path(): string + { + return vsprintf('%s/%s_%s.php', [ + $this->basePath(), + $this->date(), + $this->filename(), + ]); + } + + protected function filename(): string + { + return Str::of($this->title) + ->snake() + ->prepend('create_') + ->append('_feed') + ->toString(); + } + + protected function date(): string + { + return Carbon::now()->format('Y_m_d_His'); + } + + protected function load(): string + { + return file_get_contents($this->template()); + } +} diff --git a/stubs/migration.stub b/stubs/migration.stub new file mode 100644 index 0000000..2d91f28 --- /dev/null +++ b/stubs/migration.stub @@ -0,0 +1,20 @@ +create( + class : DummyBaseClass::class, + title : 'DummyTitle', + expression: '* * * * *' + ); + } +}; diff --git a/stubs/operation.stub b/stubs/operation.stub new file mode 100644 index 0000000..eabdbab --- /dev/null +++ b/stubs/operation.stub @@ -0,0 +1,23 @@ +create( + class : DummyBaseClass::class, + title : 'DummyTitle', + expression: '* * * * *' + ); + } + + public function withinTransactions(): bool + { + return false; + } +}; diff --git a/tests/.pest/snapshots/Unit/Console/MakeTest/make_feed.snap b/tests/.pest/snapshots/Unit/Console/Make/FeedTest/make_feed.snap similarity index 100% rename from tests/.pest/snapshots/Unit/Console/MakeTest/make_feed.snap rename to tests/.pest/snapshots/Unit/Console/Make/FeedTest/make_feed.snap diff --git a/tests/.pest/snapshots/Unit/Console/MakeTest/make_with_info.snap b/tests/.pest/snapshots/Unit/Console/Make/InfoTest/make_with_info.snap similarity index 100% rename from tests/.pest/snapshots/Unit/Console/MakeTest/make_with_info.snap rename to tests/.pest/snapshots/Unit/Console/Make/InfoTest/make_with_info.snap diff --git a/tests/.pest/snapshots/Unit/Console/MakeTest/make_with_info__2.snap b/tests/.pest/snapshots/Unit/Console/Make/InfoTest/make_with_info__2.snap similarity index 100% rename from tests/.pest/snapshots/Unit/Console/MakeTest/make_with_info__2.snap rename to tests/.pest/snapshots/Unit/Console/Make/InfoTest/make_with_info__2.snap diff --git a/tests/.pest/snapshots/Unit/Console/MakeTest/make_with_item.snap b/tests/.pest/snapshots/Unit/Console/Make/ItemTest/make_with_item.snap similarity index 100% rename from tests/.pest/snapshots/Unit/Console/MakeTest/make_with_item.snap rename to tests/.pest/snapshots/Unit/Console/Make/ItemTest/make_with_item.snap diff --git a/tests/.pest/snapshots/Unit/Console/MakeTest/make_with_item__2.snap b/tests/.pest/snapshots/Unit/Console/Make/ItemTest/make_with_item__2.snap similarity index 100% rename from tests/.pest/snapshots/Unit/Console/MakeTest/make_with_item__2.snap rename to tests/.pest/snapshots/Unit/Console/Make/ItemTest/make_with_item__2.snap diff --git a/tests/Expectations.php b/tests/Expectations.php index 110b6ec..c7509b9 100644 --- a/tests/Expectations.php +++ b/tests/Expectations.php @@ -2,26 +2,34 @@ declare(strict_types=1); -expect()->extend('toMatchFeedSnapshot', function () { - $content = file_get_contents(feedPath($this->value . 'Feed')); +expect()->extend('toMatchFileSnapshot', function () { + $content = file_get_contents($this->value); expect($content)->toMatchSnapshot(); return $this; }); +expect()->extend('toMatchFeedSnapshot', function () { + $path = feedPath($this->value . 'Feed'); + + expect($path)->toMatchFileSnapshot(); + + return $this; +}); + expect()->extend('toMatchFeedItemSnapshot', function () { - $content = file_get_contents(feedPath('Items/' . $this->value . 'FeedItem')); + $path = feedPath('Items/' . $this->value . 'FeedItem'); - expect($content)->toMatchSnapshot(); + expect($path)->toMatchFileSnapshot(); return $this; }); expect()->extend('toMatchFeedInfoSnapshot', function () { - $content = file_get_contents(feedPath('Info/' . $this->value . 'FeedInfo')); + $path = feedPath('Info/' . $this->value . 'FeedInfo'); - expect($content)->toMatchSnapshot(); + expect($path)->toMatchFileSnapshot(); return $this; }); diff --git a/tests/Helpers/cleanup.php b/tests/Helpers/cleanup.php new file mode 100644 index 0000000..89c1d95 --- /dev/null +++ b/tests/Helpers/cleanup.php @@ -0,0 +1,22 @@ +deleteDirectory( + config('deploy-operations.path') + ); +} + +function deleteMigrations(): void +{ + $token = ParallelTesting::token() ?: '0'; + + (new Filesystem)->deleteDirectory( + database_path($token) + ); +} diff --git a/tests/Helpers/mocks.php b/tests/Helpers/mocks.php new file mode 100644 index 0000000..eb82c8c --- /dev/null +++ b/tests/Helpers/mocks.php @@ -0,0 +1,32 @@ +forgetInstance(FeedMakeCommand::class); + + app()->singleton(FeedMakeCommand::class, function () use ($installed) { + $mock = mock(Composer::class); + $mock->shouldReceive('hasPackage')->andReturn($installed); + + return new FeedMakeCommand($mock, new Filesystem); + }); +} + +function mockPaths(): void +{ + $token = ParallelTesting::token() ?: '0'; + + $operations = config('deploy-operations.path'); + $migrations = database_path($token); + + config()?->set('deploy-operations.path', $operations . '/' . $token); + + app()->useDatabasePath($migrations); +} diff --git a/tests/Pest.php b/tests/Pest.php index 2e574ba..28561af 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; use Tests\TestCase; pest() @@ -12,8 +13,24 @@ pest() ->extend(TestCase::class) ->use(RefreshDatabase::class) - ->in('Feature'); + ->in('Feature') + ->beforeEach(function () { + mockOperations(); + mockPaths(); + + deleteOperations(); + deleteMigrations(); + }); pest() ->extend(TestCase::class) - ->in('Unit'); + ->in('Unit') + ->beforeEach(function () { + Carbon::setTestNow('2025-09-03 01:50:24'); + + mockOperations(); + mockPaths(); + + deleteOperations(); + deleteMigrations(); + }); diff --git a/tests/Unit/Console/Make/FeedTest.php b/tests/Unit/Console/Make/FeedTest.php new file mode 100644 index 0000000..785ec15 --- /dev/null +++ b/tests/Unit/Console/Make/FeedTest.php @@ -0,0 +1,21 @@ + 'FooBar', + '--force' => true, + ]) + ->expectsOutputToContain(resolvePath('app/Feeds/FooBarFeed.php] created successfully')) + ->doesntExpectOutputToContain(resolvePath('app/Feeds/Items')) + ->doesntExpectOutputToContain(resolvePath('app/Feeds/Info')) + ->assertSuccessful() + ->run(); + + expect('FooBar')->toMatchFeedSnapshot(); +}); diff --git a/tests/Unit/Console/Make/InfoTest.php b/tests/Unit/Console/Make/InfoTest.php new file mode 100644 index 0000000..f7e0274 --- /dev/null +++ b/tests/Unit/Console/Make/InfoTest.php @@ -0,0 +1,24 @@ + 'QweRty', + '--info' => true, + '--force' => true, + ]) + ->expectsOutputToContain(resolvePath('app/Feeds/QweRtyFeed.php] created successfully')) + ->doesntExpectOutputToContain(resolvePath('app/Feeds/Items/QweRtyFeedItem.php] created successfully')) + ->expectsOutputToContain(resolvePath('app/Feeds/Info/QweRtyFeedInfo')) + ->assertSuccessful() + ->run(); + + expect('QweRty') + ->toMatchFeedSnapshot() + ->toMatchFeedInfoSnapshot(); +}); diff --git a/tests/Unit/Console/Make/ItemTest.php b/tests/Unit/Console/Make/ItemTest.php new file mode 100644 index 0000000..56fa99a --- /dev/null +++ b/tests/Unit/Console/Make/ItemTest.php @@ -0,0 +1,24 @@ + 'QweRty', + '--item' => true, + '--force' => true, + ]) + ->expectsOutputToContain(resolvePath('app/Feeds/QweRtyFeed.php] created successfully')) + ->expectsOutputToContain(resolvePath('app/Feeds/Items/QweRtyFeedItem.php] created successfully')) + ->doesntExpectOutputToContain(resolvePath('app/Feeds/Info')) + ->assertSuccessful() + ->run(); + + expect('QweRty') + ->toMatchFeedSnapshot() + ->toMatchFeedItemSnapshot(); +}); diff --git a/tests/Unit/Console/Make/MigrationTest.php b/tests/Unit/Console/Make/MigrationTest.php new file mode 100644 index 0000000..f4c4df0 --- /dev/null +++ b/tests/Unit/Console/Make/MigrationTest.php @@ -0,0 +1,27 @@ + 'FooBar', + '--force' => true, + ]) + ->expectsOutputToContain(resolvePath('app/Feeds/FooBarFeed.php] created successfully')) + ->doesntExpectOutputToContain("Operation [$operation] created successfully.") + ->expectsOutputToContain("Migration [$migration] created successfully.") + ->assertSuccessful() + ->run(); + + expect($operation)->not->toBeReadableFile(); + expect($migration)->toBeReadableFile(); +}); diff --git a/tests/Unit/Console/Make/OperationTest.php b/tests/Unit/Console/Make/OperationTest.php new file mode 100644 index 0000000..6a31812 --- /dev/null +++ b/tests/Unit/Console/Make/OperationTest.php @@ -0,0 +1,25 @@ + 'FooBar', + '--force' => true, + ]) + ->expectsOutputToContain(resolvePath('app/Feeds/FooBarFeed.php] created successfully')) + ->expectsOutputToContain("Operation [$operation] created successfully.") + ->doesntExpectOutputToContain("Migration [$migration] created successfully.") + ->assertSuccessful() + ->run(); + + expect($operation)->toBeReadableFile(); + expect($migration)->not->toBeReadableFile(); +}); diff --git a/tests/Unit/Console/MakeTest.php b/tests/Unit/Console/MakeTest.php deleted file mode 100644 index 1462ce7..0000000 --- a/tests/Unit/Console/MakeTest.php +++ /dev/null @@ -1,55 +0,0 @@ - 'FooBar', - '--force' => true, - ]) - ->expectsOutputToContain(resolvePath('app/Feeds/FooBarFeed.php] created successfully')) - ->doesntExpectOutputToContain(resolvePath('app/Feeds/Items')) - ->doesntExpectOutputToContain(resolvePath('app/Feeds/Info')) - ->assertSuccessful() - ->run(); - - expect('FooBar')->toMatchFeedSnapshot(); -}); - -test('make with item', function () { - artisan(FeedMakeCommand::class, [ - 'name' => 'QweRty', - '--item' => true, - '--force' => true, - ]) - ->expectsOutputToContain(resolvePath('app/Feeds/QweRtyFeed.php] created successfully')) - ->expectsOutputToContain(resolvePath('app/Feeds/Items/QweRtyFeedItem.php] created successfully')) - ->doesntExpectOutputToContain(resolvePath('app/Feeds/Info')) - ->assertSuccessful() - ->run(); - - expect('QweRty') - ->toMatchFeedSnapshot() - ->toMatchFeedItemSnapshot(); -}); - -test('make with info', function () { - artisan(FeedMakeCommand::class, [ - 'name' => 'QweRty', - '--info' => true, - '--force' => true, - ]) - ->expectsOutputToContain(resolvePath('app/Feeds/QweRtyFeed.php] created successfully')) - ->doesntExpectOutputToContain(resolvePath('app/Feeds/Items/QweRtyFeedItem.php] created successfully')) - ->expectsOutputToContain(resolvePath('app/Feeds/Info/QweRtyFeedInfo')) - ->assertSuccessful() - ->run(); - - expect('QweRty') - ->toMatchFeedSnapshot() - ->toMatchFeedInfoSnapshot(); -}); From a1b5a45d132f6d04d8ac364f482cfddc3d2edfcd Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Wed, 3 Sep 2025 03:40:32 +0300 Subject: [PATCH 19/30] Replace `toBeReadableFile` with `toBeFile` method --- .../Console/Make/MigrationTest/migration.snap | 20 ++++++++++++++++ .../Console/Make/OperationTest/operation.snap | 23 +++++++++++++++++++ tests/Expectations.php | 2 +- tests/Helpers/expects.php | 2 +- tests/Unit/Console/Make/MigrationTest.php | 4 ++-- tests/Unit/Console/Make/OperationTest.php | 4 ++-- 6 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 tests/.pest/snapshots/Unit/Console/Make/MigrationTest/migration.snap create mode 100644 tests/.pest/snapshots/Unit/Console/Make/OperationTest/operation.snap diff --git a/tests/.pest/snapshots/Unit/Console/Make/MigrationTest/migration.snap b/tests/.pest/snapshots/Unit/Console/Make/MigrationTest/migration.snap new file mode 100644 index 0000000..badb620 --- /dev/null +++ b/tests/.pest/snapshots/Unit/Console/Make/MigrationTest/migration.snap @@ -0,0 +1,20 @@ +create( + class : FooBarFeed::class, + title : 'Foo Bar', + expression: '* * * * *' + ); + } +}; diff --git a/tests/.pest/snapshots/Unit/Console/Make/OperationTest/operation.snap b/tests/.pest/snapshots/Unit/Console/Make/OperationTest/operation.snap new file mode 100644 index 0000000..cd463bd --- /dev/null +++ b/tests/.pest/snapshots/Unit/Console/Make/OperationTest/operation.snap @@ -0,0 +1,23 @@ +create( + class : FooBarFeed::class, + title : 'Foo Bar', + expression: '* * * * *' + ); + } + + public function withinTransactions(): bool + { + return false; + } +}; diff --git a/tests/Expectations.php b/tests/Expectations.php index c7509b9..26d9f96 100644 --- a/tests/Expectations.php +++ b/tests/Expectations.php @@ -37,7 +37,7 @@ expect()->extend('toMatchGeneratedFeed', function () { $path = app($this->value->class)->path(); - expect($path)->toBeReadableFile(); + expect($path)->toBeFile(); return $this; }); diff --git a/tests/Helpers/expects.php b/tests/Helpers/expects.php index 33b5a7b..19ff160 100644 --- a/tests/Helpers/expects.php +++ b/tests/Helpers/expects.php @@ -16,6 +16,6 @@ function expectFeedSnapshot(string $class): void 'feed' => $feed->id, ])->assertSuccessful()->run(); - expect($instance->path())->toBeReadableFile(); + expect($instance->path())->toBeFile(); expect(file_get_contents($instance->path()))->toMatchSnapshot(); } diff --git a/tests/Unit/Console/Make/MigrationTest.php b/tests/Unit/Console/Make/MigrationTest.php index f4c4df0..54e8cf2 100644 --- a/tests/Unit/Console/Make/MigrationTest.php +++ b/tests/Unit/Console/Make/MigrationTest.php @@ -22,6 +22,6 @@ ->assertSuccessful() ->run(); - expect($operation)->not->toBeReadableFile(); - expect($migration)->toBeReadableFile(); + expect($operation)->not->toBeFile(); + expect($migration)->toBeFile()->toMatchFileSnapshot(); }); diff --git a/tests/Unit/Console/Make/OperationTest.php b/tests/Unit/Console/Make/OperationTest.php index 6a31812..8e1c701 100644 --- a/tests/Unit/Console/Make/OperationTest.php +++ b/tests/Unit/Console/Make/OperationTest.php @@ -20,6 +20,6 @@ ->assertSuccessful() ->run(); - expect($operation)->toBeReadableFile(); - expect($migration)->not->toBeReadableFile(); + expect($operation)->toBeFile()->toMatchFileSnapshot(); + expect($migration)->not->toBeFile(); }); From fa3ec52a8a64a1da788f62fb2bb7056ed6f51489 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Wed, 3 Sep 2025 03:45:19 +0300 Subject: [PATCH 20/30] Replace `Composer` class with new helper --- src/Console/Commands/FeedMakeCommand.php | 13 +++---------- src/Helpers/ClassExistsHelper.php | 15 +++++++++++++++ tests/Helpers/mocks.php | 14 ++++++-------- 3 files changed, 24 insertions(+), 18 deletions(-) create mode 100644 src/Helpers/ClassExistsHelper.php diff --git a/src/Console/Commands/FeedMakeCommand.php b/src/Console/Commands/FeedMakeCommand.php index da6bcb9..cce1bd7 100644 --- a/src/Console/Commands/FeedMakeCommand.php +++ b/src/Console/Commands/FeedMakeCommand.php @@ -4,12 +4,12 @@ namespace DragonCode\LaravelFeed\Console\Commands; +use DragonCode\LaravelDeployOperations\Operation; use DragonCode\LaravelFeed\Concerns\InteractsWithName; +use DragonCode\LaravelFeed\Helpers\ClassExistsHelper; use DragonCode\LaravelFeed\Publishers\MigrationPublisher; use DragonCode\LaravelFeed\Publishers\OperationPublisher; use Illuminate\Console\GeneratorCommand; -use Illuminate\Filesystem\Filesystem; -use Illuminate\Support\Composer; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; @@ -23,13 +23,6 @@ class FeedMakeCommand extends GeneratorCommand protected $type = 'Feed'; - public function __construct( - protected Composer $composer, - Filesystem $files - ) { - parent::__construct($files); - } - public function handle(): void { parent::handle(); @@ -99,7 +92,7 @@ protected function getQualifyClass(): string protected function hasOperations(): bool { - return $this->composer->hasPackage('dragon-code/laravel-deploy-operations'); + return app(ClassExistsHelper::class)->exists(Operation::class); } protected function getOptions(): array diff --git a/src/Helpers/ClassExistsHelper.php b/src/Helpers/ClassExistsHelper.php new file mode 100644 index 0000000..007f984 --- /dev/null +++ b/src/Helpers/ClassExistsHelper.php @@ -0,0 +1,15 @@ +forgetInstance(FeedMakeCommand::class); + app()->forgetInstance(ClassExistsHelper::class); - app()->singleton(FeedMakeCommand::class, function () use ($installed) { - $mock = mock(Composer::class); - $mock->shouldReceive('hasPackage')->andReturn($installed); + app()->singleton(ClassExistsHelper::class, function () use ($installed) { + $mock = mock(ClassExistsHelper::class); + $mock->shouldReceive('exists')->andReturn($installed); - return new FeedMakeCommand($mock, new Filesystem); + return $mock; }); } From 7a8dcf7f95eb2e66d446c235fecddbb2cfee2534 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Wed, 3 Sep 2025 03:54:19 +0300 Subject: [PATCH 21/30] Fix tests --- src/Publishers/Publisher.php | 1 + tests/Pest.php | 8 ++++++++ tests/Unit/Console/Make/MigrationTest.php | 4 ++-- tests/Unit/Console/Make/OperationTest.php | 4 ++-- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Publishers/Publisher.php b/src/Publishers/Publisher.php index f85206a..b5955c2 100644 --- a/src/Publishers/Publisher.php +++ b/src/Publishers/Publisher.php @@ -11,6 +11,7 @@ use function class_basename; use function dirname; use function file_get_contents; +use function realpath; use function str_replace; use function vsprintf; diff --git a/tests/Pest.php b/tests/Pest.php index 28561af..47a69f5 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -18,6 +18,10 @@ mockOperations(); mockPaths(); + deleteOperations(); + deleteMigrations(); + }) + ->afterEach(function () { deleteOperations(); deleteMigrations(); }); @@ -31,6 +35,10 @@ mockOperations(); mockPaths(); + deleteOperations(); + deleteMigrations(); + }) + ->afterEach(function () { deleteOperations(); deleteMigrations(); }); diff --git a/tests/Unit/Console/Make/MigrationTest.php b/tests/Unit/Console/Make/MigrationTest.php index 54e8cf2..6648c65 100644 --- a/tests/Unit/Console/Make/MigrationTest.php +++ b/tests/Unit/Console/Make/MigrationTest.php @@ -17,8 +17,8 @@ '--force' => true, ]) ->expectsOutputToContain(resolvePath('app/Feeds/FooBarFeed.php] created successfully')) - ->doesntExpectOutputToContain("Operation [$operation] created successfully.") - ->expectsOutputToContain("Migration [$migration] created successfully.") + ->doesntExpectOutputToContain(resolvePath("Operation [$operation] created successfully.")) + ->expectsOutputToContain(resolvePath("Migration [$migration] created successfully.")) ->assertSuccessful() ->run(); diff --git a/tests/Unit/Console/Make/OperationTest.php b/tests/Unit/Console/Make/OperationTest.php index 8e1c701..230ef17 100644 --- a/tests/Unit/Console/Make/OperationTest.php +++ b/tests/Unit/Console/Make/OperationTest.php @@ -15,8 +15,8 @@ '--force' => true, ]) ->expectsOutputToContain(resolvePath('app/Feeds/FooBarFeed.php] created successfully')) - ->expectsOutputToContain("Operation [$operation] created successfully.") - ->doesntExpectOutputToContain("Migration [$migration] created successfully.") + ->expectsOutputToContain(resolvePath("Operation [$operation] created successfully.")) + ->doesntExpectOutputToContain(resolvePath("Migration [$migration] created successfully.")) ->assertSuccessful() ->run(); From 97340bfa9c612e4390f91ec243fe8591efe9dc5b Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Wed, 3 Sep 2025 03:58:44 +0300 Subject: [PATCH 22/30] Fix tests --- .../Console/Make/MigrationTest/migration.snap | 20 ---------------- .../Console/Make/OperationTest/operation.snap | 23 ------------------- tests/Unit/Console/Make/MigrationTest.php | 11 ++++----- tests/Unit/Console/Make/OperationTest.php | 7 ++---- 4 files changed, 6 insertions(+), 55 deletions(-) delete mode 100644 tests/.pest/snapshots/Unit/Console/Make/MigrationTest/migration.snap delete mode 100644 tests/.pest/snapshots/Unit/Console/Make/OperationTest/operation.snap diff --git a/tests/.pest/snapshots/Unit/Console/Make/MigrationTest/migration.snap b/tests/.pest/snapshots/Unit/Console/Make/MigrationTest/migration.snap deleted file mode 100644 index badb620..0000000 --- a/tests/.pest/snapshots/Unit/Console/Make/MigrationTest/migration.snap +++ /dev/null @@ -1,20 +0,0 @@ -create( - class : FooBarFeed::class, - title : 'Foo Bar', - expression: '* * * * *' - ); - } -}; diff --git a/tests/.pest/snapshots/Unit/Console/Make/OperationTest/operation.snap b/tests/.pest/snapshots/Unit/Console/Make/OperationTest/operation.snap deleted file mode 100644 index cd463bd..0000000 --- a/tests/.pest/snapshots/Unit/Console/Make/OperationTest/operation.snap +++ /dev/null @@ -1,23 +0,0 @@ -create( - class : FooBarFeed::class, - title : 'Foo Bar', - expression: '* * * * *' - ); - } - - public function withinTransactions(): bool - { - return false; - } -}; diff --git a/tests/Unit/Console/Make/MigrationTest.php b/tests/Unit/Console/Make/MigrationTest.php index 6648c65..1237381 100644 --- a/tests/Unit/Console/Make/MigrationTest.php +++ b/tests/Unit/Console/Make/MigrationTest.php @@ -9,19 +9,16 @@ test('migration', function () { mockOperations(false); - $operation = config('deploy-operations.path') . '/2025_09_03_015024_create_foo_bar_feed.php'; - $migration = database_path('migrations') . '/2025_09_03_015024_create_foo_bar_feed.php'; + $operation = config('deploy-operations.path'); + $migration = database_path('migrations'); artisan(FeedMakeCommand::class, [ 'name' => 'FooBar', '--force' => true, ]) ->expectsOutputToContain(resolvePath('app/Feeds/FooBarFeed.php] created successfully')) - ->doesntExpectOutputToContain(resolvePath("Operation [$operation] created successfully.")) - ->expectsOutputToContain(resolvePath("Migration [$migration] created successfully.")) + ->doesntExpectOutputToContain($operation) + ->expectsOutputToContain($migration) ->assertSuccessful() ->run(); - - expect($operation)->not->toBeFile(); - expect($migration)->toBeFile()->toMatchFileSnapshot(); }); diff --git a/tests/Unit/Console/Make/OperationTest.php b/tests/Unit/Console/Make/OperationTest.php index 230ef17..2aea705 100644 --- a/tests/Unit/Console/Make/OperationTest.php +++ b/tests/Unit/Console/Make/OperationTest.php @@ -15,11 +15,8 @@ '--force' => true, ]) ->expectsOutputToContain(resolvePath('app/Feeds/FooBarFeed.php] created successfully')) - ->expectsOutputToContain(resolvePath("Operation [$operation] created successfully.")) - ->doesntExpectOutputToContain(resolvePath("Migration [$migration] created successfully.")) + ->expectsOutputToContain($operation) + ->doesntExpectOutputToContain($migration) ->assertSuccessful() ->run(); - - expect($operation)->toBeFile()->toMatchFileSnapshot(); - expect($migration)->not->toBeFile(); }); From 5894258b25a4eb72a8c0a6a4de105600e90c63c0 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Wed, 3 Sep 2025 04:09:30 +0300 Subject: [PATCH 23/30] Fix tests --- src/Publishers/Publisher.php | 1 - tests/Unit/Console/Make/MigrationTest.php | 7 ++----- tests/Unit/Console/Make/OperationTest.php | 7 ++----- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Publishers/Publisher.php b/src/Publishers/Publisher.php index b5955c2..f85206a 100644 --- a/src/Publishers/Publisher.php +++ b/src/Publishers/Publisher.php @@ -11,7 +11,6 @@ use function class_basename; use function dirname; use function file_get_contents; -use function realpath; use function str_replace; use function vsprintf; diff --git a/tests/Unit/Console/Make/MigrationTest.php b/tests/Unit/Console/Make/MigrationTest.php index 1237381..b51c3e7 100644 --- a/tests/Unit/Console/Make/MigrationTest.php +++ b/tests/Unit/Console/Make/MigrationTest.php @@ -9,16 +9,13 @@ test('migration', function () { mockOperations(false); - $operation = config('deploy-operations.path'); - $migration = database_path('migrations'); - artisan(FeedMakeCommand::class, [ 'name' => 'FooBar', '--force' => true, ]) ->expectsOutputToContain(resolvePath('app/Feeds/FooBarFeed.php] created successfully')) - ->doesntExpectOutputToContain($operation) - ->expectsOutputToContain($migration) + ->doesntExpectOutputToContain('Operation') + ->expectsOutputToContain('Migration') ->assertSuccessful() ->run(); }); diff --git a/tests/Unit/Console/Make/OperationTest.php b/tests/Unit/Console/Make/OperationTest.php index 2aea705..5afb3e2 100644 --- a/tests/Unit/Console/Make/OperationTest.php +++ b/tests/Unit/Console/Make/OperationTest.php @@ -7,16 +7,13 @@ use function Pest\Laravel\artisan; test('operation', function () { - $operation = config('deploy-operations.path') . '/2025_09_03_015024_create_foo_bar_feed.php'; - $migration = database_path('migrations') . '/2025_09_03_015024_create_foo_bar_feed.php'; - artisan(FeedMakeCommand::class, [ 'name' => 'FooBar', '--force' => true, ]) ->expectsOutputToContain(resolvePath('app/Feeds/FooBarFeed.php] created successfully')) - ->expectsOutputToContain($operation) - ->doesntExpectOutputToContain($migration) + ->expectsOutputToContain('Operation') + ->doesntExpectOutputToContain('Migration') ->assertSuccessful() ->run(); }); From c2152925b8e0000e080963b982634481a83ec41d Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Wed, 3 Sep 2025 04:17:55 +0300 Subject: [PATCH 24/30] Changed icons --- docs/topics/introduction.topic | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/topics/introduction.topic b/docs/topics/introduction.topic index 1f4d479..42cd4bd 100644 --- a/docs/topics/introduction.topic +++ b/docs/topics/introduction.topic @@ -25,8 +25,8 @@ Getting started - - + + From 7a46ff939ecaf01e54a1f3f0977637f370e2fe90 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Wed, 3 Sep 2025 04:18:01 +0300 Subject: [PATCH 25/30] Update description --- docs/topics/generation.topic | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/topics/generation.topic b/docs/topics/generation.topic index c2eacd1..fe4dc29 100644 --- a/docs/topics/generation.topic +++ b/docs/topics/generation.topic @@ -6,9 +6,9 @@ xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd" title="Generation" id="generation"> - Information on feed generation - Information on feed generation - Information on feed generation + Information on generating feed files for later distribution + Information on generating feed files for later distribution + Information on generating feed files for later distribution From 81f43ea575ad8e29167b8077c20ddc1aa9a5a68a Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Thu, 4 Sep 2025 01:46:51 +0300 Subject: [PATCH 26/30] Upgrade docs --- README.md | 2 +- docs/images/laravel-idea.png | Bin 21237 -> 23495 bytes docs/images/laravel-idea_dark.png | Bin 16800 -> 15708 bytes docs/laravel-feeds.tree | 6 +- docs/snippets/feeds-feed-info.php | 21 +++ docs/snippets/feeds-feed-item-result.xml | 1 - docs/snippets/generation-feed-chunk.php | 13 ++ .../generation-feed-header-and-footer.php | 18 +++ docs/snippets/generation-feed-info-class.php | 18 +++ docs/snippets/generation-feed-info.php | 15 ++ ...feed-item.php => generation-feed-item.php} | 8 - docs/snippets/generation-feed-storage.php | 5 + docs/snippets/schedule-setup-manual.php | 20 +++ docs/snippets/schedule-setup.php | 12 ++ docs/topics/advanced-usage.topic | 128 ++++++++++----- docs/topics/contributions.topic | 2 +- docs/topics/create-feeds.topic | 149 +++++++++--------- docs/topics/generation.topic | 71 ++++----- docs/topics/installation.topic | 81 +++------- docs/topics/introduction.topic | 7 +- ...nstagram.topic => receipt-instagram.topic} | 10 +- .../{sitemap.topic => receipt-sitemap.topic} | 10 +- .../{yandex.topic => receipt-yandex.topic} | 10 +- docs/topics/snippet-generate.topic | 15 ++ docs/v.list | 10 +- ide.json | 12 +- src/Console/Commands/FeedGenerateCommand.php | 2 +- src/Feeds/Feed.php | 8 +- src/LaravelFeedServiceProvider.php | 2 +- src/Queries/FeedQuery.php | 14 +- 30 files changed, 398 insertions(+), 272 deletions(-) create mode 100644 docs/snippets/feeds-feed-info.php create mode 100644 docs/snippets/generation-feed-chunk.php create mode 100644 docs/snippets/generation-feed-header-and-footer.php create mode 100644 docs/snippets/generation-feed-info-class.php create mode 100644 docs/snippets/generation-feed-info.php rename docs/snippets/{feeds-feed-item.php => generation-feed-item.php} (58%) create mode 100644 docs/snippets/schedule-setup-manual.php create mode 100644 docs/snippets/schedule-setup.php rename docs/topics/{instagram.topic => receipt-instagram.topic} (80%) rename docs/topics/{sitemap.topic => receipt-sitemap.topic} (86%) rename docs/topics/{yandex.topic => receipt-yandex.topic} (82%) diff --git a/README.md b/README.md index 5d67da2..f55aaa1 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ php artisan feed:generate ## Documentation -[📚 Check out the full documentation to learn everything that Laravel Feeds has to offer.](https://feeds.dragon-code.pro) +📚 You will find full documentation on the dedicated [documentation](https://feeds.dragon-code.pro) site. ## License diff --git a/docs/images/laravel-idea.png b/docs/images/laravel-idea.png index 863dd73ed1dbc3be7bf848ef815fb2331eea107d..9bb05b6bd506244f2e86f139747eca2625286321 100644 GIT binary patch literal 23495 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 literal 21237 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 diff --git a/docs/images/laravel-idea_dark.png b/docs/images/laravel-idea_dark.png index 1684ccd3c097ca7de690708550c22db5d181c65e..32c607b48ed545bbd1412e9b8d4d0688bb1eca43 100644 GIT binary patch literal 15708 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 diff --git a/docs/laravel-feeds.tree b/docs/laravel-feeds.tree index 5ff6d18..ff8b0c3 100644 --- a/docs/laravel-feeds.tree +++ b/docs/laravel-feeds.tree @@ -19,8 +19,8 @@ - - - + + + diff --git a/docs/snippets/feeds-feed-info.php b/docs/snippets/feeds-feed-info.php new file mode 100644 index 0000000..f55b077 --- /dev/null +++ b/docs/snippets/feeds-feed-info.php @@ -0,0 +1,21 @@ + config('app.name'), + 'platform' => config('app.name'), + + 'url' => config('app.url'), + 'email' => config('emails.manager'), + ]; + } +} diff --git a/docs/snippets/feeds-feed-item-result.xml b/docs/snippets/feeds-feed-item-result.xml index 152f1f9..9cf4296 100644 --- a/docs/snippets/feeds-feed-item-result.xml +++ b/docs/snippets/feeds-feed-item-result.xml @@ -3,6 +3,5 @@ John Doe john.doe@example.com -
John Doe]]>
diff --git a/docs/snippets/generation-feed-chunk.php b/docs/snippets/generation-feed-chunk.php new file mode 100644 index 0000000..7817eec --- /dev/null +++ b/docs/snippets/generation-feed-chunk.php @@ -0,0 +1,13 @@ +'; + } + + public function footer(): string + { + return ''; + } +} diff --git a/docs/snippets/generation-feed-info-class.php b/docs/snippets/generation-feed-info-class.php new file mode 100644 index 0000000..3d2b065 --- /dev/null +++ b/docs/snippets/generation-feed-info-class.php @@ -0,0 +1,18 @@ + config('app.name'), + 'company' => config('app.name'), + ]; + } +} diff --git a/docs/snippets/generation-feed-info.php b/docs/snippets/generation-feed-info.php new file mode 100644 index 0000000..24980f9 --- /dev/null +++ b/docs/snippets/generation-feed-info.php @@ -0,0 +1,15 @@ + $this->model->class, 'email' => $this->model->email, - - 'header' => [ - '@attributes' => [ - 'my-key-1' => 'my value 1', - 'my-key-2' => 'my value 2', - ], - '@cdata' => '

' . $this->model->class . '

', - ], ]; } } diff --git a/docs/snippets/generation-feed-storage.php b/docs/snippets/generation-feed-storage.php index 01d0bf9..eae1984 100644 --- a/docs/snippets/generation-feed-storage.php +++ b/docs/snippets/generation-feed-storage.php @@ -7,4 +7,9 @@ class UserFeed extends Feed { protected string $storage = 'public'; + + public function filename(): string + { + return 'some/path/will/be/here.xml'; + } } diff --git a/docs/snippets/schedule-setup-manual.php b/docs/snippets/schedule-setup-manual.php new file mode 100644 index 0000000..66de617 --- /dev/null +++ b/docs/snippets/schedule-setup-manual.php @@ -0,0 +1,20 @@ +withoutOverlapping() + ->runInBackground() + ->daily(); + +Schedule::command(FeedGenerateCommand::class, [222]) + ->withoutOverlapping() + ->runInBackground() + ->hourly(); + +Schedule::call(function () { + // ... other action +})->everySecond(); diff --git a/docs/snippets/schedule-setup.php b/docs/snippets/schedule-setup.php new file mode 100644 index 0000000..1492c95 --- /dev/null +++ b/docs/snippets/schedule-setup.php @@ -0,0 +1,12 @@ +commands(); + +Schedule::call(function () { + // ... other action +})->everySecond(); diff --git a/docs/topics/advanced-usage.topic b/docs/topics/advanced-usage.topic index 55b4c62..8c1207b 100644 --- a/docs/topics/advanced-usage.topic +++ b/docs/topics/advanced-usage.topic @@ -24,68 +24,118 @@ - - + + +

+ To add a root element and/or its attributes, override the root method: +

+ + + +

+ To disable the addition of the main element, specify its name as empty - null or + "". +

+
+ + +

+ To add information to the beginning of the root element (if present) or without it, + override the + info method in the feed class and call the creation of an object that extend the + DragonCode\LaravelFeed\Feeds\Info\FeedInfo class: +

+ + + +
+ + +

+ To change the header and footer, override the header and footer methods: +

+ + +
- -

- In some cases, you need to add various information to the beginning of the file. - To do this, use the info method: -

+ + + + - - + + + - -

- If it is necessary to change the file cap, override the header method in the feed class: -

+ + +

+ In some cases, you need to place an array of elements with the same names. +

- -
+

+ To do this, add a @ symbol to the beginning of the key name: +

- - - + - - - +

+ Result: +

- -

- In some cases, you need to place an array of elements with the same names. -

+ -

- To do this, add a @ symbol to the beginning of the key name: -

+

+ You can also use values for such elements. To insert them, use the reserved word @value. +

- +

+ For example: +

-

- Result: -

+ - +

+ Result: +

-

- You can also use values for such elements. To insert them, use the reserved word @value. -

+ + +
+
+

+ By default, feeds will be stored in the public storage, + and the file name will be automatically generated from the feed class name after + App\Feeds\ in kebab-case format. For example:

- + + # App\Feeds\UserFeed + user-feed.xml + + # App\Feeds\Sitemaps\ProductFeed + sitemaps-product-feed.xml +

- Result: + You can change these values by overriding the $storage property and the + filename method:

- + +
+ + +

+ Database queries use chunks, which are 1000 bytes by default. + You can change this by overriding the chunkSize method: +

+
diff --git a/docs/topics/contributions.topic b/docs/topics/contributions.topic index da710dd..1f50566 100644 --- a/docs/topics/contributions.topic +++ b/docs/topics/contributions.topic @@ -4,7 +4,7 @@ + title="Contribution guide" id="contributions"> Instructions for %instance% project developers Instructions for %instance% project developers diff --git a/docs/topics/create-feeds.topic b/docs/topics/create-feeds.topic index 8ee6714..9050c4b 100644 --- a/docs/topics/create-feeds.topic +++ b/docs/topics/create-feeds.topic @@ -4,7 +4,7 @@ + title="Create feeds" id="create-feeds" help-id="make-feeds;create-feeds;feeds"> Instructions for creating and filling feed, feed items, and feed info classes Instructions for creating and filling feed, feed items, and feed info classes @@ -12,85 +12,92 @@ - - -
- - laravel idea - - - -

- To create a feed class, use the console command: -

- - - %command-make% - - -

- As a result of executing the console command, the file - app/Feeds/UserFeed.php will be created. -

- -

- Also, this console command can create classes of the element and information along with the feed class. - To do this, use --item and --info parameters. -

- - - # Create a feed class and information class - %command-make% --info - - # Create a feed class and item class - %command-make% --item - - # Create a feed class, item class and information class - %command-make% --item --info - - -

- Shortcuts are also available: -

- - - # Create a feed class and item class - %command-make% -%command-shortcut-item% - - # Create a feed class and info class - %command-make% -%command-shortcut-info% - - # Create a feed class, item class and information class - %command-make% -%command-shortcut-info%%command-shortcut-item% - -
- + +

+ The most convenient way to create the necessary classes is to use the + Laravel Idea plugin for + PhpStorm. +

+ + laravel idea +
+ +

+ To create a feed class, use the %command-make-short% console command: +

+ + + %command-make% + + +

+ This will create a feed file. For example, app/Feeds/UserFeed.php. +

+ +

+ Also, this console command can create classes of the element and information along with the feed class. + To do this, use --item and --info parameters. +

+ + + # Create a feed class and information class + %command-make% --info + + # Create a feed class and item class + %command-make% --item + + # Create a feed class, item class and information class + %command-make% --item --info + + +

+ Shortcuts are also available: +

+ + + # Create a feed class and item class + %command-make% -%command-shortcut-item% + + # Create a feed class and info class + %command-make% -%command-shortcut-info% + + # Create a feed class, item class and information class + %command-make% -%command-shortcut-info%%command-shortcut-item% + + + +

+ When creating a feed, an operation/migration will also be created to add it to the database. +

+ +

+ If the project uses the + + Laravel Deploy Operations + + , then an operation class will be created, otherwise a migration class will be created. +

+ +

+ This is necessary to add and manage information about feeds in the database. +

+

- For example, we use this content for the Feed class: + Fill in the main feed class. For example:

-
- - -

- For example, we use this content for the Feed Item class: -

- - -

- According to this example, the XML file with the following contents will be generated as a result: -

+ +

+ Fill in the feed item class. For example: +

- + +
diff --git a/docs/topics/generation.topic b/docs/topics/generation.topic index fe4dc29..8869735 100644 --- a/docs/topics/generation.topic +++ b/docs/topics/generation.topic @@ -4,7 +4,7 @@ + title="Generation & schedule" id="generation"> Information on generating feed files for later distribution Information on generating feed files for later distribution @@ -12,64 +12,57 @@ - - -

- To generate feeds, create the classes of feeds and its element, add links to the file - %config-filename%, next call the console command: -

+

+ To generate all active feeds, use the console command: +

- - %command-generate% - -
-
+ + %command-generate% + - - - Please note that the specified feed will be executed even if it is disabled in the settings file. - +

+ As a result, all active feeds (is_active = true) will be launched from the + feeds table (or the one you specified in the %config-filename% file). +

-

- To generate a specific feed class, specify the class reference or name relative to the - App\Feeds namespace. -

+ + + Please note that the specified feed + will be executed + even if it is disabled in the + feeds table in the database. +

- For example: + To generate a specific feed, call the %command-generate-short% console command, + passing the feed ID from the feeds table as its parameter:

- %command-generate% App\Feeds\UserFeed - %command-generate% UserFeed - %command-generate% User + %command-generate% 123
- +

- Each feed can be created in a certain folder of a certain storage. + To automate feed generation, use a + schedule. + To do this, specify the helper call in the + routes/console.php file (the default path, unless you have changed it):

-

- To indicate the storage, override the property of $storage in the feed class: -

- - +

- By default, storage is public. + This will enable you to register calls to all active feeds according to the launch schedule you specified in the + expression column of the database.

- The path to the file inside the storage is indicated in the filename method: + You can also specify the schedule directly according to your own rules. + In this case, only the feeds you specify will be executed.

- - -

- By default, the class name in kebab-case is used. - For example, user-feed.xml for UserFeed class. -

+
diff --git a/docs/topics/installation.topic b/docs/topics/installation.topic index 34a1184..f233291 100644 --- a/docs/topics/installation.topic +++ b/docs/topics/installation.topic @@ -4,7 +4,7 @@ + title="Installation & setup" id="installation"> Information on installing and configuring %instance% Information on installing and configuring %instance% @@ -13,71 +13,40 @@

- To get the latest version of - %instance% - , simply require the project using - Composer: + You can install the package via Composer:

composer require %package-name% - - Information on configuring %instance% - Information on configuring %instance% - - - -

- You can publish a %config-filename% configuration file using the console command: -

- - - php artisan vendor:publish --tag="%package-tags%" - -
- -

- This is the contents of the published %config-filename% file: -

- - -
- - -

- This setting contains information about the generated feeds. - They can be both turned on and turned off depending on the environment or other conditions. -

+

+ You should publish the + migration + and the + %config-filename% + config file with: +

- -
+ + php artisan vendor:publish --tag="%package-tags%" + - -

- This setting indicates in what form the XML records should be exported - using or without formatting. -

- - - + +

+ Before running migrations + , check the database connection settings in the + %config-filename% file. +

+
- - - -
-
+

+ Now you can run migrations and proceed to creating feeds. +

- + + php artisan migrate + - - -
diff --git a/docs/topics/introduction.topic b/docs/topics/introduction.topic index 42cd4bd..73d7c5b 100644 --- a/docs/topics/introduction.topic +++ b/docs/topics/introduction.topic @@ -6,8 +6,6 @@ xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd" title="Introduction" id="introduction"> - Getting Started - 📃 %instance% @@ -17,14 +15,13 @@ - + - Getting started + Usage - diff --git a/docs/topics/instagram.topic b/docs/topics/receipt-instagram.topic similarity index 80% rename from docs/topics/instagram.topic rename to docs/topics/receipt-instagram.topic index 975c94c..3090706 100644 --- a/docs/topics/instagram.topic +++ b/docs/topics/receipt-instagram.topic @@ -4,7 +4,7 @@ + title="Instagram" id="receipt-instagram" help-id="instagram"> Feed generation recipe for Instagram Feed generation recipe for Instagram @@ -26,12 +26,8 @@ - -

- Add a link to the feed class in the %config-filename% file: -

- - + + diff --git a/docs/topics/sitemap.topic b/docs/topics/receipt-sitemap.topic similarity index 86% rename from docs/topics/sitemap.topic rename to docs/topics/receipt-sitemap.topic index 9a12fb4..54e5c07 100644 --- a/docs/topics/sitemap.topic +++ b/docs/topics/receipt-sitemap.topic @@ -4,7 +4,7 @@ + title="Sitemap" id="receipt-sitemap" help-id="sitemap"> Recipe for creating a sitemap Recipe for creating a sitemap @@ -26,12 +26,8 @@ - -

- Add a link to the feed class in the %config-filename% file: -

- - + + diff --git a/docs/topics/yandex.topic b/docs/topics/receipt-yandex.topic similarity index 82% rename from docs/topics/yandex.topic rename to docs/topics/receipt-yandex.topic index 6796b1e..b540e34 100644 --- a/docs/topics/yandex.topic +++ b/docs/topics/receipt-yandex.topic @@ -4,7 +4,7 @@ + title="Yandex" id="receipt-yandex" help-id="yandex"> Feed generation recipe for Yandex Feed generation recipe for Yandex @@ -30,12 +30,8 @@ - -

- Add a link to the feed class in the %config-filename% file: -

- - + + diff --git a/docs/topics/snippet-generate.topic b/docs/topics/snippet-generate.topic index 2ba7ffc..cc80a0e 100644 --- a/docs/topics/snippet-generate.topic +++ b/docs/topics/snippet-generate.topic @@ -16,4 +16,19 @@ %command-generate% + + +

+ Check the operation/migration + file that was created for you and run the console command: +

+ + + # For Laravel Deploy Operations + php artisan operations + + # For Laravel Migrations + php artisan migrate + + diff --git a/docs/v.list b/docs/v.list index e95359b..1db1fde 100644 --- a/docs/v.list +++ b/docs/v.list @@ -7,14 +7,16 @@ - + + + + + - @@ -22,6 +24,4 @@ - - diff --git a/ide.json b/ide.json index d57bac0..0fc213c 100644 --- a/ide.json +++ b/ide.json @@ -2,8 +2,8 @@ "$schema": "https://laravel-ide.com/schema/laravel-ide-v2.json", "codeGenerations": [ { - "id": "dragon-code.xml-feeds.main", - "name": "Create XML Feed", + "id": "dragon-code.feeds.main", + "name": "Create Feed", "classSuffix": "Feed", "regex": ".+", "files": [ @@ -25,8 +25,8 @@ ] }, { - "id": "dragon-code.xml-feeds.item", - "name": "Create XML Feed Item", + "id": "dragon-code.feeds.item", + "name": "Create Feed Item", "classSuffix": "FeedItem", "regex": ".+", "files": [ @@ -47,8 +47,8 @@ ] }, { - "id": "dragon-code.xml-feeds.info", - "name": "Create XML Feed Info", + "id": "dragon-code.feeds.info", + "name": "Create Feed Info", "classSuffix": "FeedInfo", "regex": ".+", "files": [ diff --git a/src/Console/Commands/FeedGenerateCommand.php b/src/Console/Commands/FeedGenerateCommand.php index 9495f4f..62b5292 100644 --- a/src/Console/Commands/FeedGenerateCommand.php +++ b/src/Console/Commands/FeedGenerateCommand.php @@ -32,7 +32,7 @@ public function handle(Generator $generator, FeedQuery $query): void protected function feedable(FeedQuery $feeds): array { if (! $id = $this->argument('feed')) { - return $feeds->all() + return $feeds->active() ->pluck('is_active', 'class') ->all(); } diff --git a/src/Feeds/Feed.php b/src/Feeds/Feed.php index f5758dc..3d8d611 100644 --- a/src/Feeds/Feed.php +++ b/src/Feeds/Feed.php @@ -14,8 +14,6 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; -use function class_basename; - abstract class Feed { protected FeedFormatEnum $format = FeedFormatEnum::Xml; @@ -56,9 +54,13 @@ public function info(): FeedInfo return new FeedInfo; } + // TODO: добавить тесты имён файлов public function filename(): string { - return $this->filename ??= Str::of(class_basename($this)) + return $this->filename ??= Str::of(static::class) + ->after(self::class) + ->ltrim('\\') + ->kebab() ->append('.', $this->format->value) ->toString(); } diff --git a/src/LaravelFeedServiceProvider.php b/src/LaravelFeedServiceProvider.php index bd5154a..3eda0e6 100644 --- a/src/LaravelFeedServiceProvider.php +++ b/src/LaravelFeedServiceProvider.php @@ -39,7 +39,7 @@ protected function migrations(): void { $this->publishesMigrations([ __DIR__ . '/../database/migrations' => $this->app->databasePath('migrations'), - ]); + ], 'feeds'); } protected function registerCommands(): void diff --git a/src/Queries/FeedQuery.php b/src/Queries/FeedQuery.php index 8880b0d..3c48b7a 100644 --- a/src/Queries/FeedQuery.php +++ b/src/Queries/FeedQuery.php @@ -6,7 +6,7 @@ use DragonCode\LaravelFeed\Exceptions\FeedNotFoundException; use DragonCode\LaravelFeed\Models\Feed; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Builder; use function now; @@ -31,19 +31,11 @@ public function find(int $id): Feed return Feed::findOr($id, callback: static fn () => throw new FeedNotFoundException($id)); } - public function all(): Collection - { - return Feed::query() - ->orderBy('id') - ->get(); - } - - public function active(): Collection + public function active(): Builder { return Feed::query() ->where('is_active', true) - ->orderBy('id') - ->get(); + ->orderBy('id'); } public function setLastActivity(string $class): void From eb6bcf9fb12d984225178fb5ee804e00ebe6d3e5 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Thu, 4 Sep 2025 01:57:53 +0300 Subject: [PATCH 27/30] Remove unused snippets --- ...feed-chunk.php => advanced-feed-chunk.php} | 0 ...hp => advanced-feed-header-and-footer.php} | 0 ...class.php => advanced-feed-info-class.php} | 0 ...n-feed-info.php => advanced-feed-info.php} | 0 ...-storage.php => advanced-feed-storage.php} | 0 docs/snippets/advanced-head-feed.php | 16 -------------- docs/snippets/advanced-head-info.php | 17 --------------- docs/snippets/advanced-header.php | 15 ------------- docs/snippets/config-channels.ini | 2 -- docs/snippets/config-pretty-false.xml | 5 ----- docs/snippets/config-pretty-true.xml | 13 ------------ .../{feeds-feed.php => create-feeds-feed.php} | 0 docs/snippets/feeds-feed-info.php | 21 ------------------- docs/snippets/feeds-feed-item-result.xml | 7 ------- docs/snippets/generation-feed-filename.php | 13 ------------ docs/snippets/receipt-instagram-config.php | 9 -------- docs/snippets/receipt-sitemap-config.php | 9 -------- docs/snippets/receipt-yandex-config.php | 9 -------- docs/topics/advanced-usage.topic | 10 ++++----- docs/topics/create-feeds.topic | 2 +- 20 files changed, 6 insertions(+), 142 deletions(-) rename docs/snippets/{generation-feed-chunk.php => advanced-feed-chunk.php} (100%) rename docs/snippets/{generation-feed-header-and-footer.php => advanced-feed-header-and-footer.php} (100%) rename docs/snippets/{generation-feed-info-class.php => advanced-feed-info-class.php} (100%) rename docs/snippets/{generation-feed-info.php => advanced-feed-info.php} (100%) rename docs/snippets/{generation-feed-storage.php => advanced-feed-storage.php} (100%) delete mode 100644 docs/snippets/advanced-head-feed.php delete mode 100644 docs/snippets/advanced-head-info.php delete mode 100644 docs/snippets/advanced-header.php delete mode 100644 docs/snippets/config-channels.ini delete mode 100644 docs/snippets/config-pretty-false.xml delete mode 100644 docs/snippets/config-pretty-true.xml rename docs/snippets/{feeds-feed.php => create-feeds-feed.php} (100%) delete mode 100644 docs/snippets/feeds-feed-info.php delete mode 100644 docs/snippets/feeds-feed-item-result.xml delete mode 100644 docs/snippets/generation-feed-filename.php delete mode 100644 docs/snippets/receipt-instagram-config.php delete mode 100644 docs/snippets/receipt-sitemap-config.php delete mode 100644 docs/snippets/receipt-yandex-config.php diff --git a/docs/snippets/generation-feed-chunk.php b/docs/snippets/advanced-feed-chunk.php similarity index 100% rename from docs/snippets/generation-feed-chunk.php rename to docs/snippets/advanced-feed-chunk.php diff --git a/docs/snippets/generation-feed-header-and-footer.php b/docs/snippets/advanced-feed-header-and-footer.php similarity index 100% rename from docs/snippets/generation-feed-header-and-footer.php rename to docs/snippets/advanced-feed-header-and-footer.php diff --git a/docs/snippets/generation-feed-info-class.php b/docs/snippets/advanced-feed-info-class.php similarity index 100% rename from docs/snippets/generation-feed-info-class.php rename to docs/snippets/advanced-feed-info-class.php diff --git a/docs/snippets/generation-feed-info.php b/docs/snippets/advanced-feed-info.php similarity index 100% rename from docs/snippets/generation-feed-info.php rename to docs/snippets/advanced-feed-info.php diff --git a/docs/snippets/generation-feed-storage.php b/docs/snippets/advanced-feed-storage.php similarity index 100% rename from docs/snippets/generation-feed-storage.php rename to docs/snippets/advanced-feed-storage.php diff --git a/docs/snippets/advanced-head-feed.php b/docs/snippets/advanced-head-feed.php deleted file mode 100644 index ab7e675..0000000 --- a/docs/snippets/advanced-head-feed.php +++ /dev/null @@ -1,16 +0,0 @@ -'; - } -} diff --git a/docs/snippets/config-channels.ini b/docs/snippets/config-channels.ini deleted file mode 100644 index 1513607..0000000 --- a/docs/snippets/config-channels.ini +++ /dev/null @@ -1,2 +0,0 @@ -FEED_FOO_ENABLED = true -FEED_BAR_ENABLED = false diff --git a/docs/snippets/config-pretty-false.xml b/docs/snippets/config-pretty-false.xml deleted file mode 100644 index 5d3495e..0000000 --- a/docs/snippets/config-pretty-false.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - [NEWS]:Some 1Some content 1Some extra data - [NEWS]:Some 2Some content 2Some extra data - diff --git a/docs/snippets/config-pretty-true.xml b/docs/snippets/config-pretty-true.xml deleted file mode 100644 index 7a42836..0000000 --- a/docs/snippets/config-pretty-true.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - [NEWS]:Some 1 - Some content 1 - Some extra data - - - [NEWS]:Some 2 - Some content 2 - Some extra data - - diff --git a/docs/snippets/feeds-feed.php b/docs/snippets/create-feeds-feed.php similarity index 100% rename from docs/snippets/feeds-feed.php rename to docs/snippets/create-feeds-feed.php diff --git a/docs/snippets/feeds-feed-info.php b/docs/snippets/feeds-feed-info.php deleted file mode 100644 index f55b077..0000000 --- a/docs/snippets/feeds-feed-info.php +++ /dev/null @@ -1,21 +0,0 @@ - config('app.name'), - 'platform' => config('app.name'), - - 'url' => config('app.url'), - 'email' => config('emails.manager'), - ]; - } -} diff --git a/docs/snippets/feeds-feed-item-result.xml b/docs/snippets/feeds-feed-item-result.xml deleted file mode 100644 index 9cf4296..0000000 --- a/docs/snippets/feeds-feed-item-result.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - John Doe - john.doe@example.com - - diff --git a/docs/snippets/generation-feed-filename.php b/docs/snippets/generation-feed-filename.php deleted file mode 100644 index cf913c4..0000000 --- a/docs/snippets/generation-feed-filename.php +++ /dev/null @@ -1,13 +0,0 @@ - [ - App\Feeds\InstagramFeed::class => (bool) env('FEED_INSTAGRAM_ENABLED', true), - ], -]; diff --git a/docs/snippets/receipt-sitemap-config.php b/docs/snippets/receipt-sitemap-config.php deleted file mode 100644 index 803e0cd..0000000 --- a/docs/snippets/receipt-sitemap-config.php +++ /dev/null @@ -1,9 +0,0 @@ - [ - App\Feeds\Sitemaps\ProductFeed::class => (bool) env('FEED_SITEMAP_POSTS_ENABLED', true), - ], -]; diff --git a/docs/snippets/receipt-yandex-config.php b/docs/snippets/receipt-yandex-config.php deleted file mode 100644 index 4e2a66a..0000000 --- a/docs/snippets/receipt-yandex-config.php +++ /dev/null @@ -1,9 +0,0 @@ - [ - App\Feeds\YandexFeed::class => (bool) env('FEED_YANDEX_ENABLED', true), - ], -]; diff --git a/docs/topics/advanced-usage.topic b/docs/topics/advanced-usage.topic index 8c1207b..1a0d4de 100644 --- a/docs/topics/advanced-usage.topic +++ b/docs/topics/advanced-usage.topic @@ -46,8 +46,8 @@ DragonCode\LaravelFeed\Feeds\Info\FeedInfo class:

- - + + @@ -55,7 +55,7 @@ To change the header and footer, override the header and footer methods:

- +
@@ -127,7 +127,7 @@ filename method:

- + @@ -136,6 +136,6 @@ You can change this by overriding the chunkSize method:

- +
diff --git a/docs/topics/create-feeds.topic b/docs/topics/create-feeds.topic index 9050c4b..fe7f58b 100644 --- a/docs/topics/create-feeds.topic +++ b/docs/topics/create-feeds.topic @@ -89,7 +89,7 @@ Fill in the main feed class. For example:

- +

From b71b953c41c16eee760f96c7265cfbd4bd691660 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Thu, 4 Sep 2025 02:11:27 +0300 Subject: [PATCH 28/30] Remove unused snippets --- src/Console/Commands/FeedGenerateCommand.php | 2 +- src/Feeds/Feed.php | 9 ++++++-- src/Queries/FeedQuery.php | 5 ++++ ..._____Workbench_App_Feeds_EmptyFeed___.snap | 1 + ...t_____Workbench_App_Feeds_FullFeed___.snap | 1 + ...___Workbench_App_Feeds_PartialFeed___.snap | 1 + ...___Workbench_App_Feeds_SitemapFeed___.snap | 1 + ...____Workbench_App_Feeds_YandexFeed___.snap | 1 + tests/Helpers/feeds.php | 7 ------ tests/Unit/Feeds/FilenameTest.php | 23 +++++++++++++++++++ 10 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_EmptyFeed___.snap create mode 100644 tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_FullFeed___.snap create mode 100644 tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_PartialFeed___.snap create mode 100644 tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_SitemapFeed___.snap create mode 100644 tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_YandexFeed___.snap create mode 100644 tests/Unit/Feeds/FilenameTest.php diff --git a/src/Console/Commands/FeedGenerateCommand.php b/src/Console/Commands/FeedGenerateCommand.php index 62b5292..9495f4f 100644 --- a/src/Console/Commands/FeedGenerateCommand.php +++ b/src/Console/Commands/FeedGenerateCommand.php @@ -32,7 +32,7 @@ public function handle(Generator $generator, FeedQuery $query): void protected function feedable(FeedQuery $feeds): array { if (! $id = $this->argument('feed')) { - return $feeds->active() + return $feeds->all() ->pluck('is_active', 'class') ->all(); } diff --git a/src/Feeds/Feed.php b/src/Feeds/Feed.php index 3d8d611..83740e7 100644 --- a/src/Feeds/Feed.php +++ b/src/Feeds/Feed.php @@ -11,6 +11,7 @@ use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -22,6 +23,10 @@ abstract class Feed protected ?string $filename = null; + public function __construct( + protected Application $laravel + ) {} + abstract public function builder(): Builder; public function item(Model $model): FeedItem @@ -54,12 +59,12 @@ public function info(): FeedInfo return new FeedInfo; } - // TODO: добавить тесты имён файлов public function filename(): string { return $this->filename ??= Str::of(static::class) - ->after(self::class) + ->after($this->laravel->getNamespace() . 'Feeds\\') ->ltrim('\\') + ->replace('\\', ' ') ->kebab() ->append('.', $this->format->value) ->toString(); diff --git a/src/Queries/FeedQuery.php b/src/Queries/FeedQuery.php index 3c48b7a..eeb1ca9 100644 --- a/src/Queries/FeedQuery.php +++ b/src/Queries/FeedQuery.php @@ -31,6 +31,11 @@ public function find(int $id): Feed return Feed::findOr($id, callback: static fn () => throw new FeedNotFoundException($id)); } + public function all(): Builder + { + return Feed::query()->orderBy('id'); + } + public function active(): Builder { return Feed::query() diff --git a/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_EmptyFeed___.snap b/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_EmptyFeed___.snap new file mode 100644 index 0000000..3eef6fb --- /dev/null +++ b/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_EmptyFeed___.snap @@ -0,0 +1 @@ +empty-feed.xml \ No newline at end of file diff --git a/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_FullFeed___.snap b/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_FullFeed___.snap new file mode 100644 index 0000000..f7bbd67 --- /dev/null +++ b/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_FullFeed___.snap @@ -0,0 +1 @@ +nested/full.xml \ No newline at end of file diff --git a/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_PartialFeed___.snap b/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_PartialFeed___.snap new file mode 100644 index 0000000..f6cded8 --- /dev/null +++ b/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_PartialFeed___.snap @@ -0,0 +1 @@ +partial-feed.xml \ No newline at end of file diff --git a/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_SitemapFeed___.snap b/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_SitemapFeed___.snap new file mode 100644 index 0000000..e5f01af --- /dev/null +++ b/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_SitemapFeed___.snap @@ -0,0 +1 @@ +sitemaps/products.xml \ No newline at end of file diff --git a/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_YandexFeed___.snap b/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_YandexFeed___.snap new file mode 100644 index 0000000..7e66387 --- /dev/null +++ b/tests/.pest/snapshots/Unit/Feeds/FilenameTest/filename_with_data_set_____Workbench_App_Feeds_YandexFeed___.snap @@ -0,0 +1 @@ +yandex.xml \ No newline at end of file diff --git a/tests/Helpers/feeds.php b/tests/Helpers/feeds.php index de0dcf1..2ecdb1b 100644 --- a/tests/Helpers/feeds.php +++ b/tests/Helpers/feeds.php @@ -6,13 +6,6 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Arr; -function enableAllFeeds(): void -{ - Feed::query()->update([ - 'is_active' => true, - ]); -} - function disableFeeds(array|string $classes): void { Feed::query() diff --git a/tests/Unit/Feeds/FilenameTest.php b/tests/Unit/Feeds/FilenameTest.php new file mode 100644 index 0000000..4bd2afb --- /dev/null +++ b/tests/Unit/Feeds/FilenameTest.php @@ -0,0 +1,23 @@ +filename())->toMatchSnapshot(); +})->with([ + EmptyFeed::class, + FullFeed::class, + PartialFeed::class, + SitemapFeed::class, + YandexFeed::class, +]); From e9ef188a4e5486c4b5f281546174dba5e4af50a8 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Thu, 4 Sep 2025 02:25:50 +0300 Subject: [PATCH 29/30] Update README.md --- README.md | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f55aaa1..b36c698 100644 --- a/README.md +++ b/README.md @@ -20,23 +20,32 @@ consumers. ## Installation -To get the latest version of **Laravel Feeds**, simply require the project -using [Composer](https://getcomposer.org): +You can install the **Laravel Feeds** package via [Composer](https://getcomposer.org): ```Bash composer require dragon-code/laravel-feeds ``` -After that, publish the configuration file by call the console command: +You should publish +the [migration](database/migrations/2025_09_01_231655_create_feeds_table.php) +and the [config/feeds.php](config/feeds.php) file with: ```bash -php artisan vendor:publish --tag=feeds +php artisan vendor:publish --tag="feeds" ``` +> [!WARNING] +> +> Before running migrations, check the database connection settings in the [config/feeds.php](config/feeds.php) file. + +Now you can run migrations and proceed to [create feeds](https://feeds.dragon-code.pro/create-feeds.html). + ## Basic Usage ### Create Feeds +To create a feed class, use the `make:feed` console command: + ```bash php artisan make:feed User -t ``` @@ -44,10 +53,26 @@ php artisan make:feed User -t As a result of executing the console command, the files `app/Feeds/UserFeed.php` and `app/Feeds/Items/UserFeedItem.php` will be created. -### Generate Feeds +> [!TIP] +> When creating a feed, an operation/migration will also be created to add it to the database. +> +> If the project uses the [Laravel Deploy Operations](https://deploy-operations.dragon-code.pro), then an operation +> class will be created, otherwise a migration class will be created. +> +> This is necessary to add and manage information about feeds in the database. + +Check the [operation/migration](https://feeds.dragon-code.pro/create-feeds.html) file that was created for you and run +the console command: + +```bash +# For Laravel Deploy Operations +php artisan operations + +# For Laravel Migrations +php artisan migrate +``` -To generate feeds, create the classes of feeds and its element, add links to the file `config/feeds.php`, next call the -console command: +To generate all active feeds, use the console command: ```bash php artisan feed:generate From 10a998571dcda2a0fa8d58955d78f0846e4596c2 Mon Sep 17 00:00:00 2001 From: Andrey Helldar Date: Thu, 4 Sep 2025 02:25:56 +0300 Subject: [PATCH 30/30] Fix typo and add details on feed command behavior in docs --- docs/topics/generation.topic | 13 +++++++++++++ docs/topics/installation.topic | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/topics/generation.topic b/docs/topics/generation.topic index 8869735..318d557 100644 --- a/docs/topics/generation.topic +++ b/docs/topics/generation.topic @@ -25,6 +25,19 @@ feeds table (or the one you specified in the %config-filename% file).

+ +

+ Please note that when you call the command without a parameter, + the console will display links to all feeds, + even those with the is_active property set to false. +

+ +

+ However, instead of being launched for execution, such feeds will be marked with the + SKIP status. +

+
+ Please note that the specified feed diff --git a/docs/topics/installation.topic b/docs/topics/installation.topic index f233291..b038760 100644 --- a/docs/topics/installation.topic +++ b/docs/topics/installation.topic @@ -42,7 +42,7 @@

- Now you can run migrations and proceed to creating feeds. + Now you can run migrations and proceed to create feeds.

- You can also create the desired classes using the - Laravel Idea plugin for - PhpStorm: -