codegreen's avatar

Laravel 11 PHPUnit Testing SQLite morphTo attach double prefix in database name

I just upgraded to Laravel 11 and started running my PHPUnit tests when I ran into this error:

SQLSTATE[HY000]: General error: 1 no such table: main.PRE_PRE_event_registrations (Connection: sqlite, SQL: insert into "PRE_event_registration_items" ("cost", "created_at", "event_registration_id", "name", "quantity", "registerable_id", "registerable_type", "updated_at") values (2222.17, 2024-06-28 19:51:42, 1, Devan Moen, 1, 1, App\Models\Events\EventSponsorship, 2024-06-28 19:51:42))

This is the code throwing the error:

$this->registration->sponsorships()->attach($this->sponsorship, [
    'name' => $this->sponsorship->name,
    'cost' => $this->sponsorship->cost,
    'quantity' => 1,
]);

This is the relationship in the event registration model:

public function sponsorships(): MorphTo
{
    return $this->morphTo();
}

In my env, I have this for my database prefix:

DB_PREFIX=PRE_

The query looks fine. Why is SQLite complaining and showing that the database table has two prefixes? main.PRE_PRE_event_registrations. I can use the relationship in other ways. For example:

dd($this->registration->sponsorships);

This will die and dump an empty array.

Illuminate\Database\Eloquent\Collection^ {
  #items: []
  #escapeWhenCastingToString: false
}

I spent a lot of time researching this and using AI, but I have not found any answers, nor is anyone else having this issue. Is this a bug in Laravel? I cannot imagine it is an SQLite issue since the version installed is newer than the required version Laravel suggests.

0 likes
15 replies
LaryAI's avatar
Level 58

It looks like the issue you're encountering is related to the database prefix being applied twice in your SQLite database. This can happen due to the way Laravel handles table names with prefixes in certain database operations, especially when using polymorphic relationships.

Here’s a step-by-step solution to address this issue:

  1. Check the Database Configuration: Ensure that your database configuration is correctly set up and that the prefix is only applied once. In your config/database.php file, make sure the prefix is set correctly for your SQLite connection.

    'connections' => [
        'sqlite' => [
            'driver' => 'sqlite',
            'url' => env('DATABASE_URL'),
            'database' => env('DB_DATABASE', database_path('database.sqlite')),
            'prefix' => env('DB_PREFIX', ''),
            'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
        ],
        // other connections...
    ],
    
  2. Override the Table Name in the Model: You can explicitly set the table name in your model to avoid the double prefix issue. This can be done by overriding the $table property in your model.

    class EventRegistration extends Model
    {
        protected $table = 'event_registrations';
        // other model properties and methods...
    }
    
  3. Check the Morph Map: Ensure that you have correctly set up the morph map if you are using custom morph types. This can help Laravel resolve the correct table names.

    use Illuminate\Database\Eloquent\Relations\Relation;
    
    Relation::morphMap([
        'sponsorship' => 'App\Models\Events\EventSponsorship',
        // other morph mappings...
    ]);
    
  4. Debugging the Query: To further debug the issue, you can log the actual SQL queries being executed. This can help you see if the prefix is being applied twice.

    In your AppServiceProvider or a dedicated service provider, add the following code:

    use Illuminate\Support\Facades\DB;
    use Illuminate\Support\Facades\Log;
    
    public function boot()
    {
        DB::listen(function ($query) {
            Log::info($query->sql, $query->bindings, $query->time);
        });
    }
    
  5. Testing Without Prefix: As a temporary measure, you can try running your tests without the prefix to see if the issue persists. This can help you isolate whether the prefix is indeed the cause of the problem.

    In your .env.testing file, set the DB_PREFIX to an empty string:

    DB_PREFIX=
    
  6. Check for Laravel Bugs: If none of the above solutions work, it might be worth checking the Laravel GitHub repository for any reported issues related to this. If you believe it’s a bug, you can report it with detailed information.

