diff --git a/ActiveRecord.php b/ActiveRecord.php index 084587547..7728ad40e 100644 --- a/ActiveRecord.php +++ b/ActiveRecord.php @@ -25,7 +25,7 @@ require __DIR__.'/lib/Cache.php'; if (!defined('PHP_ACTIVERECORD_AUTOLOAD_DISABLE')) - spl_autoload_register('activerecord_autoload',false,PHP_ACTIVERECORD_AUTOLOAD_PREPEND); + spl_autoload_register('activerecord_autoload',true,PHP_ACTIVERECORD_AUTOLOAD_PREPEND); function activerecord_autoload($class_name) { @@ -40,11 +40,11 @@ function activerecord_autoload($class_name) foreach ($namespaces as $directory) $directories[] = $directory; - $root .= DIRECTORY_SEPARATOR . implode($directories, DIRECTORY_SEPARATOR); + $root .= DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $directories); } $file = "$root/$class_name.php"; if (file_exists($file)) require_once $file; -} \ No newline at end of file +} diff --git a/composer.json b/composer.json index 63f932948..b978187f1 100644 --- a/composer.json +++ b/composer.json @@ -6,14 +6,18 @@ "homepage": "http://www.phpactiverecord.org/", "license": "MIT", "require": { - "php": ">=5.3.0" + "php": ">=5.3.0", + "rector/rector": "^1.0" }, "require-dev": { - "phpunit/phpunit": "4.*", + "phpunit/phpunit": "^9", "pear/pear_exception": "1.0-beta1", "pear/log": "~1.12" }, "autoload": { "files": [ "ActiveRecord.php" ] + }, + "autoload-dev": { + "classmap": ["test/helpers"] } } diff --git a/lib/Column.php b/lib/Column.php index db6c0a3a6..45d66bc3f 100644 --- a/lib/Column.php +++ b/lib/Column.php @@ -18,6 +18,7 @@ class Column const DATETIME = 4; const DATE = 5; const TIME = 6; + const BOOLEAN = 7; /** * Map a type to an column type. @@ -25,6 +26,7 @@ class Column * @var array */ static $TYPE_MAPPING = array( + 'datetime' => self::DATETIME, 'timestamp' => self::DATETIME, 'date' => self::DATE, @@ -40,7 +42,11 @@ class Column 'double' => self::DECIMAL, 'numeric' => self::DECIMAL, 'decimal' => self::DECIMAL, - 'dec' => self::DECIMAL); + 'dec' => self::DECIMAL, + + 'boolean' => self::BOOLEAN, + + ); /** * The true name of this column. @@ -102,6 +108,8 @@ class Column */ public $sequence; + public $is_array = false; + /** * Cast a value to an integer type safely * @@ -128,11 +136,6 @@ public static function castIntegerSafely($value) elseif (is_numeric($value) && floor($value) != $value) return (int) $value; - // If adding 0 to a string causes a float conversion, - // we have a number over PHP_INT_MAX - elseif (is_string($value) && is_float($value + 0)) - return (string) $value; - // If a float was passed and its greater than PHP_INT_MAX // (which could be wrong due to floating point precision) // We'll also check for equal to (>=) in case the precision @@ -150,16 +153,31 @@ public static function castIntegerSafely($value) * @param Connection $connection The Connection this column belongs to * @return mixed type-casted value */ - public function cast($value, $connection) + public function cast($value, $connection, bool $process_arrays = true) { if ($value === null) return null; + if ($this->is_array && $process_arrays) { + + // Convert database array representation to PHP array + $value_array = is_array($value) ? $value : $connection->database_string_to_array($value); + + // Cast each array member according to database type + $self = $this; + + return array_map(function ($value) use ($self, $connection) { + return $self->cast($value, $connection, false); + }, $value_array); + + } + switch ($this->type) { case self::STRING: return (string)$value; case self::INTEGER: return static::castIntegerSafely($value); case self::DECIMAL: return (double)$value; + case self::BOOLEAN: return (bool) $value; case self::DATETIME: case self::DATE: if (!$value) @@ -179,6 +197,7 @@ public function cast($value, $connection) return $connection->string_to_datetime($value); } + return $value; } diff --git a/lib/Connection.php b/lib/Connection.php index 69e8a1b20..ca62aca73 100644 --- a/lib/Connection.php +++ b/lib/Connection.php @@ -329,8 +329,32 @@ public function query($sql, &$values=array()) $sth->setFetchMode(PDO::FETCH_ASSOC); + $numeric_keys = false; + + foreach ($values as $key => &$value) { + switch (gettype($value)) { + case 'boolean' : + $pdo_type = PDO::PARAM_BOOL; + break; + case 'integer' : + $pdo_type = PDO::PARAM_INT; + break; + case 'NULL' : + $pdo_type = PDO::PARAM_NULL; + break; + default : + $pdo_type = PDO::PARAM_STR; + break; + } + if ($numeric_keys || $key === 0) { + $numeric_keys = true; + ++$key; + } + $sth->bindParam($key, $value, $pdo_type); + } + try { - if (!$sth->execute($values)) + if (!$sth->execute()) throw new DatabaseException($this); } catch (PDOException $e) { throw new DatabaseException($e); @@ -499,6 +523,32 @@ public function string_to_datetime($string) ); } + /** + * + * Converts arrays to string for inserting/updating in database. Necessary because PDO doesn't support arrays directly. + * + * @param array $array The array to serialize + * @return string The serialized array + */ + + public function array_to_database_string(array $array) + { + throw new DatabaseException(get_called_class() . ' does not support arrays'); + } + + /** + * + * Converts arrays to string for inserting/updating in database. Necessary because PDO doesn't support arrays directly. + * + * @param array $array The array to serialize + * @return string The serialized array + */ + + public function database_string_to_array(string $value) + { + throw new DatabaseException(get_called_class() . ' does not support arrays'); + } + /** * Adds a limit clause to the SQL query. * diff --git a/lib/DateTime.php b/lib/DateTime.php index 43ef8a8ef..80537f232 100644 --- a/lib/DateTime.php +++ b/lib/DateTime.php @@ -84,7 +84,7 @@ public function attribute_of($model, $attribute_name) * @param string $format A format string accepted by get_format() * @return string formatted date and time string */ - public function format($format=null) + public function format($format=null) : string { return parent::format(self::get_format($format)); } @@ -123,7 +123,7 @@ public static function createFromFormat($format, $time, $tz = null) if (!$phpDate) return false; // convert to this class using the timestamp - $ourDate = new static(null, $phpDate->getTimezone()); + $ourDate = new static('', $phpDate->getTimezone()); $ourDate->setTimestamp($phpDate->getTimestamp()); return $ourDate; } @@ -153,49 +153,49 @@ private function flag_dirty() $this->model->flag_dirty($this->attribute_name); } - public function setDate($year, $month, $day) + public function setDate($year, $month, $day) : \DateTime { $this->flag_dirty(); return parent::setDate($year, $month, $day); } - public function setISODate($year, $week , $day = 1) + public function setISODate($year, $week , $day = 1) : \DateTime { $this->flag_dirty(); return parent::setISODate($year, $week, $day); } - public function setTime($hour, $minute, $second = 0, $microseconds = 0) + public function setTime($hour, $minute, $second = 0, $microseconds = 0) : \DateTime { $this->flag_dirty(); return parent::setTime($hour, $minute, $second); } - public function setTimestamp($unixtimestamp) + public function setTimestamp($unixtimestamp) : \DateTime { $this->flag_dirty(); return parent::setTimestamp($unixtimestamp); } - public function setTimezone($timezone) + public function setTimezone($timezone) : \DateTime { $this->flag_dirty(); return parent::setTimezone($timezone); } - - public function modify($modify) + + public function modify($modify) : \DateTime { $this->flag_dirty(); return parent::modify($modify); } - - public function add($interval) + + public function add($interval) : \DateTime { $this->flag_dirty(); return parent::add($interval); } - public function sub($interval) + public function sub($interval) : \DateTime { $this->flag_dirty(); return parent::sub($interval); diff --git a/lib/Expressions.php b/lib/Expressions.php index 3fa2962fa..be55b3cd4 100644 --- a/lib/Expressions.php +++ b/lib/Expressions.php @@ -11,7 +11,7 @@ * 'name = :name AND author = :author' * 'id = IN(:ids)' * 'id IN(:subselect)' - * + * * @package ActiveRecord */ class Expressions @@ -21,6 +21,7 @@ class Expressions private $expressions; private $values = array(); private $connection; + private $array_placeholders = []; public function __construct($connection, $expressions=null /* [, $values ... ] */) { @@ -55,9 +56,10 @@ public function bind($parameter_number, $value) $this->values[$parameter_number-1] = $value; } - public function bind_values($values) + public function bind_values($values, $array_placeholders = []) { $this->values = $values; + $this->array_placeholders = $array_placeholders; } /** @@ -90,7 +92,7 @@ public function set_connection($connection) public function to_s($substitute=false, &$options=null) { if (!$options) $options = array(); - + $values = array_key_exists('values',$options) ? $options['values'] : $this->values; $ret = ""; @@ -145,6 +147,7 @@ private function build_sql_from_hash(&$hash, $glue) private function substitute(&$values, $substitute, $pos, $parameter_index) { $value = $values[$parameter_index]; + $is_array_placeholder = in_array($parameter_index, $this->array_placeholders); if (is_array($value)) { @@ -165,7 +168,7 @@ private function substitute(&$values, $substitute, $pos, $parameter_index) return $ret; } - return join(',',array_fill(0,$value_count,self::ParameterMarker)); + return join(',', array_fill(0, $is_array_placeholder ? 1 : $value_count, self::ParameterMarker)); } if ($substitute) @@ -189,4 +192,5 @@ private function quote_string($value) return "'" . str_replace("'","''",$value) . "'"; } -} \ No newline at end of file +} + diff --git a/lib/Model.php b/lib/Model.php index 99667b41f..f25947011 100644 --- a/lib/Model.php +++ b/lib/Model.php @@ -355,7 +355,7 @@ public function &__get($name) */ public function __isset($attribute_name) { - return array_key_exists($attribute_name,$this->attributes) || array_key_exists($attribute_name,static::$alias_attribute); + return array_key_exists($attribute_name,$this->attributes) || array_key_exists($attribute_name,static::$alias_attribute) || static::table()->get_relationship($attribute_name) || method_exists($this, "get_$attribute_name"); } /** @@ -427,6 +427,9 @@ public function __set($name, $value) foreach (static::$delegate as &$item) { + if (!is_array($item)) { + continue; + } if (($delegated_name = $this->is_delegated($name,$item))) return $this->{$item['to']}->{$delegated_name} = $value; } @@ -526,6 +529,9 @@ public function &read_attribute($name) foreach (static::$delegate as &$item) { + if (!is_array($item)) { + continue; + } if (($delegated_name = $this->is_delegated($name,$item))) { $to = $item['to']; @@ -1311,7 +1317,7 @@ public function reset_dirty() * * @var array */ - static $VALID_OPTIONS = array('conditions', 'limit', 'offset', 'order', 'select', 'joins', 'include', 'readonly', 'group', 'from', 'having'); + static $VALID_OPTIONS = array('conditions', 'limit', 'offset', 'order', 'select', 'joins', 'include', 'readonly', 'group', 'from', 'having', 'array_placeholders'); /** * Enables the use of dynamic finders. @@ -1665,13 +1671,15 @@ public static function find_by_pk($values, $options) $options['conditions'] = static::pk_conditions($values); $list = $table->find($options); } + $results = count($list); + if (!is_array($values)) $values = array($values); + if ($results != ($expected = count($values))) { $class = get_called_class(); - if (is_array($values)) - $values = join(',',$values); + $values = join(',',$values); if ($expected == 1) { @@ -1695,7 +1703,7 @@ public static function find_by_pk($values, $options) * @param array $values An array of values for any parameters that needs to be bound * @return array An array of models */ - public static function find_by_sql($sql, $values=null) + public static function find_by_sql($sql, $values=[]) { return static::table()->find_by_sql($sql, $values, true); } @@ -1707,7 +1715,7 @@ public static function find_by_sql($sql, $values=null) * @param array $values Bind values, if any, for the query * @return object A PDOStatement object */ - public static function query($sql, $values=null) + public static function query($sql, $values=[]) { return static::connection()->query($sql, $values); } diff --git a/lib/SQLBuilder.php b/lib/SQLBuilder.php index ef239ed87..26dd4463d 100644 --- a/lib/SQLBuilder.php +++ b/lib/SQLBuilder.php @@ -22,6 +22,7 @@ class SQLBuilder private $group; private $having; private $update; + private $array_placeholders = []; // for where private $where; @@ -105,6 +106,12 @@ public function order($order) return $this; } + public function array_placeholders(array $array_placeholders) + { + $this->array_placeholders = $array_placeholders; + return $this; + } + public function group($group) { $this->group = $group; @@ -303,21 +310,31 @@ private function apply_where_conditions($args) } elseif ($num_args > 0) { + // if the values has a nested array then we'll need to use Expressions to expand the bind marker for us $values = array_slice($args,1); + $index_position = 0; - foreach ($values as $name => &$value) - { - if (is_array($value)) - { - $e = new Expressions($this->connection,$args[0]); - $e->bind_values($values); - $this->where = $e->to_s(); + foreach ($values as $name => &$value) { + if (!is_array($value)) { + continue; + } + + $is_array_placeholder = in_array($name, $this->array_placeholders); + + $e = new Expressions($this->connection, $args[0]); + $e->bind_values($values, $this->array_placeholders); + $this->where = $e->to_s(); + + if (in_array($name, $this->array_placeholders)) { + $this->where_values = $e->values(); + } else { $this->where_values = array_flatten($e->values()); - return; } + return; } + // no nested array so nothing special to do $this->where = $args[0]; $this->where_values = &$values; @@ -419,4 +436,4 @@ private function quoted_key_names() return $keys; } -} \ No newline at end of file +} diff --git a/lib/Singleton.php b/lib/Singleton.php index fe370c2fc..2cb46696c 100644 --- a/lib/Singleton.php +++ b/lib/Singleton.php @@ -41,7 +41,7 @@ final public static function instance() * * @return void */ - final private function __clone() {} + private function __clone() {} /** * Similar to a get_called_class() for a child class to invoke. @@ -53,4 +53,4 @@ final protected function get_called_class() $backtrace = debug_backtrace(); return get_class($backtrace[2]['object']); } -} \ No newline at end of file +} diff --git a/lib/Table.php b/lib/Table.php index 7434ff11c..f197ca9d1 100644 --- a/lib/Table.php +++ b/lib/Table.php @@ -162,6 +162,9 @@ public function options_to_sql($options) $table = array_key_exists('from', $options) ? $options['from'] : $this->get_fully_qualified_table_name(); $sql = new SQLBuilder($this->conn, $table); + if (array_key_exists('array_placeholders',$options)) + $sql->array_placeholders($options['array_placeholders']); + if (array_key_exists('joins',$options)) { $sql->joins($this->create_joins($options['joins'])); @@ -215,8 +218,9 @@ public function find($options) $sql = $this->options_to_sql($options); $readonly = (array_key_exists('readonly',$options) && $options['readonly']) ? true : false; $eager_load = array_key_exists('include',$options) ? $options['include'] : null; + $array_placeholders = array_key_exists('array_placeholders',$options) ? $options['array_placeholders'] : []; - return $this->find_by_sql($sql->to_s(),$sql->get_where_values(), $readonly, $eager_load); + return $this->find_by_sql($sql->to_s(),$sql->get_where_values(), $readonly, $eager_load, $array_placeholders); } public function cache_key_for_model($pk) @@ -228,13 +232,13 @@ public function cache_key_for_model($pk) return $this->class->name . '-' . $pk; } - public function find_by_sql($sql, $values=null, $readonly=false, $includes=null) + public function find_by_sql($sql, $values=null, $readonly=false, $includes=null, $array_placeholders = []) { $this->last_sql = $sql; $collect_attrs_for_includes = is_null($includes) ? false : true; $list = $attrs = array(); - $sth = $this->conn->query($sql,$this->process_data($values)); + $sth = $this->conn->query($sql, $this->process_data($values, $array_placeholders)); $self = $this; while (($row = $sth->fetch())) @@ -423,7 +427,7 @@ private function map_names(&$hash, &$map) return $ret; } - private function &process_data($hash) + private function &process_data($hash, $array_placeholders = []) { if (!$hash) return $hash; @@ -431,15 +435,17 @@ private function &process_data($hash) $date_class = Config::instance()->get_date_class(); foreach ($hash as $name => &$value) { - if ($value instanceof $date_class || $value instanceof \DateTime) - { - if (isset($this->columns[$name]) && $this->columns[$name]->type == Column::DATE) + 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); - else + } else { $hash[$name] = $this->conn->datetime_to_string($value); - } - else + } + } elseif (is_array($value) && ((isset($this->columns[$name]) && $this->columns[$name]->is_array || in_array($name, $array_placeholders))) ) { + $hash[$name] = $this->conn->array_to_database_string($value); + } else { $hash[$name] = $value; + } } return $hash; } diff --git a/lib/Utils.php b/lib/Utils.php index 105d28c3a..d7da16d19 100644 --- a/lib/Utils.php +++ b/lib/Utils.php @@ -150,7 +150,7 @@ function wrap_strings_in_arrays(&$strings) { if (!is_array($strings)) $strings = array(array($strings)); - else + else { foreach ($strings as &$str) { @@ -173,7 +173,7 @@ public static function extract_options($options) return is_array(end($options)) ? end($options) : array(); } - public static function add_condition(&$conditions=array(), $condition, $conjuction='AND') + public static function add_condition(&$conditions, $condition, $conjuction='AND') { if (is_array($condition)) { @@ -367,4 +367,4 @@ public static function add_irregular($singular, $plural) { self::$irregular[$singular] = $plural; } -} \ No newline at end of file +} diff --git a/lib/adapters/PgsqlAdapter.php b/lib/adapters/PgsqlAdapter.php index 72da44182..f49207a9a 100644 --- a/lib/adapters/PgsqlAdapter.php +++ b/lib/adapters/PgsqlAdapter.php @@ -6,13 +6,14 @@ /** * Adapter for Postgres (not completed yet) - * + * * @package ActiveRecord */ class PgsqlAdapter extends Connection { static $QUOTE_CHARACTER = '"'; static $DEFAULT_PORT = 5432; + static $VERSION; public function supports_sequences() { @@ -26,7 +27,7 @@ public function get_sequence_name($table, $column_name) public function next_sequence_value($sequence_name) { - return "nextval('" . str_replace("'","\\'",$sequence_name) . "')"; + return !stristr($sequence_name, 'uuid_seq') ? "nextval('" . str_replace("'","\\'",$sequence_name) . "')" : 'DEFAULT'; } public function limit($sql, $offset, $limit) @@ -34,8 +35,25 @@ public function limit($sql, $offset, $limit) return $sql . ' LIMIT ' . intval($limit) . ' OFFSET ' . intval($offset); } + public function get_version() + { + if (self::$VERSION === null) { + $version_stmt = $this->query("SELECT version();"); + $version_stmt->execute(); + $version_string = $version_stmt->fetch()['version']; + preg_match('/^PostgreSQL ([0-9\.]+)/', $version_string, $matches); + self::$VERSION = $matches[1]; + } + return self::$VERSION; + } + public function query_column_info($table) { + + $default_select = self::get_version() < 12 ? + 'pg_attrdef.adsrc' : + 'pg_get_expr(adbin, adrelid)'; + $sql = <<pk = ($column['pk'] ? true : false); $c->auto_increment = false; + $is_array = false; + if (substr($column['type'],0,9) == 'timestamp') { $c->raw_type = 'datetime'; @@ -90,25 +110,36 @@ public function create_column(&$column) } else { - preg_match('/^([A-Za-z0-9_]+)(\(([0-9]+(,[0-9]+)?)\))?/',$column['type'],$matches); + preg_match('/^([A-Za-z0-9_]+(?:\[\])?)(\(([0-9]+(,[0-9]+)?)\))?/',$column['type'],$matches); - $c->raw_type = (count($matches) > 0 ? $matches[1] : $column['type']); + $raw_type = (count($matches) > 0 ? $matches[1] : $column['type']); $c->length = count($matches) >= 4 ? intval($matches[3]) : intval($column['attlen']); if ($c->length < 0) $c->length = null; + + if (substr($raw_type, -2) == '[]') { + $raw_type = substr($raw_type, 0, -2); + $is_array = true; + } + + $c->raw_type = $raw_type; + } + $c->is_array = $is_array; $c->map_raw_type(); if ($column['default']) { preg_match("/^nextval\('(.*)'\)$/",$column['default'],$matches); - - if (count($matches) == 2) + if (count($matches) == 2) { $c->sequence = $matches[1]; - else - $c->default = $c->cast($column['default'],$this); + } elseif ($column['type'] == 'boolean') { + $c->default = $c->cast($column['default'] === 'true' ? true : ($column['default'] === 'false' ? false : $column['default']), $this); + } else { + $c->default = $c->cast($column['default'], $this); + } } return $c; } @@ -135,5 +166,28 @@ public function native_database_types() ); } + public function array_to_database_string(array $value) + { + return '{' . $this->str_putcsv($value) . '}'; + } + + public function database_string_to_array(string $value) + { + preg_match('/^{(.*)}$/', $value, $matches); + if ($matches && !strlen($matches[1])) { + return []; + } + return $matches ? str_getcsv($matches[1]) : [$value]; + } + + private function str_putcsv(array $input, $delimiter = ',', $enclosure = '"') + { + $h = fopen('php://temp', 'r+b'); + fputcsv($h, $input, $delimiter, $enclosure); + rewind($h); + $data = rtrim(stream_get_contents($h), "\n"); + fclose($h); + return $data; + } + } -?> diff --git a/test/SqliteAdapterTest.php b/test/SqliteAdapterTest.php index 0490c8137..874201cc7 100644 --- a/test/SqliteAdapterTest.php +++ b/test/SqliteAdapterTest.php @@ -8,7 +8,7 @@ public function set_up($connection_name=null) parent::set_up('sqlite'); } - public function tearDown() + public function tearDown() : void { parent::tearDown(); @@ -16,7 +16,7 @@ public function tearDown() } - public static function tearDownAfterClass() + public static function tearDownAfterClass() : void { parent::tearDownAfterClass(); @@ -79,4 +79,4 @@ public function test_date_to_string() // not supported public function test_connect_with_port() {} } -?> \ No newline at end of file +?> diff --git a/test/helpers/SnakeCase_PHPUnit_Framework_TestCase.php b/test/helpers/SnakeCase_PHPUnit_Framework_TestCase.php index a5ac79c85..f25879a11 100644 --- a/test/helpers/SnakeCase_PHPUnit_Framework_TestCase.php +++ b/test/helpers/SnakeCase_PHPUnit_Framework_TestCase.php @@ -1,5 +1,5 @@ assert_equals($expected->format(DateTime::ISO8601),$actual->format(DateTime::ISO8601)); } } -?> \ No newline at end of file +?>