src/ActiveRecord.php
<?php
namespace Thru\ActiveRecord;
use Guzzle\Common\Version;
use Thru\ActiveRecord\DatabaseLayer\TableBuilder;
use Thru\JsonPrettyPrinter\JsonPrettyPrinter;
abstract class ActiveRecord
{
static public $MYSQL_FORMAT = "Y-m-d H:i:s";
static public $showSql;
protected $_label_column = null;
protected $_columns;
protected $_table;
protected $_force_insert = false;
protected $_caching_enabled = true;
/**
* Start a Search on this type of active record
* @return Search
*/
public static function search()
{
$class = get_called_class();
return new Search(new $class);
}
/**
* Generic Factory constructor
* @return ActiveRecord
*/
public static function factory()
{
$name = get_called_class();
return new $name();
}
/**
* Override-able __construct call
*/
public function __construct()
{
$tableBuilder = $this->getTableBuilder();
$tableBuilder->build($this);
}
/**
* Override-able calls
*/
public function postConstruct()
{
}
public function preSave()
{
}
public function postSave()
{
}
/**
* Find an item by the Primary Key ID. This does not use the search() functionality
* @param integer $id
* @return ActiveRecord
*/
public function getById($id)
{
$database = DatabaseLayer::getInstance();
$select = $database->select($this->getTableName(), $this->getTableAlias());
$select->fields($this->getTableAlias());
$select->condition($this->getIDField(), $id);
$results = $select->execute(get_called_class());
$result = end($results);
return $result;
}
/**
* Get the field that is being used for the id.
* @return string|false
*/
public function getIDField()
{
if (count($this->getPrimaryKeyIndex()) > 0) {
return $this->getPrimaryKeyIndex()[0];
}
return false;
}
/**
* Get the short alias name of a table.
*
* @param string $table_name Optional table name
* @return string Table alias
*/
public function getTableAlias($table_name = null)
{
if (!$table_name) {
$table_name = $this->getTableName();
}
$bits = explode("_", $table_name);
$alias = '';
foreach ($bits as $bit) {
$alias .= strtolower(substr($bit, 0, 1));
}
return $alias;
}
/**
* Get the table name
*
* @return string Table Name
*/
public function getTableName()
{
return $this->_table;
}
/**
* Get table primary key column name
*
* @deprecated
* @return string|false
*/
public function getTablePrimaryKey()
{
trigger_error('getTablePrimaryKey() is deprecated. Use getIDField() instead.', E_USER_DEPRECATED);
$keys = $this->getPrimaryKeyIndex();
return isset($keys[0])?$keys[0]:false;
}
/**
* Get a unique key to use as an index
*
* @return string[]
*/
public function getPrimaryKeyIndex()
{
$database = DatabaseLayer::getInstance();
$columns = array();
if ($this instanceof VersionedActiveRecord) {
$schema = $this->getClassSchema();
$firstColumn = reset($schema)['name'];
$columns = [$firstColumn => $firstColumn, "sequence" => "sequence"];
} else {
foreach ($database->getTableIndexes($this->_table) as $key) {
$columns[$key->Column_name] = $key->Column_name;
}
}
return array_values($columns);
}
/**
* Get object ID
* @return integer
*/
public function getId()
{
$col = $this->getIDField();
if (property_exists($this, $col)) {
$id = $this->$col;
if ($id > 0) {
return $id;
}
}
return false;
}
/**
* Get a label for the object. Perhaps a Name or Description field.
* @return string
*/
public function getLabel()
{
if (property_exists($this, '_label_column')) {
if (property_exists($this, $this->_label_column)) {
$label_column = $this->_label_column;
return $this->$label_column;
}
}
if (property_exists($this, 'name')) {
return $this->name;
}
if (property_exists($this, 'description')) {
return $this->description;
}
return "No label for " . get_called_class() . " ID " . $this->getId();
}
/**
* Work out which columns should be saved down.
*/
public function __calculateSaveDownRows()
{
if (!$this->_columns) {
foreach (get_object_vars($this) as $potential_column => $discard) {
switch ($potential_column) {
case 'table':
case substr($potential_column, 0, 1) == "_":
// Not a valid column
break;
default:
$this->_columns[] = $potential_column;
break;
}
}
}
// reorder the columns to match get_class_schema
//TODO: Write test to verify that this works right.
foreach ($this->getClassSchema() as $schemaKey => $dontCare) {
if (in_array($schemaKey, $this->_columns)) {
$sortedColumns[$schemaKey] = $schemaKey;
}
}
foreach ($this->_columns as $column) {
if (!isset($sortedColumns[$column])) {
$class_name = get_called_class();
throw new Exception("No type hinting/docblock found for '{$column}' in '{$class_name}'.", E_USER_WARNING);
}
}
$this->_columns = array_values($sortedColumns);
// Return sorted columns.
return $this->_columns;
}
protected function getCacheIdentifier()
{
$elements = [
$this->getClass(),
$this instanceof VersionedActiveRecord ? $this->getId() . "-" . $this->sequence : $this->getId(),
];
return md5(implode("::", $elements));
}
/**
* Load an object from data fed to us as an array (or similar.)
*
* @param Array $row
*
* @return ActiveRecord
*/
public function loadFromRow($row)
{
// Loop over the columns, sanitise and store it into the new properties of this object.
foreach ($row as $column => &$value) {
// Only save columns beginning with a normal letter.
if (preg_match('/^[a-z]/i', $column)) {
$this->$column = & $value;
}
}
$this->postConstruct();
if (DatabaseLayer::getInstance()->useCache() && $this->_caching_enabled) {
$cache = DatabaseLayer::getInstance()->getCache();
$cache->save($this->getCacheIdentifier(), serialize($this));
}
return $this;
}
public function setForceInsert($force_insert = true)
{
$this->_force_insert = $force_insert;
return $this;
}
/**
* Save the selected record.
* This will do an INSERT or UPDATE as appropriate
*
* @param boolean $automatic_reload Whether or not to automatically reload
*
* @return ActiveRecord
*/
public function save($automatic_reload = true)
{
// Run Pre-saver.
$this->preSave();
// Run Field Fixer.
$this->__fieldFix();
// Calculate row to save_down
$this->__calculateSaveDownRows();
$primary_key_column = $this->getIDField();
// Make an array out of the objects columns.
$data = array();
foreach ($this->_columns as $column) {
// Never update the primary key. Bad bad bad. Except if we're versioned.
if ($column != $primary_key_column ||
$this instanceof VersionedActiveRecord ||
$this->_force_insert
) {
$data["`{$column}`"] = $this->$column;
}
}
// If we already have an ID, this is an update.
$database = DatabaseLayer::getInstance();
if (!$this->getId() ||
(property_exists($this, '_is_versioned') && $this->_is_versioned == true) ||
$this->_force_insert
) {
$operation = $database->insert($this->getTableName(), $this->getTableAlias());
$this->_force_insert = false;
} else { // Else, we're an insert.
$operation = $database->update($this->getTableName(), $this->getTableAlias());
}
$operation->setData($data);
if ($this->getId() && $primary_key_column) {
$operation->condition($primary_key_column, $this->$primary_key_column);
$operation->execute();
} else { // Else, we're an insert.
$new_id = $operation->execute($this->getClass());
if ($primary_key_column) {
$this->$primary_key_column = $new_id;
}
}
// Expire cache.
if (DatabaseLayer::getInstance()->useCache()) {
$cache = DatabaseLayer::getInstance()->getCache();
$cache->delete($this->getCacheIdentifier());
}
// Expire any existing copy of this object.
SearchIndex::getInstance()->expire(get_called_class() . "/" . $this->getTableName(), $this->getId());
if ($automatic_reload && $primary_key_column) {
$this->reload();
}
// Run Post Save.
$this->postSave();
// Return object. Should this return true/false based on success instead?
return $this;
}
/**
* Reload the selected record
* @return ActiveRecord|false
*/
public function reload()
{
$item = $this->getById($this->getId());
if ($item !== false) {
$this->loadFromRow($item);
return $this;
} else {
return false;
}
}
/**
* Delete the selected record
* @return boolean
*/
public function delete()
{
$database = DatabaseLayer::getInstance();
$delete = $database->delete($this->getTableName(), $this->getTableAlias());
$delete->setModel($this);
$delete->condition($this->getIDField(), $this->getId());
$delete->execute($this->getClass());
// Invalidate cache.
SearchIndex::getInstance()->expire($this->getTableName(), $this->getId());
return true;
}
/**
* Delete the selected records table.
* WARNING YO.
*/
public static function deleteTable()
{
$class = get_called_class();
$object = new $class();
$table_builder = new TableBuilder($object);
$table_builder->destroy();
}
public static function getTable()
{
$class = get_called_class();
$object = new $class();
return $object->getTableName();
}
/**
* Set the name of the table to use for this ActiveRecord based object
* @param $table
* @return $this
*/
public function setDatabaseTable($table)
{
$this->_table = $table;
return $this;
}
/**
* Get the name of the table to use for this ActiveRecord based object
* @return string
*/
public function getDatabaseTable()
{
return $this->_table;
}
/**
* Pull a database record by the slug we're given.
*
* @param $slug string Slug
*
* @return mixed
*/
public static function getBySlug($slug)
{
$slug_parts = explode("-", $slug, 2);
$class = get_called_class();
$temp_this = new $class();
$primary_key = $temp_this->getIDField();
return self::search()->where($primary_key, $slug_parts[0])->execOne();
}
/**
* Get URL slug.
*
* @return string
*/
public function getSlug()
{
return $this->getId() . "-" . Util::slugify($this->getLabel());
}
public function __toArray($anticipated_rows = null)
{
$array = array();
foreach (get_object_vars($this) as $k => $v) {
if ($anticipated_rows === null || in_array($k, $anticipated_rows)) {
$array[$k] = $v;
}
}
return $array;
}
public function __toPublicArray()
{
$array = array();
$reflect = new \ReflectionObject($this);
foreach ($reflect->getProperties(\ReflectionProperty::IS_PUBLIC /* + ReflectionProperty::IS_PROTECTED*/) as $prop) {
if ($prop->isStatic()) {
continue;
}
$name = $prop->getName();
$array[$name] = $this->$name;
}
return $array;
}
public function __toJson($anticipated_rows = null)
{
$array = $this->__toArray($anticipated_rows);
return JsonPrettyPrinter::Json($array);
}
public function getClass($without_namespace = false)
{
if ($without_namespace) {
$bits = explode("\\", get_called_class());
return end($bits);
} else {
return get_called_class();
}
}
public function getTableBuilder()
{
return new TableBuilder($this);
}
/**
* Fix types of fields to match definition
*/
public function __fieldFix()
{
$schema = $this->getClassSchema();
foreach ($this->__calculateSaveDownRows() as $column) {
if (!isset($schema[$column]['type'])) {
throw new Exception("No type hinting/docblock found for '{$column}' in '" . get_called_class() . "'.", E_USER_WARNING);
}
$type = $schema[$column]['type'];
if ($type == "integer" && !is_int($this->$column)) {
$this->$column = intval($this->$column);
}
}
return true;
}
public function getClassSchema()
{
$current = get_class($this);
$parents[] = $current;
while ($current = get_parent_class($current)) {
$parents[] = $current;
}
$variables = array();
$rows = [];
$abstractRows = [];
foreach (array_reverse($parents) as $parent) {
$reflection_class = new \ReflectionClass($parent);
if (!$reflection_class->isAbstract()) {
$rows[] = explode("\n", $reflection_class->getDocComment());
} else {
$abstractRows[] = explode("\n", $reflection_class->getDocComment());
}
}
foreach ($rows as $rowGroup) {
foreach ($rowGroup as $row) {
$property = $this->__parseSchemaDocblockRow($row);
$variables[][$property['name']] = $property;
}
}
foreach ($abstractRows as $abstractRowGroup) {
foreach ($abstractRowGroup as $row) {
$property = $this->__parseSchemaDocblockRow($row);
$variables[][$property['name']] = $property;
}
}
$merged_variables = call_user_func_array('array_merge', $variables);
return array_filter($merged_variables);
}
private function __parseSchemaDocblockRow($row)
{
$row = str_replace("*", "", $row);
$row = trim($row);
if (substr($row, 0, 4) == '@var') {
return $this->__parseClassSchemaProperty($row);
}
}
private function __parseClassSchemaProperty($row)
{
$bits = explode(" ", $row);
$name = trim($bits[1], "$");
$type = $bits[2];
$type_bits = explode("(", $type, 2);
$type = strtolower($type_bits[0]);
$controls = implode(" ", array_slice($bits, 3));
$controls = explode(" ", $controls);
// TODO: Parse controls for relationships and so on.
if ($type == 'enum' || $type == 'decimal') {
$options = explode(",", $type_bits[1]);
foreach ($options as &$option) {
$option = trim($option);
$option = trim($option, "'\")");
}
} else {
$length = isset($type_bits[1]) ? trim($type_bits[1], ")") : null;
}
$definition = array();
$definition['name'] = $name;
$definition['type'] = $type;
if (isset($length)) {
$definition['length'] = $length;
}
if (isset($options)) {
$definition['options'] = $options;
}
if (in_array("nullable", $controls)) {
$definition['nullable'] = true;
} else {
$definition['nullable'] = false;
}
return $definition;
}
public static function enableSqlDisplay()
{
self::$showSql = true;
}
public static function disableSqlDisplay()
{
self::$showSql = false;
}
public function disableCache()
{
$this->_caching_enabled = false;
}
public function enableCache()
{
$this->_caching_enabled = true;
}
}