derekmd

derekmd

Toronto

Member Since 2 Years Ago

Experience Points 330
Experience
Level
Lessons Completed 2
Lessons
Completed
Best Reply Awards 0
Best Answer
Awards
  • Start Your Engines Achievement

    Start Your Engines

    Earned once you have completed your first Laracasts lesson.

  • First Thousand Achievement

    First Thousand

    Earned once you have earned your first 1000 experience points.

  • One Year Member Achievement

    One Year Member

    Earned when you have been with Laracasts for 1 year.

  • Two Year Member Achievement

    Two Year Member

    Earned when you have been with Laracasts for 2 years.

  • Three Year Member Achievement

    Three Year Member

    Earned when you have been with Laracasts for 3 years.

  • Four Year Member Achievement

    Four Year Member

    Earned when you have been with Laracasts for 4 years.

  • Five Year Member Achievement

    Five Year Member

    Earned when you have been with Laracasts for 5 years.

  • School In Session Achievement

    School In Session

    Earned when at least one Laracasts series has been fully completed.

  • Welcome To The Community Achievement

    Welcome To The Community

    Earned after your first post on the Laracasts forum.

  • Full Time Learner Achievement

    Full Time Learner

    Earned once 100 Laracasts lessons have been completed.

  • Pay It Forward Achievement

    Pay It Forward

    Earned once you receive your first "Best Reply" award on the Laracasts forum.

  • Subscriber Achievement

    Subscriber

    Earned if you are a paying Laracasts subscriber.

  • Lifer Achievement

    Lifer

    Earned if you have a lifetime subscription to Laracasts.

  • Laracasts Evangelist Achievement

    Laracasts Evangelist

    Earned if you share a link to Laracasts on social media. Please email [email protected] with your username and post URL to be awarded this badge.

  • Chatty Cathy Achievement

    Chatty Cathy

    Earned once you have achieved 500 forum replies.

  • Laracasts Veteran Achievement

    Laracasts Veteran

    Earned once your experience points passes 100,000.

  • Ten Thousand Strong Achievement

    Ten Thousand Strong

    Earned once your experience points hits 10,000.

  • Laracasts Master Achievement

    Laracasts Master

    Earned once 1000 Laracasts lessons have been completed.

  • Laracasts Tutor Achievement

    Laracasts Tutor

    Earned once your "Best Reply" award count is 100 or more.

  • Laracasts Sensei Achievement

    Laracasts Sensei

    Earned once your experience points passes 1 million.

  • Top 50 Achievement

    Top 50

    Earned once your experience points ranks in the top 50 of all Laracasts users.

07 Dec
1 year ago

derekmd left a reply on WithoutEvents() For Only Integration Test Setup

withoutEvents() override

@EventFellows A few weeks ago I updated my reply above with an override to Illuminate\Foundation\Testing\Concerns\withoutEvents() that will also swap the facade instance in case $this->withoutEvents() is called before $this->withoutEventsFor().

event() just uses app('events') so it's dynamic to the mock or concrete object currently in the app container. However Event::fire() resolves the app('events') instance once then keeps that instance in the Illuminate\Support\Facades\Event singleton for the remainder of the current request unless Event::swap() is invoked.

With the override, you can be sure either event dispatching technique uses the expected Mockery object or concrete Illuminate\Contracts\Events\Dispatcher instance.

Example

Here's a real world integration test I can confirm passes assertions when Event::fire() is used inside Eloquent event listeners triggered within factory() calls in the below closure.

/* @test */
public function subscription_purchase_is_succcessful_with_valid_card
{
    $this->expectsJobs(App\Jobs\Email\OrderReceiptEmail::class);
    $this->expectsEvents(App\Events\PaymentMethodsChanged::class);

    list($order, $orderItem) = $this->withoutEventsFor(function () {
        return [
            factory(Order::class)->create([
                'sub_total' => '349.00',
                'tax' => '45.37',
                'total' => '394.37',
            ]),
            factory(OrderItem::class)->create([
                'order_id' => $order->id,
                'product_id' => ProductId::ANNUAL_SUBSCRIPTION,
                'amount' => '349.00',
                'tax' => '45.37',
                'transaction_type' => OrderItemTransactionType::Purchase,
            ]),
        ];
    });

    // etc.
}
23 Aug
2 years ago

derekmd left a reply on WithoutEvents() For Only Integration Test Setup

I took another crack at this and found instead calling Event::swap($instance) (instead of $this->app->instance('events', $instance)) will correctly restore Event::fire() connector behaviour after the $this->withoutEventsFor() closure.

class TestCase extends Illuminate\Foundation\Testing\TestCase
{
    /**
     * Temporarily disable event firing for a test setup closure function.
     *
     * @param callable $callback
     *
     * @return mixed Value returned by the callback.
     */
    protected function withoutEventsFor(callable $callback)
    {
        $events = $this->app['events'];

        $this->withoutEvents();

        $value = $callback();

        Event::swap($events);

        return $value;
    }

