onDelete: 'SET NULL' not working in Symfony/Doctrine with Sqlite - php

I'm working on a Symfony 6 project and I'm using sqlite as db.
I have a ManyToOne relation between two entities: Neighborhood and PropertyForSale.
When I delete a Neighborhood I want the $Neighborhood field of PropertyForSale to be set to null so I added:
#[ORM\JoinColumn(onDelete: 'SET NULL')] to the property:
#[ORM\ManyToOne(inversedBy: 'propertiesForSale')]
#[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?Neighborhood $neighborhood = null;
Everything seems to work properly if I change the database to MySql but with Sqlite this attribute seems to be ignored. I know has something to do with the default foreign key behavior in sqlite and
PRAGMA foreign_keys = ON; should be executed but I canĀ“t find I way to make it work with Symfony and Doctrine; Any ideas?
I share a bigger portion of my code:
// PropertyForSale.php
#[ORM\Entity(repositoryClass: PropertyForSaleRepository::class)]
class PropertyForSale
{
// ...
#[ORM\ManyToOne(inversedBy: 'propertiesForSale')]
#[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?Neighborhood $neighborhood = null;
// ...
public function getNeighborhood(): ?Neighborhood
{
return $this->neighborhood;
}
public function setNeighborhood(?Neighborhood $neighborhood): self
{
$this->neighborhood = $neighborhood;
return $this;
}
}
// Neighborhood.php
#[ORM\Entity(repositoryClass: NeighborhoodRepository::class)]
class Neighborhood
{
// ...
#[ORM\OneToMany(mappedBy: 'neighborhood', targetEntity: PropertyForSale::class)]
private Collection $propertiesForSale;
// ...
public function getPropertiesForSale(): Collection
{
return $this->propertiesForSale;
}
public function addPropertiesForSale(PropertyForSale $propertiesForSale): self
{
if (!$this->propertiesForSale->contains($propertiesForSale)) {
$this->propertiesForSale->add($propertiesForSale);
$propertiesForSale->setNeighborhood($this);
}
return $this;
}
public function removePropertiesForSale(PropertyForSale $propertiesForSale): self
{
if ($this->propertiesForSale->removeElement($propertiesForSale)) {
// set the owning side to null (unless already changed)
if ($propertiesForSale->getNeighborhood() === $this) {
$propertiesForSale->setNeighborhood(null);
}
}
return $this;
}
}
The only workaround I found was to add an event listener on the entity preRemove event and manually set to null the relation:
// NeighborhoodListener
namespace App\EventListener;
use Doctrine\ORM\EntityManagerInterface;
class NeighborhoodListener
{
public function __construct(private EntityManagerInterface $entityManager) {}
public function preRemove($args) {
$properties = $args->getObject()->getPropertiesForSale();
foreach ($properties as $property) {
$property->setNeighborhood(null);
$this->entityManager->persist($property);
}
$this->entityManager->flush();
}
}

Related

Symfony Doctrine - SQL error when saving OneToOne relation

