Browse Source

* Move migrations to use pdo
* add pdo and supporting transaction and table classes

Nathaniel van Diepen 7 years ago
parent
commit
2ae7fc9333
4 changed files with 462 additions and 0 deletions
  1. 106 0
      PDO/migration.abstract.class.php
  2. 150 0
      PDO/table.class.php
  3. 79 0
      PDO/transaction.class.php
  4. 127 0
      pdo.class.php

+ 106 - 0
PDO/migration.abstract.class.php

@@ -0,0 +1,106 @@
+<?php
+	namespace Juju\PDO;
+	require_once(realpath(dirname(__DIR__).'/pdo.class.php'));
+	use Juju\{PDO, PDO\Transaction, Settings};
+
+	abstract class Migration {
+		public abstract static function up(Transaction $pdo);
+		public abstract static function down(Transaction $pdo);
+		public abstract static function change(Transaction $pdo);
+
+		const MIGRATE_UP = 'up';
+		const MIGRATE_DOWN = 'down';
+		private static $pdo;
+
+		final public static function version(){
+			$name = get_called_class();
+			return substr($name, strrpos($name, '\\') + 1);
+		}
+		final public static function version_table(){
+			if(!class_exists("Juju\\Settings")){
+				throw new \Exception("Settings not loaded");
+			}
+			return Settings::get('db.versions');
+		}
+		final public static function installed(){
+			$pdo = self::$pdo;
+			$count = $pdo->exec(
+				"select 1 ".
+				"from `".self::version_table()."` ".
+				"where version = {$pdo->quote(static::version())}"
+			);
+			if($count === false){
+				throw $pdo->getError();
+			}
+			return $count;
+		}
+		final public static function migrations(){
+			$migrations = array_filter(get_declared_classes(), function($class){
+				return 0 === strpos($class, "Migration\\");
+			});
+			sort($migrations);
+			return $migrations;
+		}
+		final public static function migrate(string $direction, int $amount = 1){
+			if($amount < 1){
+				throw new \Exception("Migration amount must be a positive integer");
+			}
+			$pdo = self::$pdo;
+			$table = $pdo->table(self::version_table());
+			if(!$table->exists){
+				$table->column('version', [
+						'type'=>'varchar(100)',
+						'null'=>false,
+						'key'=>'PRI'
+					])
+					->primaryKey('version')
+					->commit();
+			}
+			switch($direction){
+				case self::MIGRATE_UP:
+					foreach(self::migrations() as $migration){
+						if(!$migration::installed()){
+							$pdo->transaction(function($pdo) use($migration, $table){
+								$migration::up($pdo);
+							});
+							$table->insert(['version'=>$migration::version()]);
+							if(!$migration::installed()){
+								throw new \Exception("Migration {$migration::version()} {$direction} failed");
+							}
+							if(--$amount == 0){
+								break;
+							}
+						}
+					}
+				break;
+				case self::MIGRATE_DOWN:
+					foreach(array_reverse(self::migrations()) as $migration){
+						if($migration::installed()){
+							$pdo->transaction(function($pdo) use($migration, $table){
+								$migration::down($pdo);
+							});
+							$table->delete(['version'=>$migration::version()]);
+							if($migration::installed()){
+								throw new \Exception("Migration {$migration::version()} {$direction} failed");
+							}
+							if(--$amount == 0){
+								break;
+							}
+						}
+					}
+				break;
+				default:
+					throw new \Exception("Invalid migration direction '{$direction}'");
+			}
+		}
+		final public static function migrate_all(string $direction){
+			self::migrate($direction, count(self::migrations()));
+		}
+		final public static function bind(string $dsn){
+			self::$pdo = PDO::from($dsn);
+		}
+		final public static function release(){
+			self::$pdo = null;
+		}
+	}
+?>

+ 150 - 0
PDO/table.class.php

