Skip to content

Latest commit

 

History

History
707 lines (585 loc) · 19.4 KB

orm.md

File metadata and controls

707 lines (585 loc) · 19.4 KB

ORM

Divergence uses a typical ActiveRecord pattern for it's models.

Model Architecture

If you do not want any default fields extend Divergence\Models\ActiveRecord

If you would like to use default fields extend Divergence\Models\Model

Type Field Description
int ID The primary key.
enum Class Fully qualified PHP namespaced class.
timestamp Created Time when the object is created in the database.
int Creator Reserved for use with authentication system.

The trait Divergence\Models\Getters is automatically also pulled in by Divergence\Models\Model so you don't have to do it yourself.

Important Functionality

Trait Description
Divergence\Models\Getters Suite of methods to pull records from the database.
Divergence\Models\Relations Lets you build relationships between models.
Divergence\Models\Versioning Automatically tracks history of all models.

Classes that use ActiveRecord may optionally use traits to enable relationship features and versioning features respectively.

Object Oriented Architecture

When using array mapping ActiveRecord will merge public static $fields and public static $relationships at run-time giving priority to the child. You can override fields in the child class that have already been set by a parent class.

A child class may choose to unset a relationship or field simply by setting the config to null. A child class may also use a different type for the same database field name.

Overrides must use the key for the field configuration.

