Laravel custom morph relationship - php

I'd like to use Laravel Eloquent Polymorphic Relationships however it doesn't seem to be setup to work with my table structure.
Essentially I have a gamedata table which includes all the different types of gamedata (nation, league, team, player etc). For each type I have multiple tables with information separated by game_id. So there would be one row for the nation "England" in the gamedata table, which has 7 corresponding rows in the nations table with data from 7 different game_ids.
I'd like to be able to select some rows from the gamedata table and their corresponding rows from the appropriate table depending on it's type.
This is easy enough to do on a one to one relationship, but seems impossible to do with a one to many relationship.
Here is the gamedata table.
CREATE TABLE `gamedata` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`data_type` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `data_id` (`data_id`,`type`),
FULLTEXT KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
And then lots of tables like this (lots of columns removed for ease of reading):
CREATE TABLE `nations` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`game_id` tinyint(3) unsigned NOT NULL,
`gamedata_id` int(10) unsigned NOT NULL,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`short_name` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
/* more specific columns removed for ease of reading */
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `leagues` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`game_id` tinyint(3) unsigned NOT NULL,
`gamedata_id` int(10) unsigned NOT NULL,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`short_name` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
/* more specific columns removed for ease of reading */
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `teams` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`game_id` tinyint(3) unsigned NOT NULL,
`gamedata_id` int(10) unsigned NOT NULL,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`short_name` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
/* more specific columns removed for ease of reading */
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
So some rows on the gamedata table might look like this:
(144, 'nation', 'Some Nation'),
(145, 'nation', 'Another Nation'),
(146, 'league', 'Some League'),
(147, 'league', 'Another League'),
(148, 'team', 'Some Team'),
(149, 'team', 'Another Team');
So I should be able to do a polymorphic relationship from the "data_type" column and the "data_id" column to get the corresponding row from the appropriate table.
But none of the built in relationships (morphTo, morphMany, morphedByMany) etc seem to be able to handle it.
It seems like what I want is the morphTo() relationship but it seems to restrict itself to only returning one related model. All the relationships that accept multiple models require a specific model to be defined.
// This would work fine if I only wanted one related model. "data_type" being the class and "id" corresponding to "gamedata_id" on relevent table.
$this->morphTo('data');
// These require me to be explicit about the class instantiating rather than using from the "data_type" column
$this->morphMany(???, 'data');
$this->morphToMany(???, 'data');
$this->morphedByMany(???, 'data');
Is there a way to do this using the existing Laravel Relationships? Or is there a simple way to create my own relationship class based on morphTo that would suit my needs?

I think i've come up with a custom solution by extending the morphTo class.
namespace App;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* #mixin \Illuminate\Database\Eloquent\Builder
*/
class MorphHasMany extends MorphTo
{
/**
* Get the results of the relationship.
*
* #return mixed
*/
public function getResults()
{
return $this->ownerKey ? $this->query->get() : null;
}
/**
* Match the results for a given type to their parents.
*
* #param string $type
* #param \Illuminate\Database\Eloquent\Collection $results
* #return void
*/
protected function matchToMorphParents($type, Collection $results)
{
$ownerKeyName = $this->ownerKey ?: $results->first()->getKeyName();
foreach ($results->groupBy($ownerKeyName) as $ownerKey => $result) {
if (isset($this->dictionary[$type][$ownerKey])) {
foreach ($this->dictionary[$type][$ownerKey] as $model) {
$model->setRelation($this->relation, $result);
}
}
}
}
}
The morphTo() class already returns a Collection of results, but either uses first() or multiple instances of setRelation() to mean there is only one set. By overloading getResults() and matchToMorphParents() I can modify this behaviour to allow setting of a Collection instead.
In order to define the relationship i'll need a custom morphHasMany() method. This could be added to a base Model.php that extends Eloquent\Model.
/**
* Define a polymorphic has many relationship.
*
* #param string $name
* #param string $type
* #param string $id
* #param string $ownerKey
* #return MorphHasMany
*/
public function morphHasMany($name = null, $type = null, $id = null, $ownerKey = null)
{
// If no name is provided, we will use the backtrace to get the function name
// since that is most likely the name of the polymorphic interface. We can
// use that to get both the class and foreign key that will be utilized.
$name = $name ?: $this->guessBelongsToRelation();
list($type, $id) = $this->getMorphs(
Str::snake($name), $type, $id
);
// If the type value is null it is probably safe to assume we're eager loading
// the relationship. In this case we'll just pass in a dummy query where we
// need to remove any eager loads that may already be defined on a model.
if (empty($class = $this->{$type})) {
return new MorphHasMany($this->newQuery()->setEagerLoads([]), $this, $id, $ownerKey, $type, $name);
} else {
$instance = $this->newRelatedInstance(
static::getActualClassNameForMorph($class)
);
return new MorphHasMany($instance->newQuery(), $this, $id, $ownerKey ?? $instance->getKeyName(), $type, $name);
}
}
Then simply define it just like the usual morphTo() method.
public function data()
{
return $this->morphHasMany();
}
Or in my case:
public function data()
{
return $this->morphHasMany('data', 'data_type', 'id', 'gamedata_id');
}
So far no problems, but of course I may run into some in the future.

