From 5dde7b7883cc7f0374eca1728ca3d29e0b3e18f0 Mon Sep 17 00:00:00 2001 From: Clifton Molina Date: Tue, 10 Feb 2026 20:49:31 -0500 Subject: [PATCH 1/2] AR compatibility fix and query correctness - replace count() rowCount misuse with real COUNT(*) query (MySQL-safe) - refactor SELECT SQL construction into shared builder - add offset support in query processing (including MySQL offset-without-limit handling) - preserve Model::delete([...]) via static magic dispatch and move implementation to delete_where() - add instance $model->delete() compatibility via __call() -> deleteRow() - add missing assign_attribute() and attributes() instance helpers - update README examples/arguments for working instance delete and offset support --- PdoFish.class.php | 120 ++++++++++++++++++++++++++++++++++++++++------ README.md | 5 +- 2 files changed, 109 insertions(+), 16 deletions(-) diff --git a/PdoFish.class.php b/PdoFish.class.php index f891815..41a7d8d 100644 --- a/PdoFish.class.php +++ b/PdoFish.class.php @@ -145,21 +145,27 @@ public static function raw($sql) } /** - * Parse a SQL query, ActiveRecord style + * Build a SQL query, ActiveRecord style * * @param array $data - * @return stmt resource + * @param bool $include_paging include limit/offset clauses + * @return array tuple of [sql, conditions] */ - private static function process(array $data) + private static function build_select_sql(array $data, bool $include_paging = true) { $static_table = static::get_table(); if(!isset($data['from']) && isset($static_table)) { $data['from'] = $static_table; } + if(!isset($data['from'])) { + throw new Exception('No table specified for query'); + } + $conditions = []; + $postsql = ""; $select = $data['select'] ?? "*"; $sql = "SELECT ".$select." FROM ".$data['from'].""; - if(isset($data['joins'])) { $sql .= " ".$data['joins']; } + if(!empty($data['joins'])) { $sql .= " ".$data['joins']; } if(!empty($data['conditions'])) { $sql .= " WHERE ".$data['conditions'][0]; foreach($data['conditions'] as $k => $c) { @@ -167,22 +173,47 @@ private static function process(array $data) $conditions[] = $c; } } - if($data['group']) { + if(!empty($data['group'])) { $postsql .= " GROUP BY ".$data['group']; } - if($data['having']) { + if(!empty($data['having'])) { $postsql .= " HAVING ".$data['having']; } - if($data['order']) { $postsql .= " ORDER BY ".$data['order']; } - if($data['limit']) { $postsql .= " LIMIT ".abs(intval($data['limit'])); } + if(!empty($data['order'])) { $postsql .= " ORDER BY ".$data['order']; } + if($include_paging) { + $has_limit = isset($data['limit']) && $data['limit'] !== '' && !is_null($data['limit']); + $has_offset = isset($data['offset']) && $data['offset'] !== '' && !is_null($data['offset']); + if($has_limit) { + $postsql .= " LIMIT ".abs(intval($data['limit'])); + } + if($has_offset) { + if(!$has_limit) { + // MySQL requires LIMIT with OFFSET + $postsql .= " LIMIT 18446744073709551615"; + } + $postsql .= " OFFSET ".abs(intval($data['offset'])); + } + } + return [trim($sql." ".$postsql), $conditions]; + } + + /** + * Parse and execute a SQL query, ActiveRecord style + * + * @param array $data + * @return stmt resource + */ + private static function process(array $data) + { + list($sql, $conditions) = static::build_select_sql($data, true); // uncomment next line for SQL debugger // error_log($sql." ".$postsql); - static::$last_sql = $sql." ".$postsql; + static::$last_sql = $sql; if(!empty($conditions)) { - $stmt = static::$db->prepare($sql." ".$postsql); + $stmt = static::$db->prepare($sql); $stmt->execute($conditions); } else { - $stmt = static::$db->query($sql." ".$postsql); + $stmt = static::$db->query($sql); } return $stmt; } @@ -300,7 +331,17 @@ public static function find($id, $fetch_mode = NULL) */ public static function count($data=[]) { - return (int) static::process($data)->rowCount(); + $data['select'] = '1'; + list($base_sql, $conditions) = static::build_select_sql($data, false); + $sql = "SELECT COUNT(*) AS pdo_fish_count FROM (".$base_sql.") AS pdo_fish_count_subquery"; + static::$last_sql = $sql; + if(!empty($conditions)) { + $stmt = static::$db->prepare($sql); + $stmt->execute($conditions); + } else { + $stmt = static::$db->query($sql); + } + return (int) $stmt->fetchColumn(); } /** @@ -444,8 +485,11 @@ public function deleteRow() * @param array $where array of columns and values * @param integer $limit limit number of records */ - public static function delete($where, $limit = NULL) + public static function delete_where($where, $limit = NULL) { + if(!is_array($where) || empty($where)) { + return 0; + } //collect the values from collection $values = array_values($where); @@ -461,10 +505,19 @@ public static function delete($where, $limit = NULL) if (is_numeric($limit)) { $limit = "LIMIT $limit"; } - $stmt = static::run("DELETE FROM `".static::get_table()."` WHERE $whereDetails", $values); + $sql = "DELETE FROM `".static::get_table()."` WHERE $whereDetails"; + if (!empty($limit)) { + $sql .= " ".$limit; + } + $stmt = static::run($sql, $values); return $stmt->rowCount(); } + public static function deleteWhere($where, $limit = NULL) // camel-case alias of delete_where + { + return self::delete_where($where, $limit); + } + /** * Delete multiple records * @@ -595,6 +648,42 @@ public function save($debug=NULL) return (object) $data; } + /** + * Assign arbitrary/computed data to a model instance + * + * @param string $key + * @param mixed $val + * @return object + */ + public function assign_attribute($key, $val) + { + $this->$key = $val; + return $this; + } + + /** + * Return all instance attributes as an array + * + * @return array + */ + public function attributes() + { + return (array) $this; + } + + /** + * Instance delete alias for active-record style calls + * + * @return object + */ + public function __call(string $name, array $args) + { + if('delete' === $name) { + return $this->deleteRow(); + } + throw new BadMethodCallException('Undefined method '.get_called_class().'::'.$name.'()'); + } + /** * dynamic callable * @@ -604,6 +693,9 @@ public function save($debug=NULL) public static function __callStatic ( string $name , array $args ) { + if ('delete' === $name) { + return static::delete_where($args[0] ?? [], $args[1] ?? NULL); + } # one record if (preg_match('/^find_by_(.+)/', $name, $matches)) { $var_name = $matches[1]; diff --git a/README.md b/README.md index 644d686..75b59bd 100644 --- a/README.md +++ b/README.md @@ -239,9 +239,9 @@ ModeName::delete_all([ 'conditions'=>['user_id=? OR user_id=? OR user_id=?',1,2, // this will truncate an entire table. You MUST call this via the PdoFish class, and not a child class PdoFish::truncate('tableName'); -// you cannot use table()->method() functions +// delete active-record style $y = ModelName::find(3); //find a model with primary key=3 -$y->delete(); // this will not work +$y->delete(); // this will work ``` ## Arguments supported @@ -254,6 +254,7 @@ The following arguments are supported in the PdoFish queries: ```having``` - having, _e.g. 'count(x)>3'_ ```order``` - order by, _e.g. 'id DESC' ```limit``` - a positive integer greater than 0 +```offset``` - a positive integer greater than or equal to 0 ## Credits Some of this code has roots in the [David Carr](https://twitter.com/dcblogdev)'s [PDOWrapper](https://dcblog.dev/docs/pdo-wrapper) project. From e1590cb6b0933d5f44828dec7797fc29a9370877 Mon Sep 17 00:00:00 2001 From: Clifton Molina Date: Tue, 10 Feb 2026 22:06:21 -0500 Subject: [PATCH 2/2] Improve PdoFish ActiveRecord compatibility, hydration, and query behavior - add robust SQL builder path with support for offset and safer pagination assembly - fix count() to execute a real COUNT(*) query instead of relying on rowCount() for SELECT - add ActiveRecord compatibility helpers: - instance $model->delete() support - assign_attribute() and attributes() - static delete dispatch aliases - introduce PdoFishDateTime and model hydration for date/datetime/timestamp-like fields - normalize persistence flows: - sanitize object-to-array conversion to skip mangled keys - handle DateTime serialization on save - improve PK-aware update/delete behavior across methods - add primary-key auto-detection with per-table cache when model PK is not declared - expand dynamic finder support for _and_ compound finders - add find_one_by_sql() convenience method and improve object hydration paths for query APIs - update docs to reflect current ActiveRecord delete and query argument support --- PdoFish.class.php | 214 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 168 insertions(+), 46 deletions(-) diff --git a/PdoFish.class.php b/PdoFish.class.php index 41a7d8d..7695c06 100644 --- a/PdoFish.class.php +++ b/PdoFish.class.php @@ -6,6 +6,28 @@ * modeled after phpActiveRecord */ +/** + * DateTime subclass that casts to string automatically, + * matching php-activerecord's ActiveRecord\DateTime behaviour. + * Dates echo as "YYYY-MM-DD", datetimes as "YYYY-MM-DD HH:MM:SS". + */ +class PdoFishDateTime extends DateTime +{ + private bool $has_time; + + public function __construct(string $value) + { + parent::__construct($value); + // If original value had a time component, preserve it + $this->has_time = (bool) preg_match('/\d{2}:\d{2}:\d{2}/', $value); + } + + public function __toString(): string + { + return $this->format($this->has_time ? 'Y-m-d H:i:s' : 'Y-m-d'); + } +} + class PdoFish { // database connection @@ -16,6 +38,8 @@ class PdoFish static $tbl = null; // primary key, defaults to 'id' static $pk = 'id'; + // cache of auto-detected PKs keyed by table name + static private $pk_cache = []; // stores last SQL query static $last_sql = null; // default return type, which defaults to object @@ -30,6 +54,8 @@ public function __construct($args=null) { if(is_array($args)) { foreach($args as $k=>$v) { + // Skip mangled private/protected keys from (array) casts + if(is_string($k) && isset($k[0]) && $k[0] === "\0") { continue; } $this->$k = $v; } } @@ -95,7 +121,46 @@ public static function return_data($stmt, $fetch_mode=NULL) if($fetch_mode != PDO::FETCH_OBJ) { return $stmt->fetch($fetch_mode); } - return $stmt->fetchObject(get_called_class()); + $obj = $stmt->fetchObject(get_called_class()); + if($obj) { static::hydrate($obj); } + return $obj; + } + + /** + * Cast date/datetime/timestamp string columns to DateTime objects, + * mirroring php-activerecord behaviour. + * + * @param object $obj + */ + protected static function hydrate($obj) + { + // Regex matches ISO date strings: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS" + static $date_pattern = '/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$/'; + // Column name suffixes that are always dates + static $date_col_suffixes = ['date', 'time', 'at', 'on', 'stamp', 'pubdate', 'tstamp']; + foreach (get_object_vars($obj) as $key => $val) { + if (!is_string($val) || $val === '') { continue; } + // Check by column name suffix + $lower = strtolower($key); + $is_date_col = false; + foreach ($date_col_suffixes as $suffix) { + if ($lower === $suffix || substr($lower, -strlen($suffix)) === $suffix) { + $is_date_col = true; + break; + } + } + // Also check by value pattern regardless of column name + if (!$is_date_col && !preg_match($date_pattern, $val)) { continue; } + if ($is_date_col || preg_match($date_pattern, $val)) { + // Skip zero-dates — leave as string so templates can compare to '0000-00-00' + if(preg_match('/^0000-00-00/', $val)) { continue; } + try { + $obj->$key = new PdoFishDateTime($val); + } catch (Exception $e) { + // leave as string if unparseable + } + } + } } /** @@ -123,13 +188,26 @@ public static function get_fetch_mode() } /** - * Gets the primary key + * Gets the primary key. + * If $primary_key is not declared on the subclass, auto-detects from DB schema + * (result is cached per table to avoid repeated SHOW KEYS queries). * - * @return current primary key, defaults to 'id' + * @return string primary key column name */ public static function get_pk() { - return static::$primary_key ?? static::$pk; + if(isset(static::$primary_key)) { return static::$primary_key; } + $table = static::get_table(); + if(!$table) { return 'id'; } + if(isset(self::$pk_cache[$table])) { return self::$pk_cache[$table]; } + try { + $stmt = self::$db->query("SHOW KEYS FROM `{$table}` WHERE Key_name='PRIMARY'"); + $row = $stmt->fetch(PDO::FETCH_OBJ); + self::$pk_cache[$table] = $row ? $row->Column_name : 'id'; + } catch (Exception $e) { + self::$pk_cache[$table] = 'id'; + } + return self::$pk_cache[$table]; } /** @@ -224,6 +302,11 @@ public static function all($data=[], $fetch_mode=NULL) $fetch_mode = static::get_fetch_mode(); } $stmt = static::process($data); + if($fetch_mode == PDO::FETCH_OBJ) { + $rows = $stmt->fetchAll(PDO::FETCH_CLASS, get_called_class()); + foreach($rows as $row) { static::hydrate($row); } + return $rows; + } return $stmt->fetchAll($fetch_mode); } @@ -240,11 +323,25 @@ public static function last($data=[], $fetch_mode=NULL) return array_pop($all); } + /** + * Find a single record by raw SQL. + */ public static function find_by_sql($sql, $args=NULL, $fetch_mode=NULL) { $stmt = static::run($sql,$args); return static::return_data($stmt,$fetch_mode); } + + /** + * Find a single record by raw SQL. + */ + public static function find_one_by_sql($sql, $args=NULL, $fetch_mode=NULL) + { + $stmt = static::run($sql, $args); + $obj = $stmt->fetchObject(get_called_class()); + if($obj) { static::hydrate($obj); } + return $obj; + } public static function connection() : ?object { @@ -265,6 +362,11 @@ public static function find_all_by_sql($sql, $args=NULL, $fetch_mode=NULL) $fetch_mode = static::get_fetch_mode(); } $stmt = static::run($sql,$args); + if($fetch_mode == PDO::FETCH_OBJ) { + $rows = $stmt->fetchAll(PDO::FETCH_CLASS, get_called_class()); + foreach($rows as $row) { static::hydrate($row); } + return $rows; + } return $stmt->fetchAll($fetch_mode); } @@ -310,16 +412,21 @@ public static function find_by_pk($id, $fetch_mode = NULL) * @param object $fetch_mode set return mode, e.g. PDO::FETCH_OBJ or PDO::FETCH_ASSOC * @return object/array returns single record */ - public static function find($id, $fetch_mode = NULL) + public static function find($id, $data = NULL) { - if('all' == strtolower($id)) { return static::all($fetch_mode); } - if('first' == strtolower($id)) { return static::first($fetch_mode); } - if(is_null($fetch_mode)) { $fetch_mode=static::$fetch_mode; } - $field = static::$primary_key ?? 'id'; + // AR-style string: find('all', $options) / find('first', $options) + if(is_string($id) && 'all' == strtolower($id)) { return static::all(is_array($data) ? $data : []); } + if(is_string($id) && 'first' == strtolower($id)) { return static::first(is_array($data) ? $data : []); } + if(is_string($id) && 'last' == strtolower($id)) { return static::last(is_array($data) ? $data : []); } + // Backward compatibility: second arg can be fetch mode or AR options + $fetch_mode = is_int($data) ? $data : static::$fetch_mode; + $field = static::get_pk(); if($fetch_mode != PDO::FETCH_OBJ) { return static::run("SELECT * FROM `".static::get_table()."` WHERE ".$field." = ?", [$id])->fetch($fetch_mode); } - return static::run("SELECT * FROM `".static::get_table()."` WHERE ".$field." = ?", [$id])->fetchObject(get_called_class()); + $obj = static::run("SELECT * FROM `".static::get_table()."` WHERE ".$field." = ?", [$id])->fetchObject(get_called_class()); + if($obj) { static::hydrate($obj); } + return $obj; } /** @@ -406,7 +513,7 @@ public static function update_by_id(array $data, int $id) } $fieldDetails = rtrim($fieldDetails, ','); - $stmt = static::run("UPDATE `".static::get_table()."` SET ".$fieldDetails." WHERE id=?", $values); + $stmt = static::run("UPDATE `".static::get_table()."` SET ".$fieldDetails." WHERE ".static::get_pk()."=?", $values); return $stmt->rowCount(); } @@ -472,8 +579,9 @@ public static function update($data, $where) */ public function deleteRow() { - if(isset($this->id)) { - self::delete_by_id($this->id); + $pk = static::get_pk(); + if(isset($this->$pk)) { + self::delete_by_pk($this->$pk); return $this; } return (object) $this; @@ -623,27 +731,26 @@ final public static function truncate($table) public function save($debug=NULL) { if(1 == $debug) { var_dump($this); return; } - // next lines, updating a record with a PK that isn't ID - if(isset(static::$primary_key)) { - $data = (array) $this; - if(!is_array($data)) { return false; } - $pk = static::$primary_key; - if(isset($data[$pk])) { - $pk_val = $data[$pk]; - unset($data[$pk]); - self::update_by_pk($data,$pk_val); - return (object) $data; - } - } - $data = (array) $this; + $raw = (array) $this; + $data = []; + foreach($raw as $k => $v) { + if(is_string($k) && isset($k[0]) && $k[0] === "\0") { continue; } + $data[$k] = $v; + } if(!is_array($data)) { return false; } - // next lines, updating a record with a PK of ID - if($data['id']) { - unset($data['id']); - self::update_by_id($data,$this->id); + $pk = static::get_pk(); + // Cast any DateTime objects back to strings for storage + foreach($data as $k => $v) { + if($v instanceof DateTime) { $data[$k] = $v->format('Y-m-d H:i:s'); } + } + if(isset($data[$pk]) && $data[$pk] !== '' && $data[$pk] !== null) { + $pk_val = $data[$pk]; + unset($data[$pk]); + self::update_by_pk($data, $pk_val); return (object) $data; - } - // otherwise, insert as new record + } + // no PK value — insert as new record + if(isset($data[$pk])) { unset($data[$pk]); } static::insert($data); return (object) $data; } @@ -662,13 +769,19 @@ public function assign_attribute($key, $val) } /** - * Return all instance attributes as an array + * Return all instance attributes as an array, excluding internal/mangled keys. * * @return array */ public function attributes() { - return (array) $this; + $raw = (array) $this; + $out = []; + foreach($raw as $k => $v) { + if(is_string($k) && isset($k[0]) && $k[0] === "\0") { continue; } + $out[$k] = $v; + } + return $out; } /** @@ -696,20 +809,29 @@ public static function __callStatic ( string $name , array $args ) if ('delete' === $name) { return static::delete_where($args[0] ?? [], $args[1] ?? NULL); } - # one record + # one record: find_by_col or find_by_col1_and_col2_and_... if (preg_match('/^find_by_(.+)/', $name, $matches)) { - $var_name = $matches[1]; - $sql = "SELECT * FROM `".static::get_table()."` WHERE ".$var_name."=?"; - $stmt = static::$db->prepare($sql); - $stmt->execute([ $args[0] ]); - return static::return_data($stmt,$fetch_mode); - } - # multiple records + $columns = explode('_and_', $matches[1]); + $where = implode('=? AND ', $columns) . '=?'; + $sql = "SELECT * FROM `".static::get_table()."` WHERE ".$where; + $stmt = static::$db->prepare($sql); + $stmt->execute(array_slice($args, 0, count($columns))); + $obj = $stmt->fetchObject(get_called_class()); + if($obj) { static::hydrate($obj); } + return $obj; + } + # multiple records: find_all_by_col or find_all_by_col1_and_col2_and_... if (preg_match('/^find_all_by_(.+)/', $name, $matches)) { - $var_name = $matches[1]; - $sql = "SELECT * FROM `".static::get_table()."` WHERE ".$var_name."=?"; - $stmt = static::$db->prepare($sql); - $stmt->execute([ $args[0] ]); + $columns = explode('_and_', $matches[1]); + $where = implode('=? AND ', $columns) . '=?'; + $sql = "SELECT * FROM `".static::get_table()."` WHERE ".$where; + $stmt = static::$db->prepare($sql); + $stmt->execute(array_slice($args, 0, count($columns))); + if(static::get_fetch_mode() == PDO::FETCH_OBJ) { + $rows = $stmt->fetchAll(PDO::FETCH_CLASS, get_called_class()); + foreach($rows as $row) { static::hydrate($row); } + return $rows; + } return $stmt->fetchAll(static::get_fetch_mode()); } }