You must define these seven configurables. (Don't worry most you can copy and paste.)

Subclassing

   public static $rootClass = __CLASS__;
   public static $defaultClass = __CLASS__;
   public static $subClasses = [__CLASS__];

In the event that you have subclasses you can define them here. By default just use the above configuration. You'll want to override $rootClass and $defaultClass if necessary for yourself.

Table Name & Nouns

    public static $tableName = 'table';
    public static $singularNoun = 'table';
    public static $pluralNoun = 'tables';

Table name is for the database table. Singular noun and plural noun are mostly used by RecordsRequestHandler to load the right template so think of those as template filenames.

Field Mapping

As of 2.0 Divergence now supports field mapping using PHP Attributes.

For example:

    #[Column(type: "integer", primary:true, autoincrement:true, unsigned:true)]
    protected $ID;

Because the field is protected we still trigger __get from outside of the object. Unfortunately this means we must manually run getValue('ID') when used inside the object.

For field mapping by array ActiveRecord also merges each static::$field for every class from child to parent. Any defined $fields are usable as $Model->$fieldName.

    public static $fields = [
        'Tag',
        'Slug',
    ];

About Default Field Configs

By default if you just have a string that will be treated as the name of the field for the model. By default it's treated as a string by PHP and a varchar(255) by the database if allowed to be automatically generated by the framework. There are no default validators so the database will truncate any values above 255 characters.

protected $title; // this will be a varchar(255) treated as a string in PHP

Automatically Create Tables

If you try to use a Model and the database responds with the error for "table not found" then it will will automatically build you the SQL to create the table, run it, and rerun the original query without throwing an error to the user.

You can disable this behavior by setting public static $autoCreateTable to false in your model.

Making a Basic Model

Here's an example of a minimum Model

<?php
namespace yourApp\Models;

class Tag extends \Divergence\Models\Model
{
    
    // support subclassing
    public static $rootClass = __CLASS__;
    public static $defaultClass = __CLASS__;
    public static $subClasses = [__CLASS__];


    // ActiveRecord configuration
    public static $tableName = 'tags';
    public static $singularNoun = 'tag';
    public static $pluralNoun = 'tags';
    
    public static $fields = [
        'Tag',
    ];

We get these fields from \Divergence\Models\Model as defaults.

    #[Column(type: "integer", primary:true, autoincrement:true, unsigned:true)]
    protected $ID;

    #[Column(type: "enum", notnull:true, values:[])]
    protected $Class;

    #[Column(type: "timestamp", default:'CURRENT_TIMESTAMP')]
    protected $Created;

    #[Column(type: "integer", notnull:false)]
    protected $CreatorID;

Create, Update, and Delete

Divergence ActiveRecord is super simple and easy making use of native PHP architecture whenever possible.

Creating


Example without defaults

$Tag = new Tag();
echo $Tag->Name; // prints null
$Tag->Name = 'Divergence';
echo $Tag->Name; // prints Divergence

Example with record instantiation via construct

$Tag = new Tag([
    'Name'  => 'Divergence',
]);
echo $Tag->Name; // prints Divergence

Example with record instantiation via create method

$Tag = Tag::create([
    'Name'  => 'Divergence',
]);
echo $Tag->Name; // prints Divergence

Example with record instantiation via create method and save to database

$Tag = Tag::create([
    'Name'  => 'Divergence',
],true); // save directly to database right away
echo $Tag->Name; // prints Divergence
echo $Tag->ID; // prints ID assigned by the database auto increment

Another save example

$Tag = new Tag();
$Tag->Name = 'Divergence';
echo $Tag->ID; // prints null
$Tag->save();
echo $Tag->ID; // prints ID assigned by the database auto increment

Update


$Tag = Tag::getByID(1);
echo $Tag->ID; // prints 1
$Tag->Name = 'Divergence';
$Tag->save();

Get By Field

$Tag = Tag::getByField('ID',1);
echo $Tag->ID; // prints 1
$Tag->Name = 'Divergence';
$Tag->save();

Delete


$Tag = Tag::getByID(1);
echo $Tag->ID; // prints 1
$Tag->destroy(); // record still in variable

or statically

Tag::delete(1); // returns true if DB::affectedRows > 0

Versioning

Your model must be defined with a use Versioning in it's definition.

<?php
namespace Divergence\Tests\MockSite\Models;

use \Divergence\Models\Model;
use \Divergence\Models\Versioning;

class Tag extends Model
{
    use Versioning;

Configurables

You must provide these settings to use versioning.

    // versioning
    static public $historyTable = 'test_history';
    static public $createRevisionOnDestroy = true;
    static public $createRevisionOnSave = true;

If you did not create your tables yet a versioned model will have it's history table automatically created.

If you add versioning support after your main table is already in use you must use the SQL class to build yourself a table creation query.

Trait \Divergence\Models\Versioning provides these fields.

Definition
    #[Column(type: "integer", unsigned:true, notnull:false)]
    protected $RevisionID;

Trait \Divergence\Models\Versioning provides these methods.

Method Purpose
getRevisionsByID Returns an array of versions of a model by ID and $options config.
getRevisions Returns an array of versions of a model by $options config.

Trait \Divergence\Models\Versioning provides these relationships.

Relationship Type Purpose
History History Pulls old versions of this Model
Definition
    'History' => [
        'type' => 'history',
        'order' => ['RevisionID' => 'DESC'],
    ],
Example - Must use the Relational trait
$Model->getByID(1);
$Model->History; // array of revisions where ID == 1 ordered by RevisionID

($Model->History === $Model->History[0]->History) // returns true

Relationships

Your model must be defined with a use Relationships in it's definition.

<?php
namespace Divergence\Tests\MockSite\Models;

use \Divergence\Models\Model;
use \Divergence\Models\Relations;

class Tag extends Model
{
    use Relations;

Configurables

For array mapping must provide relationship configurations in the static variable $relationships.

    // relationships
    static public $relationships = [
    /*
        'RelationshipName' => [ 
            .. config ...
        ]
        ... more configs
    */
    ]

Otherwise you can define it with attributes like so.

    #[Relation(
        type:'one-one',
        class:Tag::class,
        local: 'ThreadID',
        foreign: 'ID',
    )]
    protected $Tag;

Keep in Mind

  • Relationships should not have the same name.
  • The second will override the first.
  • Children classes can override parent classes by setting the class configuration to null.
  • Relationship configs will be stacked with priority given to the child class.
  • Relationships are callable by their key name from $this->$relationshipKey but model field names take priority!

Examples


Both of these are actually doing the same thing. Some fields are assumed.

One-One

    #[Relation(
        type:'one-one',
        class:Tag::class,
        local: 'ThreadID',
        foreign: 'ID',
    )]
    protected $Tag;

    #[Relation(
        type:'one-one',
        class:Post::class,
        local: 'PostID',
        foreign: 'ID',
    )]
    protected $Post;

One-Many

Feel free to create multiple relationship configurations with different conditions and orders.

    #[Relation(
        type:'one-many',
        class:Thread::class,
        local: 'ID',
        foreign: 'CategoryID'
    )]
    protected $Threads;

    #[Relation(
        type:'one-many',
        class:Thread::class,
        local: 'ID',
        foreign: 'CategoryID',
        conditions: [
            'Created > DATE_SUB(NOW(), INTERVAL 1 HOUR)',
        ],
        order: ['Title'=>'ASC']
    )]