Related

Eloquent hasMany doesn't load collection

I'm relatively new to eloquent, and have problems loading data from a hasMany relation in an app that uses eloquent as the database layer. My (simplified) code is as follows:
use Illuminate\Database\Eloquent\Model;
class Answer extends Model{
protected $table = 'answer';
public $timestamps = false;
public $incrementing = false;
protected $fillable = [
"nerdId",
"invitationid",
"eatingno",
"price",
"haspaid",
"attending",
"noeating",
];
public function topping() {
return $this->hasMany(AnswerTopping::class, 'answerid');
}
}
class AnswerTopping extends Model{
protected $table = 'eating';
public $timestamps = false;
protected $fillable = [
'answerid',
'toppingid',
'add'
];
public function answer() {
return $this->belongsTo(Answer::class);
}
}
The SQL Schema is like below
CREATE TABLE `answer` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`nerdid` int(11) NOT NULL,
`invitationid` int(11) NOT NULL,
`eatingno` tinytext,
`price` float NOT NULL,
`haspaid` tinyint(4) NOT NULL,
`attending` tinyint(1) NOT NULL DEFAULT '0',
`noeating` tinyint(4) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2119 DEFAULT CHARSET=utf8;
CREATE TABLE `eating` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`answerId` int(11) NOT NULL,
`toppingid` int(11) NOT NULL,
`add` tinyint(4) NOT NULL DEFAULT '1',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=280 DEFAULT CHARSET=utf8;
I then do the following query:
$answer = Answer::with('topping')->find(32);
return $answer->toJson());
This results in a json like the following
{"id":32,"nerdid":1,"invitationid":54,"eatingno":"51","price":60,"haspaid":1,"attending":2,"noeating":0,"topping":[]}
Raw SQL query shows me that I do have data in the relation, so it should return more in "topping".
UPDATE
Checking sql queries in mysql (Setting it up for logging), I see that it actually do the expected queries on the database:
select * from `answer` where `answer`.`id` = 32 limit 1;
select * from `eating` where `eating`.`answerid` in (32);
Manually executing the SQL are giving me 2 entries in the eating table. But they are not showing up on the upper "Answer" json.
Found the culprit.. db schema for the "eating" table, had answerId (uppercase I), and the relation in hasMany used answerid (lowercase i), which apparently confused eloquent..
Now I get the expected json..

Seeding database with random states in Laravel

