9 minutes to read, 22.21K views since 2019.10.10 Читать на русском

How to create an ORM framework in pure php. ORM Creation Tutorial

Writing raw sql queries is cool. Its cool when you don't manage project with thouthands of lines of code.

When the project grow its better to automatize building sql queries.

Nevertheless ORM is very complex and heavy structure that slows down your application, it brings some extra ability to your application too: you can add auto security checks, sanitization of user input and other stuff.

Let's start

We need to get rows in table mapped to objects in php code. So, at least we need class for each table we going to use in our code:

class Post {

   // ... another code

   public function save() { // this methods save obejct to databases table
     // ... code
   }

}

This is the class which refers to post table. The same structure would be in class User or Comment. Actually we should have save method on every table-class. Thats why we create abstract class for every Entity class:

abstract class Entity {

  public function save() {
    // this method construct sql using reflection 
  }
}

We already have written method save in previous lesson of the course, so we can just copy and modify its content:

public function save() {

    $class = new \ReflectionClass($this);
    $tableName = strtolower($class->getShortName());

    $propsToImplode = [];

    foreach ($class->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { // consider only public properties of the providen 
      $propertyName = $property->getName();
      $propsToImplode[] = '`'.$propertyName.'` = "'.$this->{$propertyName}.'"';
    }

    $setClause = implode(',',$propsToImplode); // glue all key value pairs together
    $sqlQuery = '';

    if ($this->id > 0) {
      $sqlQuery = 'UPDATE `'.$tableName.'` SET '.$setClause.' WHERE id = '.$this->id;
    } else {
      $sqlQuery = 'INSERT INTO `'.$tableName.'` SET '.$setClause.', id = '.$this->id;
    }

    $result = self::$db->exec($sqlQuery);

    if (self::$db->errorCode()) {
        throw new \Exception(self::$db->errorInfo()[2]);
    }

    return $result;
}

In example above we use classname as table name. To make it more flexible we should introduce

abstract class Entity {

  protected $tableName; 

  // ...

and in save method check if its set to some value. If its set - then use value of this variable as the table name:

public function save() {

    $class = new \ReflectionClass($this);
    $tableName = '';

    if ($this->tableName != '') {
      $tableName = $this->tableName;
    } else {
      $tableName = strtolower($class->getShortName());
    }

    // ... rest of the code

We also need the actually database connection. We would put a PDO object into protected $db into Entity:

/**
*
* @var PDO
*/
protected $db;

public function __construct() {
    try {
        $this->db = new \PDO('mysql:host=localhost;dbname=blog','root', '');
    } catch (\Exception $e) {
        throw new \Exception('Error creating a database connection ');
    }
}

I've initialized the PDO object in the constructor of Entity only to show you that this is really PDO object. In real systems you shouldn't do that. Use a dependency injection or service container in such case. We would talk about them in the further lessons of this course.

Mapping fields to columns in table

We would use the easiest way to map columns of the table to object - public properties. Some people say that using public properties in classes breaks incapsulation feature of Object Orientated Proprgamming. They're right in some case, but using public properties lowers overcomplexity of orm and gives strict descriptive view of classes.

Let's have a look at class which uses ORM features we promoted:

class Post extends Entity {

  protected $tableName = 'posts'; // usually tables are named in plural when the object should be named singular

  public $id;

  public $title;

  public $body;

  public $author_id;

  public $date;

  public $views;

  public $finished;
}

Looks awesome, no ?

Now its time to map the fields from the database's table into this object. This would be done by the method morph which converts array into Entity object. The simplest implementation is the following:

/**
*
* @return Entity
*/
public static function morph(array $object) {
  $class = new \ReflectionClass(get_called_class()); // this is static method that's why i use get_called_class

  $entity = $class->newInstance();

  foreach($class->getProperties(\ReflectionProperty::PUBLIC) as $prop) {
    if (isset($object[$prop->getName()])) {
      $prop->setValue($entity,$object[$prop->getName()]);
    }
  }

  $entity->initialize(); // soft magic

  return $entity;
}

This is very simple implementation of the morphing array to object as i say and many things can be added to it: casting to proper types values from the array, validation and other. Probably, we would taking over the casting of types in next lessons.

At the end of the morph i invoke a method initialize of the entity. Its kind of magic as i mentioned in comment: every object could implement method initialize and it would be invoked on each object created. This would help to set up all things up when you have some extra logic on your entity.

Creating and saving objects

At this moment we can already create an object in php and save it to the database via object orientated api without running any line of SQL:

$post = new Post(); // this creates post object

$post->title = 'How to cook pizza';
$post->date = time();
$post->finished = false;

$post->save(); // here we construct sql and execute it on the database

here we already have an AUTO_INCREMENT id of the table in $id prop of the Post (because its a primary index of the table):

echo "new post id: ".$post->id;

Searching over tables with orm classes

we should implement at least two methods which would give ability to search for one element in database and return instance of the entity class and the version which retun bulk of the objects. Let's define method find which returns array of items:

/**
*
* @return Entity[]
*/
public static function find ($options = []) {

    $result = [];
    $query = '';

    $whereClause = '';
    $whereConditions = [];

    if (!empty($options)) {
      foreach ($options as $key => $value) {
        $whereConditions[] = '`'.$key.'` = "'.$value.'"';
      }
      $whereClause = " WHERE ".implode(' AND ',$whereConditions);
    }

    $raw = self::$db->query($query);

    if (self::$db->errorCode()) {
        throw new \Exception(self::$db->errorInfo()[2]);
    }

    foreach ($raw as $rawRow) {
        $result[] = self::morph($rawRow);
    }

    return $result;
}

To simplify the example i'm only implemented simple situation when you can pass the fields names and values fields should equal to. For example next code will return items which have title field equal to Some title:

$posts = Post::find([
  'title' => 'Some title'
]);

We can overcome this limit by adding extra feature to our find method: add possibility to pass raw SQL query part (where clause) to find method. So, if you need to execute some non standart WHERE just pass the conditions to the find method as a string parameter:

$posts = Post::find('id IN (1,5,10)');

We should alter code of the find method to get it work as in example above:

/**
*
* @return Entity[]
*/
public static function find ($options = []) {

    $result = [];
    $query = '';

    if (is_array($options)) {
      // ... old code
    } elseif (is_stirng($options)) { 
      $query = 'WHERE '.$options;
    } else {
      throw new \Exception('Wrong parameter type of options');
    }

    $raw =  $this->database->execute($query);
    foreach ($raw as $rawRow) {
      $result[] = self::morph($rawRow);
    }

    return $result;
}

Going ahead

Actually, i forget to implement limit and order in find method so you can make it as a part of the hometask :)

Give you a hint: we should extends the $options parameter, the example from above would look like:

[
  'condtions' => [
    'title' => 'Some title'
  ]
]

Now we can add the limit and/or order and other options to find:

$posts = Post::find([
  'conditions' => 'id IN (1,2,5)',
  'order' => 'id DESC' 
]);
Read next article Adding extra flexibility to php programs via PhpDoc comments in course Reflection in PHP