Supported Field Types

Field Type Database Type Default Options
int int notnull = false, unsigned = true, required = false, $default = null
string varchar (255) notnull = false, required = false, $default = null
float float notnull = false, unsigned = true, required = false, $default = null
enum enum values = [], notnull = false, required = false, $default = null
clob text notnull = false, required = false, $default = null
boolean int (1) notnull = false, unsigned = true, required = false, $default = null
password varchar (255) notnull = false, unsigned = true, required = false, $default = null
timestamp timestamp notnull = false, unsigned = true, required = false, $default = null
date date notnull = false, unsigned = true, required = false, $default = null
serialized text notnull = false, unsigned = true, required = false, $default = null
set set notnull = false, unsigned = true, required = false, $default = null
list list notnull = false, unsigned = true, required = false, $default = null

Canary Model - An Example Utilizing Every Field Type

<?php
namespace App\Models;

use Divergence\Models\Relations;
use Divergence\Models\Versioning;
use Divergence\Models\Mapping\Column;

/*
 *  The purpose of this Model is to provide an example of every field type
 *  hopefully in every possible configuration.
 *
 */
class Canary extends \Divergence\Models\Model
{
    use Versioning;
    
    // support subclassing
    public static $rootClass = __CLASS__;
    public static $defaultClass = __CLASS__;
    public static $subClasses = [__CLASS__];


    // ActiveRecord configuration
    public static $tableName = 'canaries';
    public static $singularNoun = 'canary';
    public static $pluralNoun = 'canaries';
    
    // versioning
    public static $historyTable = 'canaries_history';
    public static $createRevisionOnDestroy = true;
    public static $createRevisionOnSave = true;

    #[Column(type: 'int', default:7)]
    protected $ContextID;

    #[Column(type: 'enum', values: [Tag::class], default: Tag::class)]
    protected $ContextClass;

    #[Column(type: 'clob', notnull:true)]
    protected $DNA;

    #[Column(type: 'string', required: true, notnull:true)]
    protected $Name;

    #[Column(type: 'string', blankisnull: true, notnull:false)]
    protected $Handle;

    #[Column(type: 'boolean', default: true)]
    protected $isAlive;

    #[Column(type: 'password')]
    protected $DNAHash;

    #[Column(type: 'timestamp', notnull: false)]
    protected $StatusCheckedLast;

    #[Column(type: 'serialized')]
    protected $SerializedData;

    #[Column(type: 'set', values: [
        "red",
        "pink",
        "purple",
        "deep-purple",
        "indigo",
        "blue",
        "light-blue",
        "cyan",
        "teal",
        "green",
        "light-green",
        "lime",
        "yellow",
        "amber",
        "orange",
        "deep-orange",
        "brown",
        "grey",
        "blue-grey",
    ])]
    protected $Colors;

    #[Column(type: 'list', delimiter: '|')]
    protected $EyeColors;

    #[Column(type: 'float')]
    protected $Height;

    #[Column(type: 'int', notnull: false)]
    protected $LongestFlightTime;

    #[Column(type: 'uint')]
    protected $HighestRecordedAltitude;

    #[Column(type: 'integer', notnull: true)]
    protected $ObservationCount;

    #[Column(type: 'date')]
    protected $DateOfBirth;

    #[Column(type: 'decimal', notnull: false, precision: 5, scale: 2)]
    protected $Weight;

    public static $indexes = [
        'Handle' => [
            'fields' => [
                'Handle',
            ],
            'unique' => true,
        ],
        'DateOfBirth' => [
            'fields' => [
                'DateOfBirth',
            ],
        ],
    ];
}

Validation

Validation is available to you through a static config in your model. The config is an array of validator configs. Whenever possible Divergence validators will use built in PHP validator filters.

Validators are evaluated in the order in which they appear and validation stops if there's an error. ActiveRecord will throw a simple Exception. Be aware that setting validators will open you up to Exceptions that you should catch.

A snippet from ActiveRecord's save method.

// validate
if (!$this->validate($deep)) {
    throw new Exception('Cannot save invalid record');
}
Deep is true by default. It will validate loaded relationships as well.

Set validators in your model.

public static $validators = [
    [
        'field' => 'Name',
        'required' => true,
        'errorMessage' => 'Name is required.',
    ],
];

Examples


