atk4 / data

Data Access PHP Framework for SQL & high-latency databases
https://atk4-data.readthedocs.io
MIT License
271 stars 46 forks source link

Implement easy N:N save #393

Open PhilippGrashoff opened 5 years ago

PhilippGrashoff commented 5 years ago

Hi there,

as I am using lots of MToM relations in my project, I thought a bit about a native atk4\data MToM implementation.

Ok, first lets get a good example: Students and Lessons. A Student can have many Lessons, a Lesson can have many Students.

The only solution for mapping this I know so far is to have an extra Model which stores the relations. Like:

class StudentToLesson extends \atk4\data\Model {
    public function init() {
        parent::init();
        $this->addFields([
            ['lesson_id', 'type' => 'integer'],
            ['student_id', 'type' => 'integer'],
        ]);
    }
}

What I am aiming for is to directly add and remove relations by passing either id or object. Passing id:

$student = new Student($app->db);
$student->load(3);
//adds a new StudentToLesson having student_id=3 and lesson_id=1
$student->addMToMRelation('Lesson', 1);

Passing Object:

$student = new Student($app->db);
$student->load(3);
$lesson = new Lesson($app->db);
$lesson->load(1);
//adds a new StudentToLesson having student_id=3 and lesson_id=1
$student->addMToMRelation($lesson);

And of course having the same for removal and checking if the relation exists

$student->removeMToMRelation('Lesson', 1);
$student->removeMToMRelation($lesson);

$student->hasMToMRelation('Lesson', 1);
$student->hasMToMRelation($lesson);

When traversing, we'd usually like the get the lessons of a student, not the StudentToLesson records. Like:

//iterates all Lessons of the student
foreach($student->ref('Lesson') as $lesson) {

}

So how could a MToM relation be defined in model?

What about (In Student's init()):

$this->hasManyToMany(['Lesson', new Lesson()], ['StudentToLesson', 'our_field' => 'student_id', 'their_field' => 'lesson_id']);

What do others think about this possible usage?

PhilippGrashoff commented 5 years ago

Old content, removed

PhilippGrashoff commented 5 years ago

I refactored the MToM functions I have yesterday. Idea is still the same, create 1 line functions for each model like:

//in Student
public function addLesson($lesson) {
    return $this->_addMToMRelation($lesson, new StudentToLesson($this->persistence), 'Lesson', , 'student_id', 'lesson_id');
}

Implementation of MToMfunctions currently is:


    /*
     * function used to add data to the MtoM relations like GroupToTour,
     * GuestToGroup etc.
     * First checks if record does exist already, and only then adds new relation.
     */
    protected function _addMToMRelation($object, \atk4\data\Model $mtom_object, string $object_class, string $our_field, string $their_field):bool {
        //$this needs to be loaded to get ID
        if(!$this->loaded()) {
            throw new \atk4\data\Exception('$this needs to be loaded in '.__FUNCTION__);
        }

        $object = $this->_mToMLoadObject($object, $object_class);

        //set values and conditions
        $mtom_object->set($our_field, $this->get('id'));
        $mtom_object->set($their_field, $object->get('id'));
        //no reload neccessary after insert
        $mtom_object->reload_after_save = false;
        //if that record already exists mysql will throw an error if unique index is set, catch here
        try {
            $mtom_object->save();
            return $mtom_object->loaded();
        }
        catch(\Exception $e) {
            return false;
        }
    }

    /*
     * function used to remove a record the MtoM relations like GroupToTour,
     * GuestToGroup etc.
     */
    protected function _removeMToMRelation($object, \atk4\data\Model $mtom_object, string $object_class, string $our_field, string $their_field):bool {
        //$this needs to be loaded to get ID
        if(!$this->loaded()) {
            throw new \atk4\data\Exception('$this needs to be loaded in '.__FUNCTION__);
        }

        $object = $this->_mToMLoadObject($object, $object_class);

        $mtom_object->addCondition($our_field, $this->get('id'));
        $mtom_object->addCondition($their_field, $object->get('id'));
        //atk needs active record to be loaded to delete
        $mtom_object->tryLoadAny();
        if(!$mtom_object->loaded()) {
            return false;
        }
        $mtom_object->delete();
        return true;
    }

    /*
     * checks if a MtoM reference to the given object exists or not
     *
     * @param object The object to check if its referenced with $this
     * @param object The MToM Refence class, e.g. GroupToTour
     *
     * @return bool
     */
    protected function _hasMToMRelation($object, \atk4\data\Model $mtom_model, string $object_class, string $our_field, string $their_field):bool {
        if(!$this->loaded()) {
            throw new \atk4\data\Exception('$this needs to be loaded in '.__FUNCTION__);
        }
        $object = $this->_mToMLoadObject($object, $object_class);

        $mtom_model->addCondition($our_field, $this->get('id'));
        $mtom_model->addCondition($their_field, $object->get('id'));
        $mtom_model->tryLoadAny();

        return $mtom_model->loaded();
    }

    /*
     * helper function for MToMFunctions: Loads the object if only id is passed,
     * else checks if object matches rules
     */
    private function _mToMLoadObject($object, string $object_class) {
        //if object is passed, extract id
        if(is_object($object)) {
            //check if passed object is of desired type
            if(!$object instanceOf $object_class) {
                throw new \atk4\data\Exception('Wrong class:'.(new \ReflectionClass($object))->getName().' was passed, '.$object_class.' was expected in '.__FUNCTION__);
            }

        }
        //we need to have an Object to get table property
        else {
            $object_id = $object;
            $object = new $object_class($this->persistence);
            $object->tryLoad($object_id);
        }

        //make sure object is loaded
        if(!$object->loaded()) {
            throw new \atk4\data\Exception('Object could not be loaded in '.__FUNCTION__);
        }

        return $object;
    }
romaninsh commented 5 years ago

I'm following this, keep this up, and together with some of my own ideas this should be implemented after I'm finished with Actions. Thanks!

PhilippGrashoff commented 5 years ago

needs some $this->get($this->id_field) instead of $this->get('id') definitely :)

DarkSide666 commented 5 years ago

how about $this->id ? That should be the same as $this->get($this->id_field).

romaninsh commented 5 years ago

in some persistences, there may NOT BE the id field accessible through get(). I don't want code to rely on get($this->id_field)

mvorisek commented 2 years ago

With entity design, we should cascade save to all dirty models, and if delete is called on a child model, delete it's owner.