@@ -0,0 +1,150 @@
+<?php
+	namespace Juju\PDO;
+	require_once(realpath(dirname(__DIR__).'/pdo.class.php'));
+	use Juju\{PDO, PDO\Transaction};
+
+	class Table {
+		private $pdo;
+		private $name;
+		private $exists;
+		private $columns;
+
+		public function __construct($pdo, string $name){
+			if($pdo instanceof PDO || $pdo instanceof Transaction){
+				$this->pdo = $pdo;
+				$this->name = $name;
+				$this->rollback();
+			}else{
+				throw new \Exception("Invalid pdo argument");
+			}
+		}
+		public function __get($name){
+			switch($name){
+				case 'exists':case 'name':case 'columns':
+					return $this->$name;
+				break;
+				default:
+					throw new \Exception("Invalid property {$name}");
+			}
+		}
+		public function exists(){
+			$pdo = $this->pdo;
+			try{
+				$count = $pdo->exec("select count(1) from `{$this->name}` limit 1");
+				$this->exists = $count > 0;
+			}catch(\Exception $e){
+				$this->exists = false;
+			}
+			return $this->exists;
+		}
+		public function describe(){
+			$this->columns = [];
+			if($this->exists){
+				$query = $this->pdo->query("describe `{$this->name}`");
+				while($col = $query->fetch()){
+					$this->columns[$col['field']] = [
+						'type'=> $col['type'],
+						'null'=> $col['null'] !== 'NO',
+						'key'=> $col['key'],
+						'default'=> $col['default'],
+						'extra'=> $col['extra']
+					];
+				}
+				$query->closeCursor();
+			}
+		}
+		public function stringFilter(array $filter = null){
+			if(!is_null($filter)){
+				$where = 'where ';
+				foreach($filter as $name => $value){
+					$where .= "`{$name}` = {$this->pdo->quote($value)}";
+				}
+			}
+			return "$where";
+		}
+		public function commit(){
+			$pdo = $this->pdo;
+			if(!$this->exists){
+				$columns = '';
+				foreach($this->columns as $name => $column){
+					$columns .= "{$name} {$column['type']},";
+				}
+				if(count($columns) > 0){
+					$columns = substr($columns, 0, count($count) - 1);
+				}
+				$pk = $this->primaryKey();
+				if($pk){
+					$pk = ", primary key ({$pk})";
+				}
+				if($pdo->exec("create table `{$this->name}` ({$columns} {$pk})") === false){
+					throw $pdo->getError();
+				}
+				$this->exists();
+			}else{
+				// @todo alter table to add and remove columns
+			}
+			$this->describe();
+		}
+		public function rollback(){
+			$this->exists();
+			$this->describe();
+		}
+		public function drop(){
+			if($this->exists){
+				$this->pdo->exec("drop table `{$this->name}`");
+			}
+		}
+		public function column(string $name, array $column){
+			$this->columns[$name] = $column;
+			return $this;
+		}
+		public function primaryKey(...$columns){
+			if(count($columns) > 0){
+				foreach($columns as $name){
+					if(!isset($this->columns[$name])){
+						throw new \Exception("Can't add Primary key. Column {$this->name}.{$name} doesn't exist");
+					}
+					$this->columns[$name]['key'] = 'PRI';
+				}
+				foreach($this->columns as $name => &$col){
+					if(!in_array($name, $columns) && $col['key'] == 'PRI'){
+						$col['key'] = '';
+					}
+				}
+				return $this;
+			}else{
+				$columns = $this->columns;
+				return substr(array_reduce(array_keys($columns), function($ret, $name) use($columns){
+					$col = $columns[$name];
+					if($col['key'] == 'PRI'){
+						$ret .= ",{$name}";
+					}
+					return $ret;
+				}), 1);
+			}
+		}
+		public function insert(array $data){
+			$pdo = $this->pdo;
+			$sets = '';
+			foreach($data as $name => $value){
+				$sets .= "`{$name}` = {$pdo->quote($value)},";
+			}
+			if(count($sets) > 0){
+				$sets = substr($sets, 0, count($sets) - 2);
+			}
+			return $pdo->exec("insert into `{$this->name}` set {$sets}");
+		}
+		public function delete(array $filter = null){
+			return $this->pdo->exec("delete from `{$this->name}` {$this->stringFilter($filter)}");
+		}
+		public function fetch(array $filter = null){
+			$query = $this->pdo->query("select * from `{$this->name}` {$this->stringFilter($filter)}");
+			$results = $query->fetchAll();
+			$query->closeCursor();
+			return $results;
+		}
+		public function count(array $filter = null){
+			return $this->pdo->exec("select 1 from `{$this->name}` {$this->stringFilter($filter)}");
+		}
+	}
+?>

+ 79 - 0
PDO/transaction.class.php

@@ -0,0 +1,79 @@
+<?php
+	namespace Juju\PDO;
+	require_once(realpath(dirname(__DIR__).'/pdo.class.php'));
+	use Juju\PDO;
+
+	class Transaction {
+		private $pdo;
+		private $_pdo;
+		private $savepoint = 0;
+		private $dirty = false;
+		public function __construct(PDO $pdo){
+			$this->pdo = $pdo;
+			if(!$pdo->beginTransaction()){
+				throw $this->getError();
+			}
+		}
+		public function __destruct(){
+			if($this->dirty){
+				throw new \Exception("Transaction not committed");
+			}
+			$this->pdo->setAttribute(\PDO::ATTR_AUTOCOMMIT, true);
+			$this->savepoint = 0;
+		}
+		public function __get(string $name){
+			switch($name){
+				case 'savepoint':case 'dirty':
+					return $this->$name;
+				break;
+				default:
+					throw new \Exception("Invalid property {$name}");
+			}
+		}
+		public function commit(bool $final = false){
+			if($this->dirty){
+				$this->savepoint++;
+				$this->exec("savepoint trans_{$this->savepoint}");
+				$this->dirty = false;
+			}
+		}
+		public function rollback(bool $final = false){
+			if($this->savepoint > 0){
+				if($this->dirty){
+					$this->exec("rollback to trans_{$this->savepoint}");
+				}
+				$this->savepoint--;
+			}
+			$this->dirty = false;
+		}
+		public function transaction(callable $fn){
+			$this->commit();
+			if($fn($this) === false){
+				$this->rollback();
+			}else{
+				$this->commit();
+			}
+		}
+		public function table(string $name){
+			return new Table($this, $name);
+		}
+		public function prepare(...$args){
+			$this->dirty = true;
+			return $this->pdo->prepare(...$args);
+		}
+		public function exec(...$args){
+			$this->dirty = true;
+			return $this->pdo->exec(...$args);
+		}
+		public function query(...$args){
+			$this->dirty = true;
+			return $this->pdo->query(...$args);
+		}
+		public function quote(...$args){
+			return $this->pdo->quote(...$args);
+		}
+		public function getError(){
+			return $this->pdo->getError();
+		}
+	}
+?>

