How to setup a HasAndBelongsToMany association with the same table? - php
What I want
i want to setup a table tasks table with a manyToMany relation between the tasks. One task should have many successors and one can have many predecessors.
My tasks table also has a foreign key to itself, which identifies superior tasks in a oneToMany relation.
The tables look like the following:
tasks(id, superior_task_id, [other attributes])
tasks_predecessors(task_id, pre_id)
Where tasks.id is the primary key, tasks.superior_task_id is a foreign key contraint to tasks and tasks_predecessors.task_id with tasks_predecessors.pre_id is the primary key of tasks_predecesssors, both attributes of tasks_predecessors are foreign keys of tasks.
My status quo
I have tried to use the standard way of creating models from tables: bake in the command line. And used this as my starting point. With this, nothing happend. It was like the tasks_predecessors table didn't exist. The files TasksPredecessors.php and TasksPredecessorsTable.php were created and were fitted with the "belongsTo" associations to the tasks table. In the tasks table, there were one Predecessors association to correct table. With the standard templates and controllers, the data were not accessible.
Now I did some changes to try to set it up as a normal manyToMany relation with both "sides" in one table.
My Code in TasksTable.php:
<?php
declare(strict_types=1);
namespace App\Model\Table;
use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;
/**
* Tasks Model
*
* #property \App\Model\Table\ProjectsTable&\Cake\ORM\Association\BelongsTo $Projects
* #property \App\Model\Table\CompaniesTable&\Cake\ORM\Association\BelongsTo $Companies
* #property \App\Model\Table\ToolsTable&\Cake\ORM\Association\BelongsToMany $Tools
*
* #method \App\Model\Entity\Task newEmptyEntity()
* #method \App\Model\Entity\Task newEntity(array $data, array $options = [])
* #method \App\Model\Entity\Task[] newEntities(array $data, array $options = [])
* #method \App\Model\Entity\Task get($primaryKey, $options = [])
* #method \App\Model\Entity\Task findOrCreate($search, ?callable $callback = null, $options = [])
* #method \App\Model\Entity\Task patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
* #method \App\Model\Entity\Task[] patchEntities(iterable $entities, array $data, array $options = [])
* #method \App\Model\Entity\Task|false save(\Cake\Datasource\EntityInterface $entity, $options = [])
* #method \App\Model\Entity\Task saveOrFail(\Cake\Datasource\EntityInterface $entity, $options = [])
* #method \App\Model\Entity\Task[]|\Cake\Datasource\ResultSetInterface|false saveMany(iterable $entities, $options = [])
* #method \App\Model\Entity\Task[]|\Cake\Datasource\ResultSetInterface saveManyOrFail(iterable $entities, $options = [])
* #method \App\Model\Entity\Task[]|\Cake\Datasource\ResultSetInterface|false deleteMany(iterable $entities, $options = [])
* #method \App\Model\Entity\Task[]|\Cake\Datasource\ResultSetInterface deleteManyOrFail(iterable $entities, $options = [])
*
* #mixin \Cake\ORM\Behavior\TimestampBehavior
*/
class TasksTable extends Table
{
/**
* Initialize method
*
* #param array $config The configuration for the Table.
* #return void
*/
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable('tasks');
$this->setDisplayField('name');
$this->setPrimaryKey('id');
$this->addBehavior('Timestamp');
$this->belongsTo('UsersCreator', [
'foreignKey' => 'creator_id',
'className' => 'Users'
]);
$this->belongsTo('TasksSup', [
'foreignKey' => 'superior_task_id',
'className' => 'Tasks'
]);
$this->belongsTo('Projects', [
'foreignKey' => 'project_id',
'joinType' => 'INNER',
]);
$this->belongsTo('Companies', [
'foreignKey' => 'company_id',
'joinType' => 'INNER',
]);
$this->hasMany('Predecessors', [
'foreignKey' => 'task_id',
'className' => 'Tasks',
'targetForeignKey' => 'pre_id',
'joinTable' => 'tasks_predecessors'
]);
$this->belongsToMany('Successors', [
'foreignKey' => 'pre_id',
'className' => 'Tasks',
'targetForeignKey' => 'task_id',
'joinTable' => 'tasks_predecessors'
]);
$this->belongsToMany('Tools', [
'foreignKey' => 'task_id',
'targetForeignKey' => 'tool_id',
'joinTable' => 'tasks_tools',
]);
$this->belongsToMany('UsersAssigned', [
'foreignKey' => 'task_id',
'targetForeignKey' => 'user_id',
'joinTable' => 'tasks_users',
'className' => 'Users'
]);
}
/**
* Default validation rules.
*
* #param \Cake\Validation\Validator $validator Validator instance.
* #return \Cake\Validation\Validator
*/
public function validationDefault(Validator $validator): Validator
{
$validator
->nonNegativeInteger('id')
->allowEmptyString('id', null, 'create');
$validator
->scalar('name')
->maxLength('name', 128)
->requirePresence('name', 'create')
->notEmptyString('name');
$validator
->dateTime('start_at')
->allowEmptyDateTime('start_at');
$validator
->dateTime('end_at')
->allowEmptyDateTime('end_at');
$validator
->allowEmptyString('restricted');
$validator
->integer('status')
->allowEmptyString('status');
return $validator;
}
/**
* Returns a rules checker object that will be used for validating
* application integrity.
*
* #param \Cake\ORM\RulesChecker $rules The rules object to be modified.
* #return \Cake\ORM\RulesChecker
*/
public function buildRules(RulesChecker $rules): RulesChecker
{
$rules->add($rules->existsIn('creator_id', 'UsersCreator'), ['errorField' => 'creator_id']);
$rules->add($rules->existsIn('superior_task_id', 'TasksSup'), ['errorField' => 'superior_task_id']);
$rules->add($rules->existsIn('project_id', 'Projects'), ['errorField' => 'project_id']);
$rules->add($rules->existsIn('company_id', 'Companies'), ['errorField' => 'company_id']);
return $rules;
}
}
My code in TasksPredecessorsTable.php:
<?php
declare(strict_types=1);
namespace App\Model\Table;
use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;
/**
* TasksPredecessors Model
*
* #property \App\Model\Table\TasksTable&\Cake\ORM\Association\BelongsTo $Tasks
*
* #method \App\Model\Entity\TasksPredecessor newEmptyEntity()
* #method \App\Model\Entity\TasksPredecessor newEntity(array $data, array $options = [])
* #method \App\Model\Entity\TasksPredecessor[] newEntities(array $data, array $options = [])
* #method \App\Model\Entity\TasksPredecessor get($primaryKey, $options = [])
* #method \App\Model\Entity\TasksPredecessor findOrCreate($search, ?callable $callback = null, $options = [])
* #method \App\Model\Entity\TasksPredecessor patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
* #method \App\Model\Entity\TasksPredecessor[] patchEntities(iterable $entities, array $data, array $options = [])
* #method \App\Model\Entity\TasksPredecessor|false save(\Cake\Datasource\EntityInterface $entity, $options = [])
* #method \App\Model\Entity\TasksPredecessor saveOrFail(\Cake\Datasource\EntityInterface $entity, $options = [])
* #method \App\Model\Entity\TasksPredecessor[]|\Cake\Datasource\ResultSetInterface|false saveMany(iterable $entities, $options = [])
* #method \App\Model\Entity\TasksPredecessor[]|\Cake\Datasource\ResultSetInterface saveManyOrFail(iterable $entities, $options = [])
* #method \App\Model\Entity\TasksPredecessor[]|\Cake\Datasource\ResultSetInterface|false deleteMany(iterable $entities, $options = [])
* #method \App\Model\Entity\TasksPredecessor[]|\Cake\Datasource\ResultSetInterface deleteManyOrFail(iterable $entities, $options = [])
*/
class TasksPredecessorsTable extends Table
{
/**
* Initialize method
*
* #param array $config The configuration for the Table.
* #return void
*/
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable('tasks_predecessors');
$this->setDisplayField(['task_id', 'pre_id']);
$this->setPrimaryKey(['task_id', 'pre_id']);
$this->belongsToMany('TasksSuc', [
'foreignKey' => 'task_id',
'joinType' => 'INNER',
'className' => 'Tasks'
]);
$this->belongsToMany('TasksPre', [
'foreignKey' => 'pre_id',
'joinType' => 'INNER',
'className' => 'Tasks'
]);
}
/**
* Returns a rules checker object that will be used for validating
* application integrity.
*
* #param \Cake\ORM\RulesChecker $rules The rules object to be modified.
* #return \Cake\ORM\RulesChecker
*/
public function buildRules(RulesChecker $rules): RulesChecker
{
$rules->add($rules->existsIn('task_id', 'TasksSuc'), ['errorField' => 'task_id']);
$rules->add($rules->existsIn('pre_id', 'TasksPre'), ['errorField' => 'pre_id']);
return $rules;
}
}
The $accessible array of tasks looks like the following:
protected $_accessible = [
'name' => true,
'start_at' => true,
'end_at' => true,
'creator_id' => true,
'created' => true,
'modified' => true,
'superior_task_id' => true,
'restricted' => true,
'project_id' => true,
'company_id' => true,
'status' => true,
'users_creator' => true,
'tasks_sup' => true,
'project' => true,
'company' => true,
'predecessors' => true,
'successors' => true,
'tools' => true,
'users_assigned' => true,
];
And in TasksPredecessors.php like this:
protected $_accessible = [
'tasks_suc' => true,
'tasks_pre' => true,
];
I access the data in the tasks_controller like this:
public function view($id = null)
{
$task = $this->Tasks->get($id, [
'contain' => ['UsersCreator', 'TasksSup', 'Projects', 'Companies', 'Successors', 'Tools', 'UsersAssigned', 'Predecessors'],
]);
$this->set(compact('task'));
}
public function add()
{
$task = $this->Tasks->newEmptyEntity();
if ($this->request->is('post')) {
$task = $this->Tasks->patchEntity($task, $this->request->getData(), [
'associated' => ['Tools._joinData'], //adding quantity of tools
]);
if ($this->Tasks->save($task)) {
$this->Flash->success(__('The task has been saved.'));
return $this->redirect(['action' => 'index']);
}
$this->Flash->error(__('The task could not be saved. Please, try again.'));
}
$usersCreator = $this->Tasks->UsersCreator->find('list', ['limit' => 200])->all();
$tasksSup = $this->Tasks->TasksSup->find('list', ['limit' => 200])->all();
$projects = $this->Tasks->Projects->find('list', ['limit' => 200])->all();
$companies = $this->Tasks->Companies->find('list', ['limit' => 200])->all();
$successors = $this->Tasks->Successors->find('list', ['limit' => 200])->all();
$tools = $this->Tasks->Tools->find('list', ['limit' => 200])->all();
$usersAssigned = $this->Tasks->UsersAssigned->find('list', ['limit' => 200])->all();
$this->set(compact('task', 'usersCreator', 'tasksSup', 'projects', 'companies', 'successors', 'tools', 'usersAssigned'));
}
public function edit($id = null)
{
$task = $this->Tasks->get($id, [
'contain' => ['Successors', 'Tools', 'UsersAssigned'],
]);
if ($this->request->is(['patch', 'post', 'put'])) {
$task = $this->Tasks->patchEntity($task, $this->request->getData());
if ($this->Tasks->save($task)) {
$this->Flash->success(__('The task has been saved.'));
return $this->redirect(['action' => 'index']);
}
$this->Flash->error(__('The task could not be saved. Please, try again.'));
}
$usersCreator = $this->Tasks->UsersCreator->find('list', ['limit' => 200])->all();
$tasksSup = $this->Tasks->TasksSup->find('list', ['limit' => 200])->all();
$projects = $this->Tasks->Projects->find('list', ['limit' => 200])->all();
$companies = $this->Tasks->Companies->find('list', ['limit' => 200])->all();
$successors = $this->Tasks->Successors->find('list', ['limit' => 200])->all();
$tools = $this->Tasks->Tools->find('list', ['limit' => 200])->all();
$usersAssigned = $this->Tasks->UsersAssigned->find('list', ['limit' => 200])->all();
$this->set(compact('task', 'usersCreator', 'tasksSup', 'projects', 'companies', 'successors', 'tools', 'usersAssigned'));
}
These are the functions created by the bake command.
My Errors
If I add a task via /tasks/add, everything seems to work properly, until I look it up in the tasks_predecessors table. It still doesn't contain any entry.
If I now try to view one task via /tasks/view/1, the error SQLSTATE[42S22]: Column not found: 1054 Unknown column 'Predecessors.task_id' in 'where clause' comes up, with the corresponding SQL query:
SELECT Predecessors.id AS Predecessors__id, Predecessors.name AS Predecessors__name, Predecessors.start_at AS Predecessors__start_at, Predecessors.end_at AS Predecessors__end_at, Predecessors.creator_id AS Predecessors__creator_id, Predecessors.created AS Predecessors__created, Predecessors.modified AS Predecessors__modified, Predecessors.superior_task_id AS Predecessors__superior_task_id, Predecessors.restricted AS Predecessors__restricted, Predecessors.project_id AS Predecessors__project_id, Predecessors.company_id AS Predecessors__company_id, Predecessors.status AS Predecessors__status FROM tasks Predecessors WHERE Predecessors.task_id in (:c0)
If I try to edit the task, an other error occurs:
Warning (2): array_combine(): Both parameters should have an equal number of elements [CORE\src\ORM\Rule\ExistsIn.php, line 143]
Warning (512): Unable to emit headers. Headers sent in file=C:\xampp\htdocs\Handwerker_Projektmanagement_IP2021\vendor\cakephp\cakephp\src\Error\Debugger.php line=988 [CORE\src\Http\ResponseEmitter.php, line 71]
Warning (2): Cannot modify header information - headers already sent by (output started at C:\xampp\htdocs\Handwerker_Projektmanagement_IP2021\vendor\cakephp\cakephp\src\Error\Debugger.php:988) [CORE\src\Http\ResponseEmitter.php, line 168]
Warning (2): Cannot modify header information - headers already sent by (output started at C:\xampp\htdocs\Handwerker_Projektmanagement_IP2021\vendor\cakephp\cakephp\src\Error\Debugger.php:988) [CORE\src\Http\ResponseEmitter.php, line 197]
Warning (2): Cannot modify header information - headers already sent by (output started at C:\xampp\htdocs\Handwerker_Projektmanagement_IP2021\vendor\cakephp\cakephp\src\Error\Debugger.php:988) [CORE\src\Http\ResponseEmitter.php, line 197]
with
SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'LIMIT 1' at line 1
and the SQL query: SELECT 1 AS existing FROM tasks TasksSuc WHERE LIMIT 1
Conclusion
I can't make an sense of these errors. If I try different things in the controller or ...Table.php files, I will be surprised with different kind of errors, which all don't make sense to me. I hope someone can help me quick, because I could't find anything about this type of relation, which in my opinion is not that uncommon.
Related
Laravel 9 query builder where clause has empty model id
I'm working on a Laravel 9 project and have created a custom validation rule called ValidModelOwnership which should check that the field a user is trying to add is owned by a model based on some values passed to it. I've written the rule, but when debugging and outputting $model->toSql() the id is empty? What am I missing? My rule: <?php namespace App\Rules; use Illuminate\Contracts\Validation\Rule; use Illuminate\Support\Facades\Log; class ValidModelOwnership implements Rule { /** * The model we're checking */ protected $model; /** * Array of ownership keys */ protected $ownershipKeys; /** * Create a new rule instance. * * #return void */ public function __construct($model, $ownershipKeys) { $this->model = $model; $this->ownershipKeys = $ownershipKeys; } /** * Determine if the validation rule passes. * * #param string $attribute * #param mixed $value * #return bool */ public function passes($attribute, $value) { $model = $this->model::query(); $model = $model->where($this->ownershipKeys); Log::debug($model->toSql()); if (!$model->exists()) { return false; } return true; } /** * Get the validation error message. * * #return string */ public function message() { return "The :attribute field doesn't belong to you and/or your company."; } } And my usage in my controller: /** * Store a newly created resource in storage. * * #param \Illuminate\Http\Request $request * #return \Illuminate\Http\Response */ public function store($company_id, $buyer_id, Request $request) { $this->authorize('create', BuyerTier::class); $validator = Validator::make($request->all(), [ 'name' => [ 'required', 'string', Rule::unique(BuyerTier::class) ->where('buyer_id', $buyer_id) ->where('company_id', $company_id) ], 'country_id' => [ 'required', 'numeric', new ValidModelOwnership(Country::class, [ ['company_id', 80] ]) ], 'product_id' => [ 'required', 'numeric', new ValidModelOwnership(Product::class, [ ['company_id', 80] ]) ], 'processing_class' => 'required|string', 'is_default' => [ 'required', 'boolean', new ValidDefaultModel(BuyerTier::class, $buyer_id) ], 'is_enabled' => 'required|boolean' ]); if ($validator->fails()) { return response()->json([ 'message' => 'One or more fields has been missed or is invalid.', 'errors' => $validator->messages(), ], 400); } try { $tier = new BuyerTier; $tier->user_id = Auth::id(); $tier->company_id = $company_id; $tier->buyer_id = $buyer_id; $tier->country_id = $request->input('country_id'); $tier->product_id = $request->input('product_id'); $tier->name = trim($request->input('name')); $tier->description = $request->input('description') ?? null; $tier->processing_class = $request->input('processing_class'); $tier->is_default = $request->boolean('is_default'); $tier->is_enabled = $request->boolean('is_enabled'); $tier->save(); return response()->json([ 'message' => 'Buyer tier has been created successfully', 'tier' => $tier ], 201); } catch (\Exception $e) { return response()->json([ 'message' => $e->getMessage() ], 400); } } I've hard-coded my id's to illustrate that even when set statically, it's not passed through: [2023-01-19 09:40:59] local.DEBUG: select * from products where (company_id = ?) and products.deleted_at is null
Laravel (and most other frameworks) extract out variables when building SQL queries to prevent SQL injection. So the following eloquent query: User::where('name', 'Larry'); will become: SELECT * FROM `users` WHERE `name` = ? and it will also pass an array of bindings: ['Larry']. When SQL processes the query it replaces replaces the ? with the values in the bindings. So if you want to see the full query you need to log the SQL and the bindings: Log::debug($model->toSql()); Log::debug($model->getBindings());
Contain only works when specifying either one of required models
If I add two models in contain, at the same time, only one property will be populated. If I add either one separately, the corresponding property will be populated fine. I suspect a clash with the users table somehow. Basics: task lead: tasks hasOne organizational_units hasOne users tasks [id, organizational_unit_id, ...] > organizational_units [id, user_id, ...] > users [id, ...] task collaborators: tasks hasAndBelongsToMany users (tasks_users) tasks [id, ...] > tasks_users [task_id, user_id] > users [id, ...] Code: //populates $task->organizational_unit property $this->Tasks->find()->contain(['OrganizationalUnits.Users'])->where(['Tasks.id' => $id]); //populates $task->users property $this->Tasks->find()->contain(['Users'])->where(['Tasks.id' => $id]); //only populates organizational_unit property, missing users property $this->Tasks->find()->contain(['OrganizationalUnits.Users','Users'])->where(['Tasks.id' => $id]); Task Table: <?php declare(strict_types=1); namespace App\Model\Table; use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\Validation\Validator; /** * Tasks Model * * #property \App\Model\Table\TasksTable&\Cake\ORM\Association\BelongsTo $ParentTasks * #property \App\Model\Table\OrganizationalUnitsTable&\Cake\ORM\Association\BelongsTo $OrganizationalUnits * #property \App\Model\Table\FiscalYearsTable&\Cake\ORM\Association\BelongsTo $FiscalYears * #property \App\Model\Table\TaskStatusesTable&\Cake\ORM\Association\BelongsTo $TaskStatuses * #property \App\Model\Table\QuartersTable&\Cake\ORM\Association\BelongsTo $Quarters * #property \App\Model\Table\TaskTypesTable&\Cake\ORM\Association\BelongsTo $TaskTypes * #property \App\Model\Table\TaskPrioritiesTable&\Cake\ORM\Association\BelongsTo $TaskPriorities * #property \App\Model\Table\TaskDiscussionNotesTable&\Cake\ORM\Association\HasMany $TaskDiscussionNotes * #property \App\Model\Table\TaskOutcomesTable&\Cake\ORM\Association\HasMany $TaskOutcomes * #property \App\Model\Table\TasksTable&\Cake\ORM\Association\HasMany $ChildTasks * #property \App\Model\Table\UsersTable&\Cake\ORM\Association\BelongsToMany $Users * * #method \App\Model\Entity\Task newEmptyEntity() * #method \App\Model\Entity\Task newEntity(array $data, array $options = []) * #method \App\Model\Entity\Task[] newEntities(array $data, array $options = []) * #method \App\Model\Entity\Task get($primaryKey, $options = []) * #method \App\Model\Entity\Task findOrCreate($search, ?callable $callback = null, $options = []) * #method \App\Model\Entity\Task patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = []) * #method \App\Model\Entity\Task[] patchEntities(iterable $entities, array $data, array $options = []) * #method \App\Model\Entity\Task|false save(\Cake\Datasource\EntityInterface $entity, $options = []) * #method \App\Model\Entity\Task saveOrFail(\Cake\Datasource\EntityInterface $entity, $options = []) * #method \App\Model\Entity\Task[]|\Cake\Datasource\ResultSetInterface|false saveMany(iterable $entities, $options = []) * #method \App\Model\Entity\Task[]|\Cake\Datasource\ResultSetInterface saveManyOrFail(iterable $entities, $options = []) * #method \App\Model\Entity\Task[]|\Cake\Datasource\ResultSetInterface|false deleteMany(iterable $entities, $options = []) * #method \App\Model\Entity\Task[]|\Cake\Datasource\ResultSetInterface deleteManyOrFail(iterable $entities, $options = []) * * #mixin \Cake\ORM\Behavior\TimestampBehavior * #mixin \Cake\ORM\Behavior\TreeBehavior */ class TasksTable extends Table { /** * Initialize method * * #param array $config The configuration for the Table. * #return void */ public function initialize(array $config): void { parent::initialize($config); $this->setTable('tasks'); $this->setDisplayField('name'); $this->setPrimaryKey('id'); $this->addBehavior('Tree', [ 'level' => 'level', // Defaults to null, i.e. no level saving ]); $this->belongsTo('ParentTasks', [ 'className' => 'Tasks', 'foreignKey' => 'parent_id', ]); $this->belongsTo('OrganizationalUnits', [ 'foreignKey' => 'organizational_unit_id', 'joinType' => 'INNER', ]); $this->belongsTo('FiscalYears', [ 'foreignKey' => 'fiscal_year_id', 'joinType' => 'INNER', ]); $this->belongsTo('TaskStatuses', [ 'foreignKey' => 'task_status_id', ]); $this->belongsTo('Quarters', [ 'foreignKey' => 'quarter_id', ]); $this->belongsTo('TaskTypes', [ 'foreignKey' => 'task_type_id', 'joinType' => 'INNER', ]); $this->belongsTo('TaskPriorities', [ 'foreignKey' => 'task_priority_id', 'joinType' => 'INNER', ]); $this->hasMany('TaskDiscussionNotes', [ 'foreignKey' => 'task_id', ]); $this->hasMany('TaskOutcomes', [ 'foreignKey' => 'task_id', ]); $this->hasMany('ChildTasks', [ 'className' => 'Tasks', 'foreignKey' => 'parent_id', ]); $this->belongsToMany('Users', [ 'foreignKey' => 'task_id', 'targetForeignKey' => 'user_id', 'joinTable' => 'tasks_users', ]); } /** * Default validation rules. * * #param \Cake\Validation\Validator $validator Validator instance. * #return \Cake\Validation\Validator */ public function validationDefault(Validator $validator): \Cake\Validation\Validator { //removed return $validator; } /** * Returns a rules checker object that will be used for validating * application integrity. * * #param \Cake\ORM\RulesChecker $rules The rules object to be modified. * #return \Cake\ORM\RulesChecker */ public function buildRules(RulesChecker $rules): \Cake\ORM\RulesChecker { //removed return $rules; } }
Laravel 5.5 API resources for collections (standalone data)
I was wondering if it is possible to define different data for item resource and collection resource. For collection I only want to send ['id', 'title', 'slug'] but the item resource will contain extra details ['id', 'title', 'slug', 'user', etc.] I want to achieve something like: class PageResource extends Resource { /** * Transform the resource into an array. * * #param \Illuminate\Http\Request * #return array */ public function toArray($request) { return [ 'id' => $this->id, 'title' => $this->title, 'slug' => $this->slug, 'user' => [ 'id' => $this->user->id, 'name' => $this->user->name, 'email' => $this->user->email, ], ]; } } class PageResourceCollection extends ResourceCollection { /** * Transform the resource collection into an array. * * #param \Illuminate\Http\Request * #return array */ public function toArray($request) { return [ 'id' => $this->id, 'title' => $this->title, 'slug' => $this->slug, ]; } } PageResourceCollection will not work as expected because it uses PageResource so it needs return [ 'data' => $this->collection, ]; I could duplicate the resource into PageFullResource / PageListResource and PageFullResourceCollection / PageListResourceCollection but I am trying to find a better way to achieve the same result.
The Resource class has a collection method on it. You can return that as the parameter input to your ResourceCollection, and then specify your transformations on the collection. Controller: class PageController extends Controller { public function index() { return new PageResourceCollection(PageResource::collection(Page::all())); } public function show(Page $page) { return new PageResource($page); } } Resources: class PageResource extends Resource { public function toArray($request) { return [ 'id' => $this->id, 'title' => $this->title, 'slug' => $this->slug, 'user' => [ 'id' => $this->user->id, 'name' => $this->user->name, 'email' => $this->user->email, ], ]; } } class PageResourceCollection extends ResourceCollection { public function toArray($request) { return [ 'data' => $this->collection->transform(function($page){ return [ 'id' => $page->id, 'title' => $page->title, 'slug' => $page->slug, ]; }), ]; } }
If you want the response fields to have the same value in the Resource and Collection, you can reuse the Resource inside the Collection PersonResource.php <?php namespace App\Http\Resources; use Illuminate\Http\Resources\Json\Resource; class PersonResource extends Resource { /** * Transform the resource into an array. * * #param \Illuminate\Http\Request $request * #return array */ public function toArray($request) { // return parent::toArray($request); return [ 'id' => $this->id, 'person_type' => $this->person_type, 'first_name' => $this->first_name, 'last_name' => $this->last_name, 'created_at' => (string) $this->created_at, 'updated_at' => (string) $this->updated_at, ]; } } PersonCollection.php <?php namespace App\Http\Resources; use Illuminate\Http\Resources\Json\ResourceCollection; class PersonCollection extends ResourceCollection { /** * Transform the resource collection into an array. * * #param \Illuminate\Http\Request $request * #return \Illuminate\Http\Resources\Json\AnonymousResourceCollection */ public function toArray($request) { // return parent::toArray($request); return PersonResource::collection($this->collection); } }
The accepted answer works, if you are not interested in using links and meta data. If you want, simply return: return new PageResourceCollection(Page::paginate(10)); in your controller. You should also look to eager load other dependent relationships before passing over to the resource collection.
Yii2 rest api join query with ActiveDataProvider
I have a custom action in ActiveController and need to fetch some data by joining two tables. I have written following query . $query = Item::find()->joinWith(['subcategory'])->select(['item.*', 'sub_category.name'])->where(['item.active' => 1])->addOrderBy(['item.id' => SORT_DESC]); $pageSize = (isset($_GET["limit"]) ? $_GET["limit"] : 1) * 10; $page = isset($_GET["page"]) ? $_GET["page"] : 1; $dataProvider = new ActiveDataProvider(['query' => $query, 'pagination' => ['pageSize' => $pageSize, "page" => $page]]); $formatter = new ResponseFormatter(); return $formatter->formatResponse("", $dataProvider->getTotalCount(), $dataProvider->getModels()); but it is throwing an exception "message": "Setting unknown property: common\\models\\Item::name", Here is the item Model with all the fields and relation. <?php namespace common\models; use Yii; use yii\behaviors\TimestampBehavior; use yii\db\BaseActiveRecord; use yii\db\Expression; /** * This is the model class for table "item". * * #property integer $id * #property integer $subcategory_id * #property string $title * #property resource $description * #property integer $created_by * #property integer $updated_by * #property string $created_at * #property string $updated_at * #property string $image * #property integer $active * * #property SubCategory $subcategory */ class Item extends \yii\db\ActiveRecord { public $imageFile; /** * #inheritdoc */ public static function tableName() { return 'item'; } /** * #inheritdoc */ public function rules() { return [ [['created_by', 'updated_by'], 'required'], [['subcategory_id', 'created_by', 'updated_by', 'active'], 'integer'], [['description'], 'string'], [['created_at', 'updated_at'], 'safe'], [['title', 'image'], 'string', 'max' => 999], [['title'], 'unique'], [['imageFile'], 'file', 'skipOnEmpty' => true, 'extensions' => 'png, jpg'], ]; } /** * #inheritdoc */ public function attributeLabels() { return [ 'id' => 'ID', 'subcategory_id' => 'Subcategory ID', 'title' => 'Title', 'description' => 'Description', 'created_by' => 'Created By', 'updated_by' => 'Updated By', 'created_at' => 'Created At', 'updated_at' => 'Updated At', 'image' => 'Image', 'active' => 'Active', 'imageFile' => 'Image', ]; } /** * #return \yii\db\ActiveQuery */ public function getSubcategory() { return $this->hasOne(SubCategory::className(), ['id' => 'subcategory_id']); } /** * #return \yii\db\ActiveQuery */ public function getCreatedBy() { return $this->hasOne(User::className(), ['id' => 'created_by']); } /** * #return \yii\db\ActiveQuery */ public function getUpdatedBy() { return $this->hasOne(User::className(), ['id' => 'updated_by']); } public function behaviors() { return [ 'timestamp' => [ 'class' => TimestampBehavior::className(), 'attributes' => [ BaseActiveRecord::EVENT_BEFORE_INSERT => ['created_at', 'updated_at'], BaseActiveRecord::EVENT_BEFORE_UPDATE => 'updated_at', ], 'value' => new Expression('NOW()'), ], ]; } }
The joinWith makes a query using the joins requested, but result data are mapped in source model (in this case Item). Since you have select(['item.*', 'sub_category.name']) , the framework will try to fill 'name' field of Item model, that does not exist and this generates the error. According with documentation (http://www.yiiframework.com/doc-2.0/guide-rest-resources.html#overriding-extra-fields) you should have db relation subcategory populated from db, by default, but I don't see subcategory relation in your model. So you have only to create subcategory relation in your model, such as: public function getSubcategory() { return $this->hasOne(Subcategory::className(), ['id' => 'subcategory_id']); } So you should solve your problem. Other solution to have custom fields from more models could be: 1) Create a sql View (and from that create the Model) with fields that you want and pass it to ActiveDataProvide 2) Override extraFields method of the model (http://www.yiiframework.com/doc-2.0/yii-base-arrayabletrait.html#extraFields%28%29-detail) Again, I suggest you to read this good article: http://www.yiiframework.com/wiki/834/relational-query-eager-loading-in-yii-2-0/
How can I correctly relate these tables in cakephp?
I'm trying to create a set of CRUDs using cakephp3. My database model looks like this: I used the cake's tutorial on authentication to create the users table and it's classes, it's working fine. But I want to use a more complex set of roles, so I created these other tables. After creating the database model I baked the corresponding classes, made a few tweaks and got the systems and the roles CRUD's to work. Now I want to integrate the roles_users table, probably inside of user's CRUD. I would like to see how cake's bake would do it before coding this relation myself, but I'm unable to open /rolesUsers. When I call the URL, I get the following error message: Cannot match provided foreignKey for "Roles", got "(role_id)" but expected foreign key for "(id, system_id)" RuntimeException I think it happens because system_id is a PK in roles table and isn't present in roles_users (I'll show the baked models and this PK will be present at roles class). Is there an easy way to make it work without adding system_id in roles_users? IMO adding this extra field wouldn't be a big problem, but I would like to know if I'm doing something wrong, some bad design decision. My src/Model/Table/RolesUsersTable.php: <?php namespace App\Model\Table; use App\Model\Entity\RolesUser; use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\Validation\Validator; /** * RolesUsers Model * * #property \Cake\ORM\Association\BelongsTo $Users * #property \Cake\ORM\Association\BelongsTo $Roles */ class RolesUsersTable extends Table { /** * Initialize method * * #param array $config The configuration for the Table. * #return void */ public function initialize(array $config) { parent::initialize($config); $this->table('roles_users'); $this->displayField('user_id'); $this->primaryKey(['user_id', 'role_id']); $this->belongsTo('Users', [ 'foreignKey' => 'user_id', 'joinType' => 'INNER' ]); $this->belongsTo('Roles', [ 'foreignKey' => 'role_id', 'joinType' => 'INNER' ]); } /** * Default validation rules. * * #param \Cake\Validation\Validator $validator Validator instance. * #return \Cake\Validation\Validator */ public function validationDefault(Validator $validator) { $validator ->add('valido_ate', 'valid', ['rule' => 'date']) ->requirePresence('valido_ate', 'create') ->notEmpty('valido_ate'); return $validator; } /** * Returns a rules checker object that will be used for validating * application integrity. * * #param \Cake\ORM\RulesChecker $rules The rules object to be modified. * #return \Cake\ORM\RulesChecker */ public function buildRules(RulesChecker $rules) { $rules->add($rules->existsIn(['user_id'], 'Users')); $rules->add($rules->existsIn(['role_id'], 'Roles')); return $rules; } } My src/Model/Table/RolesTable.php: <?php namespace App\Model\Table; use App\Model\Entity\Role; use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\Validation\Validator; /** * Roles Model * * #property \Cake\ORM\Association\BelongsTo $Systems */ class RolesTable extends Table { /** * Initialize method * * #param array $config The configuration for the Table. * #return void */ public function initialize(array $config) { parent::initialize($config); $this->table('roles'); $this->displayField('name'); $this->primaryKey(['id', 'system_id']); $this->belongsTo('Systems', [ 'foreignKey' => 'system_id', 'joinType' => 'INNER' ]); } /** * Default validation rules. * * #param \Cake\Validation\Validator $validator Validator instance. * #return \Cake\Validation\Validator */ public function validationDefault(Validator $validator) { $validator ->add('id', 'valid', ['rule' => 'numeric']) ->allowEmpty('id', 'create'); $validator ->requirePresence('name', 'create') ->notEmpty('name'); $validator ->add('status', 'valid', ['rule' => 'numeric']) ->requirePresence('status', 'create') ->notEmpty('status'); return $validator; } /** * Returns a rules checker object that will be used for validating * application integrity. * * #param \Cake\ORM\RulesChecker $rules The rules object to be modified. * #return \Cake\ORM\RulesChecker */ public function buildRules(RulesChecker $rules) { $rules->add($rules->existsIn(['system_id'], 'Systems')); return $rules; } } My src/Model/Table/UsersTable: <?php namespace App\Model\Table; use Cake\ORM\Table; use Cake\Validation\Validator; class UsersTable extends Table{ public function validationDefault(Validator $validator){ return $validator ->notEmpty('username', 'O campo nome de usuário é obrigatório') ->notEmpty('password', 'O campo senha é obrigatório') ->notEmpty('role', 'O campo perfil é obrigatório') ->add('role', 'inList', [ 'rule' => ['inList', ['admin', 'author']], 'message' => 'Escolha um perfil válido' ] ); } } ?>
Answered by user jose_zap in #cakephp #freenode: In RolesUsersTable.php, initialize function, I added a parameter to both $this->belongsTo calls, including the 'bindingKey' and value 'id'. So this old code: $this->belongsTo('Users', [ 'foreignKey' => 'user_id', 'joinType' => 'INNER' ]); $this->belongsTo('Roles', [ 'foreignKey' => 'role_id', 'joinType' => 'INNER' ]); became this: $this->belongsTo('Users', [ 'foreignKey' => 'user_id', 'bindingKey' => 'id', 'joinType' => 'INNER' ]); $this->belongsTo('Roles', [ 'foreignKey' => 'role_id', 'bindingKey' => 'id', 'joinType' => 'INNER' ]);