laravel-shift / blueprint

A code generation tool for Laravel developers.
MIT License
2.8k stars 269 forks source link

Explicit foreign not explicit #685

Closed misog closed 2 weeks ago

misog commented 3 months ago

Issue:

Explicit foreign: does not work. It tries to be smart when it should not be smart.

models:
  Offer:
    uuid: uuid
    text: text
    user_id: id foreign:user.id // explicit table and column

This generates this schema:

Schema::create('offers', function (Blueprint $table) {
    $table->id();
    $table->uuid('uuid');
    $table->text('text');
    $table->foreignId('user_id')->constrained(); // this is wrong, should be constrained('user', 'id')
    $table->timestamps();
});

This will try to create constrained foreign key to non-existing table users. It should create constrained foreign key to table user instead. Specifying protected $table = 'user'; in User.php has no effect on the behaviour.

Another issue:

models:
  Offer:
    uuid: uuid
    text: text
    user_id: id foreign:persons.id // explicit table and column

This generates this schema:

Schema::create('offers', function (Blueprint $table) {
    $table->id();
    $table->uuid('uuid');
    $table->text('text');
    $table->foreignId('user_id')->constrained('persons'); // this is wrong, should be constrained('persons', 'id');
    $table->timestamps();
});

While it is true that constrained('persons'); has the same effect on database as constrained('persons', 'id'); today - Shift should not deal with effects on database at all. It should act as translation layer between .yaml file and Laravel migration specs. One would want to set explicit column in migration files for various reasons. For example, there are migration text analyzers which do not deal with Laravel magic conventions, they deal with explicit options.

So, it tries to be smart when it should not be smart. It should be just dumb when explicit parameters are specified.

jasonmccreary commented 3 months ago

Thanks. Feel free to open a PR. Otherwise, I'll look into this in the coming weeks.

AaronLil commented 3 weeks ago

Another bug that seems to be related to this: Blueprint can't handle well when a model has more than one relationship to the same table.

My YAML file:

models:
  Client:
    name: string
    birth_date: datetime nullable
    email: string nullable
    mobile_phone: string,45 index
    other_phone: string,45 nullable
    cpf: string,20 nullable index
    rg: string,20 nullable
    facebook: string nullable
    instagram: string nullable
  Contact:
    name: string
    email: string
    mobile_phone: string,45
    client_id: id nullable
  Quote:
    quoted_value: string
    description: text
    date: datetime
    client_id: id nullable
    user_id_professional: id foreign:users.id nullable
    user_id_attendant: id foreign:users.id nullable
    user_id_creator: id foreign:users.id
  Service:
    price: string
    description: text
    date: datetime
    client_id: id
    quote_id: id nullable
    user_id_professional: id foreign:users.id
    user_id_attendant: id foreign:users.id nullable
    user_id_creator: id foreign:users.id

and it builds the model's methods with the same name, ignoring the Laravel naming conventions. Generated Model Quote.php:

public function client(): BelongsTo
{
    return $this->belongsTo(Client::class);
}

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

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

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

and the generated migration file for the model quote:

Schema::create('quotes', function (Blueprint $table) {
    $table->id();
    $table->string('quoted_value');
    $table->text('description');
    $table->dateTime('date');
    $table->foreignId('client_id')->nullable();
    $table->foreignId('user_id_professional')->nullable()->constrained('users');
    $table->foreignId('user_id_attendant')->nullable()->constrained('users');
    $table->foreignId('user_id_creator')->constrained('users');
    $table->timestamps();
});

I have been able to fix the method names locally, but I believe that this issue needs to be fixed first; otherwise, my PR will possibly cause some strange behavior.

jasonmccreary commented 3 weeks ago

Sorry, I'm not following. You referenced a PR or something than needs to be fixed first? Can you clarify what that is, or, ideally, provide a link?

AaronLil commented 3 weeks ago

Sorry, I'm not following. Did you reference a PR or something that needs to be fixed first? Can you clarify what that is, or, ideally, provide a link?

