tighten / tlint

Tighten linter for Laravel conventions.
MIT License
521 stars 32 forks source link

search for Model fixtures / real world examples #174

Closed Gummibeer closed 3 years ago

Gummibeer commented 4 years ago

Hey all,

possibly you've seen that I'm working on the ModelMethodOrder linter and to improve its stability I would like to add several more fixtures to test with. So I'm asking everyone able/willing to add some examples, in best case the most complex, unconventional and/or strange ones. You can anonymize them if you don't want/can publish your real code - but it would be great if at least the return statements, visibility and return type-hints stay the same as these are what we use to detect the method types and order them.

What makes your model special?

How to submit your model? If you want to add one/some you can copy the code here and use the github codeblock styling

```php
// add your model code here
```

or you upload your files as an attachment - but GitHub only accepts TXT or ZIP files, so change the extension or zip them before upload.


Thanks in advance to everyone willing to share some code. πŸ™ πŸŽ‰

bakerkretzmar commented 4 years ago

Base Model:

<?php

namespace App;

use DateTimeInterface;
use Illuminate\Database\Eloquent\Model as BaseModel;
use Illuminate\Database\Eloquent\SoftDeletes;

class Model extends BaseModel
{
    use SoftDeletes,
        Concerns\Translatable,
        Concerns\Uuidable;

    protected $guarded = [];

    public function updateQuietly(array $attributes = [])
    {
        return static::withoutEvents(function () use ($attributes) {
            return $this->update($attributes);
        });
    }

    // Laravel <= 6.x behaviour
    protected function serializeDate(DateTimeInterface $date)
    {
        return $date->format('Y-m-d H:i:s');
    }
}

Event:

<?php

namespace App;

use App\Enums\EventTypes;
use BenSampo\Enum\Traits\CastsEnums;
use Laravel\Nova\Actions\Actionable;
use Laravel\Scout\Searchable;

class Event extends Model
{
    use Actionable,
        CastsEnums,
        Concerns\Featurable,
        Concerns\Flaggable,
        Concerns\HasCoordinates,
        Concerns\HasPreviewImage,
        Concerns\HasTimeslots,
        Concerns\Publishable,
        Concerns\Validatable,
        Searchable;

    protected $attributes = [
        'files' => '[]',
        'links' => '[]',
        'times' => '[]',
        'is_submitter_contact' => true,
        'languages' => '[]',
        'accessibility' => '[]',
    ];

    protected $casts = [
        'covid_terms' => 'boolean',
        'files' => 'array',
        'links' => 'array',
        'times' => 'array',
        'is_submitter_contact' => 'boolean',
        'languages' => 'array',
        'accessibility' => 'array',
        'is_pwyc' => 'boolean',
        'unbounded' => 'boolean',
        'type' => 'integer',
    ];

    protected $enumCasts = [
        'type' => EventTypes::class,
    ];

    protected $hidden = [
        'notes',
    ];

    public $translatable = [
        'name',
        'description',
        'organizer_name',
        'organizer_description',
        'place_name',
        'directions',
    ];

    public static function draftRules(): array
    {
        return [
            'name.en' => [],
            'name.fr' => [],
            'files' => ['array'],
            'description.en' => [],
            'description.fr' => [],
            'type' => [],
            'covid_terms' => [],
            'times' => ['array', 'max:6'],
            'times.*.start' => [],
            'times.*.end' => [],
            'unbounded' => [],
            'tag_slugs' => ['array', 'max:5'],
            'links' => ['array'],
            'links.*.en' => [],
            'links.*.fr' => [],
            'links.*.url' => [],
            'video' => ['max:255'],
            'recording' => ['max:255'],
            'coordinates' => ['array'],
            'place_name.en' => [],
            'place_name.fr' => [],
            'address' => ['max:255'],
            'city' => ['max:255'],
            'province_id' => ['required', 'enum_value:App\Enums\Provinces,false'],
            'directions.en' => [],
            'directions.fr' => [],
            'organizer_name.en' => [],
            'organizer_name.fr' => [],
            'organizer_description.en' => [],
            'organizer_description.fr' => [],
            'organizer_type' => ['nullable'],
            'is_submitter_contact' => ['boolean'],
            'contact_name' => ['max:255'],
            'contact_email' => ['max:255'],
            'contact_phone' => ['max:30'],
            'languages' => ['array'],
            'accessibility' => ['array'],
            'accessibility_other' => ['max:255'],
            'is_pwyc' => ['boolean'],
        ];
    }