By following these steps, you should be able to identify and resolve the issue with the double prefix in your SQLite database when running PHPUnit tests in Laravel 11.

Tray2's avatar

It's very simple, the table does not exist. You need to run the migration that creates the table.

My guess is that somewhere in your code you add the additional PRE,so instead of looking for

PRE_event_registrations, it looks for PRE_PRE_event_registrations

1 like
codegreen's avatar

It would be simple if the table did not exist. However, that is not the case. The migration is called 2024_02_07_160013_create_event_registrations_table. The migrations run for every test since I use the RefreshDatabase trait. This is the name of the table in the migration:

Schema::create('event_registrations', function (Blueprint $table) {

Also, the code I am testing works fine in the application but not in the test, which leads me to believe something is wrong with the testing suite, Laravel, or SQLite. This test also ran fine before upgrading to Laravel 11.

Tray2's avatar

@codegreen SQL never lies, if it tells you the table doesn't exist then it doesn't exist.

1 like
codegreen's avatar

@Tray2 The table does exist, but for some reason, a morphTo adds an additional prefix to the table name. It's very strange. As I mentioned, I can select from the table but cannot attach new records to it.

codegreen's avatar

@Tray2 How do you explain the query:

insert into "PRE_event_registration_items" ("cost", "created_at", "event_registration_id", "name", "quantity", "registerable_id", "registerable_type", "updated_at") values (2222.17, 2024-06-28 19:51:42, 1, Devan Moen, 1, 1, App\Models\Events\EventSponsorship, 2024-06-28 19:51:42))

versus the error?

General error: 1 no such table: main.PRE_PRE_event_registrations
Tray2's avatar

@codegreen Like I said it never lies, the table does not exist. The real question here is, do you get that error from running that insert from the command line, or when you try to run it from your code? My bet is on when you are running it from your code, and that means that you either found a bug in Laravel, or more likely mage a mistake in your own code.

1 like
codegreen's avatar

@Tray2, it's fine to state that SQL never lies, but not really helpful to keep stating that fact repeatedly. I still don't understand the issue. I would agree that the table PRE_PRE_event_registrations does not exist, but it also should not exist. The insert statement is attempting to insert into PRE_event_registration_items which does exist and I proved that by attempting to select from it and it did not error.

My command is herd php artisan test tests/Feature/Controller --stop-on-error --stop-on-failure --filter CommitmentControllerTest. I suppose this would qualify as my code that is running.

I'm going to try a different method from attach to see if that solves the issue.

codegreen's avatar

@Tray2, just as I suspected. This code also produces the same error.

$this->registration->sponsorships()->sync([
    $this->sponsorship->id,
    [
        'name' => $this->sponsorship->name,
        'cost' => $this->sponsorship->cost,
        'quantity' => 1,
    ],
]);
Tray2's avatar

@codegreen What does your EventRegistration, and Sponsorship models look like?

codegreen's avatar

@Tray2 thank you for being willing to take a closer look.

EventRegistration model

<?php

namespace App\Models\Events;

use App\Models\Action;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphToMany;

class EventRegistration extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'vendor_id',
        'payment_type',
        'payment_amount',
        'notes',
        'completed_at',
        'transaction_ids',
        'first_paid_at',
    ];

    protected $hidden = ['event_id', 'user_id'];

    protected $casts = ['completed_at' => 'datetime', 'payment_amount' => 'float', 'transaction_ids' => 'collection'];

    /**
     * @return Attribute
     */
    public function status(): Attribute
    {
        $status = 'incomplete';
        if (
            !is_null($this->completed_at) &&
            !empty($this->payment_amount) &&
            $this->payment_amount === $this->subtotal
        ) {
            $status = 'paid';
        } elseif (!is_null($this->completed_at)) {
            $status = 'completed';
        }

        return Attribute::make(get: fn() => $status);
    }

    /**
     * @return Attribute
     */
    public function subtotal(): Attribute
    {
        return Attribute::make(
            get: fn() => round($this->items->sum(fn($item) => $item->cost * $item->quantity), 2, PHP_ROUND_HALF_UP)
        );
    }

    /**
     * Total amount due.
     *
     * @return Attribute
     */
    public function total(): Attribute
    {
        return Attribute::make(
            get: fn() => round($this->subtotal - (float) $this->payment_amount, 2, PHP_ROUND_HALF_UP)
        );
    }

    /**
     * @return Attribute
     */
    public function invoiceNumber(): Attribute
    {
        return Attribute::make(get: fn() => sprintf('INV-%s-%s', $this->vendor_id, $this->event_id));
    }

    /**
     * @return Attribute
     */
    public function deadline(): Attribute
    {
        if ($this->exists) {
            $hasIncompleteItems = $this->items()
                ->whereNull('completed_at')
                ->exists();

            $deadline = $this->updated_at->copy()->addMinutes(30);

            return Attribute::make(
                get: fn() => $deadline->gt(now()) && $hasIncompleteItems ? $deadline->toISOString() : null
            );
        }

        return Attribute::make(get: fn() => null);
    }

    /**
     * @return BelongsTo
     */
    public function event(): BelongsTo
    {
        return $this->belongsTo(Event::class);
    }

    /**
     * @return BelongsTo
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(\App\Models\User::class);
    }

    /**
     * @return BelongsTo
     */
    public function vendor(): BelongsTo
    {
        return $this->belongsTo(\App\Models\Vendor::class);
    }

    /**
     * @return HasMany
     */
    public function items(): HasMany
    {
        return $this->hasMany(EventRegistrationItem::class);
    }

    /**
     * @return MorphToMany
     */
    public function sponsorships(): MorphToMany
    {
        return $this->morphedByMany(EventSponsorship::class, 'registerable', 'event_registration_items')
            ->withPivot('id', 'name', 'cost', 'quantity', 'azure_file_id')
            ->withTimestamps();
    }

    /**
     * @return MorphToMany
     */
    public function booths(): MorphToMany
    {
        return $this->morphedByMany(EventBooth::class, 'registerable', 'event_registration_items')
            ->withPivot(['name', 'quantity'])
            ->withTimestamps();
    }

    /**
     * @return MorphMany
     */
    public function actions(): MorphMany
    {
        return $this->morphMany(Action::class, 'actionable');
    }

    /**
     * @param  Builder  $query
     * @return void
     */
    public function scopeExpired(Builder $query): void
    {
        $query
            ->whereHas('items', fn(Builder $query) => $query->whereNull('completed_at'))
            ->where('updated_at', '<', now()->subMinutes(30));
    }

    /**
     * @param Builder $query
     * @return void
     */
    public function scopeCompleted(Builder $query): void
    {
        $query
            ->whereNotNull('completed_at')
            ->whereNotNull('payment_amount')
            ->whereNotNull('first_paid_at');
    }
}