    /**
     * Create a user instance and authenticate on the session.
     *
     * Optional parameters:
     * - array $attributes Optional values for the User model.
     * - string $role
     */
    protected function beUser()
    {
        $arguments = func_get_args();
        $attributes = isset($arguments[0]) && is_array($arguments[0]) ? $arguments[0] : [];

        $this->user = factory(User::class)->create($attributes);
        $this->be($this->user);

        $role = array_get(array_slice($arguments, -1), 0);

        if (is_string($role) && !empty(trim($role))) {
            factory(RoleUser::class, $role)->create([
                'user_id' => $this->user->id,
            ]);
        }
    }
}

Above is a /tests project class my test suite always extends. You could also implement this as a trait although this method depends on [email protected]() and $this->app declared in Illuminate\Foundation\Testing\TestCase.

So in my scenario, Eloquent User::create() internally-triggered event may potentially dispatch another queued event depending on the condition of that model. e.g., App\Events\UserVerificationChanged is dispatched for newly registered users if they weren't a) created by an admin and b) preset as verified by that admin. The queued event would cause a notification on their account dashboard asking them to verify their account.

When I'm writing an integration test covering them later changing the account's email address, that above use case doesn't matter! But Laravel's factory(User::class)->create() will be dispatching those unrelated events. So now this works:

class UserApiControllerTest extends TestCase
{
    public function testAccountEmailChangeTriggersTwoEmailNotifications()
    {
        $this->expectsJobs([
            App\Jobs\Email\Auth\AccountEmailChangeEmail::class,
            App\Jobs\Email\Auth\VerificationEmail::class,
        ]);

        $this->withoutEventsFor([$this, 'beUser']);

        $faker = Faker\Factory::create();

        $inputs = [
            'email' => $faker->email,
        ];

        $url = route('api.v1.user.update');

        $this->put($url, $inputs)
            ->seeJson(['status' => 'success'])
            ->assertResponseOk();

        $this->seeInDatabase('users', [
            'id' => $this->user->id,
            'email' => $inputs['email'],
            'is_email_verified' => false,
        ]);
    }
}

If I remove either of those $this->expectsJobs() entries, the integration test now fails. Before it would still be suppressed due to the $this->withoutEventsFor()-invoked $this->withoutEvents() instance still being bound in the app container.

30 Jun
2 years ago

derekmd started a new conversation WithoutEvents() For Only Integration Test Setup

I have a scenario where Event::fire() is called in both created and deleted events of a model. During the API's PUT update endpoint integration test I want to know:

  • that event isn't fired when the update action is successful. (i.e., "InvalidArgumentException: No connector for []" wouldn't being thrown during the integration test scenario.)

However factory()->create() when setting up the integration test does fire the created model's event. That means I'd have to call $this->withoutEvents() which means the integration test won't assert that event was mistakenly fired for the update action itself (not the test setup.)

So I'd like to temporarily suppress events in setup then re-enable for the test itself. A few things I tried:

  • Use $this->withoutEvents() in a PHPUnit @dataProvider method. However Laravel's service container isn't available in those methods so I can't call factory() to make the model instance.

  • Override the application's 'events' facade and restore it after a closure executes:

    /**
     * Temporarily disable event firing for a test setup closure function.
     *
     * @param callable $callback
     *
     * @return mixed Value returned by the callback.
     */
    protected function withoutEventsFor(callable $callback)
    {
        $events = $this->app['events'];
    
        $this->withoutEvents();
    
        $value = $callback();
    
        $this->app->instance('events', $events);
    
        return $value;
    }
    
    
    public function testUpdateIsPrimary()
    {
        $this->beUser();
    
        list($primary, $secondary) = $this->withoutEventsFor(function () {
            return [
                factory(UserPayoutMethod::class, 'paypal')->create([
                    'user_id' => $this->user->id,
                    'is_primary' => true,
                ]),
                factory(UserPayoutMethod::class, 'emt')->create([
                    'user_id' => $this->user->id,
                    'is_primary' => false,
                ]),
            ];
        });
    
        // then perform API test
    }
    

For the latter approach, I found Event::fire() referencing the app's 'events' singleton instance were still being suppressed as if the mock created by $this->withoutEvents() was still in place.

Anyone have an idea how I could go about ignoring event dispatches for factory()->create()?

11 Feb
2 years ago

derekmd left a reply on Combining Multiple HasMany Relationships Into One Model Relation Query Builder

I didn't even think of an accessor method! For the sake of later chaining query conditions, messages() should likely be changed to a nested disjunctive:

public function messages()
{
    return Message::where(function ($query) {
        return $query->where('sender_user_id', $this->id)->orWhere('recipient_user_id', $this->id);
    });
}

However you're also correct about this relationship being unusable for with() or load(). Both of these methods require the relationship builder implementing Illuminate\Database\Eloquent\Relations\Relation. Obviously it's not too useful to eager load all of a user's messages but you can re-use the relationship for finer purposes. e.g., retrieve this user and the latest message from their 5 most recent conversation threads.

