diff --git a/src/Database/Mysql/Connection.php b/src/Database/Mysql/Connection.php new file mode 100644 index 0000000..42dcef6 --- /dev/null +++ b/src/Database/Mysql/Connection.php @@ -0,0 +1,114 @@ +connection = new mysqli($host, $user, $password, $db, $port, $socket); + + if ($this->connection->connect_error) { + throw new \Exception('Connection failed: ' . $this->connection->connect_error); + } + + if (!$this->connection->set_charset('utf8mb4')) { + throw new \Exception($this->connection->error); + } + } + + public function __destruct() + { + $this->connection->close(); + } + + public function startTransaction(): void + { + if (!$this->connection->autocommit(false)) { + throw new \Exception($this->connection->error); + } + } + + public function commit(): void + { + if (!$this->connection->commit() || !$this->connection->autocommit(true)) { + throw new \Exception($this->connection->error); + } + } + + public function rollback(): void + { + if (!$this->connection->rollback() || !$this->connection->autocommit(true)) { + throw new \Exception($this->connection->error); + } + } + + public function query(string $query): ?IResultSet + { + if (!($result = $this->connection->query($query))) { + throw new \Exception($this->connection->error . '. Query: ' . $query); + } + + if ($result !== true) { + return new ResultSet($result); + } + + return null; + } + + public function multiQuery(string $query): array + { + if (!$this->connection->multi_query($query)) { + throw new \Exception($this->connection->error . '. Query: ' . $query); + } + + $ret = []; + do { + if ($result = $this->connection->store_result()) { + $ret[] = new ResultSet($result); + } else { + $ret[] = null; + } + + $this->connection->more_results(); + } while ($this->connection->next_result()); + + if ($this->connection->error) { + throw new \Exception($this->connection->error . '. Query: ' . $query); + } + + return $ret; + } + + public function prepare(string $query): IStatement + { + if (!($stmt = $this->connection->prepare($query))) { + throw new \Exception($this->connection->error . '. Query: ' . $query); + } + + return new Statement($stmt); + } + + public function lastId(): int + { + return $this->connection->insert_id; + } + + public function getAffectedRows(): int + { + return $this->connection->affected_rows; + } +} diff --git a/src/Database/Mysql/ResultSet.php b/src/Database/Mysql/ResultSet.php new file mode 100644 index 0000000..caf127c --- /dev/null +++ b/src/Database/Mysql/ResultSet.php @@ -0,0 +1,62 @@ +result = $result; + } + + public function fetch(int $type = IResultSet::FETCH_ASSOC) + { + return $this->result->fetch_array($this->convertFetchType($type)); + } + + public function fetchAll(int $type = IResultSet::FETCH_ASSOC) + { + return $this->result->fetch_all($this->convertFetchType($type)); + } + + public function fetchOneColumn(string $valueName, string $keyName = null) + { + $array = []; + + while ($r = $this->fetch(IResultSet::FETCH_ASSOC)) { + if (isset($keyName)) { + $array[$r[$keyName]] = $r[$valueName]; + } else { + $array[] = $r[$valueName]; + } + } + + return $array; + } + + private function convertFetchType(int $type): int + { + switch ($type) { + case IResultSet::FETCH_ASSOC: + $internal_type = MYSQLI_ASSOC; + break; + + case IResultSet::FETCH_BOTH: + $internal_type = MYSQLI_BOTH; + break; + + case IResultSet::FETCH_NUM: + $internal_type = MYSQLI_NUM; + break; + + default: + $internal_type = MYSQLI_BOTH; + break; + } + + return $internal_type; + } +} diff --git a/src/Database/Mysql/Statement.php b/src/Database/Mysql/Statement.php new file mode 100644 index 0000000..c380d54 --- /dev/null +++ b/src/Database/Mysql/Statement.php @@ -0,0 +1,79 @@ +stmt = $stmt; + } + + public function __destruct() + { + $this->stmt->close(); + } + + public function execute(array $params = []): ?IResultSet + { + if ($params) { + $ref_params = ['']; + + foreach ($params as &$param) { + $type = gettype($param); + + switch ($type) { + case 'integer': + case 'double': + case 'string': + $t = $type[0]; + break; + + case 'NULL': + $t = 's'; + break; + + case 'boolean': + $param = (string) (int) $param; + $t = 's'; + break; + + case 'array': + $param = json_encode($param); + $t = 's'; + break; + } + + if (!isset($t)) { + throw new \Exception('Data type ' . $type . ' not supported!'); + } + + $ref_params[] = &$param; + $ref_params[0] .= $t; + } + + if (!call_user_func_array([$this->stmt, 'bind_param'], $ref_params)) { + throw new \Exception($this->stmt->error); + } + } + + if (!$this->stmt->execute()) { + throw new \Exception($this->stmt->error); + } + + if ($result_set = $this->stmt->get_result()) { + return new ResultSet($result_set); + } + + return null; + } + + public function getAffectedRows(): int + { + return $this->stmt->affected_rows; + } +} diff --git a/src/Database/Query/Modify.php b/src/Database/Query/Modify.php new file mode 100755 index 0000000..da74873 --- /dev/null +++ b/src/Database/Query/Modify.php @@ -0,0 +1,157 @@ +connection = $connection; + $this->table = $table; + } + + public function setIdName(string $idName): Modify + { + $this->idName = $idName; + + return $this; + } + + public function setAutoIncrement(bool $autoIncrement = true): Modify + { + $this->autoIncrement = $autoIncrement; + + return $this; + } + + public function fill(array $attributes): Modify + { + $this->attributes = array_merge($this->attributes, $attributes); + + return $this; + } + + public function set(string $name, $value): Modify + { + $this->attributes[$name] = $value; + + return $this; + } + + public function setId($id): Modify + { + $this->attributes[$this->idName] = $id; + + return $this; + } + + public function save(): void + { + if (isset($this->attributes[$this->idName])) { + $this->update(); + } else { + $this->insert(); + } + } + + public function delete(): void + { + if (!isset($this->attributes[$this->idName])) { + throw new \Exception('No primary key specified!'); + } + + $query = 'DELETE FROM ' . Utils::backtick($this->table) . ' WHERE ' . Utils::backtick($this->idName) . '=?'; + + $stmt = $this->connection->prepare($query); + $stmt->execute([$this->idName => $this->attributes[$this->idName]]); + } + + private function insert(): void + { + if (!$this->autoIncrement) { + $this->attributes[$this->idName] = $this->generateKey(); + } + + $set = $this->generateColumnsWithBinding(array_keys($this->attributes)); + + $query = 'INSERT INTO ' . Utils::backtick($this->table) . ' SET ' . $set; + + $stmt = $this->connection->prepare($query); + $stmt->execute($this->attributes); + + if ($this->autoIncrement) { + $this->attributes[$this->idName] = $this->connection->lastId(); + } + } + + private function update(): void + { + $diff = $this->generateDiff(); + + if (count($diff) === 0) { + return; + } + + $set = $this->generateColumnsWithBinding(array_keys($diff)); + + $query = 'UPDATE ' . Utils::backtick($this->table) . ' SET ' . $set . ' WHERE ' . Utils::backtick($this->idName) . '=?'; + + $stmt = $this->connection->prepare($query); + $stmt->execute(array_merge($diff, [$this->idName => $this->attributes[$this->idName]])); + } + + private function readFromDB(array $columns): void + { + $select = (new Select($this->connection, $this->table)) + ->setIdName($this->idName) + ->whereId($this->attributes[$this->idName]) + ->columns($columns); + + $this->original = $select->execute()->fetch(IResultSet::FETCH_ASSOC); + } + + private function generateDiff(): array + { + $this->readFromDB(array_keys($this->attributes)); + + $diff = []; + + foreach ($this->attributes as $name => $value) { + $original = $this->original[$name]; + + if ($original != $value) { + $diff[$name] = $value; + } + } + + return $diff; + } + + public static function generateColumnsWithBinding(array $columns): string + { + array_walk($columns, function(&$value, $key) { + $value = Utils::backtick($value) . '=?'; + }); + + return implode(',', $columns); + } + + private function generateKey(): string + { + return substr(hash('sha256', serialize($this->attributes) . random_bytes(10) . microtime()), 0, 7); + } +} diff --git a/src/Database/Query/Select.php b/src/Database/Query/Select.php new file mode 100644 index 0000000..4d32f9b --- /dev/null +++ b/src/Database/Query/Select.php @@ -0,0 +1,402 @@ + [], self::CONDITION_HAVING => []]; + + private array $groups = []; + + private array $orders = []; + + private array $limit; + + public function __construct(IConnection $connection, string $table) + { + $this->connection = $connection; + $this->table = $table; + } + + public function setIdName(string $idName): Select + { + $this->idName = $idName; + + return $this; + } + + public function setTableAliases(array $tableAliases): Select + { + $this->tableAliases = array_merge($this->tableAliases, $tableAliases); + + return $this; + } + + public function columns(array $columns): Select + { + $this->columns = array_merge($this->columns, $columns); + + return $this; + } + + public function innerJoin($table, $column1, string $relation, $column2): Select + { + $this->addJoin('INNER', $table, $column1, $relation, $column2); + + return $this; + } + + public function leftJoin($table, $column1, string $relation, $column2): Select + { + $this->addJoin('LEFT', $table, $column1, $relation, $column2); + + return $this; + } + + public function whereId($value): Select + { + $this->addWhereCondition('AND', $this->idName, '=', $value); + + return $this; + } + + public function where($column, string $relation = null, $value = null): Select + { + $this->addWhereCondition('AND', $column, $relation, $value); + + return $this; + } + + public function orWhere($column, string $relation = null, $value = null): Select + { + $this->addWhereCondition('OR', $column, $relation, $value); + + return $this; + } + + public function having($column, string $relation = null, $value = null): Select + { + $this->addHavingCondition('AND', $column, $relation, $value); + + return $this; + } + + public function orHaving($column, string $relation = null, $value = null): Select + { + $this->addHavingCondition('OR', $column, $relation, $value); + + return $this; + } + + public function groupBy($column): Select + { + $this->groups[] = $column; + + return $this; + } + + public function orderBy($column, string $type = 'asc'): Select + { + $this->orders[] = [$column, $type]; + + return $this; + } + + public function limit(int $limit, int $offset = 0): Select + { + $this->limit = [$limit, $offset]; + + return $this; + } + + public function resetLimit(): void + { + $this->limit = null; + } + + public function paginate(int $page, int $itemsPerPage) + { + $this->limit($itemsPerPage, ($page - 1) * $itemsPerPage); + + return $this; + } + + public function execute(): IResultSet + { + list($query, $params) = $this->generateQuery(); + + return $this->connection->prepare($query)->execute($params); + } + + public function count(): int + { + if (count($this->groups) > 0 || count($this->conditions[self::CONDITION_HAVING]) > 0) { + $orders = $this->orders; + + $this->orders = []; + + list($query, $params) = $this->generateQuery(); + + $result = $this->connection->prepare('SELECT COUNT(*) num_rows FROM (' . $query . ') x') + ->execute($params) + ->fetch(IResultSet::FETCH_NUM); + + $this->orders = $orders; + + return $result[0]; + } else { + $columns = $this->columns; + $orders = $this->orders; + + $this->columns = [new RawExpression('COUNT(*) num_rows')]; + $this->orders = []; + + list($query, $params) = $this->generateQuery(); + + $result = $this->connection->prepare($query) + ->execute($params) + ->fetch(IResultSet::FETCH_NUM); + + $this->columns = $columns; + $this->orders = $orders; + + return $result[0]; + } + } + + private function addJoin(string $type, $table, $column1, string $relation, $column2): void + { + $this->joins[] = [$type, $table, $column1, $relation, $column2]; + } + + private function addWhereCondition(string $logic, $column, string $relation, $value): void + { + $this->conditions[self::CONDITION_WHERE][] = [$logic, $column, $relation, $value]; + } + + private function addHavingCondition(string $logic, $column, string $relation, $value): void + { + $this->conditions[self::CONDITION_HAVING][] = [$logic, $column, $relation, $value]; + } + + private function generateQuery(): array + { + $queryString = 'SELECT ' . $this->generateColumns() . ' FROM ' . $this->generateTable($this->table, true); + + if (count($this->joins) > 0) { + $queryString .= ' ' . $this->generateJoins(); + } + + if (count($this->conditions[self::CONDITION_WHERE]) > 0) { + list($wheres, $whereParams) = $this->generateConditions(self::CONDITION_WHERE); + + $queryString .= ' WHERE ' . $wheres; + } else { + $whereParams = []; + } + + if (count($this->groups) > 0) { + $queryString .= ' GROUP BY ' . $this->generateGroupBy(); + } + + if (count($this->conditions[self::CONDITION_HAVING]) > 0) { + list($havings, $havingParams) = $this->generateConditions(self::CONDITION_HAVING); + + $queryString .= ' HAVING ' . $havings; + } else { + $havingParams = []; + } + + if (count($this->orders) > 0) { + $queryString .= ' ORDER BY ' . $this->generateOrderBy(); + } + + if (isset($this->limit)) { + $queryString .= ' LIMIT ' . $this->limit[1] . ', ' . $this->limit[0]; + } + + return [$queryString, array_merge($whereParams, $havingParams)]; + } + + private function generateTable($table, bool $defineAlias = false): string + { + if ($table instanceof RawExpression) { + return (string) $table; + } + + if (isset($this->tableAliases[$table])) { + return ($defineAlias ? Utils::backtick($this->tableAliases[$table]) . ' ' . Utils::backtick($table) : Utils::backtick($table)); + } + + return Utils::backtick($table); + } + + private function generateColumn($column): string + { + if ($column instanceof RawExpression) { + return (string) $column; + } + + if (is_array($column)) { + $out = ''; + + if ($column[0]) { + $out .= $this->generateTable($column[0]) . '.'; + } + + $out .= Utils::backtick($column[1]); + + if (!empty($column[2])) { + $out .= ' ' . Utils::backtick($column[2]); + } + + return $out; + } else { + return Utils::backtick($column); + } + } + + private function generateColumns(): string + { + $columns = $this->columns; + + array_walk($columns, function (&$value, $key) { + $value = $this->generateColumn($value); + }); + + return implode(',', $columns); + } + + private function generateJoins(): string + { + $joins = $this->joins; + + array_walk($joins, function (&$value, $key) { + $value = $value[0] . ' JOIN ' . $this->generateTable($value[1], true) . ' ON ' . $this->generateColumn($value[2]) . ' ' . $value[3] . ' ' . $this->generateColumn($value[4]); + }); + + return implode(' ', $joins); + } + + private function generateConditions(string $type): array + { + $conditions = ''; + $params = []; + + foreach ($this->conditions[$type] as $condition) { + list($logic, $column, $relation, $value) = $condition; + + if ($column instanceof Closure) { + list($conditionsStringFragment, $paramsFragment) = $this->generateComplexConditionFragment($type, $column); + } else { + list($conditionsStringFragment, $paramsFragment) = $this->generateConditionFragment($condition); + } + + if ($conditions !== '') { + $conditions .= ' ' . $logic . ' '; + } + + $conditions .= $conditionsStringFragment; + $params = array_merge($params, $paramsFragment); + } + + return [$conditions, $params]; + } + + private function generateConditionFragment(array $condition): array + { + list($logic, $column, $relation, $value) = $condition; + + if ($column instanceof RawExpression) { + return [(string) $column, []]; + } + + $conditionsString = $this->generateColumn($column) . ' '; + + if ($value === null) { + return [$conditionsString . ($relation == '=' ? 'IS NULL' : 'IS NOT NULL'), []]; + } + + $conditionsString .= strtoupper($relation) . ' ';; + + switch ($relation = strtolower($relation)) { + case 'between': + $params = [$value[0], $value[1]]; + + $conditionsString .= '? AND ?'; + break; + + case 'in': + case 'not in': + $params = $value; + + if (count($value) > 0) { + $conditionsString .= '(' . implode(', ', array_fill(0, count($value), '?')) . ')'; + } else { + $conditionsString = $relation == 'in' ? '0' : '1'; + } + break; + + default: + $params = [$value]; + + $conditionsString .= '?'; + } + + return [$conditionsString, $params]; + } + + private function generateComplexConditionFragment(string $type, Closure $conditionCallback): array + { + $instance = new static($this->connection, $this->table); + $instance->tableAliases = $this->tableAliases; + + $conditionCallback($instance); + + list($conditions, $params) = $instance->generateConditions($type); + + return ['(' . $conditions . ')', $params]; + } + + private function generateGroupBy(): string + { + $groups = $this->groups; + + array_walk($groups, function (&$value, $key) { + $value = $this->generateColumn($value); + }); + + return implode(',', $groups); + } + + private function generateOrderBy(): string + { + $orders = $this->orders; + + array_walk($orders, function (&$value, $key) { + $value = $this->generateColumn($value[0]) . ' ' . $value[1]; + }); + + return implode(',', $orders); + } +} diff --git a/src/Database/RawExpression.php b/src/Database/RawExpression.php new file mode 100644 index 0000000..e2b0328 --- /dev/null +++ b/src/Database/RawExpression.php @@ -0,0 +1,16 @@ +expression = $expression; + } + + public function __toString(): string + { + return $this->expression; + } +} diff --git a/src/Database/Utils.php b/src/Database/Utils.php new file mode 100644 index 0000000..3c4042e --- /dev/null +++ b/src/Database/Utils.php @@ -0,0 +1,7 @@ +