I'm coding an API in Symfony with API Platform and I have an issue when I persist a relation of my object.
I have few entities. Entity Lead can have few LeadJob and for each LeadJob I create a Project. I use a Subscriber to trigger those creations.
Project relation of entity LeadJob :
/**
* #ORM\OneToOne(targetEntity=Project::class, inversedBy="leadJob", cascade={"persist", "remove"})
*/
private $project;
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): self
{
$this->project = $project;
return $this;
}
LeadJob relation of entity Project :
/**
* #ORM\OneToOne(targetEntity=LeadJob::class, mappedBy="project", cascade={"persist", "remove"})
*/
private $leadJob;
public function getLeadJob(): ?LeadJob
{
return $this->leadJob;
}
public function setLeadJob(?LeadJob $leadJob): self
{
$this->leadJob = $leadJob;
// set (or unset) the owning side of the relation if necessary
$newProject = null === $leadJob ? null : $this;
if ($leadJob->getProject() !== $newProject) {
$leadJob->setProject($newProject);
}
return $this;
}
And the Subscriber that create the Project :
final class LeadCreateProjectSubscriber implements EventSubscriber
{
protected EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
public function getSubscribedEvents(): array
{
return [
Events::postPersist,
Events::postUpdate,
];
}
public function postPersist(LifecycleEventArgs $event): void
{
$entity = $event->getObject();
if (!$entity instanceof Lead) {
return;
}
$this->createProject($entity);
}
public function postUpdate(LifecycleEventArgs $event): void
{
$entity = $event->getObject();
if (!$entity instanceof Lead) {
return;
}
$this->createProject($entity);
}
private function createProject(Lead $lead): void
{
if (LeadStatusEnum::FINISHED !== $lead->getStatus()) {
return;
}
foreach ($lead->getLeadJobs() as $leadJob) {
$project = (new Project())
->setOwner($leadJob->getUser())
->addUser($lead->getOwner())
->setOrganization($lead->getOrganization())
->setContact($lead->getContact())
->setName('Lead '.$lead->getSource()->getName())
->setJob($leadJob->getJob())
->setLeadJob($leadJob) //this line that causes the error
->setComment($lead->getDescription());
$this->entityManager->persist($project);
}
$this->entityManager->flush();
}
}
So, when I trigger the creation of an Project with everything I need, I have this error message thrown from my Subscriber. There is some properties that I didn't notice, this is the raw error message :
"An exception occurred while executing 'INSERT INTO lead_job (id, deleted_at, created_at,
updated_at, job_id, user_id, lead_id, project_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
with params [\"eafb3b13-bc14-4eb8-92e8-cf3acc55719e\", \"2021-07-22
16:54:45\"]:\n\nSQLSTATE[08P01]: <<Unknown error>>: 7 ERROR: bind message supplies 2 parameters, but prepared statement \"\" requires 8"
The only way that work is to persist the project, flush, set the relation and persist it in the Subscriber. And delete the setLeadJob on the Project object :
$this->entityManager->persist($project);
$this->entityManager->flush();
$leadJob->setProject($project);
$this->entityManager->persist($leadJob);
$this->entityManager->flush();
Why the cascade persist is not doing the job? And why I have this error?
From the Doctrine documentation:
postUpdate, postRemove, postPersist
The three post events are called
inside EntityManager#flush(). Changes in here are not relevant to the
persistence in the database, but you can use these events to alter
non-persistable items, like non-mapped fields, logging or even
associated classes that are not directly mapped by Doctrine.
I think this is not very clear, so in other words: don't persist (other) entities inside a postPersist listener. Because postPersist happens during the flush event, new persisted entities (like your Project) aren't flushed. And flushing during a flush event leads to unexpected behaviour, like the error in your question.
You should use a onFlush event instead:
class FlushExampleListener
{
public function onFlush(OnFlushEventArgs $eventArgs)
{
$em = $eventArgs->getEntityManager();
$uow = $em->getUnitOfWork();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof Lead) {
$this->createProject($entity);
}
}
}
private function createProject(Lead $lead): void
{
// your logic here
}
}

Eloquent select using the wrong class?

