Skip to content

Commit

Permalink
Merge pull request #495 from pinkary-project/feat/hashtag-storage
Browse files Browse the repository at this point in the history
Feat(hashtags): Add hashtag storage and relation syncing
  • Loading branch information
nunomaduro authored Aug 25, 2024
2 parents 442f366 + cfe4b34 commit d23a3c2
Show file tree
Hide file tree
Showing 13 changed files with 394 additions and 7 deletions.
59 changes: 59 additions & 0 deletions app/EventActions/UpdateQuestionHashtags.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace App\EventActions;

use App\Models\Hashtag;
use App\Models\Question;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;

final readonly class UpdateQuestionHashtags
{
/**
* Create a new class instance.
*/
public function __construct(
public Question $question,
) {
//
}

/**
* @return array{attached: array<int, int>, detached: array<int, int>, updated: array<int, int>}
*/
public function handle(): array
{
$parsedHashtags = $this->parsedHashtagNames();

$existingHashtags = Hashtag::query()->whereIn('name', $parsedHashtags->all())->get();

$newHashtags = $parsedHashtags->diff($existingHashtags->pluck('name'))
->map(fn (string $name): Hashtag => Hashtag::query()->create(['name' => $name]));

return $this->question->hashtags()->sync($existingHashtags->merge($newHashtags));
}

/**
* Get the unique hashtag names found in the question.
*
* @return Collection<int, string>
*/
private function parsedHashtagNames(): Collection
{
$matches = [];

preg_match_all(
'/(<(a|code|pre)\s+[^>]*>.*?<\/\2>)|(?<!&)#([a-z0-9]+)/is',
"{$this->question->answer} {$this->question->content}",
$matches,
);

return collect($matches[3] ?? [])
->filter()
->unique()
->values()
->map(fn (string $hashtag): string => Str::limit($hashtag, 50, ''));
}
}
46 changes: 46 additions & 0 deletions app/Models/Hashtag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace App\Models;

use Carbon\Carbon;
use Database\Factories\HashtagFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

/**
* @property int $id
* @property string $name
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read Collection<int, Question> $questions
*/
final class Hashtag extends Model
{
/** @use HasFactory<HashtagFactory> */
use HasFactory;

/**
* The attributes that should be cast.
*
* @return array<string, string>
*/
public function casts(): array
{
return [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}

/**
* @return BelongsToMany<Question>
*/
public function questions(): BelongsToMany
{
return $this->belongsToMany(Question::class);
}
}
10 changes: 10 additions & 0 deletions app/Models/Question.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;

Expand All @@ -39,6 +40,7 @@
* @property-read Collection<int, User> $mentions
* @property-read Question|null $parent
* @property-read Collection<int, Question> $children
* @property-read Collection<int, Hashtag> $hashtags
*/
#[ObservedBy(QuestionObserver::class)]
final class Question extends Model implements Viewable
Expand Down Expand Up @@ -186,4 +188,12 @@ public function children(): HasMany
->where('is_ignored', false)
->where('is_reported', false);
}

/**
* @return BelongsToMany<Hashtag>
*/
public function hashtags(): BelongsToMany
{
return $this->belongsToMany(Hashtag::class);
}
}
9 changes: 9 additions & 0 deletions app/Observers/QuestionObserver.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Observers;

use App\EventActions\UpdateQuestionHashtags;
use App\Models\Question;
use App\Models\User;
use App\Notifications\QuestionAnswered;
Expand All @@ -30,6 +31,8 @@ public function created(Question $question): void
$question->loadMissing('to');
$question->to->notify(new QuestionCreated($question));
}

(new UpdateQuestionHashtags($question))->handle();
}

/**
Expand All @@ -47,6 +50,10 @@ public function updated(Question $question): void
$question->to->notifications()->whereJsonContains('data->question_id', $question->id)->delete();
}

if ($question->isDirty(['answer', 'content'])) {
(new UpdateQuestionHashtags($question))->handle();
}

if ($question->isDirty('answer') === false) {
return;
}
Expand All @@ -72,5 +79,7 @@ public function deleted(Question $question): void
});

$question->children->each->delete();

$question->hashtags()->detach();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Services\ParsableContentProviders;

use App\Contracts\Services\ParsableContentProvider;
use Illuminate\Support\Str;

final readonly class HashtagProviderParsable implements ParsableContentProvider
{
Expand All @@ -14,10 +15,13 @@
public function parse(string $content): string
{
return (string) preg_replace_callback(
'/(<(a|code|pre)\s+[^>]*>.*?<\/\2>)|(?<!&)#([a-z0-9_]+)/is',
'/(<(a|code|pre)\s+[^>]*>.*?<\/\2>)|(?<!&)#([a-z0-9]+)/is',
fn (array $matches): string => $matches[1] !== ''
? $matches[1]
: '<span class="text-blue-500">#'.$matches[3].'</span>',
: sprintf(
'<span class="text-blue-500">#%s</span>',
Str::limit($matches[3], 200, '')
),
$content
);
}
Expand Down
29 changes: 29 additions & 0 deletions database/factories/HashtagFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\Hashtag;
use Database\Factories\Concerns\RefreshOnCreate;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends Factory<Hashtag>
*/
final class HashtagFactory extends Factory
{
use RefreshOnCreate;

/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => $this->faker->word(),
];
}
}
34 changes: 34 additions & 0 deletions database/migrations/2024_08_07_203106_create_hashtags_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