+ 127 - 0
pdo.class.php

@@ -0,0 +1,127 @@
+<?php
+	namespace Juju;
+	require_once('Data/securestring.class.php');
+	require_once('PDO/transaction.class.php');
+	require_once('PDO/table.class.php');
+	use Juju\{Data\SecureString, PDO\Table, PDO\Transaction};
+
+	class PDO {
+		public static function from(string $dsnstring){
+			$parts = explode(':', $dsnstring);
+			$dsnstring = $parts[1];
+			$dsn = explode(';', $dsnstring);
+			$dsn = array_reduce($dsn, function($dsn, $item){
+				$item = explode('=', $item);
+				$dsn[$item[0]] = $item[1];
+				return $dsn;
+			});
+			if(!isset($dsn['host'])){
+				throw new \Exception("DSN '{$dsnstring}' missing host");
+			}
+			if(!isset($dsn['dbname'])){
+				throw new \Exception("DSN '{$dsnstring}' missing dbname");
+			}
+			if(!isset($dsn['user'])){
+				$user = $dsn['dbname'];
+			}else{
+				$user = $dsn['user'];
+			}
+			if(!isset($dsn['pass'])){
+				$dsn['pass'] = $user;
+			}
+			$pass = SecureString::from($dsn['pass']);
+			unset($dsn['pass'], $dsn['user']);
+			$dsn = array_reduce(array_keys($dsn), function($a, $key) use($dsn){
+				$a[] = "{$key}={$dsn[$key]}";
+				return $a;
+			});
+			$dsnstring = $parts[0].':'.implode(';', $dsn);
+			return new PDO($dsnstring, $user, $pass);
+		}
+
+		private $pdo;
+		private function __construct(string $dsn, string $user, SecureString $pass){
+			$mysql = explode(':', $dsn)[0] === 'mysql';
+			$options = [];
+			if($mysql){
+				$options[\PDO::MYSQL_ATTR_INIT_COMMAND] = "SET NAMES 'UTF8'";
+				$options[\PDO::MYSQL_ATTR_MULTI_STATEMENTS] = false;
+			}
+			$pdo = new \PDO($dsn, $user, (string)$pass, $options);
+			$pdo->setAttribute(\PDO::ATTR_AUTOCOMMIT, true);
+			$pdo->setAttribute(\PDO::ATTR_CASE, \PDO::CASE_LOWER);
+			$pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false);
+			$pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
+			$this->pdo = $pdo;
+		}
+		public function transaction(callable $fn){
+			$pdo = $this->pdo;
+			if($pdo->inTransaction()){
+				throw new \Exception("Unable to start a new transaction");
+			}
+			$transaction = new Transaction($this);
+			if($fn($transaction) === false){
+				do{
+					$transaction->rollback();
+				}while($transaction->savepoint);
+				$pdo->rollback();
+			}else{
+				$transaction->commit();
+				if($pdo->inTransaction()){
+					$pdo->commit();
+				}
+			}
+			unset($transaction);
+			$pdo->setAttribute(\PDO::ATTR_AUTOCOMMIT, true);
+			return $this;
+		}
+		public function table(string $name){
+			return new Table($this, $name);
+		}
+		public function prepare(string $statement, array $options = []){
+			$pdo = $this->pdo;
+			if($pdo->getAttribute(\PDO::ATTR_DRIVER_NAME) == 'mysql'){
+				$options[\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY] = true;
+			}
+			$query = $pdo->prepare($statement);
+			if($query === false){
+				throw $this->getError();
+			}
+			return $query;
+		}
+		public function exec(string $statement){
+			$query = $this->prepare($statement);
+			$count = 0;
+			$query->execute();
+			while($query->fetch() !== false){
+				$count++;
+			}
+			$query->closeCursor();
+			return $count;
+		}
+		public function query(string $statement, int $mode = null, ...$args){
+			$query = $this->prepare($statement);
+			if(!is_null($mode)){
+				$query->setFetchMode($mode, ...$args);
+			}
+			$query->execute();
+			return $query;
+		}
+		public function quote(...$args){
+			return $this->pdo->quote(...$args);
+		}
+		public function beginTransaction(...$args){
+			return $this->pdo->beginTransaction(...$args);
+		}
+		public function setAttribute(...$args){
+			return $this->pdo->setAttribute(...$args);
+		}
+		public function getAttribute(...$args){
+			return $this->pdo->getAttribute(...$args);
+		}
+		public function getError(){
+			$error = $this->pdo->errorInfo();
+			return new \Exception($error[2], $error[1]);
+		}
+	}
+?>