Context: Trying to extend Laravel models from a database table. Table models linked to App\Models\BootableModel, App\Models\User extends this class.
I have the following code:
<?php
class BootableModel extends Model
{
protected $table = 'models';
protected $fillable = [
'name',
'class',
'table',
];
public function __construct(array $attributes = [])
{
$this->bootFromDatabase();
parent::__construct($attributes);
}
private function bootFromDatabase()
{
$class = static::class;
$bModClass = self::class;
Log::debug($class);
Log::debug($bModClass);
//$bootableModel = DB::table('models')->where('class', $class)->first();
$bootableModel = $bModClass::where('class', $class)->first();
if(!$bootableModel) {
return;
}
Log::debug($bootableModel->id);
The debug of $bModClass shows App\Models\BootableModel as expected (self vs static), but for some reason the $bModClass::where is trying to query the users table. Using a direct reference to App\Models\BootableModel::class does not change this, so it's not self::class that is the issue. The debug output as proof:
[2021-02-21 17:33:39] local.DEBUG: App\Models\User
[2021-02-21 17:33:39] local.DEBUG: App\Models\BootableModel
It should never try to access User::where(), and as such, it should never try to use User::$table either, but somehow it does.
Is Laravel doing some weird reflection, or is this normal PHP behavior? Is there a way around this?
Update:
I have found a workaround, but I'm not satisfied with this being the correct/only solution:
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
static::bootFromDatabase();
}
public static function bootFromDatabase()
{
$class = static::class;
$bModClass = self::class;
if($class === $bModClass) {
return;
}
Have you ever tried self::where(...) instead of $bModClass::where(...) ?
Similar situation:
class Base {
public static function where()
{
return 'where from base';
}
public static function getName()
{
return self::where();
}
}
class User extends Base {
public static function where()
{
return 'where from user';
}
}
echo User::getName();
Output: where from base

Chaining Symfony Repository Methods ( Filters )

What is the best practice to chain repository methods to reuse query building logic?
Here is how I did it, but I doubt if this is the right way:
use Doctrine\ORM\Mapping;
use Doctrine\ORM\EntityManager;
class OrderRepository extends \Doctrine\ORM\EntityRepository
{
private $q;
public function __construct(EntityManager $em, Mapping\ClassMetadata $class)
{
parent::__construct($em, $class);
$this->q = $this->createQueryBuilder('o');
}
public function getOneResult()
{
return $this->q->getQuery()->getOneOrNullResult();
}
public function getResult()
{
return $this->q->getQuery()->getResult();
}
public function filterByStatus($status)
{
$this->q->andWhere('o.status = :status')->setParameter('status', $status);
return $this;
}
public function findNextForPackaging()
{
$this->q->leftjoin('o.orderProducts', 'p')
->orderBy('o.deliveryDate', 'ASC')
->andHaving('SUM(p.qtePacked) < SUM(p.qte)')
->groupBy('o.id')
->setMaxResults(1);
return $this;
}
}
This allows me to chain method like this:
$order = $em->getRepository('AppBundle:Order')->filterByStatus(10)->findNextForPackaging()->getOneResult();
This is of course just an example. In reality there are many more methods that can be chained.
One big problem with this is the fact that I need a join for some of the "filters", so I have to check if the join has already been set by some method/filter before I add it. ( I did not put it in the example, but I figured it out, but it is not very pretty )
The other problem is that I have to be careful when using the repository, as the query could already be set to something, so I would need to reset the query every time before using it.
I also understand that I could use the doctrine "matching" method with criteria, but as far as I understood, this is rather expensive, and also, I don't know how to solve the "join" Problem with that approach.
Any thoughts?
I made something similar to what you want:
Controller, this is how you use it. I am not returning Response instance but serialize the array in kernel.view listener but it is still valid example:
/**
* #Route("/root/pending_posts", name="root_pending_posts")
* #Method("GET")
*
* #return Post[]
*/
public function pendingPostsAction(PostRepository $postRepository, ?UserInterface $user): array
{
if (!$user) {
return [];
}
return $postRepository->begin()
->wherePublished(false)
->whereCreator($user)
->getResults();
}
PostRepository:
class PostRepository extends BaseRepository
{
public function whereCreator(User $user)
{
$this->qb()->andWhere('o.creator = :creator')->setParameter('creator', $user);
return $this;
}
public function leftJoinRecentComments(): self
{
$this->qb()
->leftJoin('o.recentCommentsReference', 'ref')->addSelect('ref')
->leftJoin('ref.comment', 'c')->addSelect('c');
return $this;
}
public function andAfter(\DateTime $after)
{
$this->qb()->andWhere('o.createdAt > :after')->setParameter('after', $after);
return $this;
}
public function andBefore(\DateTime $before)
{
$this->qb()->andWhere('o.createdAt < :before')->setParameter('before', $before);
return $this;
}
public function wherePublished(bool $bool)
{
$this->qb()->andWhere('o.isPending = :is_pending')->setParameter('is_pending', !$bool);
return $this;
}
}
and BaseRepository has most used stuff, still work in progress:
namespace wjb\CoreBundle\Model;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
abstract class BaseRepository extends EntityRepository
{
/** #var QueryBuilder */
private $qb;
public function begin()
{
$this->qb = $this->createQueryBuilder('o');
return $this;
}
public function qb(): QueryBuilder
{
return $this->qb;
}
public function none()
{
$this->qb()->where('o.id IS NULL');
return $this;
}
public function setMaxResults($maxResults)
{
$this->qb()->setMaxResults($maxResults);
return $this;
}
public function addOrderBy($sort, $order = null)
{
$this->qb()->addOrderBy($sort, $order);
return $this;
}
public function getResults()
{
return $this->qb()->getQuery()->getResult();
}
}
This helps me a lot in chaining calls like in controller example.

Eloquent model relationship for intermediate table

Consider the following table structure:
user table
id
name
lang_region_id
lang_region table
id
lang_id
region_id
lang table
id
name
region table
id
name
Fairly new to the Laravel framework, but trying to setup Eloquent models and relationships to an existing database. I want to establish the relationship between my user model and the lang and region models. The lang_region table defines what language and region combinations are available and then we can link each user to a valid combination.
I have read through the Laravel documentation several times looking for the proper relationship type, but is seems that the Many to Many and Has Many Through relationships are close, but since our user.id isn't used in the intermediate table I may be out of luck.
Sorry for the amateur question, but just getting used to Laravel and ORMs in general.
I would use the lang_region table as both a pivot table and a regular table with its own model.
class LangRegion extends model
{
protected $table = 'lang_region';
public function language()
{
return $this->belongsTo(Language::class, 'lang_id');
}
public function region()
{
return $this->belongsTo(Region::class);
}
public function users()
{
return $this->hasMany(User::class);
}
}
class User extends model
{
protected $table = 'user';
public function langRegion()
{
return $this->belongsTo(LangRegion::class);
}
}
class Language extends model
{
protected $table = 'lang';
public function regions()
{
$this->belongsToMany(Region::class, 'lang_region', 'lang_id', 'region_id');
}
public function users()
{
$this->hasManyThrough(User::class, LangRegion::class, 'lang_id', 'lang_region_id');
}
}
class Region extends model
{
protected $table = 'region';
public function languages()
{
$this->belongsToMany(Language::class, 'lang_region', 'region_id', 'lang_id');
}
public function users()
{
$this->hasManyThrough(User::class, LangRegion::class, 'region_id', 'lang_region_id');
}
}
If I understand what you want correctly:
class User extends Model {
private function lang_region() {
return $this->hasOne(LangRegion::class)
}
public function lang() {
return $this->lang_region()->lang();
}
public function region() {
return $this->lang_region()->region();
}
}
class LangRegion extends Model {
public function lang() {
return $this->belongsTo(Lang::class);
}
public function region() {
return $this->belongsTo(Region::class);
}
}

eloquent boot not firing only for one class

I'm using eloquent for a project and I would like to delete articles. The problem is that the database is complicated. There are articles, that have article_lvl1 and article_lvl1 have article_lvl2. When I delete an article the event functions work fine :
<?php
namespace app\model;
class Article extends \Illuminate\Database\Eloquent\Model
{
public static function boot()
{
parent::boot();
// cause a delete of a product to cascade to children so they are also deleted
static::deleting(function($article)
{
$article->ArticleNiveau1()->delete();
$article->ArticleNiveau2()->delete();
});
}
public function ArticleNiveau1()
{
return $this->hasMany('app\model\ArticleNiveau1', 'id_article');
}
public function ArticleNiveau2()
{
return $this->hasMany('app\model\ArticleNiveau2', 'id_article');
}
protected $table = 'Article';
public $timestamps = false;
protected $primaryKey = 'id_article';
}
But an article can also have article_Informations, which can have article_Date. My problem is that everything in the boot functions executes fine on delete, but not the article_Date. Here is the code :
<?php
namespace app\model;
class Article_Informations extends \Illuminate\Database\Eloquent\Model
{
public function Article()
{
return $this->belongsTo('app\model\Article', 'id_article');
}
public function ArticleNiveau1()
{
return $this->belongsTo('app\model\ArticleNiveau1', 'id_nv1');
}
public function ArticleNiveau2()
{
return $this->belongsTo('app\model\ArticleNiveau2', 'id_nv2');
}
public function Article_Date()
{
return $this->hasMany('app\model\Article_Date', 'id_info');
}
public static function boot()
{
parent::boot();
// cause a delete of a product to cascade to children so they are also deleted
static::deleting(function($info)
{
$info->Article_Date()->delete();
});
}
protected $table = 'Article_Informations';
public $timestamps = false;
protected $primaryKey = 'id_info';
}
I don't know why, it's the only event that is not fired on delete.
Can someone explain me ?

Categories