Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

bam

  • Loading branch information...
commit 20523eefb5674858b79adaafff283ddf3d77f4d9 0 parents
@kla kla authored
Showing with 9,425 additions and 0 deletions.
  1. +39 −0 ActiveRecord.php
  2. +23 −0 LICENSE
  3. +120 −0 README
  4. +15 −0 TODO
  5. +37 −0 examples/orders/models/Order.php
  6. +9 −0 examples/orders/models/Payment.php
  7. +13 −0 examples/orders/models/Person.php
  8. +79 −0 examples/orders/orders.php
  9. +29 −0 examples/orders/orders.sql
  10. +17 −0 examples/simple/simple.php
  11. +7 −0 examples/simple/simple.sql
  12. +32 −0 examples/simple/simple_with_options.php
  13. +6 −0 examples/simple/simple_with_options.sql
  14. +184 −0 lib/CallBack.php
  15. +134 −0 lib/Column.php
  16. +142 −0 lib/Config.php
  17. +198 −0 lib/Connection.php
  18. +38 −0 lib/ConnectionManager.php
  19. +61 −0 lib/Exceptions.php
  20. +189 −0 lib/Expressions.php
  21. +90 −0 lib/Inflector.php
  22. +955 −0 lib/Model.php
  23. +69 −0 lib/Reflections.php
  24. +309 −0 lib/Relationship.php
  25. +276 −0 lib/SQLBuilder.php
  26. +182 −0 lib/Serialization.php
  27. +57 −0 lib/Singleton.php
  28. +303 −0 lib/Table.php
  29. +477 −0 lib/URL.php
  30. +318 −0 lib/Utils.php
  31. +506 −0 lib/Validations.php
  32. +79 −0 lib/adapters/AbstractMysqlAdapter.php
  33. +71 −0 lib/adapters/MysqlAdapter.php
  34. +144 −0 lib/adapters/MysqliAdapter.php
  35. +161 −0 lib/adapters/PgsqlAdapter.php
  36. +166 −0 lib/adapters/Sqlite3Adapter.php
  37. +326 −0 test/ActiveRecordFindTest.php
  38. +222 −0 test/ActiveRecordTest.php
  39. +208 −0 test/ActiveRecordWriteTest.php
  40. +25 −0 test/AllTests.php
  41. +20 −0 test/AllValidationsTest.php
  42. +237 −0 test/CallbackTest.php
  43. +97 −0 test/ColumnTest.php
  44. +80 −0 test/ConfigTest.php
  45. +28 −0 test/ConnectionManagerTest.php
  46. +36 −0 test/ConnectionTest.php
  47. +193 −0 test/ExpressionsTest.php
  48. +17 −0 test/InflectorTest.php
  49. +12 −0 test/MysqlAdapterTest.php
  50. +15 −0 test/MysqliAdapterTest.php
  51. +362 −0 test/RelationshipTest.php
  52. +209 −0 test/SQLBuilderTest.php
  53. +101 −0 test/SerializationTest.php
  54. +37 −0 test/Sqlite3AdapterTest.php
  55. +41 −0 test/UtilsTest.php
  56. +112 −0 test/ValidatesFormatOfTest.php
  57. +158 −0 test/ValidatesInclusionAndExclusionOfTest.php
  58. +241 −0 test/ValidatesLengthOfTest.php
  59. +148 −0 test/ValidatesNumericalityOfTest.php
  60. +57 −0 test/ValidatesPresenceOfTest.php
  61. +32 −0 test/fixtures/data.sql
  62. +69 −0 test/fixtures/mysql.sql
  63. +72 −0 test/fixtures/pgsql.sql
  64. +69 −0 test/fixtures/sqlite3.sql
  65. +265 −0 test/helpers/AdapterTest.php
  66. +93 −0 test/helpers/DatabaseTest.php
  67. +23 −0 test/helpers/config.php
  68. +6 −0 test/models/Author.php
  69. +16 −0 test/models/Book.php
  70. +10 −0 test/models/BookAttrAccessible.php
  71. +6 −0 test/models/Employee.php
  72. +6 −0 test/models/Event.php
  73. +6 −0 test/models/Host.php
  74. +7 −0 test/models/JoinAuthor.php
  75. +6 −0 test/models/JoinBook.php
  76. +7 −0 test/models/NamespaceTest/SomeModel.php
  77. +6 −0 test/models/Position.php
  78. +33 −0 test/models/RmBldg.php
  79. +12 −0 test/models/Venue.php
  80. +109 −0 test/models/VenueCB.php
  81. +55 −0 test/models/VenueGenericCallBacks.php