I was willing to fix the problem I mentioned and send a PR. So I cloned the repository and made a small correction (I haven't sent any commits yet). However, I believe that my changes will cause more problems than solutions. Anyway, I can do it and you can evaluate whether it is a valid change or not, or maybe send you the modified piece of code right here. I'm happy to help, but I've just started using the tool, and I think I need to understand a little more about how it works before making any changes to its core.

jasonmccreary commented 3 weeks ago

No worries. This is not something I have encountered. That's not to say it's not an issue. More that it might not fall under common use and as such something I welcome a PR for.

AaronLil commented 2 weeks ago

I decided to dive into the code and address the issues mentioned in this thread. I've just submitted a pull request with the changes and fixes I made, and I hope they will be helpful for the project.

A Few Important Notes: I believe that when we specify something in the YAML file, the tool should follow exactly what we want to do. However, it’s important to note that Blueprint is a Laravel package and should adhere to the framework's development standards.

In one of the examples mentioned above, it was suggested that the tool should add the relationship column in the migration even when it's the default 'id'. I respectfully disagree with this statement. One thing every Laravel developer can agree on is that the framework is here to facilitate and speed up development. Therefore, in the changes I made, the tool will only add the foreign key column information in the migration if it’s specified in the YAML file with dot notation, different from 'id', or outside of Laravel's naming conventions.

I also made it so the Lexer file internally creates an array specifically for relationships and foreign keys with all the specifications we add in the YAML file. This will ensure the tool follows exactly what we want without trying to guess things.

I am available to clarify any doubts and would like to reiterate what I said in another message: I don’t know the development of the tool in depth, so bugs may still occur, and if they do, you can count on me to help with whatever is needed.

Greetings from Brazil, Aaron

vr4content commented 2 weeks ago

In your case, for me: $table->foreignId('user_id')->constrained(); // this is wrong, should be constrained('user', 'id')

It looks right for me. There is laravel magic behind.

https://laravel.com/docs/11.x/migrations#foreign-key-constraints

Look at the second example:

Schema::table('posts', function (Blueprint $table) {
    $table->foreignId('user_id')->constrained();
});

"foreignId" is not the same as the old "foreign", is taking care of the id and the table name

What's the final result of the table?

AaronLil commented 2 weeks ago
models:
  Offer:
    uuid: uuid
    text: text
    user_id: id foreign:user.id // explicit table and column

It's not actually ok because the yaml file is specifying that the relationship table is 'user' and not 'users' (or persons, in the second example). By omitting the table in the migration file the Laravel standard comes into action and the relationship constraint will be made based on the column name (user_id = table users)

jasonmccreary commented 2 weeks ago

This issue has become a bit mixed. What are we trying to solve here? I think maybe @AaronLil needs to open a separate issue and @misog should restate theirs.

Please follow the template with a concise description of the issue and the expect YAML and expected output.

AaronLil commented 2 weeks ago

jasonmccreary I don't think it's necessary. @misog reported that when he tried to use explicit foreign relationships, the blueprint just ignored what he put in the yaml file. This is probably because the tool automatically converts the table name to singular or plural when necessary to generate models or migrations. And he was right in thinking that this is a bug, because if we want the blueprint to create the relationship using the laravel standard, we just need to add the id and foreign without others modifiers and let all the magic happen. But since we're talking about the case where a table and/or id is specified, the correct thing to do is to generate the files with what we put in, and not try to guess what we meant.

If, for example, we specify user_id: id foreign:user.primary_key (not users), the correct thing to do is to generate migrations and models with a relationship to the user table, and if it's wrong, just correct the declaration in the file and generate it again.

The main point of the fix I sent with the PR was what @misog reported. The rest of the fixes were just other bugs that are somehow related. After the merge, when declaring country_id: id foreign:country.primary the blueprint will create the table relationship to the country.primary table and not to countries.primary. And if it is country_id: id foreign it will be the countries.id, as the laravel standard dictates.

jasonmccreary commented 2 weeks ago

Yes, I think it is correct that Blueprint takes the dot syntax and converts it to a model. Blueprint is all about conventions, so I would not consider this a "bug". However, I do think if the dot syntax is used, more information could be extracted and used.

For example:

If someone wants to ensure those are implemented in a PR (with tests and preserving current Model reference behavior) I am willing to merge it. Otherwise, I do not intend to work on this issue based on Blueprint's adherence to Laravel conventions.

misog commented 2 weeks ago

@jasonmccreary could you write blueprint line which will create foreign key with table "user" and column "id"?

AaronLil commented 2 weeks ago

@jasonmccreary I think we're missing the point here. The proposed change is not changing the standard for Models! Blueprint will continue to follow Laravel's naming standards (singular for classes, method names and relationships, etc.). The main change is in migrations, and at the end of the day that's what matters: creating an intermediate layer between the database structure and the development framework.

I strongly believe that we still need this change because many times it's not possible to use the Laravel standard, especially when we're creating a new application on top of an existing structure/application (which is my case). In these cases, Blueprint should be able to configure models and migrations with any definition that was placed in the yaml file. Maybe a smart option would be to use the $table property in the models, I don't know: https://laravel.com/docs/11.x/eloquent#table-names - "If your model's corresponding database table does not fit this convention, you may manually specify the model's table name by defining a table property on the model".

For now, the issue still exists. The user is specifying a table and the blueprint is simply ignoring this and creating the files pointing to the table it thinks is correct. So let's go to another example of how the blueprint works today without any changes/fixes:

models:
  Client:
    name: string
    birth_date: datetime nullable
    email: string nullable
    mobile_phone: string,45 index
    other_phone: string,45 nullable
    cpf: string,20 nullable index
    rg: string,20 nullable
    facebook: string nullable
    instagram: string nullable
    relationships:
      hasMany: Proposal, Service, Payment
  Contact:
    name: string
    email: string
    mobile_phone: string,45
    client_id: id foreign nullable
  Proposal:
    price: string
    description: text
    date: datetime
    client_id: id foreign nullable
    user_id_professional: id foreign:usuarios.primary nullable
    user_id_attendant: id foreign:usuarios.id nullable
    user_id_creator: id foreign:usuarios
  Service:
    price: string
    description: text
    date: datetime
    client_id: id foreign
    proposal_id: id foreign nullable
    user_id_professional: id foreign:users
    user_id_attendant: id foreign:users nullable
    user_id_creator: id foreign:users
    relationships:
      hasMany: Payment
  Payment:
    price: decimal
    date: dateTime
    description: text
    service_id: id foreign
    client_id: id foreign
    user_id: id foreign

In the proposal model we are specifying that the relationship will be made in the 'usuarios' table (users in Portuguese).

I'm omitting some files, but some generated files that clearly have issues:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Proposal extends Model
{
    use HasFactory;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'price',
        'description',
        'date',
        'client_id',
        'user_id_professional',
        'user_id_attendant',
        'user_id_creator',
    ];

    /**
     * The attributes that should be cast to native types.
     *
     * @var array
     */
    protected $casts = [
        'id' => 'integer',
        'date' => 'datetime',
        'client_id' => 'integer',
        'user_id_professional' => 'integer',
        'user_id_attendant' => 'integer',
        'user_id_creator' => 'integer',
    ];

    public function client(): BelongsTo
    {
        return $this->belongsTo(Client::class);
    }

    public function usuario(): BelongsTo
    {
        return $this->belongsTo(Usuario::class, 'user_id_professional', 'primary');
    }

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

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

Errors: 2 methods with the same name (user); there is no table or column information that the method is related to; created the correct method only when specifying a non-standard laravel id;

<?php

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::disableForeignKeyConstraints();

        Schema::create('proposals', function (Blueprint $table) {
            $table->id();
            $table->string('price');
            $table->text('description');
            $table->dateTime('date');
            $table->foreignId('client_id')->nullable()->constrained();
            $table->foreignId('user_id_professional')->nullable()->constrained('usuarios', 'primary');
            $table->foreignId('user_id_attendant')->nullable()->constrained('usuarios');
            $table->foreignId('user_id_creator')->constrained('usuarios', 'creator');
            $table->timestamps();
        });

        Schema::enableForeignKeyConstraints();
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('proposals');
    }
};

Errors: Totally delusional about the relationship column for user_id_creator, and since it was not specified in the yaml file, the correct one should just be 'id'; Omitted the 'id' for user_id_attendant (which is okay in my opinion);

Anyway, you are the maintainer of the project and the final word is yours, but the fixes are already done and ready to merge. In fact, all that's left is to update the tests so that the PR can pass the build process, which I honestly don't know much about.