EventSponsorship model

<?php

namespace App\Models\Events;

use App\Models\Action;
use App\Models\BlueBookCategory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class EventSponsorship extends Model
{
    use HasFactory;

    protected $fillable = [
        'event_sponsorship_type_id',
        'blue_book_category_id',
        'name',
        'slug',
        'image_url',
        'sold_image_url',
        'sell_sheet_url',
        'spec_sheet_url',
        'media_url',
        'cost',
        'quantity',
    ];

    protected $casts = [
        'quantity' => 'integer',
        'cost' => 'decimal:2',
    ];

    protected $hidden = ['event_id', 'event_sponsorship_type_id', 'blue_book_category_id'];

    /**
     * @return Attribute
     */
    public function description(): Attribute
    {
        return Attribute::make(get: fn() => $this->sponsorshipType->description);
    }

    /**
     * @return Attribute
     */
    public function available(): Attribute
    {
        $available = 0;

        $purchased = $this->query()
            ->select('id')
            ->withSum('items', 'quantity')
            ->with('items')
            ->where('id', $this->id)
            ->first();

        $available = is_null($purchased) ? $this->quantity : $this->quantity - $purchased->items_sum_quantity;

        return Attribute::make(get: fn() => $available);
    }

    /**
     * @return Attribute
     */
    public function pending(): Attribute
    {
        $pending = 0;

        $query = $this->query()
            ->select('id')
            ->withSum('items', 'quantity')
            ->with('items')
            ->whereHas('event.registrations', function ($query) {
                $query->whereNull('completed_at');
            })
            ->where('id', $this->id)
            ->first();

        $pending = is_null($query) ? $this->quantity : $query->items_sum_quantity;

        return Attribute::make(get: fn() => $pending);
    }

    /**

     */
    public function items()
    {
        return $this->morphMany(EventRegistrationItem::class, 'registerable');
    }

    /**
     * @return BelongsTo
     */
    public function event(): BelongsTo
    {
        return $this->belongsTo(Event::class);
    }

    /**
     * @return BelongsTo
     */
    public function sponsorshipType(): BelongsTo
    {
        return $this->belongsTo(EventSponsorshipType::class, 'event_sponsorship_type_id');
    }

    /**
     * @return BelongsTo
     */
    public function category(): BelongsTo
    {
        return $this->belongsTo(BlueBookCategory::class, 'blue_book_category_id');
    }

    /**
     * @return MorphMany
     */
    public function actions(): MorphMany
    {
        return $this->morphMany(Action::class, 'actionable');
    }

    public function registrations()
    {
        return $this->morphToMany(EventRegistration::class, 'registerable', 'event_registration_items')
            ->withPivot('id', 'name', 'cost', 'quantity', 'azure_file_id')
            ->withTimestamps();
    }
}
codegreen's avatar