[
    'field' => 'Name',
    'minlength' => 2,
    'required' => true,
    'errorMessage' => 'Name is required.',
]
[
    'field' => 'Name',
    'maxlength' => 5,
    'required' => true,
    'errorMessage' => 'Name is too big. Max 5 characters.',
]
[
    'field' => 'ID',
    'required' => true,
    'validator' => 'number',
    'max' => PHP_INT_MAX,
    'min' => 1,
    'errorMessage' => 'ID must be between 0 and PHP_INT_MAX ('.PHP_INT_MAX.')',
]
[
    'field' => 'Float',
    'required' => true,
    'validator' => 'number',
    'max' => 0.759,
    'min' => 0.128,
    'errorMessage' => 'ID must be between 0.127 and 0.760',
]

Email Validation

[
    'field' => 'Email',
    'required' => true,
    'validator' => 'email',
]

Custom Validation

[
    'field' => 'Email',
    'required' => true,
    'validator' => [
        Validate::class,  // this is the actual Validate class that comes with Divergence
        'email', // so look at this method for an example for how to make your own validator
    ],
]

Event Binding

Every ActiveRecord save will call $class::$beforeSave and $class::$afterSave if they are set to PHP callables.

If you set ActiveRecord::$beforeSave you can hook into every save for every model on the entire site.

Both $beforeSave and $afterSave get passed an instance of the object being saved as the only parameter.

Events are not overriden by child classes. An event will fire for every parent of a child class.

The two relevant snippets from ActiveRecord's save.

foreach (static::$_classBeforeSave as $beforeSave) {
    if (is_callable($beforeSave)) {
        $beforeSave($this);
    }
}
foreach (static::$_classAfterSave as $afterSave) {
    if (is_callable($afterSave)) {
        $afterSave($this);
    }
}

Please look at ActiveRecord::_defineEvents() to see how ActiveRecord builds the event chain.

Please also note that if validation failed $afterSave will never fire.

Advanced Techniques

Here I'll show a few examples of how to use ActiveRecord but still do some custom things with your model.

Dynamic Fields

This is a case where you'll want to extend getValue($field).

Here I'm showing different ways of doing it for different reasons.

    public getValue($field) {
        switch($field) {
            case 'HeightCM':
                return static::inchesToCM($this->Height);
            case 'calculateTax':
                return $this->calculateTaxTotal();
            default:
                return parent::getValue($field);
        }
    }

    public static function inchesToCM($value)
    {
        return $value * 2.54;
    }

    public function calculateTaxTotal() {
        $taxTotal = 0;
        if($state = $this->getStateTaxRate()) {
            $taxTotal += ($state * $this->Price);
        }
        if($local = $this->getLocalTaxRate()) {
            $taxTotal += ($local * $this->Price);
        }
        return $taxTotal;
    }

Get Models By Custom Join

In this example we let the table names come right from the class. One thing less to remember. We also make sure our query only gives us the one model we actually want to instantiate from the data. This way we can do complex conditions on other tables in relation to the model we care about. Here we are pulling all BlogPost objects by TagID.

Standalone Example


if (App::$App->is_loggedin()) {
    $where = "`Status` IN ('Draft','Published')";
} else {
    $where = "`Status` IN ('Published')";
}

$BlogPosts = BlogPost::getAllByQuery(
    "SELECT `bp`.* FROM `%s` `bp`
    INNER JOIN %s as `t` ON `t`.`BlogPostID`=`bp`.`ID`
    WHERE `t`.`TagID`='%s' AND $where",
    [
        BlogPost::$tableName,
        PostTags::$tableName,
        $Tag->ID,
    ]
);

Same thing as a Dynamic Field

public getValue($field) {
    switch($field) {
        case 'getAllByTag':
            return static::getAllByTag($_REQUEST['tag']);
        default:
            return parent::getValue($field);
    }
}

public static function getAllByTag($slug) {
    if($Tag = Tag::getByField('Slug', $slug)) {
        if (App::$App->is_loggedin()) {
            $where = "`Status` IN ('Draft','Published')";
        } else {
            $where = "`Status` IN ('Published')";
        }

        return static::getAllByQuery(
            "SELECT `bp`.* FROM `%s` `bp`
            INNER JOIN %s as `t` ON `t`.`BlogPostID`=`bp`.`ID`
            WHERE `t`.`TagID`='%s' AND $where",
            [
                static::$tableName,
                PostTags::$tableName,
                $Tag->ID,
            ]
        );
    } // if
} // getAllByTag