    public static function publishRules(): array
    {
        return array_merge_recursive(static::draftRules(), [
            'name.en' => ['required_without:name.fr', 'max:100'],
            'name.fr' => ['required_without:name.en', 'max:100'],
            'files' => ['required', 'max:4'],
            'description.en' => [
                'required_without:description.fr',
                'min_words_without:description.fr,30',
                'max_words:500',
            ],
            'description.fr' => [
                'required_without:description.en',
                'min_words_without:description.en,30',
                'max_words:500',
            ],
            'type' => ['required', 'enum_value:App\Enums\EventTypes,false'],
            'covid_terms' => ['exclude_unless:type,' . EventTypes::InPerson, 'accepted'],
            'times' => [
                'required_if:require_times,true',
                'exclude_if:unbounded,true',
            ],
            'times.*.start' => [
                'bail',
                'required',
                'date',
                'before:times.*.end',
                'on_culturedays',
            ],
            'times.*.end' => [
                'bail',
                'required',
                'date',
                'after:times.*.start',
                'on_culturedays',
            ],
            'unbounded' => ['nullable', 'boolean'],
            'links' => ['max:4'],
            'links.*.en' => ['required_without:links.*.fr', 'max:100'],
            'links.*.fr' => ['required_without:links.*.en', 'max:100'],
            'links.*.url' => ['required', 'active_url'],
            'video' => ['nullable', 'active_url'],
            'recording' => ['nullable', 'active_url'],
            'coordinates.*' => ['required'],
            'address' => ['max:100'],
            'city' => ['required', 'max:100'],
            'place_name.en' => ['max:100'],
            'place_name.fr' => ['max:100'],
            'directions.en' => ['max_words:100'],
            'directions.fr' => ['max_words:100'],
            'organizer_name.en' => ['required_without:organizer_name.fr', 'max:100'],
            'organizer_name.fr' => ['required_without:organizer_name.en', 'max:100'],
            'organizer_description.en' => [
                'required_without:organizer_description.fr',
                // 'min_words_without:organizer_description.fr,30',
                'max_words:500',
            ],
            'organizer_description.fr' => [
                'required_without:organizer_description.en',
                // 'min_words_without:organizer_description.en,30',
                'max_words:500',
            ],
            'organizer_type' => ['required', 'enum_value:App\Enums\Organizers,false'],
            'contact_name' => ['required_if:is_submitter_contact,0', 'max:100'],
            'contact_email' => ['nullable', 'required_if:is_submitter_contact,0', 'email:filter'],
            'languages' => ['required', 'min:1'],
            'languages.*' => ['not_regex:/\b(the|and|or|any|no)\b/i']
        ]);
    }

    public function getTagSlugsAttribute(): array
    {
        return $this->tags->pluck('slug')->all();
    }

    public function setTagSlugsAttribute(array $tags)
    {
        $this->tags()->sync(Tag::whereIn('slug', $tags)->get());
    }

    public function collections()
    {
        return $this->belongsToMany(Collection::class)
                    ->using(CollectionEvent::class)
                    ->withPivot(['id', 'order']);
    }

    public function hubs()
    {
        return $this->belongsToMany(Hub::class)
                    ->using(EventHub::class)->as('invitation')
                    ->withPivot(['id', 'accepted_at', 'declined_at'])
                    ->withTimestamps();
    }

    public function getHubAttribute(): ?Hub
    {
        return $this->hubs->where('published_at', '!=', null)->firstWhere('invitation.accepted_at', '!=', null);
    }

