Over the last 6 months I have been working on Ditto Music’s new “Record Label In A Box” system, this is a new way to create your own record label with the added benefit of receiving help, support and all the tools needed to run a successful label.
Part of this system is the ability to create legally binding contracts for your artists, initially we offered 6 contracts but this is always subject to change and the data for each contract type can change dramatically.
Initially it’d be tempting to create a model for each contract type, i.e. ContractMerchandise.php, ContractRight.php – this is a little messy and not very DRY. Instead I set up the system so that we had 3 models rather than 8. Here is a sample case ERD for which I will show you what and how I did it.
In the above image you can see we’re allowing our users to create their own school records, these records could be one of 4 types (If you notice the INSERTS for school_types, we specify the table as well as the name). In the example I have specified custom_field_x for our different schools but these could be anything depending on what you need.
Setting up the Primary Keys
To begin I personally like to store the important primary keys in my own custom configuration file, people have different methods of doing this, you can check my post here about storing primary keys.
1 2 3 4 5 6 7 |
/** * School Types */ Configure::write('SchoolTypePrimaryId', 1); Configure::write('SchoolTypeSecondaryId', 2); Configure::write('SchoolTypeCollegeId', 3); Configure::write('SchoolTypeUniversityId', 4); |
Setting up the School Model
Next let’s set up the main School model method, here we just need to specify our relationships, you can feel free to add any methods you need from your SchoolsController if you were actually building this but for now we’ll leave it blank.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
<?php /** * School * * CakePHP(tm) : Rapid Development Framework (http://cakephp.org) * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) * * @package App.Model */ App::uses('AppModel', 'Model'); /** * Holds the parent school record shared between all school types * * @package App.model */ class School extends AppModel { /** * Defines any belongsTo relationships * * @var array */ public $belongsTo = array( 'SchoolType', 'User' ); /** * Defines any hasOne relationships * * @var array */ public $hasOne = array( 'SchoolDetail' ); // Specify your methods here where needed ... } |
Setting up the SchoolType Model
Next we’ll set up the SchoolType model, in this model I will do a few things, firstly I will set up a few methods to be able to find the SchoolType by id, as a full list or simple list. As well as this I will also introduce caching to save querying the data each time.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
<?php /** * SchoolType. * * CakePHP(tm) : Rapid Development Framework (http://cakephp.org) * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) * * @package App.model */ App::uses('AppModel', 'Model'); /** * Holds the different types of schools and their table names * * @package App.model */ class SchoolType extends AppModel { /** * Defines any hasMany relationships * * @var array */ public $hasMany = array( 'School' ); /** * Callback: After a record is deleted, delete the cache. * * @return void */ public function afterDelete() { Cache::clearGroup('school_type'); } /** * Callback: After a record is created or updated, delete the cache. * * @param bool $created If true means a new record, else update * @param array $options The same as passed to Model::save() * @return void */ public function afterSave($created, $options = array()) { Cache::clearGroup('school_type'); } /** * Finds the school type, uses a monthly cache to avoid strain as will be queried often * * @param int $schoolTypeId The id of the school type * @return array|bool The school type fields or false if failed */ public function findById($schoolTypeId = null) { $schoolType = Cache::read("school_type.{$schoolTypeId}", 'school'); if(! $schoolType) { // Check the type exists before caching if(! $this->exists($schoolTypeId)) { return false; } // Write the data to the cache $schoolType = $this->find('first', array( 'conditions' => array( 'SchoolType.id' => $schoolTypeId ), 'fields' => array( 'SchoolType.id', 'SchoolType.name', 'SchoolType.table' ) )); Cache::write("school_type.{$schoolTypeId}", $schoolType, 'school'); $schoolType['SchoolType']['is_query'] = true; } return $schoolType; } /** * Finds a list of school types and the necessary information (uses cache group) * * @return array An array with the different school types */ public function findList() { $schoolTypes = Cache::read('school_type.list', 'school'); if(! $schoolTypes) { $schoolTypes = $this->find('all', array( 'fields' => array( 'SchoolType.id', 'SchoolType.name', 'SchoolType.table', 'SchoolType.modified' ) )); Cache::write('school_type.list', $schoolTypes, 'school'); $schoolTypes['SchoolType']['is_query'] = true; } return $schoolTypes; } /** * Finds a simplified list of school types with id and name (uses cache group) * * @return array An array with the different school types */ public function findSimpleList() { $schoolTypes = Cache::read('school_type.simple_list', 'school'); if(! $schoolTypes) { $schoolTypes = $this->find('list', array( 'fields' => array( 'SchoolType.id', 'SchoolType.name' ) )); Cache::write('school_type.simple_list', $schoolTypes, 'school'); } return $schoolTypes; } } |
Setting up the School Cache Config
In order for the caching to work in the above example, we’ll need to add the school Cache config to the bottom of the bootstrap.php configuration file.
1 2 3 4 5 6 7 8 9 10 |
/** * School Cache */ Cache::config('school', array( 'engine' => 'File', 'duration' => '+1 month', 'groups' => array( 'school_type' ) )); |
SchoolDetail – Initial Model
This is the core of what we’re doing, this is the model which will select the appropriate table and bind it all together, for this reason I’ll build it step by step so you get an idea of what is going on.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
/** * SchoolDetail. * * CakePHP(tm) : Rapid Development Framework (http://cakephp.org) * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) * * @package App.model */ App::uses('AppModel', 'Model'); /** * Used to handle all the different types of school details (saving, querying, validating etc) * @internal You must specify $this->schoolTypeId from the controller (or model method) in most cases for this model to work. * * @package App.model */ class SchoolDetail extends AppModel { /** * Tells in the table which table to use * * @var bool|string */ public $useTable = false; /** * Defines any belongsTo relationships * * @author Stephen Speakman * @var array */ public $belongsTo = array( 'School' ); /** * Holds the schoolTypeId for selecting the appropriate table * * @var int */ public $schoolTypeId = null; } |
So far so good, we’re telling the model not to use a table, this is because school_details will not exist. We’re specifying $this->schoolTypeId which is very important for selecting the appropriate table.
SchoolDetail – Finding and Updating the appropriate table
We will be using a custom protected method _defineTable to specify the appropriate table, this will be referenced from various model callbacks so it’ll be automated – the only thing you must ensure is that you specify $schoolTypeId from the controller or model depending on what you do.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/** * Used to set the appropriate table for the query * * @throws NotFoundException If the school type could not be found */ protected function _defineTable() { $schoolType = $this->School->SchoolType->findById($this->schoolTypeId); if(! $schoolType) { throw new NotFoundException(__('We could not find the school type with id %u', $this->schoolTypeId)); } // Switching to setSource so we can change the table dynmaically on the fly while the model is instantiated $this->setSource($schoolType['SchoolType']['table']); } |
The above method references findByIdthis is a method in my AppModel file which will get all the fields by primary key – feel free to change this, initially we just want to retrieve the ‘table’ field. We use setSource to tell CakePHP which table we’re using.
1 2 3 4 5 6 7 8 9 10 11 |
/** * Executed before any query (Set the table name here) * * @param type $query Holds the additional properties for a query * @return void */ public function beforeFind($query = array()) { if($this->schoolTypeId) { $this->_defineTable(); } } |
Next we will tell the model to call $this->_defineTable(); before any find query as long as we have specified $this->schoolTypeId.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
/** * Executed before any insert/update query (Set the table name here) * * @param array $options Holds the options for the query * @return true */ public function beforeSave($options = array()) { if($this->schoolTypeId) { $this->_defineTable(); } // Fix the created/modified issue when messing about with useTable false. if(! isset($this->data['SchoolDetail']['id'])) { $this->data['SchoolDetail']['created'] = date('Y-m-d H:i:s'); } $this->data['SchoolDetail']['modified'] = date('Y-m-d H:i:s'); return true; } |
We do the same with beforeSave, only this time we’re specifying the create date if it’s an insert only and the modified is always being specified. There seems to be an issue with setSource which stops this from being automatic.
SchoolDetail – Setting up Validation Rules
It’s likely you’ll want to specify validation rules, but as each table may have different fields, you’ll want to be able to fetch the appropriate validation rules. Here is what we’ll do.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/** * Executed before validation (set the ruleset here) * * @param array $options The options * @return bool True */ public function beforeValidate($options = array()) { if($this->schoolTypeId) { $this->validate = $this->_fetchValidationRules($this->schoolTypeId); } return true; } |
If we use beforeValidate we can check there is a schoolTypeId specified, and if so change the value of $this->validate using our new protected _fetchValidationRules method (see below).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 |
/** * Function to define the validation rules for this model * * @return array The validation rules */ protected function _fetchValidationRules($schoolTypeId = null, $validate = array()) { switch($schoolTypeId) { case Configure::read('SchoolTypePrimaryId'): $validate = array( 'school_id' => array( 'doesExist' => array( 'rule' => array('checkAgainstRecord', 'School'), 'message' => 'School not found', 'allowEmpty' => false ) ), 'custom_field_1' => array( 'maxLength' => array( 'rule' => array('maxLength', 32), 'message' => 'Custom field 1 cannot exceed 32 characters', 'allowEmpty' => false ) ), 'custom_field_2' => array( 'maxLength' => array( 'rule' => array('maxLength', 32), 'message' => 'Custom field 2 cannot exceed 32 characters', 'allowEmpty' => false ) ) ); break; case Configure::read('SchoolTypeSecondaryId'): $validate = array( 'school_id' => array( 'doesExist' => array( 'rule' => array('checkAgainstRecord', 'School'), 'message' => 'School not found', 'allowEmpty' => false ) ), 'custom_field_1' => array( 'maxLength' => array( 'rule' => array('maxLength', 32), 'message' => 'Custom field 1 cannot exceed 32 characters', 'allowEmpty' => false ) ), 'custom_field_2' => array( 'maxLength' => array( 'rule' => array('maxLength', 32), 'message' => 'Custom field 2 cannot exceed 32 characters', 'allowEmpty' => false ) ) ); break; case Configure::read('SchoolTypeCollegeId'): $validate = array( 'school_id' => array( 'doesExist' => array( 'rule' => array('checkAgainstRecord', 'School'), 'message' => 'School not found', 'allowEmpty' => false ) ), 'custom_field_1' => array( 'maxLength' => array( 'rule' => array('maxLength', 32), 'message' => 'Custom field 1 cannot exceed 32 characters', 'allowEmpty' => false ) ), 'custom_field_2' => array( 'maxLength' => array( 'rule' => array('maxLength', 32), 'message' => 'Custom field 2 cannot exceed 32 characters', 'allowEmpty' => false ) ) ); break; case Configure::read('SchoolTypeUniversityId'): $validate = array( 'school_id' => array( 'doesExist' => array( 'rule' => array('checkAgainstRecord', 'School'), 'message' => 'School not found', 'allowEmpty' => false ) ), 'custom_field_1' => array( 'maxLength' => array( 'rule' => array('maxLength', 32), 'message' => 'Custom field 1 cannot exceed 32 characters', 'allowEmpty' => false ) ), 'custom_field_2' => array( 'maxLength' => array( 'rule' => array('maxLength', 32), 'message' => 'Custom field 2 cannot exceed 32 characters', 'allowEmpty' => false ) ) ); break; } return $validate; } |
The above method we’re referencing the primary keys we stored in our config file earlier, the validation rules are all the same here for demonstration purposes but you can change that however you like. This may be a little large, but it could be moved elsewhere if need be.
Note: checkAgainstRecord is a custom validation rule you can find in this post
SchoolType – Saving and Updating Records
Finally we’ll need a method to save or update records which you can easily reference from a controller.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
/** * Finds a single school detail record by using the school id key * * @param int $schoolId The id of the school * @return array The school details arary */ public function findBySchool($schoolId = null) { $schoolDetails = $this->find('first', array( 'conditions' => array( 'SchoolDetail.school_id' => $schoolId ) )); return $schoolDetails; } /** * Updates a schools details * * @param int $schoolId The school id * @param array $request An array containing the request * @param bool $validate False to skip validation * @return bool True on success or false */ public function saveRecord($schoolId = null, $request = array(), $validate = true) { $request['SchoolDetail']['school_id'] = $schoolId; $schoolDetails = $this->findBySchool($schoolId); // Update or Create? if($schoolDetails) { $this->id = $schoolDetails['SchoolDetail']['id']; $result = $this->save($request); } else { $this->create(); $result = $this->save($request, $validate); } return (count($result) > 0) ? true : false; } |
This should be fairly self explanatory, we find the SchoolDetail record via schoolId, if we find it we update the record otherwise we create a new record.
Specifying the School Type Id via SchoolsController
The last thing we need to do is actually specify some methods in which you can easily set the schoolTypeId for the model from within your controller. The first method is one you can use when you know the schoolTypeId foreign key. (flashRedirect is a combination of setFlash and redirect I have in the AppController)
1 2 3 4 5 6 7 8 9 10 11 12 |
/** * Sets the school table id by primary key without querying a school * * @param int $schoolTypeId The id of the school type * @return void */ protected function _setTableBySchoolType($schoolTypeId = null) { if(! $this->School->SchoolType->exists($schoolTypeId)) return $this->flashRedirect(__('The school type with id %u could not be found', $schoolTypeId)); $this->School->SchoolDetail->schoolTypeId = $schoolTypeId; } |
The final method will be one we can reference when we don’t know the schoolTypeId but we do know the schoolId.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
/** * Sets the school table id to SchoolDetail::$schoolTypeId and returns basic school record * * @param int $schoolId The id of the school * @return array The school array with basic details */ protected function _setTableBySchool($schoolId = null) { if(! $this->School->exists($schoolId)) return $this->flashRedirect(__('The school with id %u could not be found', $schoolId)); $school = $this->School->findById($schoolId); $this->School->SchoolDetail->schoolTypeId = $school['School']['school_type_id']; return $school; } |
And that’s it really! Put it all together and you should be able to find, save and update to the individual school tables without creating a model for each school type table. You can add or remove tables records and not have to do much duplicate code by creating a new model.
You may use $this->render() from your controller to specify various views or elements if needed.