@Tray2 I have concluded that it is isolated to just testing. Adding an empty DB_PREFIX to the phpunit.xml file solved the issue for me.

<env name="DB_PREFIX" value="" />

A little more context would be that the EventRegistrationItem has a foreign relation to the EventRegistration model. I changed the migration, updated the foreign key to a big integer, left the DB_PREFIX alone, and passed the test. So, I know it is related to foreign key constraints.

One more note: I ran these tests on Laravel 10, and they worked just fine. However, when I switched to Laravel 11, the issue reared its head again.

Tray2's avatar

@codegreen Are you using a dedicated test database, or are you using an sqlite in memory database?

1 like
codegreen's avatar

@Tray2 SQLite in memory. Here is the content of my phpunit.xml file.

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
    bootstrap="vendor/autoload.php"
    colors="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory suffix="Test.php">./tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>
    </testsuites>
    <php>
        <env name="APP_ENV" value="testing" />
        <env name="BCRYPT_ROUNDS" value="4" />
        <env name="CACHE_DRIVER" value="array" />
        <env name="DB_CONNECTION" value="sqlite" />
        <env name="DB_DATABASE" value=":memory:" />
        <env name="DB_PREFIX" value="" />
        <env name="MAIL_MAILER" value="array" />
        <env name="QUEUE_CONNECTION" value="sync" />
        <env name="SESSION_DRIVER" value="array" />
        <env name="TELESCOPE_ENABLED" value="false" />
        <env name="NOTIFICATION_DRIVER" value="database" />
        <env name="BROADCAST_DRIVER" value="null" />
    </php>
</phpunit>
russellxu's avatar

@codegreen My problem is that, when I first run test in an empty database,it won't cause any problem. But if RefreshDatabase or LazilyRefreshDatabase is added in TestCase, the second time running test is gonna cause an error, because it somehow add prefix not only on the table, but also on database name while migrating database. My actual db name is campus_testing, phpunit is trying to migrate tables on db x_campus_campus_testing since my prefix config on app\database.php is set to 'x_campus_'. Still trying to find a solution....

Please or to participate in this conversation.