    public function province()
    {
        return $this->belongsTo(Province::class);
    }

    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }

    public function user()
    {
        return $this->belongsTo(User::class);
    }

    /**
     * Get the event's searchable data.
     */
    public function toSearchableArray(): array
    {
        return $this->transform(array_merge([
            'type' => 'event',
        ], $this->only([
            'id',
            'uuid',
            'name',
            'description',
            'files',
            'place_name',
            'city',
            'status',
            'is_featured',
            'days',
            'times_by_day',
            'organizer_name',
            'languages',
            'accessibility',
            'published_at',
        ]), [
            'event_type' => optional($this->type)->value,
            'last_end_time_stamp' => optional($this->last_end_time)->timestamp,
            'unbounded' => $this->unbounded || optional($this->type)->is(EventTypes::DigitalRecording),
            'tags' => $this->tag_slugs,
            'province' => $this->province->slug,
            'recording' => filled($this->recording),
            '_geoloc' => [
                'lat' => (float) $this->latitude,
                'lng' => (float) $this->longitude,
            ],
        ]));
    }

    public function toGeoJson()
    {
        return [
            'type' => 'Feature',
            'geometry' => [
                'type' => 'Point',
                'coordinates' => [(float) $this->longitude, (float) $this->latitude],
            ],
            'properties' => [
                'icon' => 'circle',
                'uuid' => $this->uuid,
                'name' => $this->name,
                'preview_image' => $this->preview_image_url,
                'organizer_name' => $this->organizer_name,
                'hub' => optional($this->hub)->only(['name', 'uuid']),
            ],
        ];
    }
}

EventHub pivot model:

<?php

namespace App;

use Illuminate\Database\Eloquent\Relations\Pivot;

class EventHub extends Pivot
{
    public $incrementing = true;

    protected $appends = [
        'is_accepted',
        'is_declined',
        'is_pending',
    ];

    protected $dates = [
        'accepted_at',
        'declined_at',
    ];

    public function accept()
    {
        $this->update([
            'accepted_at' => now(),
            'declined_at' => null,
        ]);
    }

    public function decline()
    {
        $this->update([
            'accepted_at' => null,
            'declined_at' => now(),
        ]);
    }

    public function getIsAcceptedAttribute(): bool
    {
        return isset($this->accepted_at);
    }

    public function getIsDeclinedAttribute(): bool
    {
        return isset($this->declined_at);
    }

    public function getIsPendingAttribute(): bool
    {
        return ! $this->accepted && ! $this->declined;
    }
}
jcorrego commented 4 years ago
<?php

namespace App\XXX\Contents;

use Image;
use App\User;
use MediaUploader;
use Spatie\Tags\HasTags;
use Plank\Mediable\Media;
use Plank\Metable\Metable;
use Plank\Mediable\Mediable;
use App\XXX\Blog\Post;
use Illuminate\Database\Eloquent\Model;
use App\XXX\Challenges\ThematicArea;
use App\Http\Controllers\Traits\CommentableTrait;
use App\Http\Controllers\Traits\LikeableTrait;
use Plank\Mediable\SourceAdapters\SourceAdapterInterface;

class Content extends Model
{
    use Mediable, HasTags, Metable, CommentableTrait, LikeableTrait;

    protected $appends = ['main_image'];
    protected $guarded = [];
    protected $hidden = [];
    protected $casts = [
        'obstacles' => 'array',
        'lessons'   => 'array',
        'tools'     => 'array',
        'ideas'     => 'array',
        'case'      => 'array',
        'trends'    => 'array',
    ];

    public function posts()
    {
        return $this->belongsToMany(Post::class)->withTimestamps();
    }

    public function user()
    {
        return $this->belongsTo(User::class);
    }

    public function users()
    {
        return $this->belongsToMany(User::class)->withPivot('count', 'rate')->withTimestamps();
    }

    public function thematicArea()
    {
        return $this->belongsTo(ThematicArea::class, 'thematic_area_id');
    }

