name])){ $aliases = [ 'belongs_to' => [], 'has_one' => [], 'has_many' => [] ]; if(is_array(static::$belongs_to)){ foreach(static::$belongs_to as $alias => $details){ $aliases['belongs_to'][$alias] = array_merge( [ 'model'=>$alias, 'parent_key'=>'id', 'foreign_key'=>$this->name.static::foreign_key_suffix() ], $details ); } } if(is_array(static::$has_one)){ foreach(static::$has_one as $alias => $details){ $aliases['has_one'][$alias] = array_merge( [ 'model'=>$alias, 'foreign_key'=>$alias.static::foreign_key_suffix() ], $details ); } } if(is_array(static::$has_many)){ foreach(static::$has_many as $alias => $details){ $aliases['has_many'][$alias] = array_merge( [ 'model'=>$alias, 'foreign_key'=>$this->name.static::foreign_key_suffix(), 'through'=> null ], $details ); } } self::$aliases[$this->name] = $aliases; // Clear relationship definitions to save memory static::$belongs_to = static::$has_one = static::$has_many = null; } if(is_array($idOrData)){ if(isset($idOrData[static::primary_key()]) && self::cached($idOrData[static::primary_key()])){ throw new \Exception('Instance already cached'); } foreach($idOrData as $key => $val){ $this->set($key, $val); } }else{ if(self::cached($idOrData)){ throw new \Exception('Instance already cached'); } $this->_data[static::primary_key()] = (int)$idOrData; } self::$instances[] = $this; } public function __destruct(){ try{ $this->__sleep(); }catch(Exception $e){} $this->_changed = []; $this->_data = []; $this->_related = []; $key = array_search($this, self::$instances); if($key !== false){ array_splice(self::$instances, $key, 1); } } public function __get(string $name){ switch($name){ case 'pdo': return static::pdo(); case 'table': return static::table(); case 'name': return static::table_name(); case 'primary_key': return static::primary_key(); case 'foreign_key_suffix': return static::foreign_key_suffix(); case 'has_one':case 'has_many':case 'belongs_to': return self::$aliases[$this->name][$name]; case 'id': $pk = static::primary_key(); return $this->has($pk) ? $this->get($pk) : null; default: throw new \Exception('Unknown property '.$name); } } public function __set(string $name, $val){ switch($name){ case 'id':case 'name': throw new \Exception('Property '.$name.' is read only'); break; default: throw new \Exception('Unknown property '.$name); } } public function __sleep(){ $this->save(); } public function __wakeup(){ $this->reload(true); } public function __toString(){ return $this->name.'('.$this->id.')'; } public function __invoke(){ return $this->_data; } public function __clone(){ unset($this->$_data[static::primary_key()]); } // JsonSerializable public function jsonSerialize(){ return $this->_data; } // ArrayAccess public function offsetSet($key, $val){ $this->set($key, $val); } public function offsetExists($key){ return $this->has($key); } public function offsetUnset($key){ return $this->unset($key); } public function offsetGet($key){ return $this->get($key); } // Main API public static function table_name(){ if(is_null(static::$table_name)){ $name = get_called_class(); return substr($name, strrpos($name, '\\') + 1); } return static::$table_name; } public static function table(){ if(is_null(static::$table) || static::$table->name !== static::table_name()){ static::$table = self::$pdo->table(static::table_name()); } return static::$table; } public static function primary_key(){ return static::$primary_key; } public static function foreign_key_suffix(){ return static::$foreign_key_suffix; } public static function bind($pdo){ if(is_string($pdo)){ $pdo = PDO::from($pdo); } if($pdo instanceof SecureString){ $pdo = PDO::from((string)$pdo); } if($pdo instanceof PDO){ self::$pdo = $pdo; // @todo handle updating live instances }else{ throw new \Exception("Invalid argument. Must pass a DSN string or a PDO instance"); } } public static function pdo(){ return static::$pdo; } public static function query(...$args){ return self::$pdo->query(...$args); } public static function exec(...$args){ return self::$pdo->exec(...$args); } public static function prepare(...$args){ return self::$pdo->prepare(...$args); } public static function quote(...$args){ return self::$pdo->quote(...$args); } public static function instance($id){ $instance = self::cached_instance($id); if(!is_null($instance)){ return $instance; }elseif(self::exists($id)){ return new static($id); } return null; } public static function create(array $data){ return new static($data); } public static function exists(int $id){ $query = self::query( "select count(1) as count ". "from `".static::table_name().'` '. "where `".static::primary_key()."` = ".self::quote($id) ); $count = $query->fetch()['count']; $query->closeCursor(); return (int)$count > 0; } public static function cached_instance($id){ $name = static::table_name(); if(isset(self::$instances[$name])){ $instances = array_filter(self::$instances[$name], function(&$instance){ return $instance->id === $id; }); return isset($instances[0]) ? $instances[0] : null; } return null; } public static function cached($id){ return !is_null(self::cached_instance($id)); } public static function delete($id){ return static::table()->delete([ static::primary_key() => $id ]) > 0; } public static function each_cached(callable $fn){ $name = static::table_name(); if(self::$instances[$name]){ array_walk(self::$instances[$name], $fn); } } public static function each(callable $fn, array $filter = null, int $start = null, int $amount = null){ static::table()->each(function($row) use($fn){ $fn(self::instance((int)$row['id'])); }, [static::primary_key()], $filter, $start, $amount); } public static function fetch(array $filter = null, int $start = null, int $amount = null){ $results = []; self::each(function($item) use(&$results){ $results[] = $item; }, $filter, $start, $amount); return $results; } public static function str_replace_first($search, $replace, $source) { $explode = explode($search, $source); $shift = array_shift($explode); $implode = implode($search, $explode); return $shift.$replace.$implode; } public static function models(){ array_filter(get_declared_classes(), function($class){ return 0 === strpos($class, "Model\\"); }); } public static function count(array $filter = null){ return static::table()->count($filter); } // Instance Api public function values($values){ foreach($values as $key => $val){ $this->set($key, $val); } return $this; } public function load(bool $force = false){ if(!$force && $this->dirty()){ throw new \Exception('Cannot load, there are pending changes'); }else{ if(!is_null($this->id)){ $query = self::query( "select * " . "from {$this->name} ". "where ".static::primary_key()." = ".self::quote($this->id) ); $data = $query->fetch(); $query->closeCursor(); if($data === false){ throw new \Exception("{$this->name} with ".static::primary_key()." of {$this->id} does not exist"); } $this->_data = $data; } } return $this; } public function save(){ if($this->dirty()){ $data = []; foreach($this->_changed as $key){ if(isset($this->_data[$key]) || is_null($this->_data[$key])){ $data[$key] = $this->_data[$key]; }else{ $data[$key] = null; } } $pk = static::primary_key(); $table = static::table(); if($this->has($pk) && !is_null($this->id) && !in_array($pk, $this->_changed)){ if($table->update($data, [ $pk => $this->id ]) === 0){ trigger_error("Save of {$this->name}#{$this->id} may have failed. No affected rows.", E_USER_WARNING); } }else{ $this->_data[$pk] = $table->insert($data); if($this->_data[$pk] === 0){ trigger_error("First save of {$this->name} instance may have failed. PK is 0", E_USER_WARNING); } } foreach($this->_related as $related){ $related->save(); } } // Always fetch again from the database in case saving // forces something to change at the database level return $this->reload(true); } public function reload(bool $force = false){ if($force){ $this->_changed = []; } return $this->load($force); } public function clear(){ return $this->reload(true); } public function get($key){ return $this->_data[$key]; } public function set($key, $val){ if($key === static::primary_key() && !is_null($this->id)){ throw new \Exception('You are not allowed to change the primary key'); } $this->_data[$key] = $val; $this->_changed = array_merge($this->_changed, [$key]); return $this; } public function unset($key){ unset($this->_data[$key]); return $this; } public function has($key){ return isset($this->_data[$key]); } public function dirty(string $key = null){ if(is_null($key)){ $dirty = count($this->_changed) > 0; if(!$dirty){ foreach($this->_related as $rel){ if($rel instanceof Relationship){ $dirty = $rel->dirty(); } if($dirty){ break; } } } return $dirty; }else{ return in_array($key, $this->_changed); } } public function related(string $name){ if(!isset($this->_related[$name])){ $aliases = self::$aliases[$this->name]; if(isset($aliases['belongs_to'][$name])){ $alias = $aliases['belongs_to'][$name]; $class = "\\Model\\{$alias['model']}"; $this->_related[$name] = $class::fetch([$alias['parent_key'] => $this->get($alias['foreign_key'])])[0]; }elseif(isset($aliases['has_one'][$name])){ $alias = $aliases['has_many'][$name]; $class = "\\Model\\{$alias['model']}"; $this->_related[$name] = $class::instance($this[$alias['foreign_key']]); }elseif(isset($aliases['has_many'][$name])){ $alias = $aliases['has_many'][$name]; $class = "\\Model\\{$alias['model']}"; $sql = "select "; if($alias['through']){ $sql .= $class::table_name().$class::foreign_key_suffix()." id from {$alias['through']} "; }else{ $sql .= $class::primary_key()." id from {$alias['model']} "; } $sql .= "where ".$alias['foreign_key']." = ".self::quote($this->id); $related = []; $query = self::query($sql); foreach($query->fetchAll() as $row){ $related[$row['id']] = new $class($row['id']); } $query->closeCursor(); $this->_related[$name] = Relationship::from($this, $name, $alias, $related); } } if(isset($this->_related[$name])){ return $this->_related[$name]; }else{ throw new \Exception("Relationship {$name} does not exist"); } } public function release(string $name){ if(isset($this->_related[$name])){ unset($this->_related[$name]); } } } ?>