I have a Laravel 8 application and I want to be able to seed one of my tables with different states when I execute php artisan db:seed. Here's an example table:
Create Table: CREATE TABLE `notifications` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`message` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`is_read` tinyint(1) NOT NULL DEFAULT '0',
`recipient_role` enum('Manager','Employee') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'Manager',
`recipient_id` bigint(20) unsigned NOT NULL,
`sender_id` bigint(20) unsigned NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `notifications_recipient_id_foreign` (`recipient_id`),
KEY `notifications_sender_id_foreign` (`sender_id`),
CONSTRAINT `notifications_recipient_id_foreign` FOREIGN KEY (`recipient_id`) REFERENCES `users` (`id`),
CONSTRAINT `notifications_sender_id_foreign` FOREIGN KEY (`sender_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
In this particular case, I want to create several Notification instances using Notification::factory where is_read is random set to 1 OR 0. I also would like to make sure that two random User instances are selected (they will be seeded first) for the recipient_id and sender_id. This means I cannot hard code them into my Factory.
In the actual factory I only have message being filled in:
<?php
namespace Database\Factories;
use App\Models\Notification;
use Illuminate\Database\Eloquent\Factories\Factory;
class NotificationFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* #var string
*/
protected $model = Notification::class;
/**
* Define the model's default state.
*
* #return array
*/
public function definition()
{
return [
//
'message' => $this->faker->sentence,
];
}
}
My question is, in the NotificationSeeder.php definition, how do I ensure that I have a random is_read state and two different users assigned to recipient_id and sender_id?
I don't have experience with Laravel 8, but I think it should work:
NotificationFactory:
public function definition()
{
// Get 2 random users IDs
$users = User::inRandomOrder()->take(2)->pluck('id');
return [
'message' => $this->faker->sentence,
'is_read' => rand(0, 1),
'recipient_id' => $users->first(),
'sender_id' => $users->last(),
];
}
If you create the users first, then just get 2 random users. I did 1 call to get 2 users instead of 1 call per user. That returns a collection of users IDs, get the first one for "recipient_id" and the last one to "sender_id".
Use faker's numberBetween function to get a random number between 0-1.
https://github.com/fzaninotto/Faker#fakerproviderbase
<?php
namespace Database\Factories;
use App\Models\Notification;
use Illuminate\Database\Eloquent\Factories\Factory;
class NotificationFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* #var string
*/
protected $model = Notification::class;
/**
* Define the model's default state.
*
* #return array
*/
public function definition()
{
return [
//
'message' => $this->faker->sentence,
'is_read' => $this->faker->numberBetween(0, 1),
];
}
}
Then when you're creating the notifications, first create your user's, then provide them to the make/create method.
// create two users
$first = User::factory()->count(1)->create();
$second = User::factory()->count(1)->create();
// override the default notification attributes with the user's id
$notification = Notification::factory()->count(1)->create([
'recipient_id' => $first->id,
'sender_id' => $second->id,
]);

Lumen Eloquent save() don't update data at database

I have buyer model, but when i want to change current it's data and save it, it won't save. $buyer->save() return 1 but data at my database won't change.
P.S.
My database has id, created_at and updated_at fields. And $buyer is not empty, it's object with data that i request ('pCode', 'banned', 'hwid')
My code
namespace App\Http\Controllers;
use App\TGOBuyer as Buyer;
class SubController extends Controller
{
public function ban($key){
$buyer = Buyer::where('pCode', $key)->select('pCode', 'banned', 'hwid')->first();
if (!$buyer || $buyer->banned)
return response()->json(['wrong' => 'key']);
$buyer->comment = 'test';
$buyer->banned = 1;
$buyer->save();
}
}
Buyer model
namespace App;
use Illuminate\Database\Eloquent\Model;
class TGOBuyer extends Model {
protected $table = 'buyers';
/**
* The attributes that are mass assignable.
*
* #var array
*/
protected $fillable = [
'banned', 'comment', 'pCode', 'hwid'
];
}
Update
I tryed to return $buyer->id and it gives me null, i don't get it why it happens.
This is my db
CREATE TABLE IF NOT EXISTS `buyers` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`pCode` varchar(20) NOT NULL,
`banned` tinyint(1) NOT NULL DEFAULT '0',
`comment` text NOT NULL,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `pCode` (`pCode`),
UNIQUE KEY `id_2` (`id`),
KEY `id` (`id`),
KEY `hwid` (`hwid`),
KEY `pID` (`pID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=22468 ;
I get it. I just need to include id to my select query. Now all work's fine.
$buyer = Buyer::where('pCode', $key)->select('id', 'pCode', 'banned', 'hwid')->first();

OOP classes and external lists of data

When programming OO in PHP i never know exactly how to map a class to simple lists of data. I will try to make a simple example wich i am running into every day:
First the MySQL table creates:
/* create product table */
CREATE TABLE `product` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) COLLATE utf8_unicode_ci NOT NULL,
`description` text COLLATE utf8_unicode_ci,
`price` decimal(10,0) NOT NULL,
`brand` int(11) NOT NULL,
`deliverytime` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci
/* data list for all kind of brands */
CREATE TABLE `brand` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`brand` varchar(100) COLLATE utf8_unicode_ci NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci
/* brand data*/
insert into `brand`(`id`,`brand`) values (1,'nike'),(2,'adidas'),(3,'diesel'), (4,'dkny'),(5,'lacoste');
/* data list for deliverytime */
CREATE TABLE `deliverytime` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`deliverytime` varchar(100) COLLATE utf8_unicode_ci NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci
/* deliverytime data */
insert into `deliverytime`(`id`,`deliverytime`) values (1,'1 day'),(2,'2 days'),(3,'3 days'),(4,'4 days'),(5,'5 days'),(6,'6 days'),(7,'1 week'),(8,'1 - 2 weeks'),(9,'2 - 3 weeks'),(10,'4 - 5 weeks');
Then i create the product class.
class Product{
private
$name,
$description,
$price,
$brand
$deliverytime;
public function __construct(){
// etc etc
}
public function save(){
// save the product
}
}
Now the big question(s) are:
How should i handle $brand and $deliverytime in my Product class?
Should i make a Brand and DeliveryTime object (wich in turn are responsible for fetching the right brand and or deliverytime data)?
And what about saving the Product object?
How should i handle the brand and deliverytime data?
What is the beste practice or pattern to handle this kind of situations?
Sorry for this noobish question but i wasnt sure where to look for (tags to search for) :/
EDIT:
Ok lets say i dont want to use somekind of ORM framework (Doctrine, dORM, redbean etc) since it would be a gigantic overkill for my little system + i am realy want to know how to create the mapping myself for learning purposes... any suggestions?
This is a style I like to use
class ProductModel {
public function find($id) {
// fetch product with your database code using parameters to filter
$product = new ProductEntity(
// initialize with non foreighn values (eg. $row->id, $row->name)
new ProductBrandEntity($row->brand_id, $row->brand_name); // available because you joined brand??
new ProductDeliveryTime(/* initialize */)
);
return $product;
}
}
I like to call the object from the database Entities but you can call them whatever you want.. It's basically what you suggested in your question but i prefer to have a Model (from MVC) to initialize the entities and not the entities initializing themselves. You should do some research on ORMs and ActiveRecord because this work has basically already been done for you!