39 ActiveRecord.php
@@ -0,0 +1,39 @@
+<?
+if (!defined('PHP_VERSION_ID') || PHP_VERSION_ID < 50300)
+ die('PHP ActiveRecord requires PHP 5.3 or higher');
+
+require_once 'lib/Singleton.php';
+require_once 'lib/Config.php';
+require_once 'lib/Model.php';
+require_once 'lib/Utils.php';
+require_once 'lib/Exceptions.php';
+require_once 'lib/ConnectionManager.php';
+require_once 'lib/Connection.php';
+require_once 'lib/SQLBuilder.php';
+require_once 'lib/Table.php';
+require_once 'lib/Inflector.php';
+require_once 'lib/Validations.php';
+require_once 'lib/Serialization.php';
+require_once 'lib/Reflections.php';
+require_once 'lib/CallBack.php';
+
+spl_autoload_register('activerecord_autoload');
+
+function activerecord_autoload($class_name)
+{
+ $path = ActiveRecord\Config::instance()->get_model_directory();
+ $root = realpath(isset($path) ? $path : '.');
+
+ if (($namespaces = ActiveRecord\get_namespaces($class_name)))
+ {
+ $class_name = array_pop($namespaces);
+ $directories = array();
+ foreach ($namespaces as $directory)
+ $directories[] = $directory;
+
+ $root .= DIRECTORY_SEPARATOR .implode($directories, DIRECTORY_SEPARATOR);
+ }
+
+ @include_once $root . "/$class_name.php";
+}
+?>
23 LICENSE
@@ -0,0 +1,23 @@
+Copyright (c) 2009
+
+AUTHORS:
+Kien La
+Jacques Fuentes
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
120 README
@@ -0,0 +1,120 @@
+# PHP ActiveRecord #
+
+Version 0.9 beta - Sun 17 May 2009
+
+by Kien La and Jacques Fuentes
+<phpactiverecord@gmail.com>
+<http://www.phpactiverecord.org/>
+
+## Introduction ##
+A brief summarization of what ActiveRecord is:
+
+> Active record is an approach to access data in a database. A database table or view is wrapped into a class,
+> thus an object instance is tied to a single row in the table. After creation of an object, a new row is added to
+> the table upon save. Any object loaded gets its information from the database; when an object is updated, the
+> corresponding row in the table is also updated. The wrapper class implements accessor methods or properties for
+> each column in the table or view.
+
+More details can be found [here](http://en.wikipedia.org/wiki/Active_record_pattern).
+
+This implementation is inspired and thus borrows heavily from Ruby on Rails' ActiveRecord.
+We have tried to maintain their conventions while deviating mainly because of convenience or necessity.
+Of course, there are some differences which will be obvious to the user if they are familiar with rails.
+
+## Installation ##
+
+Setup is very easy and straight-forward. There are essentially only two configuration points you must concern yourself with:
+
+1. Setting the model auto_load directory.
+2. Configuring your database connections.
+
+Example:
+
+ ActiveRecord\Config::initialize(function($cfg)
+ {
+ $cfg->set_model_directory('/path/to/your/model_directory');
+ $cfg->set_connections(array('development' => 'mysql://username:password@localhost/database_name'));
+ });
+
+Alternatively (w/o the 5.3 closure):
+
+ $cfg = ActiveRecord\Config::instance();
+ $cfg->set_model_directory('/path/to/your/model_directory');
+ $cfg->set_connections(array('development' => 'mysql://username:password@localhost/database_name'));
+
+Once you have configured these two settings you are done. ActiveRecord takes care of the rest for you.
+It does not require that you map your table schema to yaml/xml files. It will query the database for this information and
+cache it so that it does not make multiple calls to the database for a single schema.
+
+## Features ##
+
+- Finder methods
+- Dynamic finder methods
+- Writer methods
+- Relationships
+- Validations
+- Callbacks
+- Serializations (json/xml)
+- Support for multiple adapters
+- Miscellaneous options such as: aliased/protected/accessible attributes
+
+Here are some other features we hope to include in later versions:
+- Transactions
+- Named scopes
+- More adapters
+- Relationship includes
+
+## Basic CRUD ##
+
+### Retrieve ###
+These are your basic methods to find and retrieve records from your database.
+See the *Finders* section for more details.
+
+ $post = Post::find(1);
+ echo $post->title; # 'My first blog post!!'
+ echo $post->author_id; # 5
+
+ # also the same since it is the first record in the db
+ $post = Post::first();
+
+ # finding using dynamic finders
+ $post = Post::find_by_name('The Decider');
+ $post = Post::find_by_name_and_id('The Bridge Builder',100);
+ $post = Post::find_by_name_or_id('The Bridge Builder',100);
+
+ # finding using a conditions array
+ $posts = Post::find('all',array('conditions' => array('name=? or id > ?','The Bridge Builder',100)));
+
+### Create ###
+Here we create a new post by instantiating a new object and then invoking the save() method.
+
+ $post = new Post();
+ $post->title = 'My first blog post!!';
+ $post->author_id = 5;
+ $post->save();
+ # INSERT INTO `posts` (title,author_id) VALUES('My first blog post!!', 5)
+
+### Update ###
+To update you would just need to find a record first and then change one of its attributes.
+It keeps an array of attributes that are "dirty" (that have been modified) and so our
+sql will only update the fields modified.
+
+ $post = Post::find(1);
+ echo $post->title; # 'My first blog post!!'
+ $post->title = 'Some real title';
+ $post->save();
+ # UPDATE `posts` SET title='Some real title' WHERE id=1
+
+ $post->title = 'New real title';
+ $post->author_id = 1;
+ $post->save();
+ # UPDATE `posts` SET title='New real title', author_id=1 WHERE id=1
+
+### Delete ###
+Deleting a record will not *destroy* the object. This means that it will call sql to delete
+the record in your database but you can still use the object if you need to.
+
+ $post = Post::find(1);
+ $post->delete();
+ # DELETE FROM `posts` WHERE id=1
+ echo $post->title; # 'New real title'
15 TODO
@@ -0,0 +1,15 @@
+== General
+
+- named scopes
+- add has_to_and_belongs_to_many
+- oracle adapter
+- mssql adapter
+- pgsql adapter
+- using create_xxx, build_xxx on a has_many :thru needs to create both records
+- support create! and save! functionality
+
+== Testing
+
+- make tests run faster by not re-creating whole db for every individual test
+- tests/fixes for funky table/field names
+- more tests for to_xml
37 examples/orders/models/Order.php
@@ -0,0 +1,37 @@
+<?
+class Order extends ActiveRecord\Model
+{
+ // order belongs to a person
+ static $belongs_to = array(
+ array('person'));
+
+ // order can have many payments by many people
+ // the conditions is just there as an example as it makes no logical sense
+ static $has_many = array(
+ array('payments'),
+ array('people',
+ 'through' => 'payments',
+ 'select' => 'people.*, payments.amount',
+ 'conditions' => 'payments.amount < 200'));
+
+ // order must have a price and tax > 0
+ static $validates_numericality_of = array(
+ array('price', 'greater_than' => 0),
+ array('tax', 'greater_than' => 0));
+
+ // setup a callback to automatically apply a tax
+ static $before_validation_on_create = array('apply_tax');
+
+ public function apply_tax()
+ {
+ if ($this->person->state == 'VA')
+ $tax = 0.045;
+ elseif ($this->person->state == 'CA')
+ $tax = 0.10;
+ else
+ $tax = 0.02;
+
+ $this->tax = $this->price * $tax;
+ }
+}
+?>
9 examples/orders/models/Payment.php
@@ -0,0 +1,9 @@
+<?
+class Payment extends ActiveRecord\Model
+{
+ // payment belongs to a person
+ static $belongs_to = array(
+ array('person'),
+ array('order'));
+}
+?>
13 examples/orders/models/Person.php
@@ -0,0 +1,13 @@
+<?
+class Person extends ActiveRecord\Model
+{
+ // a person can have many orders and payments
+ static $has_many = array(
+ array('orders'),
+ array('payments'));
+
+ // must have a name and a state
+ static $validates_presence_of = array(
+ array('name'), array('state'));
+}
+?>
79 examples/orders/orders.php
@@ -0,0 +1,79 @@
+<?
+require_once dirname(__FILE__) . '/../../ActiveRecord.php';
+
+// initialize ActiveRecord
+ActiveRecord\Config::initialize(function($cfg)
+{
+ $cfg->set_model_directory(dirname(__FILE__) . '/models');
+ $cfg->set_connections(array('development' => 'mysql://test:test@127.0.0.1/orders_test'));
+
+ // you can change the default connection with the below
+ //$cfg->set_default_connection('production');
+});
+
+// create some people
+$jax = new Person(array('name' => 'Jax', 'state' => 'CA'));
+$jax->save();
+
+// compact way to create and save a model
+$tito = Person::create(array('name' => 'Tito', 'state' => 'VA'));
+
+// place orders. tax is automatically applied in a callback
+// create_orders will automatically place the created model into $tito->orders
+// even if it failed validation
+$pokemon = $tito->create_orders(array('item_name' => 'Live Pokemon', 'price' => 6999.99));
+$coal = $tito->create_orders(array('item_name' => 'Lump of Coal', 'price' => 100.00));
+$freebie = $tito->create_orders(array('item_name' => 'Freebie', 'price' => -100.99));
+
+if (count($freebie->errors) > 0)
+ echo "[FAILED] saving order $freebie->item_name: " . join(', ',$freebie->errors->full_messages()) . "\n\n";
+
+// payments
+$pokemon->create_payments(array('amount' => 1.99, 'person_id' => $tito->id));
+$pokemon->create_payments(array('amount' => 4999.50, 'person_id' => $tito->id));
+$pokemon->create_payments(array('amount' => 2.50, 'person_id' => $jax->id));
+
+// reload since we don't want the freebie to show up (because it failed validation)
+$tito->reload();
+
+echo "$tito->name has " . count($tito->orders) . " orders for: " . join(', ',ActiveRecord\collect($tito->orders,'item_name')) . "\n\n";
+
+// get all orders placed by Tito
+foreach (Order::find_all_by_person_id($tito->id) as $order)
+{
+ echo "Order #$order->id for $order->item_name ($$order->price + $$order->tax tax) ordered by " . $order->person->name . "\n";
+
+ if (count($order->payments) > 0)
+ {
+ // display each payment for this order
+ foreach ($order->payments as $payment)
+ echo " payment #$payment->id of $$payment->amount by " . $payment->person->name . "\n";
+ }
+ else
+ echo " no payments\n";
+
+ echo "\n";
+}
+
+// display summary of all payments made by Tito and Jax
+$conditions = array(
+ 'conditions' => array('id IN(?)',array($tito->id,$jax->id)),
+ 'order' => 'name desc');
+
+foreach (Person::all($conditions) as $person)
+{
+ $n = count($person->payments);
+ $total = array_sum(ActiveRecord\collect($person->payments,'amount'));
+ echo "$person->name made $n payments for a total of $$total\n\n";
+}
+
+// using order has_many people through payments with options
+// array('people', 'through' => 'payments', 'select' => 'people.*, payments.amount', 'conditions' => 'payments.amount < 200'));
+// this means our people in the loop below also has the payment information since it is part of an inner join
+// we will only see 2 of the people instead of 3 because 1 of the payments is greater than 200
+$order = Order::find($pokemon->id);
+echo "Order #$order->id for $order->item_name ($$order->price + $$order->tax tax)\n";
+
+foreach ($order->people as $person)
+ echo " payment of $$person->amount by " . $person->name . "\n";
+?>
29 examples/orders/orders.sql
@@ -0,0 +1,29 @@
+-- written for mysql, not tested with any other db
+
+drop table if exists people;
+create table people(
+ id int not null primary key auto_increment,
+ name varchar(50),
+ state char(2),
+ created_at datetime,
+ updated_at datetime
+);
+
+drop table if exists orders;
+create table orders(
+ id int not null primary key auto_increment,
+ person_id int not null,
+ item_name varchar(50),
+ price decimal(10,2),
+ tax decimal(10,2),
+ created_at datetime
+);
+
+drop table if exists payments;
+create table payments(
+ id int not null primary key auto_increment,
+ order_id int not null,
+ person_id int not null,
+ amount decimal(10,2),
+ created_at datetime
+);
17 examples/simple/simple.php
@@ -0,0 +1,17 @@
+<?
+require_once dirname(__FILE__) . '/../../ActiveRecord.php';
+
+// assumes a table named "books" with a pk named "id"
+// see simple.sql
+class Book extends ActiveRecord\Model { }
+
+// initialize ActiveRecord
+// change the connection settings to whatever is appropriate for your mysql server
+ActiveRecord\Config::initialize(function($cfg)
+{
+ $cfg->set_model_directory('.');
+ $cfg->set_connections(array('development' => 'mysql://test:test@127.0.0.1/test'));
+});
+
+print_r(Book::first()->attributes());
+?>
7 examples/simple/simple.sql
@@ -0,0 +1,7 @@
+create table books(
+ id int not null primary key auto_increment,
+ name varchar(50),
+ author varchar(50)
+);
+
+insert into books(name,author) values('How to be Angry','Jax');
32 examples/simple/simple_with_options.php
@@ -0,0 +1,32 @@
+<?
+require_once dirname(__FILE__) . '/../../ActiveRecord.php';
+
+class Book extends ActiveRecord\Model
+{
+ // explicit table name since our table is not "books"
+ static $table_name = 'simple_book';
+
+ // explicit pk since our pk is not "id"
+ static $primary_key = 'book_id';
+
+ // explicit connection name since we always want production with this model
+ static $connection = 'production';
+
+ // explicit database name will generate sql like so => db.table_name
+ static $db = 'test';
+}
+
+$connections = array(
+ 'development' => 'mysql://invalid',
+ 'production' => 'mysql://test:test@127.0.0.1/test'
+);
+
+// initialize ActiveRecord
+ActiveRecord\Config::initialize(function($cfg) use ($connections)
+{
+ $cfg->set_model_directory('.');
+ $cfg->set_connections($connections);
+});
+
+print_r(Book::first()->attributes());
+?>
6 examples/simple/simple_with_options.sql
@@ -0,0 +1,6 @@
+create table simple_book(
+ book_id int not null primary key auto_increment,
+ name varchar(50)
+);
+
+insert into simple_book (name) values ('simple w/ options!');
184 lib/CallBack.php
@@ -0,0 +1,184 @@
+<?php
+/**
+ * @package ActiveRecord
+ * @subpackage CallBack
+ */
+namespace ActiveRecord;
+use Closure;
+
+/**
+ * Callbacks allow the programmer to hook into the life cycle of an ActiveRecord object. You can control
+ * the state of your object by declaring certain methods to be called before or after methods
+ * are invoked on your object inside of ActiveRecord.
+ *
+ * @package ActiveRecord
+ * @subpackage CallBack
+ */
+class CallBack
+{
+ /**
+ * Array of callbacks that are available to use.
+ * @access private
+ * @static
+ * @var array
+ */
+ static private $DEFAULT_CALLBACKS = array(
+ 'after_construct',
+ 'before_save',
+ 'after_save',
+ 'before_create',
+ 'after_create',
+ 'before_update',
+ 'after_update',
+ 'before_validation',
+ 'after_validation',
+ 'before_validation_on_create',
+ 'after_validation_on_create',
+ 'before_validation_on_update',
+ 'after_validation_on_update',
+ 'before_destroy',
+ 'after_destroy'
+ );
+
+ /**
+ * Container for reflection class of given model
+ * @access private
+ * @var object
+ */
+ private $klass;
+
+ /**
+ * Reference to the given model
+ * @access private
+ * @var object
+ */
+ private $model;
+
+ /**
+ * @access private
+ * @var array
+ */
+ private $registry = array();
+
+ /**
+ * @param object ActiveRecord\Model
+ * @return void
+ */
+ public function __construct(Model $model)
+ {
+ $this->model = $model;
+ $this->klass = Reflections::instance()->get($this->model);
+ $this->registry = array_fill_keys(self::$DEFAULT_CALLBACKS, array());
+ $this->register_all();
+ }
+
+ /**
+ * Get the default/available callbacks
+ * @static
+ * @see ActiveRecord\CallBack::$DEFAULT_CALLBACKS
+ * @return array
+ */
+ public static function get_allowed_call_backs()
+ {
+ return self::$DEFAULT_CALLBACKS;
+ }
+
+ /**
+ * Get the registered callbacks
+ * @return array
+ */
+ public function get_registry()
+ {
+ return $this->registry;
+ }
+
+ /**
+ * Send a notification which will invoke methods inside the registry array based
+ * on the name passed.
+ *
+ * This is the only piece of the CallBack class that carries its own logic for the
+ * model object. For (after|before)_(create|update) callbacks, it will merge with
+ * a generic 'save' callback which is called first for the lease amount of precision.
+ *
+ * Returns null if no such name exists within the registry.
+ * Returns false if the following:
+ * a method was invoked that was for a before_* callback and that
+ * method returned false. If this happens, execution of any other callbacks after
+ * the offending callback will not occur.
+ * @param str
+ * @return mixed
+ */
+ public function send($name)
+ {
+ if (array_key_exists($name, $this->registry))
+ {
+ $registry = $this->registry[$name];
+
+ if (preg_match('/(after|before)_(create|update)/', $name))
+ {
+ $temporal_save = str_replace(array('create', 'update'), 'save', $name);
+ $registry = array_merge($this->registry[$temporal_save], $registry);
+ }
+
+ foreach ($registry as $method)
+ {
+ if ($method instanceof Closure)
+ $ret = $method($this->model);
+ else
+ $ret = $this->model->$method();
+
+ if (false === $ret && substr($name, 0, 6) === 'before')
+ return false;
+ }
+ return true;
+ }
+ return null;
+ }
+
+ /**
+ * Registers the default/generic callbacks on the model such as
+ * before_save so that you do not have to define them in a static
+ * declaration inside the model. You would only need to define the method
+ * itself on the model.
+ * @access private
+ * @return void
+ */
+ private function register_all()
+ {
+ foreach (array_values(self::$DEFAULT_CALLBACKS) as $name)
+ {
+ //load the generic/default method on model to be invoked
+ $this->register($name, $name);
+ $this->register($name, $this->klass->getStaticPropertyValue($name, null));
+ }
+ }
+
+ /**
+ * @param str
+ * @param array
+ * @param array
+ * @return void or false
+ */
+ public function register($name, $definition, $options=array())
+ {
+ $options = array_merge(array('prepend' => false), $options);
+
+ if (is_null($definition))
+ return false;
+
+ if (!is_array($definition))
+ $definition = array($definition);
+
+ foreach ($definition as $method)
+ {
+ if (!($method instanceof Closure) && !$this->klass->hasMethod($method))
+ continue;
+
+ if ($options['prepend'])
+ array_unshift($this->registry[$name], $method);
+ else
+ $this->registry[$name][] = $method;
+ }
+ }
+}
+?>
134 lib/Column.php
@@ -0,0 +1,134 @@
+<?php
+/**
+ * @package ActiveRecord
+ * @subpackage Column
+ */
+namespace ActiveRecord;
+use DateTime;
+/**
+ * @package ActiveRecord
+ * @subpackage Column
+ */
+class Column
+{
+ // types for $type
+ const STRING = 1;
+ const INTEGER = 2;
+ const DECIMAL = 3;
+ const DATETIME = 4;
+ const DATE = 5;
+
+ /**
+ * @static
+ * @var array
+ */
+ static $TYPE_MAPPING = array(
+ 'datetime' => self::DATETIME,
+ 'timestamp' => self::DATETIME,
+ 'date' => self::DATE,
+
+ 'int' => self::INTEGER,
+ 'tinyint' => self::INTEGER,
+ 'smallint' => self::INTEGER,
+ 'mediumint' => self::INTEGER,
+ 'bigint' => self::INTEGER,
+
+ 'float' => self::DECIMAL,
+ 'double' => self::DECIMAL,
+ 'numeric' => self::DECIMAL,
+ 'decimal' => self::DECIMAL,
+ 'dec' => self::DECIMAL);
+
+ /**
+ * The true name of this column.
+ * @var string
+ */
+ public $name;
+
+ /**
+ * The inflected name of this columns .. hyphens/spaces will be => _
+ * @var string
+ */
+ public $inflected_name;
+
+ /**
+ * The type of this column: STRING, INTEGER, ...
+ * @var integer
+ */
+ public $type;
+
+ /**
+ * The raw database specific type.
+ * @var string
+ */
+ public $raw_type;
+
+ /**
+ * The maximum length of this column.
+ * @var int
+ */
+ public $length;
+
+ /**
+ * True if this column allows null.
+ * @var boolean
+ */
+ public $nullable;
+
+ /**
+ * True if this column is a primary key.
+ * @var boolean
+ */
+ public $pk;
+
+ /**
+ * The default value of the column.
+ * @var mixed
+ */
+ public $default;
+
+ /**
+ * True if this column is set to auto_increment.
+ * @var boolean
+ */
+ public $auto_increment;
+
+ /**
+ * Casts a value to the column's type.
+ * @param value to cast
+ * @return mixed type-casted value
+ */
+ public function cast($value)
+ {
+ if ($value === null)
+ return null;
+
+ switch ($this->type)
+ {
+ case self::STRING: return (string)$value;
+ case self::INTEGER: return (int)$value;
+ case self::DECIMAL: return (double)$value;
+ case self::DATETIME:
+ case self::DATE: return new \DateTime($value);
+ }
+ return $value;
+ }
+
+ /**
+ * Sets the $type member variable.
+ * @return mixed
+ */
+ public function map_raw_type()
+ {
+ if ($this->raw_type == 'integer')
+ $this->raw_type = 'int';
+
+ if (array_key_exists($this->raw_type,self::$TYPE_MAPPING))
+ $this->type = self::$TYPE_MAPPING[$this->raw_type];
+ else
+ $this->type = self::STRING;
+
+ return $this->type;
+ }
+}
+?>
142 lib/Config.php
@@ -0,0 +1,142 @@
+<?php
+/**
+ * @package ActiveRecord
+ * @subpackage Config
+ */
+namespace ActiveRecord;
+use Closure;
+
+/**
+ * @package ActiveRecord
+ * @subpackage Config
+ */
+class Config extends Singleton
+{
+ /**
+ * Default connection
+ * @var string
+ */
+ private $default_connection = 'development';
+
+ /**
+ * Array of available connection strings
+ * @var array
+ */
+ private $connections = array();
+
+ /**
+ * Directory for the auto_loading of model classes
+ * @see activerecord_autoload()
+ * @var string
+ */
+ private $model_directory;
+
+ /**
+ * Allows block-like config initialization,
+ *
+ * Example:
+ *
+ * ActiveRecord\Config::initialize(function($cfg)
+ * {
+ * $cfg->set_model_directory('/path/to/your/model_directory');
+ * $cfg->set_connections(array('development' =>
+ * 'mysql://username:password@127.0.0.1/database_name'));
+ * });
+ * @static
+ * @param Closure object
+ * @return void
+ */
+ public static function initialize(Closure $initializer)
+ {
+ $initializer(parent::instance());
+ }
+
+ /**
+ * @see @var $connections
+ * @throws ActiveRecord\ConfigException
+ * @param array $connections Array of connections
+ * @param string $default_connection Optionally specify the default_connection
+ * @return void
+ */
+ public function set_connections($connections, $default_connection=null)
+ {
+ if (!is_array($connections))
+ throw new ConfigException("Connections must be an array");
+
+ if ($default_connection)
+ $this->set_default_connection($default_connection);
+
+ $this->connections = $connections;
+ }
+
+ /**
+ * @return array
+ */
+ public function get_connections()
+ {
+ return $this->connections;
+ }
+
+ /**
+ * Returns a connection string if found otherwise null
+ * @param string
+ * @return mixed
+ */
+ public function get_connection($name)
+ {
+ if (array_key_exists($name, $this->connections))
+ return $this->connections[$name];
+
+ return null;
+ }
+
+ /**
+ * Returns the default connection string or null if there is none
+ * @return mixed
+ */
+ public function get_default_connection_string()
+ {
+ return array_key_exists($this->default_connection,$this->connections) ? $this->connections[$this->default_connection] : null;
+ }
+
+ /**
+ * Returns the name of the default connection
+ * @return string
+ */
+ public function get_default_connection()
+ {
+ return $this->default_connection;
+ }
+
+ /**
+ * Set the name of the default connection
+ * @param string $connection_name Name of a connection in the connections array
+ * @return void
+ */
+ public function set_default_connection($name)
+ {
+ $this->default_connection = $name;
+ }
+
+ /**
+ * @throws ActiveRecord\ConfigException
+ * @param string
+ * @return void
+ */
+ public function set_model_directory($dir)
+ {
+ if (!file_exists($dir))
+ throw new ConfigException("Invalid or non-existent directory: $dir");
+
+ $this->model_directory = $dir;
+ }
+
+ /**
+ * @return string
+ */
+ public function get_model_directory()
+ {
+ return $this->model_directory;
+ }
+};
+?>
198 lib/Connection.php
@@ -0,0 +1,198 @@
+<?php
+namespace ActiveRecord;
+
+require_once 'URL.php';
+require_once 'Column.php';
+require_once 'Expressions.php';
+
+abstract class Connection
+{
+ public $connection;
+
+ /**
+ * Retrieve a database connection.
+ *
+ * @param string $url A database connection string (ex. mysql://user:pass@host[:port]/dbname)
+ * Everything after the protocol:// part is specific to the connection adapter.
+ * OR
+ * A connection name that is set in ActiveRecord\Config
+ * If null it will use the default connection specified by ActiveRecord\Config->set_default_connection
+ * @return An ActiveRecord::Connection object
+ */
+ public static function instance($connection_string_or_connection_name=null)
+ {
+ $config = Config::instance();
+
+ if (strpos($connection_string_or_connection_name,'://') === false)
+ {
+ $connection_string = $connection_string_or_connection_name ?
+ $config->get_connection($connection_string_or_connection_name) :
+ $config->get_default_connection_string();
+ }
+ else
+ $connection_string = $connection_string_or_connection_name;
+
+ if (!$connection_string)
+ throw new DatabaseException("Empty connection string");
+
+ $url = new Net_URL($connection_string);
+ $protocol = $url->protocol;
+ $class = ucwords($protocol) . 'Adapter';
+ $fqclass = '\ActiveRecord\\' . $class;
+ $source = dirname(__FILE__) . "/adapters/$class.php";
+
+ if (!file_exists($source))
+ throw new DatabaseException("Adapter source not found. Expected to be in $source");
+
+ require_once($source);
+
+ if (!class_exists($fqclass))
+ throw new DatabaseException("No connection adapter found for protocol: $url->protocol");
+
+ $connection = new $fqclass($connection_string);
+ $connection->protocol = $protocol;
+ $connection->class = $class;
+ $connection->fqclass = $fqclass;
+
+ return $connection;
+ }
+
+ protected function __construct($connection_string)
+ {
+ $this->connect($connection_string);
+ }
+
+ /**
+ * Use this for any adapters that can take connection info in the form below
+ * to set the adapters connection info.
+ *
+ * protocol://user:pass@host[:port]/dbname
+ *
+ * @params string $url A URL
+ * @return The parsed URL as an array.
+ */
+ public static function connection_info_from($url)
+ {
+ $url = new Net_URL($url);
+
+ if (!$url->host)
+ throw new DatabaseException('Database host must be specified in the connection string.');
+
+ if (!$url->path)
+ throw new DatabaseException('Database name must be specified in the connection string.');
+
+ $url->db = substr($url->path,1);
+
+ return $url;
+ }
+
+ /**
+ * Fetches all data in the result set into an array.
+ */
+ public function fetch_all($res)
+ {
+ $list = array();
+
+ while (($row = $this->fetch($res)))
+ $list[] = $row;
+
+ return $list;
+ }
+
+ /**
+ * Retrieves column meta data for the specified table.
+ *
+ * @param string $table Name of a table
+ * @return An array of ActiveRecord::Column objects.
+ */
+ abstract function columns($table);
+
+ /**
+ * Connects to the database. Should throw an ActiveRecord\DatabaseException
+ * if connection failed.
+ */
+ protected abstract function connect($connection_string);
+
+ /**
+ * Closes the connection. Must set $this->connection to null.
+ */
+ abstract function close();
+
+ /**
+ * Escapes a string.
+ *
+ * @param string $string String to escape
+ * @return string
+ */
+ abstract function escape($string);
+
+ /**
+ * Fetches the current row data for the specified result set.
+ *
+ * @param object $res The raw connectoin result set.
+ * @return An associative array containing the record values.
+ */
+ abstract function fetch($res);
+
+ /**
+ * Frees a result set or statement handle.
+ *
+ * @param mixed $res The result set or statement handle to free
+ */
+ abstract function free_result_set($res);
+
+ /**
+ * Retrieve the insert id of the last model saved.
+ * @return int.
+ */
+ abstract function insert_id();
+
+ /**
+ * Adds a limit clause to the SQL query.
+ *
+ * @param string $sql The SQL statement.
+ * @param int $offset Row offste to start at.
+ * @param int $limit Maximum number of rows to return.
+ */
+ abstract function limit($sql, $offset, $limit);
+
+ /**
+ * Execute a raw SQL query on the database.
+ *
+ * @param string $sql Raw SQL string to execute.
+ * @param array $values Optional array of bind values
+ * @return A result set handle or void if you used $handler closure.
+ */
+ abstract function query($sql, $values=array());
+
+ /**
+ * Execute a raw SQL query and fetch the results.
+ *
+ * @param string $sql Raw SQL string to execute.
+ * @param Closure $handler Closure that will be passed the fetched results.
+ * @return array Array of table names.
+ */
+ function query_and_fetch($sql, \Closure $handler)
+ {
+ $res = $this->query($sql);
+
+ while (($row = $this->fetch($res)))
+ $handler($row);
+ }
+
+ /**
+ * Quote a name like table names and field names.
+ *
+ * @param string $string String to quote.
+ * @return string
+ */
+ abstract function quote_name($string);
+
+ /**
+ * Returns a list of tables available to the current connection.
+ *
+ * @return array Array of table names.
+ */
+ abstract function tables();
+};
+?>
38 lib/ConnectionManager.php
@@ -0,0 +1,38 @@
+<?php
+/**
+ * @package ActiveRecord
+ * @subpackage ConnectionManager
+ */
+namespace ActiveRecord;
+
+/**
+ * @package ActiveRecord
+ * @subpackage ConnectionManager
+ */
+class ConnectionManager extends Singleton
+{
+ /**
+ * Array of ActiveRecord\Connection objects
+ * @static
+ * @var array
+ */
+ static private $connections = array();
+
+ /**
+ * If @param $name is null then the default connection will be returned.
+ * @see ActiveRecord\Config @var $default_connection
+ * @param string $name Name of a connection
+ * @return ActiveRecord\Connection instance
+ */
+ public static function get_connection($name=null)
+ {
+ if (!isset(self::$connections[$name]) || !self::$connections[$name]->connection)
+ {
+ $config = Config::instance();
+ $connection_string = $name ? $config->get_connection($name) : $config->get_default_connection();
+ self::$connections[$name] = Connection::instance($connection_string);
+ }
+ return self::$connections[$name];
+ }
+};
+?>
61 lib/Exceptions.php
@@ -0,0 +1,61 @@
+<?php
+namespace ActiveRecord;
+
+class ActiveRecordException extends \Exception {};
+
+class RecordNotFound extends ActiveRecordException {};
+
+class DatabaseException extends ActiveRecordException {};
+
+class ModelException extends ActiveRecordException {};
+
+class ExpressionsException extends ActiveRecordException {};
+
+class ConfigException extends ActiveRecordException {};
+
+class UndefinedPropertyException extends ModelException
+{
+ /**
+ * Sets the exception message to show the undefined property's name
+ *
+ * @param str $property_name name of undefined property
+ * @return void
+ */
+ public function __construct($property_name)
+ {
+ if (is_array($property_name))
+ {
+ $this->message = implode("\r\n", $property_name);
+ return;
+ }
+
+ $this->message = "Undefined property: $property_name in {$this->file} on line {$this->line}";
+ }
+};
+
+class ReadOnlyException extends ModelException
+{
+ /**
+ * Sets the exception message to show the undefined property's name
+ *
+ * @param str $class_name name of the model that is read only
+ * @param str $method_name name of method which attempted to modify the model
+ * @return void
+ */
+ public function __construct($class_name, $method_name)
+ {
+ $this->message = "Model ".get_class($this)." cannot be $method_name because it is set to read only";
+ }
+};
+
+class ValidationsArgumentError extends ActiveRecordException {};
+
+
+
+namespace ActiveRecord\Relationship;
+use ActiveRecord;
+
+class RelationshipException extends ActiveRecord\ActiveRecordException {};
+
+class HasManyThroughAssociationException extends RelationshipException {};
+?>
189 lib/Expressions.php
@@ -0,0 +1,189 @@
+<?php
+namespace ActiveRecord;
+
+require_once 'Exceptions.php';
+
+/**
+ * Templating like class for building SQL statements.
+ *
+ * Examples:
+ * 'name = :name AND author = :author'
+ * 'id = IN(:ids)'
+ * 'id IN(:subselect)'
+ */
+class Expressions
+{
+ const ParameterMarker = '?';
+
+ private $expressions;
+ private $values = array();
+ private $connection;
+
+ public function __construct($expressions=null /* [, $values ... ] */)
+ {
+ $values = null;
+
+ if (is_array($expressions))
+ {
+ $glue = func_num_args() > 1 ? func_get_arg(1) : ' AND ';
+ list($expressions,$values) = $this->build_sql_from_hash($expressions,$glue);
+ }
+
+ if (trim($expressions) != '')
+ {
+ if (!$values)
+ $values = array_slice(func_get_args(),1);
+
+ $this->values = $values;
+ $this->expressions = $expressions;
+ }
+ }
+
+ /**
+ * Bind a value to the specific one based index. There must be a bind marker
+ * for each value bound or to_s() will throw an exception.
+ */
+ public function bind($parameter_number, $value)
+ {
+ if ($parameter_number <= 0)
+ throw new ExpressionsException("Invalid parameter index: $parameter_number");
+
+ $this->values[$parameter_number-1] = $value;
+ }
+
+ public function bind_values($values)
+ {
+ $this->values = $values;
+ }
+
+ /**
+ * Returns all the values currently bound.
+ */
+ public function values()
+ {
+ return $this->values;
+ }
+
+ /**
+ * Returns the connection object.
+ */
+ public function get_connection()
+ {
+ return $this->connection;
+ }
+
+ /**
+ * Sets the connection object. It is highly recommended to set this so we can
+ * use the adapter's native escaping mechanism.
+ *
+ * @param string $connection a Connection instance
+ */
+ public function set_connection($connection)
+ {
+ $this->connection = $connection;
+ }
+
+ public function to_s($substitute=false, $options=null)
+ {
+ if (!$options) $options = array();
+
+ $values = hash_value('values',$options,$this->values);
+
+ $ret = "";
+ $replace = array();
+ $num_values = count($values);
+
+ for ($i=0,$n=strlen($this->expressions),$j=0; $i<$n; ++$i)
+ {
+ $append = $this->expressions[$i];
+
+ if ($this->is_marker($this->expressions,$i))
+ {
+ if ($j > $num_values-1)
+ throw new ExpressionsException("No bound parameter for index $j");
+
+ $append = $this->substitute($values,$substitute,$i,$j++);
+ }
+
+ $ret .= $append;
+ }
+ return $ret;
+ }
+
+ private function build_sql_from_hash(&$hash, $glue)
+ {
+ $sql = $g = "";
+
+ foreach ($hash as $name => $value)
+ {
+ if (is_array($value))
+ $sql .= "$g$name IN(?)";
+ else
+ $sql .= "$g$name=?";
+
+ $g = $glue;
+ }
+ return array($sql,array_values($hash));
+ }
+
+ private function substitute($values, $substitute, $pos, $parameter_index)
+ {
+ $value = $values[$parameter_index];
+
+ if (is_array($value))
+ {
+ if ($substitute)
+ {
+ $ret = $delim = '';
+
+ for ($i=0,$n=count($value); $i<$n; ++$i,$delim=',')
+ $ret .= $delim . $this->stringify_value($value[$i]);
+
+ return $ret;
+ }
+ return join(',',array_fill(0,count($value),self::ParameterMarker));
+ }
+
+ if ($substitute)
+ return $this->stringify_value($value);
+
+ return $this->expressions[$pos];
+ }
+
+ private function stringify_value($value)
+ {
+ if (is_null($value))
+ return "NULL";
+
+ return is_string($value) ? $this->quote_string($value) : $value;
+ }
+
+ private function quote_string($value)
+ {
+ if ($this->connection)
+ return "'" . $this->connection->escape($value) . "'";
+
+ return "'" . str_replace("'","''",$value) . "'";
+ }
+
+ private function is_marker($s, $pos)
+ {
+ if ($s[$pos] == self::ParameterMarker)
+ {
+ $count = 0;
+
+ // the number of single quotes preceeding must be even otherwise we
+ // are inside a quoted string and therefore not a marker
+ for ($i=0,$n=strlen($this->expressions); $i<$pos && $i<$n; ++$i)
+ {
+ if ($s[$i] == "'" && $i > 0 && $s[$i-1] != "\\")
+ $count++;
+ }
+
+ if ($count % 2 == 0)
+ return true;
+ }
+ return false;
+ }
+}
+?>
90 lib/Inflector.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * @package ActiveRecord
+ * @subpackage Inflector
+ */
+namespace ActiveRecord;
+
+/**
+ * @package ActiveRecord
+ * @subpackage Inflector
+ */
+abstract class Inflector
+{
+ public static function instance()
+ {
+ return new StandardInflector();
+ }
+
+ public function camelize($s)
+ {
+ $s = preg_replace('/[_-]+/','_',trim($s));
+ $s = str_replace(' ', '_', $s);
+
+ $camelized = '';
+
+ for ($i=0,$n=strlen($s); $i<$n; ++$i)
+ {
+ if ($s[$i] == '_' && $i+1 < $n)
+ $camelized .= strtoupper($s[++$i]);
+ else
+ $camelized .= $s[$i];
+ }
+
+ $camelized = trim($camelized,' _');
+
+ if (strlen($camelized) > 0)
+ $camelized[0] = strtolower($camelized[0]);
+
+ return $camelized;
+ }
+
+ /**
+ * Determines if a string contains all uppercase characters.
+ *
+ * @param string $s string to check
+ * @return bool
+ */
+ public static function is_upper($s)
+ {
+ return (strtoupper($s) === $s);
+ }
+
+ /**
+ * Determines if a string contains all lowercase characters.
+ *
+ * @param string $s string to check
+ * @return bool
+ */
+ public static function is_lower($s)
+ {
+ return (strtolower($s) === $s);
+ }
+
+ public function uncamelize($s)
+ {
+ $normalized = '';
+
+ for ($i=0,$n=strlen($s); $i<$n; ++$i)
+ {
+ if (ctype_alpha($s[$i]) && self::is_upper($s[$i]))
+ $normalized .= '_' . strtolower($s[$i]);
+ else
+ $normalized .= $s[$i];
+ }
+ return trim($normalized,' _');
+ }
+
+ public function underscorify($s)
+ {
+ return preg_replace('/[_\- ]+/','_',trim($s));
+ }
+
+ abstract function variablize($s);
+}
+
+class StandardInflector extends Inflector
+{
+ public function variablize($s) { return $this->underscorify($s); }
+}
+?>
955 lib/Model.php
@@ -0,0 +1,955 @@
+<?php
+/**
+ * @package ActiveRecord
+ * @subpackage Model
+ */
+namespace ActiveRecord;
+use DateTime;
+
+/**
+ * @package ActiveRecord
+ * @subpackage Model
+ */
+class Model
+{
+ /**
+ * Instance of ActiveRecord\Errors and will be instantiated once a
+ * write method is called
+ * @var object
+ */
+ public $errors;
+
+ /**
+ * Contains model values as column_name => value
+ * @var array
+ */
+ private $attributes = array();
+
+ /**
+ * Flag whether or not this model's attributes have been modified since
+ * it will either be null or an array of column_names that have been modified
+ * @var null/array
+ */
+ private $__dirty = null;
+
+ /**
+ * Flag that determines of this model can have a writer method invoked such
+ * as: save/update/insert/delete
+ * @var boolean
+ */
+ private $__readonly = false;
+
+ /**
+ * Array of relationship objects as model_attribute_name => relationship
+ * @var array
+ */
+ private $__relationships = array();
+
+ /**
+ * Flag that determines if a call to save() should issue an insert or an update
+ * sql statement
+ * @var boolean
+ */
+ private $__new_record;
+
+ /**
+ * A instance of CallBack for this model
+ * @static
+ * @var object ActiveRecord\CallBack
+ */
+ private static $__call_back;
+
+ /**
+ * Container of aliases which allows you to access an attribute via a
+ * different name.
+ * @static
+ * @var array
+ */
+ static $alias_attribute = array();
+
+ /**
+ * Whitelist of attributes that can be mass-assigned via an instantiation or
+ * a mass-assignment method such as Model#update_attributes()
+ * @static
+ * @var array
+ */
+ static $attr_accessible = array();
+
+ /**
+ * Blacklist of attributes that cannot be mass-assigned
+ * @see @var $attr_accessible for more info
+ * @static
+ * @var array
+ */
+ static $attr_protected = array();
+
+ /**
+ * When a user instantiates a new object (e.g.: it was not ActiveRecord that instantiated via a find)
+ * then @var $attributes will be mapped according to the schema's defaults. Otherwise, the given @param
+ * $attributes will be mapped via set_attributes_via_mass_assignment.
+ * @param array
+ * @param boolean
+ * @param boolean
+ * @return void
+ */
+ public function __construct($attributes=array(), $guard_attributes=true, $instantiating_via_find=false)
+ {
+ // initialize attributes applying defaults
+ if (!$instantiating_via_find)
+ {
+ foreach (static::table()->columns as $name => $meta)
+ $this->attributes[$meta->inflected_name] = $meta->default;
+ }
+
+ Reflections::instance()->add($this);
+ $this->set_attributes_via_mass_assignment($attributes, $guard_attributes);
+
+ self::$__call_back = new CallBack($this);
+ $this->register_call_back('before_save', function(Model $model) { $model->set_timestamps(); }, array('prepend' => true));
+ $this->register_call_back('after_save', function(Model $model) { $model->reset_dirty(); }, array('prepend' => true));
+ $this->invoke_call_back('after_construct');
+ }
+
+ /**
+ * Retrieves an attribute's value or a relationship object based on the name passed. If the attribute
+ * accessed is 'id' then it will return the model's primary key no matter what the actual attribute name is
+ * for the primary key.
+ * @throws ActiveRecord\Exception (lacking composite PK support)
+ * @throws ActiveRecord\UndefinedPropertyException (if an attr/relationshp is not found by @param $name)
+ * @param $name
+ * @return mixed
+ */
+ public function &__get($name)
+ {
+ // check for aliased attribute
+ if (array_key_exists($name, static::$alias_attribute))
+ $name = static::$alias_attribute[$name];
+
+ // check for attribute
+ if (array_key_exists($name,$this->attributes))
+ return $this->attributes[$name];
+
+ // check relationships if no attribute
+ if (array_key_exists($name,$this->__relationships))
+ return $this->__relationships[$name];
+
+ $table = static::table();
+
+ // this may be first access to the relationship so check Table
+ if (($relationship = $table->get_relationship($name)))
+ {
+ $this->__relationships[$name] = $relationship->load($this);
+ return $this->__relationships[$name];
+ }
+
+ if ($name == 'id')
+ {
+ if (count(($this->get_primary_key(true))) > 1)
+ throw new Exception("TODO composite key support");
+
+ if (isset($this->attributes[$table->pk[0]]))
+ return $this->attributes[$table->pk[0]];
+ }
+
+ throw new UndefinedPropertyException($name);
+ }
+
+ /**
+ * Determines if an attribute name exists
+ * @param string
+ * @return boolean
+ */
+ public function __isset($name)
+ {
+ return array_key_exists($name,$this->attributes);
+ }
+
+ /**
+ * Magic allows un-defined attributes to set via @var $attributes
+ * @throws ActiveRecord\UndefinedPropertyException if @param $name does not exist
+ * @param string
+ * @param mixed
+ * @return mixed value of attribute name
+ */
+ public function __set($name, $value)
+ {
+ if (array_key_exists($name, static::$alias_attribute))
+ $name = static::$alias_attribute[$name];
+
+ if (array_key_exists($name,$this->attributes))
+ {
+ $table = static::table();
+
+ if (!$this->__dirty)
+ $this->__dirty = array();
+
+ if (array_key_exists($name,$table->columns) && !is_object($value))
+ $value = $table->columns[$name]->cast($value);
+
+ $this->attributes[$name] = $value;
+ $this->__dirty[$name] = true;
+ return $value;
+ }
+
+ throw new UndefinedPropertyException($name);
+ }
+
+ /**
+ * Returns hash of attributes that have been modified since loading the model.
+ * @return null or array
+ */
+ public function dirty_attributes()
+ {
+ if (!$this->__dirty)
+ return null;
+
+ $dirty = array_intersect_key($this->attributes,$this->__dirty);
+ return count($dirty) > 0 ? $dirty : null;
+ }
+
+ /**
+ * Getter for @var $attributes
+ * @return array
+ */
+ public function attributes()
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * Retrieve the primary key name.
+ * @param boolean
+ * @return string
+ */
+ public function get_primary_key($inflect=true)
+ {
+ return Table::load(get_class($this))->pk;
+ }
+
+ /**
+ * Returns an associative array containg values for all the properties in $properties
+ * @param array of property names
+ * @return array containing $property => $value
+ */
+ public function get_values_for($properties)
+ {
+ $ret = array();
+
+ foreach ($properties as $property)
+ {
+ if (array_key_exists($property,$this->attributes))
+ $ret[$property] = $this->attributes[$property];
+ }
+ return $ret;
+ }
+
+ /**
+ * True if this model is read only.
+ * @return boolean
+ */
+ public function is_readonly()
+ {
+ return $this->__readonly;
+ }
+
+ /**
+ * True if this is a new record.
+ * @return boolean
+ */
+ public function is_new_record()
+ {
+ return isset($this->__new_record) ? $this->__new_record : false;
+ }
+
+ /**
+ * Throws an exception if this model is set to readonly.
+ * @throws ActiveRecord\ReadOnlyException
+ * @param string name of method that was invoked on model for exception message
+ * @return void
+ */
+ private function verify_not_readonly($method_name)
+ {
+ if ($this->is_readonly())
+ throw new ReadOnlyException(get_class($this), $method_name);
+ }
+
+ /**
+ * Flag model as readonly
+ * @param boolean
+ * @return void
+ */
+ public function readonly($readonly=true)
+ {
+ $this->__readonly = $readonly;
+ }
+
+ /**
+ * Retrieve the connection for this model.
+ * @static
+ * @return object instance of ActiveRecord\Connection
+ */
+ public static function connection()
+ {
+ return static::table()->conn;
+ }
+
+ /**
+ * Returns the ActiveRecord\Table object for this model. Be sure to call in static scoping.
+ * Example: static::table()
+ * @static
+ * @return object instance of ActiveRecord\Table
+ */
+ public static function table()
+ {
+ return Table::load(get_called_class());
+ }
+
+ /**
+ * Creates a model and invokes insert.
+ * @static
+ * @param array Array of the models attributes
+ * @param boolean True if the validators should be run
+ * @return object ActiveRecord\Model
+ */
+ public static function create($attributes, $validate=true)
+ {
+ $class_name = get_called_class();
+ $model = new $class_name($attributes);
+ $model->save($validate);
+ return $model;
+ }
+
+ /**
+ * Writer method that will determine whether or not the model is a new record or not. If it
+ * is then it will issue an insert statement, otherwise it will be an upate. You may pass a boolean if the
+ * writer method should invoke validations or not. Callbacks on this model will be called regardless of
+ * the flag passed. If a validation or a callback for this model returns false, then the resultant sql
+ * query will not be issued and false will be returned.
+ * @param boolean true if this should validate
+ * @return boolean
+ */
+ public function save($validate=true)
+ {
+ $this->verify_not_readonly('save');
+
+ $this->__new_record = false;
+
+ foreach ($this->get_primary_key(true) as $pk)
+ {
+ if (!isset($this->attributes[$pk]))
+ {
+ $this->__new_record = true;
+ break;
+ }
+ }
+
+ if (!$this->is_new_record())
+ return $this->update($validate);
+ else
+ return $this->insert($validate);
+ }
+
+ /**
+ * Issue an INSERT sql statement for this model's attribute.
+ * @see @method save()
+ * @param boolean
+ * @return boolean
+ */
+ public function insert($validate = true)
+ {
+ $this->verify_not_readonly('insert');
+
+ if ($validate && !$this->_validate())
+ return false;
+
+ $this->invoke_call_back('before_create');
+ if (($dirty = $this->dirty_attributes()))
+ static::table()->insert($dirty);
+ else
+ static::table()->insert($this->attributes);
+ $this->invoke_call_back('after_create');
+
+ $pk = $this->get_primary_key(false);
+ $table = static::table();
+
+ // if we've got an autoincrementing pk set it
+ if (count($pk) == 1 && $table->columns[$pk[0]]->auto_increment)
+ {
+ $inflector = Inflector::instance();
+ $this->attributes[$inflector->variablize($pk[0])] = $table->conn->insert_id();
+ }
+ return true;
+ }
+
+ /**
+ * Issue an UPDATE sql statement for this model's dirty attributes.
+ * @see @method save()
+ * @see @var $__dirty
+ * @param boolean
+ * @return boolean
+ */
+ public function update($validate = true)
+ {
+ $this->verify_not_readonly('update');
+
+ if ($validate && !$this->_validate())
+ return false;
+
+ if (($dirty = $this->dirty_attributes()))
+ {
+ $this->invoke_call_back('before_update');
+ static::table()->update($dirty,$this->values_for_pk());
+ $this->invoke_call_back('after_update');
+ }
+
+ return true;
+ }
+
+ /**
+ * Issue a DELETE statement based on this model's primary key
+ * @return boolean
+ */
+ public function delete()
+ {
+ $this->verify_not_readonly('delete');
+
+ $this->invoke_call_back('before_destroy');
+ static::table()->delete($this->values_for_pk());
+ $this->invoke_call_back('after_destroy');
+
+ return true;
+ }
+
+ /**
+ * Helper that creates an array of values for the primary key(s) of this model in the form of
+ * key_name => value
+ * @return array
+ */
+ public function values_for_pk()
+ {
+ return $this->values_for(static::table()->pk);
+ }
+
+ /**
+ * Return a hash of name => value for the specified attributes.
+ * @return array
+ */
+ public function values_for($attribute_names)
+ {
+ $filter = array();
+
+ foreach ($attribute_names as $name)
+ $filter[$name] = $this->$name;
+
+ return $filter;
+ }
+
+ /**
+ *
+ * @return boolean
+ */
+ private function _validate()
+ {
+ $validator = new Validations($this);
+
+ $validation_on = 'validation_on_' . ($this->is_new_record() ? 'create' : 'update');
+
+ foreach (array('before_validation', "before_$validation_on") as $call_back)
+ {
+ if (!$this->invoke_call_back($call_back))
+ return false;
+ }
+
+ $this->errors = $validator->validate();
+
+ foreach (array('after_validation', "after_$validation_on") as $call_back)
+ $this->invoke_call_back($call_back);
+
+ if (!$this->errors->is_empty())
+ return false;
+
+ return true;
+ }
+
+ /**
+ * Update model's timestamps based on is_new_record()?
+ * @return void
+ */
+ public function set_timestamps()
+ {
+ $now = date('Y-m-d H:i:s');
+
+ if (isset($this->updated_at))
+ $this->updated_at = $now;
+
+ if (isset($this->created_at) && $this->is_new_record())
+ $this->created_at = $now;
+ }
+
+ /**
+ * Updates all the attributes from the passed-in array and saves the record. If the object is invalid,
+ * the saving will fail and false will be returned.
+ * @param $attributes array
+ * @return boolean
+ */
+ public function update_attributes($attributes)
+ {
+ $this->set_attributes($attributes);
+ return $this->save();
+ }
+
+ /**
+ * Updates a single attribute and saves the record without going through the normal validation procedure.
+ * @param string
+ * @param mixed
+ * @return boolean
+ */
+ public function update_attribute($name, $value)
+ {
+ $this->__set($name, $value);
+ return $this->update(false);
+ }
+
+ /**
+ * Allows you to set all the attributes at once by passing in an array with
+ * keys matching the attribute names (which again matches the column names).
+ * @param array
+ * @return void
+ */
+ public function set_attributes($attributes)
+ {
+ $this->set_attributes_via_mass_assignment($attributes, true);
+ }
+
+ /**
+ * Passing strict as true will throw an exception if an attribute does not exist.
+ * @throws ActiveRecord\UndefinedPropertyException
+ * @param array
+ * @param boolean flag of whether or not attributes should be guarded
+ * @return unknown_type
+ */
+ private function set_attributes_via_mass_assignment(&$attributes, $guard_attributes)
+ {
+ if (!is_array($attributes) || empty($attributes))
+ return false;
+
+ //access uninflected columns since that is what we would have in result set
+ $table = static::table();
+ $columns = array_merge($table->inflected,$table->columns);
+ $exceptions = array();
+ $use_attr_accessible = is_array(static::$attr_accessible) && count(static::$attr_accessible) > 0;
+ $use_attr_protected = is_array(static::$attr_protected) && count(static::$attr_protected) > 0;
+
+ foreach ($attributes as $name => $value)
+ {
+ if (array_key_exists($name,$columns))
+ {
+ $name = $columns[$name]->inflected_name;
+
+ if (!$guard_attributes)
+ $value = $columns[$name]->cast($value);
+ }
+
+ if ($guard_attributes)
+ {
+ if ($use_attr_accessible && !in_array($name,static::$attr_accessible))
+ continue;
+
+ if ($use_attr_protected && in_array($name,static::$attr_protected))
+ continue;
+
+ try {
+ $this->$name = $value;
+ } catch (UndefinedPropertyException $e) {
+ $exceptions[] = $e->getMessage();
+ }
+ }
+ else
+ $this->attributes[$name] = $value;
+ }
+
+ if (!empty($exceptions))
+ throw new UndefinedPropertyException($exceptions);
+ }
+
+ /**
+ * Reloads the attributes of this object from the database and the relationships.
+ * Returns $this to support:
+ *
+ * $model->reload()->relationship_name->attribute
+ * $model->reload()->attribute
+ *
+ * @return $this
+ */
+ public function reload()
+ {
+ $this->__relationships = array();
+ $pk = array_values($this->get_values_for($this->get_primary_key()));
+ $this->set_attributes($this->find($pk)->attributes);
+ $this->reset_dirty();
+
+ return $this;
+ }
+
+ /**
+ * Resets @var $__dirty to null
+ * @return void
+ */
+ public function reset_dirty()
+ {
+ $this->__dirty = null;
+ }
+
+ /**
+ * A list of valid finder options
+ * @static
+ * @var array
+ */
+ static $VALID_OPTIONS = array('conditions', 'limit', 'offset', 'order', 'select', 'joins', 'include', 'readonly', 'group');
+
+ /**
+ * Enables the use of dynamic finders.
+ * Example: SomeModel::find_by_attribute('value');
+ * @static
+ * @throws ActiveRecord\ActiveRecordException if invalid query
+ * @param string
+ * @param mixed
+ * @return instance of ActiveRecord\Model
+ */
+ public static function __callStatic($method, $args)
+ {
+ $options = static::extract_and_validate_options($args);
+
+ if (substr($method,0,7) === 'find_by')
+ {
+ $options['conditions'] = SQLBuilder::create_conditions_from_underscored_string(substr($method,8),$args);
+ return static::find('first',$options);
+ }
+ elseif (substr($method,0,11) === 'find_all_by')
+ {
+ $options['conditions'] = SQLBuilder::create_conditions_from_underscored_string(substr($method,12),$args);
+ return static::find('all',$options);
+ }
+
+ throw new ActiveRecordException("Call to undefined method: $method");
+ }
+
+ /**
+ * Enables the use of build|create for associations.
+ * @param string
+ * @param mixed
+ * @return instance of a given ActiveRecord\Relationship
+ */
+ public function __call($method, $args)
+ {
+ //check for build|create_association methods