diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..8cc966dfd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[*.php] +indent_style = tab +indent_size = 4 + +[*.{xml,xml.dist}] +indent_size = 4 diff --git a/ActiveRecord.php b/ActiveRecord.php index f7c04a4d5..0283b49b7 100644 --- a/ActiveRecord.php +++ b/ActiveRecord.php @@ -10,6 +10,7 @@ require __DIR__.'/lib/Singleton.php'; require __DIR__.'/lib/Config.php'; require __DIR__.'/lib/Utils.php'; +require __DIR__.'/lib/DateTimeInterface.php'; require __DIR__.'/lib/DateTime.php'; require __DIR__.'/lib/Model.php'; require __DIR__.'/lib/Table.php'; diff --git a/composer.json b/composer.json index 48885d40b..63f932948 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "3.7.*", + "phpunit/phpunit": "4.*", "pear/pear_exception": "1.0-beta1", "pear/log": "~1.12" }, diff --git a/lib/Column.php b/lib/Column.php index 0bedc8b5a..3e583ef31 100644 --- a/lib/Column.php +++ b/lib/Column.php @@ -169,11 +169,17 @@ public function cast($value, $connection) if (!$value) return null; - if ($value instanceof DateTime) + $date_class = Config::instance()->get_date_class(); + + if ($value instanceof $date_class) return $value; if ($value instanceof \DateTime) - return new DateTime($value->format('Y-m-d H:i:s T')); + return $date_class::createFromFormat( + Connection::DATETIME_TRANSLATE_FORMAT, + $value->format(Connection::DATETIME_TRANSLATE_FORMAT), + $value->getTimezone() + ); return $connection->string_to_datetime($value); } diff --git a/lib/Config.php b/lib/Config.php index 7fbcfcf09..378a5b2e3 100644 --- a/lib/Config.php +++ b/lib/Config.php @@ -72,6 +72,14 @@ class Config extends Singleton */ private $logger; + /** + * Contains the class name for the Date class to use. Must have a public format() method and a + * public static createFromFormat($format, $time) method + * + * @var string + */ + private $date_class = 'ActiveRecord\\DateTime'; + /** * The format to serialize DateTime values into. * @@ -289,6 +297,28 @@ public function get_logger() return $this->logger; } + public function set_date_class($date_class) + { + try { + $klass = Reflections::instance()->add($date_class)->get($date_class); + } catch (\ReflectionException $e) { + throw new ConfigException("Cannot find date class"); + } + + if (!$klass->hasMethod('format') || !$klass->getMethod('format')->isPublic()) + throw new ConfigException('Given date class must have a "public format($format = null)" method'); + + if (!$klass->hasMethod('createFromFormat') || !$klass->getMethod('createFromFormat')->isPublic()) + throw new ConfigException('Given date class must have a "public static createFromFormat($format, $time)" method'); + + $this->date_class = $date_class; + } + + public function get_date_class() + { + return $this->date_class; + } + /** * @deprecated */ diff --git a/lib/Connection.php b/lib/Connection.php index 0010cb490..486885bd4 100644 --- a/lib/Connection.php +++ b/lib/Connection.php @@ -20,6 +20,17 @@ abstract class Connection { + /** + * The DateTime format to use when translating other DateTime-compatible objects. + * + * NOTE!: The DateTime "format" used must not include a time-zone (name, abbreviation, etc) or offset. + * Including one will cause PHP to ignore the passed in time-zone in the 3rd argument. + * See bug: https://bugs.php.net/bug.php?id=61022 + * + * @var string + */ + const DATETIME_TRANSLATE_FORMAT = 'Y-m-d\TH:i:s'; + /** * The PDO connection object. * @var mixed @@ -469,7 +480,7 @@ public function datetime_to_string($datetime) * Converts a string representation of a datetime into a DateTime object. * * @param string $string A datetime in the form accepted by date_create() - * @return DateTime + * @return object The date_class set in Config */ public function string_to_datetime($string) { @@ -479,7 +490,13 @@ public function string_to_datetime($string) if ($errors['warning_count'] > 0 || $errors['error_count'] > 0) return null; - return new DateTime($date->format(static::$datetime_format)); + $date_class = Config::instance()->get_date_class(); + + return $date_class::createFromFormat( + static::DATETIME_TRANSLATE_FORMAT, + $date->format(static::DATETIME_TRANSLATE_FORMAT), + $date->getTimezone() + ); } /** diff --git a/lib/DateTime.php b/lib/DateTime.php index 9b488236b..133d83856 100644 --- a/lib/DateTime.php +++ b/lib/DateTime.php @@ -33,7 +33,7 @@ * @package ActiveRecord * @see http://php.net/manual/en/class.datetime.php */ -class DateTime extends \DateTime +class DateTime extends \DateTime implements DateTimeInterface { /** * Default format used for format() and __toString() @@ -113,11 +113,40 @@ public static function get_format($format=null) return $format; } + /** + * This needs to be overriden so it returns an instance of this class instead of PHP's \DateTime. + * See http://php.net/manual/en/datetime.createfromformat.php + */ + public static function createFromFormat($format, $time, $tz = null) + { + $phpDate = $tz ? parent::createFromFormat($format, $time, $tz) : parent::createFromFormat($format, $time); + if (!$phpDate) + return false; + // convert to this class using the timestamp + $ourDate = new static(null, $phpDate->getTimezone()); + $ourDate->setTimestamp($phpDate->getTimestamp()); + return $ourDate; + } + public function __toString() { return $this->format(); } + /** + * Handle PHP object `clone`. + * + * This makes sure that the object doesn't still flag an attached model as + * dirty after cloning the DateTime object and making modifications to it. + * + * @return void + */ + public function __clone() + { + $this->model = null; + $this->attribute_name = null; + } + private function flag_dirty() { if ($this->model) @@ -127,24 +156,49 @@ private function flag_dirty() public function setDate($year, $month, $day) { $this->flag_dirty(); - call_user_func_array(array($this,'parent::setDate'),func_get_args()); + return parent::setDate($year, $month, $day); } - public function setISODate($year, $week , $day=null) + public function setISODate($year, $week , $day = 1) { $this->flag_dirty(); - call_user_func_array(array($this,'parent::setISODate'),func_get_args()); + return parent::setISODate($year, $week, $day); } - public function setTime($hour, $minute, $second=null) + public function setTime($hour, $minute, $second = 0) { $this->flag_dirty(); - call_user_func_array(array($this,'parent::setTime'),func_get_args()); + return parent::setTime($hour, $minute, $second); } public function setTimestamp($unixtimestamp) { $this->flag_dirty(); - call_user_func_array(array($this,'parent::setTimestamp'),func_get_args()); + return parent::setTimestamp($unixtimestamp); } -} \ No newline at end of file + + public function setTimezone($timezone) + { + $this->flag_dirty(); + return parent::setTimezone($timezone); + } + + public function modify($modify) + { + $this->flag_dirty(); + return parent::modify($modify); + } + + public function add($interval) + { + $this->flag_dirty(); + return parent::add($interval); + } + + public function sub($interval) + { + $this->flag_dirty(); + return parent::sub($interval); + } + +} diff --git a/lib/DateTimeInterface.php b/lib/DateTimeInterface.php new file mode 100644 index 000000000..3e7ca3174 --- /dev/null +++ b/lib/DateTimeInterface.php @@ -0,0 +1,35 @@ +assign_attribute() will + * know to call attribute_of() on passed values. This is so the DateTime object can flag the model + * as dirty via $model->flag_dirty() when one of its setters is called. + * + * @package ActiveRecord + * @see http://php.net/manual/en/class.datetime.php + */ +interface DateTimeInterface +{ + /** + * Indicates this object is an attribute of the specified model, with the given attribute name. + * + * @param Model $model The model this object is an attribute of + * @param string $attribute_name The attribute name + * @return void + */ + public function attribute_of($model, $attribute_name); + + /** + * Formats the DateTime to the specified format. + */ + public function format($format=null); + + /** + * See http://php.net/manual/en/datetime.createfromformat.php + */ + public static function createFromFormat($format, $time, $tz = null); +} diff --git a/lib/Model.php b/lib/Model.php index 69bdb562d..ca55e55bd 100644 --- a/lib/Model.php +++ b/lib/Model.php @@ -477,12 +477,19 @@ public function assign_attribute($name, $value) } // convert php's \DateTime to ours - if ($value instanceof \DateTime) - $value = new DateTime($value->format('Y-m-d H:i:s T')); + if ($value instanceof \DateTime) { + $date_class = Config::instance()->get_date_class(); + if (!($value instanceof $date_class)) + $value = $date_class::createFromFormat( + Connection::DATETIME_TRANSLATE_FORMAT, + $value->format(Connection::DATETIME_TRANSLATE_FORMAT), + $value->getTimezone() + ); + } - // make sure DateTime values know what model they belong to so - // dirty stuff works when calling set methods on the DateTime object - if ($value instanceof DateTime) + if ($value instanceof DateTimeInterface) + // Tell the Date object that it's associated with this model and attribute. This is so it + // has the ability to flag this model as dirty if a field in the Date object changes. $value->attribute_of($this,$name); // only update the attribute if it isn't set or has changed @@ -991,7 +998,7 @@ protected function cache_key() * Delete all using a string: * * - * YourModel::delete_all(array('conditions' => 'name = "Tito")); + * YourModel::delete_all(array('conditions' => 'name = "Tito"')); * * * An options array takes the following parameters: diff --git a/lib/Serialization.php b/lib/Serialization.php index 9285d5ed9..b4b9b324e 100644 --- a/lib/Serialization.php +++ b/lib/Serialization.php @@ -221,9 +221,10 @@ final protected function options_to_a($key) */ final public function to_a() { + $date_class = Config::instance()->get_date_class(); foreach ($this->attributes as &$value) { - if ($value instanceof \DateTime) + if ($value instanceof $date_class) $value = $value->format(self::$DATETIME_FORMAT); } return $this->attributes; diff --git a/lib/Table.php b/lib/Table.php index 044b1455c..600bbe9d6 100644 --- a/lib/Table.php +++ b/lib/Table.php @@ -428,9 +428,10 @@ private function &process_data($hash) if (!$hash) return $hash; + $date_class = Config::instance()->get_date_class(); foreach ($hash as $name => &$value) { - if ($value instanceof \DateTime) + if ($value instanceof $date_class || $value instanceof \DateTime) { if (isset($this->columns[$name]) && $this->columns[$name]->type == Column::DATE) $hash[$name] = $this->conn->date_to_string($value); @@ -518,7 +519,7 @@ private function set_associations() foreach (wrap_strings_in_arrays($definitions) as $definition) { $relationship = null; - $definition += compact('namespace'); + $definition += array('namespace' => $namespace); switch ($name) { diff --git a/lib/Utils.php b/lib/Utils.php index 3db34514c..3251f776c 100644 --- a/lib/Utils.php +++ b/lib/Utils.php @@ -241,7 +241,7 @@ public static function is_blank($var) '/(bu)s$/i' => "$1ses", '/(alias)$/i' => "$1es", '/(octop)us$/i' => "$1i", - '/(ax|test)is$/i' => "$1es", + '/(cris|ax|test)is$/i' => "$1es", '/(us)$/i' => "$1es", '/s$/i' => "s", '/$/' => "s" diff --git a/lib/Validations.php b/lib/Validations.php index 322dea247..c2cec72b3 100644 --- a/lib/Validations.php +++ b/lib/Validations.php @@ -442,7 +442,7 @@ public function validates_format_of($attrs) $attribute = $options[0]; $var = $this->model->$attribute; - if (is_null($options['with']) || !is_string($options['with']) || !is_string($options['with'])) + if (is_null($options['with']) || !is_string($options['with'])) throw new ValidationsArgumentError('A regular expression must be supplied as the [with] option of the configuration array.'); else $expression = $options['with']; @@ -600,7 +600,7 @@ public function validates_uniqueness_of($attrs) continue; $pk = $this->model->get_primary_key(); - $pk_value = $this->model->$pk[0]; + $pk_value = $this->model->{$pk[0]}; if (is_array($options[0])) { @@ -979,4 +979,4 @@ public function getIterator() { return new ArrayIterator($this->full_messages()); } -} \ No newline at end of file +} diff --git a/test/ActiveRecordTest.php b/test/ActiveRecordTest.php index ee2748ac5..cc0eaf881 100644 --- a/test/ActiveRecordTest.php +++ b/test/ActiveRecordTest.php @@ -660,11 +660,20 @@ public function test_changing_empty_attribute_value_tracks_change() { $this->assert_equals("The most fun", $changes['description'][1]); } - public function test_assigning_php_datetime_gets_converted_to_ar_datetime() + public function test_assigning_php_datetime_gets_converted_to_date_class_with_defaults() { $author = new Author(); $author->created_at = $now = new \DateTime(); - $this->assert_is_a("ActiveRecord\\DateTime",$author->created_at); + $this->assert_is_a("ActiveRecord\\DateTime", $author->created_at); + $this->assert_datetime_equals($now,$author->created_at); + } + + public function test_assigning_php_datetime_gets_converted_to_date_class_with_custom_date_class() + { + ActiveRecord\Config::instance()->set_date_class('\\DateTime'); // use PHP built-in DateTime + $author = new Author(); + $author->created_at = $now = new \DateTime(); + $this->assert_is_a("DateTime", $author->created_at); $this->assert_datetime_equals($now,$author->created_at); } diff --git a/test/ActiveRecordWriteTest.php b/test/ActiveRecordWriteTest.php index e0f0c4523..c0351413d 100644 --- a/test/ActiveRecordWriteTest.php +++ b/test/ActiveRecordWriteTest.php @@ -424,4 +424,21 @@ public function test_update_all_with_limit_and_order() $this->assert_equals(1, $num_affected); $this->assert_true(strpos(Author::table()->last_sql, 'ORDER BY name asc LIMIT 1') !== false); } + + public function test_update_native_datetime() + { + $author = Author::create(array('name' => 'Blah Blah')); + $native_datetime = new \DateTime('1983-12-05'); + $author->some_date = $native_datetime; + $this->assert_false($native_datetime === $author->some_date); + } + + public function test_update_our_datetime() + { + $author = Author::create(array('name' => 'Blah Blah')); + $our_datetime = new DateTime('1983-12-05'); + $author->some_date = $our_datetime; + $this->assert_true($our_datetime === $author->some_date); + } + } diff --git a/test/ColumnTest.php b/test/ColumnTest.php index 71db768b0..c36dceeca 100644 --- a/test/ColumnTest.php +++ b/test/ColumnTest.php @@ -130,4 +130,32 @@ public function test_empty_and_null_datetime_strings_should_return_null() $this->assert_equals(null,$column->cast(null,$this->conn)); $this->assert_equals(null,$column->cast('',$this->conn)); } + + public function test_native_date_time_attribute_copies_exact_tz() + { + $dt = new \DateTime(null, new \DateTimeZone('America/New_York')); + + $column = new Column(); + $column->type = Column::DATETIME; + + $dt2 = $column->cast($dt, $this->conn); + + $this->assert_equals($dt->getTimestamp(), $dt2->getTimestamp()); + $this->assert_equals($dt->getTimeZone(), $dt2->getTimeZone()); + $this->assert_equals($dt->getTimeZone()->getName(), $dt2->getTimeZone()->getName()); + } + + public function test_ar_date_time_attribute_copies_exact_tz() + { + $dt = new DateTime(null, new \DateTimeZone('America/New_York')); + + $column = new Column(); + $column->type = Column::DATETIME; + + $dt2 = $column->cast($dt, $this->conn); + + $this->assert_equals($dt->getTimestamp(), $dt2->getTimestamp()); + $this->assert_equals($dt->getTimeZone(), $dt2->getTimeZone()); + $this->assert_equals($dt->getTimeZone()->getName(), $dt2->getTimeZone()->getName()); + } } diff --git a/test/ConfigTest.php b/test/ConfigTest.php index 73451be14..518413f11 100644 --- a/test/ConfigTest.php +++ b/test/ConfigTest.php @@ -8,6 +8,17 @@ class TestLogger private function log() {} } +class TestDateTimeWithoutCreateFromFormat +{ + public function format($format=null) {} +} + +class TestDateTime +{ + public function format($format=null) {} + public static function createFromFormat($format, $time) {} +} + class ConfigTest extends SnakeCase_PHPUnit_Framework_TestCase { public function set_up() @@ -79,6 +90,19 @@ public function test_set_model_directories_must_be_array() $this->config->set_model_directories(null); } + public function test_get_date_class_with_default() + { + $this->assert_equals('ActiveRecord\\DateTime', $this->config->get_date_class()); + } + + /** + * @expectedException ActiveRecord\ConfigException + */ + public function test_set_date_class_when_class_doesnt_exist() + { + $this->config->set_date_class('doesntexist'); + } + /** * @expectedException ActiveRecord\ConfigException */ @@ -114,6 +138,28 @@ public function test_get_model_directory_returns_first_model_directory(){ ActiveRecord\Config::instance()->set_model_directory($home); } + /** + * @expectedException ActiveRecord\ConfigException + */ + public function test_set_date_class_when_class_doesnt_have_format_or_createfromformat() + { + $this->config->set_date_class('TestLogger'); + } + + /** + * @expectedException ActiveRecord\ConfigException + */ + public function test_set_date_class_when_class_doesnt_have_createfromformat() + { + $this->config->set_date_class('TestDateTimeWithoutCreateFromFormat'); + } + + public function test_set_date_class_with_valid_class() + { + $this->config->set_date_class('TestDateTime'); + $this->assert_equals('TestDateTime', $this->config->get_date_class()); + } + public function test_initialize_closure() { $test = $this; diff --git a/test/DateTimeTest.php b/test/DateTimeTest.php index e489e0f74..3518db782 100644 --- a/test/DateTimeTest.php +++ b/test/DateTimeTest.php @@ -1,6 +1,6 @@ original_format; } - private function assert_dirtifies($method /*, method params, ...*/) + private function get_model() { try { $model = new Author(); } catch (DatabaseException $e) { $this->mark_test_skipped('failed to connect. '.$e->getMessage()); } + + return $model; + } + + private function assert_dirtifies($method /*, method params, ...*/) + { + $model = $this->get_model(); $datetime = new DateTime(); $datetime->attribute_of($model,'some_date'); @@ -34,10 +41,16 @@ private function assert_dirtifies($method /*, method params, ...*/) public function test_should_flag_the_attribute_dirty() { + $interval = new DateInterval('PT1S'); + $timezone = new DateTimeZone('America/New_York'); $this->assert_dirtifies('setDate',2001,1,1); $this->assert_dirtifies('setISODate',2001,1); $this->assert_dirtifies('setTime',1,1); $this->assert_dirtifies('setTimestamp',1); + $this->assert_dirtifies('setTimezone',$timezone); + $this->assert_dirtifies('modify','+1 day'); + $this->assert_dirtifies('add',$interval); + $this->assert_dirtifies('sub',$interval); } public function test_set_iso_date() @@ -123,4 +136,80 @@ public function test_to_string() { $this->assert_equals(date(DateTime::get_format()), "" . $this->date); } + + public function test_create_from_format_error_handling() + { + $d = DateTime::createFromFormat('H:i:s Y-d-m', '!!!'); + $this->assert_false($d); + } + + public function test_create_from_format_without_tz() + { + $d = DateTime::createFromFormat('H:i:s Y-d-m', '03:04:05 2000-02-01'); + $this->assert_equals(new DateTime('2000-01-02 03:04:05'), $d); + } + + public function test_create_from_format_with_tz() + { + $d = DateTime::createFromFormat('Y-m-d H:i:s', '2000-02-01 03:04:05', new \DateTimeZone('Etc/GMT-10')); + $d2 = new DateTime('2000-01-31 17:04:05'); + + $this->assert_equals($d2->getTimestamp(), $d->getTimestamp()); + } + + public function test_native_date_time_attribute_copies_exact_tz() + { + $dt = new \DateTime(null, new \DateTimeZone('America/New_York')); + $model = $this->get_model(); + + // Test that the data transforms without modification + $model->assign_attribute('updated_at', $dt); + $dt2 = $model->read_attribute('updated_at'); + + $this->assert_equals($dt->getTimestamp(), $dt2->getTimestamp()); + $this->assert_equals($dt->getTimeZone(), $dt2->getTimeZone()); + $this->assert_equals($dt->getTimeZone()->getName(), $dt2->getTimeZone()->getName()); + } + + public function test_ar_date_time_attribute_copies_exact_tz() + { + $dt = new DateTime(null, new \DateTimeZone('America/New_York')); + $model = $this->get_model(); + + // Test that the data transforms without modification + $model->assign_attribute('updated_at', $dt); + $dt2 = $model->read_attribute('updated_at'); + + $this->assert_equals($dt->getTimestamp(), $dt2->getTimestamp()); + $this->assert_equals($dt->getTimeZone(), $dt2->getTimeZone()); + $this->assert_equals($dt->getTimeZone()->getName(), $dt2->getTimeZone()->getName()); + } + + public function test_clone() + { + $model = $this->get_model(); + $model_attribute = 'some_date'; + + $datetime = new DateTime(); + $datetime->attribute_of($model, $model_attribute); + + $cloned_datetime = clone $datetime; + + // Assert initial state + $this->assert_false($model->attribute_is_dirty($model_attribute)); + + $cloned_datetime->add(new DateInterval('PT1S')); + + // Assert that modifying the cloned object didn't flag the model + $this->assert_false($model->attribute_is_dirty($model_attribute)); + + $datetime->add(new DateInterval('PT1S')); + + // Assert that modifying the model-attached object did flag the model + $this->assert_true($model->attribute_is_dirty($model_attribute)); + + // Assert that the dates are equal but not the same instance + $this->assert_equals($datetime, $cloned_datetime); + $this->assert_not_same($datetime, $cloned_datetime); + } } diff --git a/test/helpers/DatabaseTest.php b/test/helpers/DatabaseTest.php index f3d26bc31..6cdcadd17 100644 --- a/test/helpers/DatabaseTest.php +++ b/test/helpers/DatabaseTest.php @@ -14,6 +14,8 @@ public function set_up($connection_name=null) $config = ActiveRecord\Config::instance(); $this->original_default_connection = $config->get_default_connection(); + $this->original_date_class = $config->get_date_class(); + if ($connection_name) $config->set_default_connection($connection_name); @@ -42,6 +44,7 @@ public function set_up($connection_name=null) public function tear_down() { + ActiveRecord\Config::instance()->set_date_class($this->original_date_class); if ($this->original_default_connection) ActiveRecord\Config::instance()->set_default_connection($this->original_default_connection); }