How do I configure my Yii model for relational queries?

A bit new to yii and have been having trouble trying to do a join query in my gii-generated model.
Summary:
I want to return videos (table 'videos') that have met specific search criteria. To do this, I have my 'videos' table, and I have another table 'searchmaps'. All searchmaps does is associate a video_id to a search_id so that I can keep track of multiple videos that met criteria for a single search scenario..
What I've tried:
I tried following yii docs for relational queries but I guess I'm missing something still... Below is my code. What am I doing wrong??
(Note: I wish to return a model using CActiveDataProvider)
Tables:
CREATE TABLE IF NOT EXISTS `videos` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`directory` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`created` varchar(20) COLLATE utf8_unicode_ci NOT NULL,
`title` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`description` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`category` int(2) NOT NULL,
`tags` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`filename` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`filetype` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`duration` int(11) NOT NULL,
`status` int(1) NOT NULL,
`error` text COLLATE utf8_unicode_ci NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=17 ;
CREATE TABLE IF NOT EXISTS `searchmaps` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`search_id` int(11) NOT NULL,
`video_id` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=69 ;
Classes:
Here is the Controller class:
//From VideosController.php
...
public function actionIndex($searchmap_id)
{
$dataProvider = new CActiveDataProvider('SearchVideos', array(
'criteria' => array(
'with' => array('search.video_id','search.search_id'),
'together' => true,
'condition'=>'videos.id = search.video_id AND search.search_id='.$searchmap_id,
)));
$this->render('index',array(
'dataProvider'=>$dataProvider,
));
}
Below is the main model class:
// From Videos.php
...
/**
* #return array relational rules.
*/
public function relations()
{
// NOTE: you may need to adjust the relation name and the related
// class name for the relations automatically generated below.
return array(
'search'=>array(self::BELONGS_TO, 'Searchmaps', 'video_id'),
);
}
Here is the model class of the related table
// From Searchmaps.php
...
/**
* #return array relational rules.
*/
public function relations()
{
// Each row has a search_id and a video_id relating to a specific video
// Multiple rows may have different videos but share the same search_id
return array(
'video'=>array(self::HAS_ONE, 'Videos', 'video_id'),
);
}
First, I would suggest using InnoDB tables so you can set up proper foreign keys -- if you do this then gii will generate the basic relations for you. If you can convert your tables, then you can add the fk with:
ALTER TABLE `searchmaps`
ADD CONSTRAINT `searchmaps_ibfk_1` FOREIGN KEY (`video_id`) REFERENCES `videos` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
Your relations don't look quite right, seems like they should be:
in Videos model:
return array(
'searchmaps' => array(self::HAS_MANY, 'Searchmaps', 'video_id'),
);
in Searchmaps model:
return array(
'video' => array(self::BELONGS_TO, 'Videos', 'video_id'),
);
then your dataProvider can look something like:
$dataProvider=new CActiveDataProvider('Videos',array(
'criteria'=>array(
'with'=>'searchmaps',
'together' => true,
'condition' => 'searchmaps.search_id='.$search_id,
)
));
to try it you can output a simple grid in your view with something like:
$this->widget('zii.widgets.grid.CGridView', array(
'id'=>'videos-grid',
'dataProvider'=>$dataProvider
));
Again, I would highly recommend using foreign keys in your table and view the relations gii outputs and once you understand what it's doing, it will be much easier to customize. Also, using foreign keys will insure the relationships are maintained. You can use a tool like MysqlWorkbench or similar if you need help creating the foreign keys.

Categories