    public function getMainImageAttribute()
    {
        if ($media = $this->getMedia('main-image')->first()) {
            return $media->getUrl();
        } else {

            $image = 'XXX/image-default.png';

            $media = MediaUploader::fromSource(url($image))->toDirectory('contents/' . $this->id)->upload();
            $this->syncMedia($media, 'main-image');

            return $media->getUrl();
        }
    }

    public function getMainImageSmallAttribute()
    {
        if ($media = $this->getMedia('main-image-small')->first()) {
            return $media->getUrl();
        } else if ($media = $this->getMedia('main-image')->first()) {
            $imageSmall = Image::make($media->getUrl())->resize(400, 225)->encode('png', 80);
            $mediaSmall    = MediaUploader::fromSource($imageSmall->stream('png'))->toDirectory('tutorials/' . $this->id)
                                          ->beforeSave(function (Media $model, SourceAdapterInterface $source) {
                                              $model->setAttribute('user_id', $this->user_id ? $this->user_id : auth()->user()->id);
                                          })->upload();
            $this->syncMedia($mediaSmall, 'main-image-small');
            return $mediaSmall->getUrl();
        }

        return $this->getMainImageAttribute();

    }

    public function getUserRateAttribute()
    {
        if (auth()->user() && ($content_user = $this->users()->wherePivot('user_id', auth()->user()->id)->first())) {
            return $content_user->pivot->rate;
        }

        return null;
    }

    public function getMetaDescriptionAttribute()
    {
        return $this->getMeta('description', '');
    }

    public function getMetaCaseAttribute()
    {
        return $this->getMeta('success_case', '');
    }

    public function getMediaDescriptionAttribute()
    {
        $file = null;
        if ($media = $this->getMedia('description')->first()) {
            $file = collect([
                                'id'   => $media->id,
                                'url'  => $media->getUrl(),
                                'name' => $media->basename,
                                'size' => $media->readableSize(),
                                'type' => $media->aggregate_type,
                            ]);
        }

        return $file;
    }

    public function getMediaCaseAttribute()
    {
        $file = null;
        if ($media = $this->getMedia('success_case')->first()) {
            $file = collect([
                                'id'   => $media->id,
                                'url'  => $media->getUrl(),
                                'name' => $media->basename,
                                'size' => $media->readableSize(),
                                'type' => $media->aggregate_type,
                            ]);
        }

        return $file;
    }

}
Gummibeer commented 4 years ago

I'm super thankful for your Models! πŸ™‚ But I'm also sorry as all of them are right now blocked by #169 & #175 - the first one is only a question of time until it's merged. But the second one will require a lot more work but also shows how important it is as it seems to block a massive amount of Models to be analyzed - starting with the official Pivot model.

@bakerkretzmar https://github.com/Gummibeer/tlint/commit/449af4845ac4ac4c0a86455d93724e893ec6c79b

@jcorrego https://github.com/Gummibeer/tlint/commit/77fbd7d68135e7fae8b706da552914c3f9030850

bakerkretzmar commented 4 years ago

I don't know a ton about tlint internals but would is_subclass_of help with either of those? None of the actual class names should matter then right?

Gummibeer commented 4 years ago

@bakerkretzmar that would only be the easy solution if we would analyze a class reference and not a file. But we get only the file content as under the hood it iterates through the filesystem as there's no get_class_classes() function. So we would have to join the namespace with the class name to get a FQCN which we could pass to PHP default is_xyz() functions which would still require that tlint has class autoload access to the named class files which is only the case if you require tlint in the project itself. But as phar or global dependency it doesn't know anything about the project autoloader and loading it could lead to tlint internal errors because of duplicated classes, different versions of dependencies and so on. But the underlying nikic/php-parser has a NameResolver which I want to use as it will also improve the method type detection. But it's not that simple as we have to adjust some more code. So to do so I want a stable testsuite which I prepare right now with all the open PRs. After we have this I will work on the name resolver logic to check if the \Illuminate\Database\Eloquent\Model is extended.

bakerkretzmar commented 4 years ago

Ahhh right, makes sense. Thanks for explaining and good luck! πŸ˜„