But anytime I'm extending vendor code (creating a dependency with that release) and passing ignored junk parameters to the parent class ($foreignKeys[0] to HasMany::__construct()), it gives an indication it's likely an over-engineered solution.

10 Feb
2 years ago

derekmd started a new conversation Combining Multiple HasMany Relationships Into One Model Relation Query Builder

This thread is more an extension of https://laracasts.com/forum/?p=431-eloquent-relation-setup-two-foreign-keys-to-same-table as I have a messages table containing two foreign key sender_user_id and recipient_user_id that both reference the users table. My problem is what if I wish to create an Eloquent Relation on the model so I can take advantage of lazy/eager-loading and re-contextualizing the results? I will be adding a messages() method to the User model below.

class User extends \Illuminate\Database\Eloquent\Model
{
    public function messagesSent()
    {
        return $this->hasMany(Message::class, 'sender_user_id');
    }

    public function messagesReceived()
    {
        return $this->hasMany(Message::class, 'recipient_user_id');
    }
}

The solution I came up with was to extend \Illuminate\Database\Eloquent\Relations\HasMany to support multiple values in its $foreignKey parameter.

<?php

namespace App\Database\Eloquent\Relations;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

/**
 * Class HasManyWithKeys.
 *
 * Extend Illuminate\Database\Eloquent\Relations\HasMany so you can reference
 * unify HasMany relationships on different foreign keys in the same table.
 *
 * @package App\Database\Eloquent\Relations
 */
class HasManyWithKeys extends HasMany
{
    /**
     * @var array
     */
    protected $foreignKeys;

    /**
     * Create a new has one or many relationship instance.
     *
     * @param \Illuminate\Database\Eloquent\Builder $query
     * @param \Illuminate\Database\Eloquent\Model $parent
     * @param array $foreignKeys
     * @param string $localKey
     *
     * @return HasManyWithKeys
     */
    public function __construct(Builder $query, Model $parent, $foreignKeys, $localKey)
    {
        $this->localKey = $localKey;
        $this->foreignKeys = $foreignKeys;

        parent::__construct($query, $parent, $foreignKeys[0], $localKey);
    }

    /**
     * Set the base constraints on the relation query.
     */
    public function addConstraints()
    {
        if (static::$constraints) {
            $foreignKeys = $this->foreignKeys;

            $this->query->where(function ($query) use ($foreignKeys) {
                foreach ($foreignKeys as $foreignKey) {
                    $query->orWhere(function ($query) use ($foreignKey) {
                        $query->where($foreignKey, '=', $this->getParentKey())
                            ->whereNotNull($this->foreignKey);
                    });
                }
            });
        }
    }

    /**
     * Set the constraints for an eager load of the relation.
     *
     * @param array $models
     */
    public function addEagerConstraints(array $models)
    {
        $foreignKeys = $this->foreignKeys;

        $this->query->where(function ($query) use ($foreignKeys, $models) {
            foreach ($foreignKeys as $foreignKey) {
                $query->orWhere(function ($query) use ($foreignKey, $models) {
                    $$query->whereIn($foreignKey, $this->getKeys($models, $this->localKey));
                });
            }
        });
    }
}

Then in the above model definition I could add:

class User extends \Illuminate\Database\Eloquent\Model
{
    // ...

    public function messages()
    {
        return $this->hasManyWithKeys(Message::class, ['user_id_to', 'user_id_from']);
    }

    /**
     * Obviously move this to a parent class or Trait.
     *
     * Define a one-to-many relationship on a table with multiple foreign keys
     * referencing the same model.
     *
     * @param string $related
     * @param array|string $foreignKeys
     * @param string $localKey
     *
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function hasManyWithKeys($related, $foreignKeys = null, $localKey = null)
    {
        // Default to Illuminate\Database\Eloquent\Relations\HasMany for less
        // than two foreign keys.
        if (empty($foreignKeys) || is_string($foreignKeys)) {
            return $this->hasMany($related, $foreignKeys, $localKey);
        }

        if (count($foreignKeys) === 1) {
            return $this->hasMany($related, $foreignKeys[0], $localKey);
        }

        $instance = new $related;

        $localKey = $localKey ?: $this->getKeyName();

        $foreignKeys = array_map(function ($foreignKey) use ($instance) {
            return $instance->getTable() . '.' . $foreignKey;
        }, $foreignKeys);

        return new HasManyWithKeys($instance->newQuery(), $this, $foreignKeys, $localKey);
    }
}

With this you can then use the query builder returned by $user->messages() to retrieve message threads or the unread count for that user.

So did I reinvent the wheel here? Reading through https://laravel.com/docs/5.2/eloquent-relationships and the source of namespace \Illuminate\Database\Eloquent\Relations\*, I was unable to find the capabilities I was looking for. (And yes, that $foreignKeys[0] constructor parameter is cringe-worthy.)