use App\Models\Hashtag;
use App\Models\Question;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('hashtags', function (Blueprint $table): void {
$table->id();
$table->string('name')->unique();
$table->timestamps();

$table->rawIndex('name collate nocase', 'name_collate_nocase');
});

Schema::create('hashtag_question', function (Blueprint $table): void {
$table->id();
$table->foreignIdFor(Hashtag::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(Question::class)->constrained()->cascadeOnDelete();

$table->unique(['hashtag_id', 'question_id']);
});
}
};
5 changes: 3 additions & 2 deletions tests/Arch/ModelsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
->toOnlyBeUsedIn([
'App\Concerns',
'App\Console',
'App\EventActions',
'App\Filament',
'App\Http',
'App\Jobs',
Expand All @@ -28,7 +29,7 @@
])->ignoring('App\Models\Concerns');

arch('ensure factories', function () {
expect($models = getModels())->toHaveCount(5);
expect($models = getModels())->toHaveCount(6);

foreach ($models as $model) {
/* @var \Illuminate\Database\Eloquent\Factories\HasFactory $model */
Expand All @@ -38,7 +39,7 @@
});

arch('ensure datetime casts', function () {
expect($models = getModels())->toHaveCount(5);
expect($models = getModels())->toHaveCount(6);

foreach ($models as $model) {
/* @var \Illuminate\Database\Eloquent\Factories\HasFactory $model */
Expand Down
75 changes: 75 additions & 0 deletions tests/Unit/EventActions/UpdateQuestionHashtagsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

use App\EventActions\UpdateQuestionHashtags;
use App\Models\Hashtag;
use App\Models\Question;

it('attaches the newly parsed hashtags', function () {
$question = Question::factory()->create();

$question->answer = '#hashtag1 #hashtag2';

$synced = (new UpdateQuestionHashtags($question))->handle();

$hashtag1 = Hashtag::query()->firstWhere('name', 'hashtag1');
$hashtag2 = Hashtag::query()->firstWhere('name', 'hashtag2');

expect($synced)->toBe([
'attached' => [
$hashtag1->id,
$hashtag2->id,
],
'detached' => [],
'updated' => [],
])
->and($question->hashtags->pluck('name')->all())->toBe(['hashtag1', 'hashtag2']);
});

it('detaches hashtags no longer found in the question', function () {
$question = Question::factory()->create([
'answer' => '#hashtag1 #hashtag2',
]);

expect($question->hashtags->pluck('name')->all())->toBe(['hashtag1', 'hashtag2']);

$question->answer = '#hashtag3';

$synced = (new UpdateQuestionHashtags($question))->handle();

$hashtag1 = Hashtag::query()->firstWhere('name', 'hashtag1');
$hashtag2 = Hashtag::query()->firstWhere('name', 'hashtag2');
$hashtag3 = Hashtag::query()->firstWhere('name', 'hashtag3');

expect($synced)->toBe([
'attached' => [
$hashtag3->id,
],
'detached' => [
$hashtag1->id,
$hashtag2->id,
],
'updated' => [],
])
->and($question->refresh()->hashtags->pluck('name')->all())->toBe(['hashtag3']);
});

it('will not parse hashtags within code and links', function () {
$question = Question::factory()->create();

$question->answer = <<<'ANSWER'
```php
// some code with a #hashtag here.
$url = https://example.com/route#segment
```
Check out this link with a segment: https://example.com/route#segment
But the #cool hashtag should be synced!
ANSWER;

(new UpdateQuestionHashtags($question))->handle();

expect($question->hashtags->pluck('name')->all())->toBe(['cool']);
});
25 changes: 25 additions & 0 deletions tests/Unit/Models/HashtagTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

use App\Models\Hashtag;
use App\Models\Question;

test('to array', function () {
$question = Hashtag::factory()->create();

expect(array_keys($question->toArray()))->toBe([
'id',
'name',
'created_at',
'updated_at',
]);
});

test('relations', function () {
$hashtag = Hashtag::factory()
->hasQuestions(1)
->create();

expect($hashtag->questions)->each->toBeInstanceOf(Question::class);
});
Loading

0 comments on commit d23a3c2

Please sign in to comment.