Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(hashtags): Add hashtag storage and relation syncing #495

Merged
merged 23 commits into from
Aug 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2e3d80b
feat(hashtags): create hashtag tables
steven-fox Aug 8, 2024
3ecf3ae
feat(hashtags): create HashtagFactory
steven-fox Aug 8, 2024
3ef16d8
feat(hashtags): create Hashtag model
steven-fox Aug 8, 2024
3458f3c
feat(hashtags): add hashtags relation to Question model
steven-fox Aug 8, 2024
ebc2f95
feat(hashtags): create QuestionHashtagSyncer
steven-fox Aug 8, 2024
a2bac55
feat(hashtags): sync question hashtags on Question create & update
steven-fox Aug 8, 2024
4af41b8
feat(hashtags): add hashtags relation property docblock to question m…
steven-fox Aug 8, 2024
f154c85
feat(hashtags): add RefreshOnCreate to HashtagFactory
steven-fox Aug 8, 2024
f782e1f
feat(hashtags): add Hashtag model test
steven-fox Aug 8, 2024
e5779d7
feat(hashtags): add hashtags relation to question model test
steven-fox Aug 8, 2024
aa33408
feat(hashtags): add hashtag syncing tests to QuestionObserverTest
steven-fox Aug 8, 2024
c9ebfa2
feat(hashtags): add QuestionHashtagSyncerTest
steven-fox Aug 8, 2024
db7732c
feat(hashtags): code style
steven-fox Aug 8, 2024
46df570
feat(hashtags): fix rector failures
steven-fox Aug 8, 2024
8ae6fdf
feat(hashtags): update ModelsTest to expect 6 models
steven-fox Aug 8, 2024
c42032d
feat(hashtags): improve name of missing hashtags on updated test desc…
steven-fox Aug 8, 2024
18a24ec
feat(hashtags): move/rename QuestionHashtagSyncer to App\EventActions…
steven-fox Aug 14, 2024
8a4b74f
feat(hashtags): remove down() from migration
steven-fox Aug 14, 2024
814fff2
feat(hashtags): permit use of models in App\EventActions namespace
steven-fox Aug 14, 2024
98969eb
feat(hashtags): add nocase collated index to hashtags name column
steven-fox Aug 16, 2024
9c9c33a
feat(hashtags): disallow _ within hashtags
steven-fox Aug 16, 2024
2511e4d
feat(hashtags): limit hashtags to 50 chars
steven-fox Aug 16, 2024
cfe4b34
feat(hashtags): increase permitted length of hashtags to 200 chars
steven-fox Aug 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');
steven-fox marked this conversation as resolved.
Show resolved Hide resolved
});

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