diff --git a/src/DB.php b/src/DB.php index 1504b4a..1e150ab 100644 --- a/src/DB.php +++ b/src/DB.php @@ -4,6 +4,11 @@ namespace Effectra\Database; +use Effectra\Database\Data\DataOptimizer; +use Effectra\Database\Data\DataValidator; +use Effectra\Database\Exception\DatabaseException; +use Effectra\Database\Exception\DataValidatorException; +use Effectra\SqlQuery\Operations\Insert; use Effectra\SqlQuery\Query; use PDO; use PDOStatement; @@ -15,44 +20,91 @@ */ class DB { - protected PDO|null $conn = null; - protected PDOStatement|false $stmt = false; - protected string $q = ""; - protected string $table = ""; - protected array $data = []; + use TargetTable; - const FETCH_ASSOC = PDO::FETCH_ASSOC; + /** + * @var string $QUERY The SQL query string. + */ + protected static string $QUERY = ''; + + /** + * @var PDOStatement|false $statement The PDO statement or false if no statement is set. + */ + protected PDOStatement|false $statement = false; + + /** + * @var array $dataInserted The data inserted during operations. + */ + protected array $dataInserted = []; + + /** + * @var string $table The name of the database table. + */ + protected string $table; + + /** + * Constructor for DB. + * + * @param string $driver The database driver. + * @param PDO $connection The PDO database connection. + */ + public function __construct( + protected string $driver, + protected \PDO $connection, + ) { + Query::driver($this->driver); + } /** - * DB constructor. + * Get the PDO database connection. * - * @param PDO|null $conn The PDO connection instance. + * @return PDO The PDO database connection. */ - public function __construct(PDO $conn = null) + private function getConnection(): PDO { - if ($conn) { - $this->conn = $conn; - } + return $this->connection; + } + + /** + * Set the PDO database connection. + * + * @param PDO $connection The PDO database connection. + */ + private function setConnection(PDO $connection): void + { + $this->connection = $connection; + } + + /** + * Get the current SQL query string. + * + * @return string The current SQL query string. + */ + private function getQuery(): string + { + return static::$QUERY; } /** - * Get the PDO connection instance. + * Set the current SQL query string. * - * @return PDO The PDO connection instance. + * @param string $query The SQL query string to set. */ - public function getConn(): PDO + private function setQuery(string $query): void { - return $this->conn; + static::$QUERY = $query; } /** - * Get the current query. + * Set the current SQL query string. * - * @return string The current query. + * @param string $query The SQL query string to set. + * @return self The DB instance with the specified table. */ - public function getQuery(): string + public function query(string $query): self { - return $this->q; + $this->setQuery($query); + return $this; } /** @@ -62,240 +114,288 @@ public function getQuery(): string */ public function getStatement(): PDOStatement|false { - return $this->stmt; + return $this->statement; } /** - * Set the PDO connection instance. - * - * @param PDO $conn The PDO connection instance. + * Check statement instance + * @return bool return true if statement is not false */ - public function setConn(PDO $conn) + private function hasStatement(): bool { - $this->conn = $conn; + return $this->statement !== false; } /** - * Set the query string. + * Set the statement instance. * - * @param string $query The query string. - * @return DB The updated DB instance. + * @param PDOStatement|false $statement The prepared statement instance. */ - public function withQuery($query): self + private function setStatement(PDOStatement|false $statement): void { - $clone = clone $this; - $clone->q = (string) $query; - return $clone; + $this->statement = $statement; } /** - * Set the prepared statement instance. - * - * @param PDOStatement|false $stmt The prepared statement instance. - * @return DB The updated DB instance. + * Get table name + * @return string the name of table */ - public function withStatement(PDOStatement|false $stmt): self + private function getTable(): string { - $clone = clone $this; - $clone->stmt = $stmt; - return $clone; + return $this->table; } /** - * Add to the current query string. + * Create table name. * - * @param string $query The query string to add. - * @return string The updated query. + * @param string $table The name of the database table. + * @return void */ - public function query(string $query = ''): string + private function setTable(string $table): void { - if (!empty($query)) { - $this->q .= $query; - } - return $this->getQuery(); + $this->table = $table; } /** - * Run the query with optional parameters. + * Create and return a new DB instance with the specified table name. * - * @param array|null $params The query parameters. - * @return bool True if the query executed successfully, false otherwise. + * @param string $table The name of the database table. + * @return self The DB instance with the specified table. */ - public function run(array|null $params = null): bool + public function table(string $table): self { - $query = (string) $this->q; - $this->stmt = $this->conn->prepare($query); - return $this->stmt->execute($params); + $this->table = $table; + return $this; } /** - * Execute the query and fetch all rows as an associative array. + * Execute the current SQL query with optional parameters. + * + * @param array|null $params Optional parameters to bind to the query. + * @return bool True if the query was executed successfully, false otherwise. * - * @param array|null $params The query parameters. - * @return array The fetched data. + * @throws \Exception If there's an error executing the query. */ - public function get(?array $params = null): array + public function run(?array $params = null): bool { - $this->run($params); - $this->stmt->setFetchMode(PDO::FETCH_ASSOC); - $data = $this->stmt->fetchAll(); - - return $data; + try { + $stmt = $this->getConnection()->prepare(static::$QUERY); + $this->setStatement($stmt); + return $this->getStatement()->execute($params); + } catch (\PDOException $e) { + throw new \Exception($e->getMessage()); + } } /** - * Execute the query and fetch the first row as an object. + * Fetch all rows from the executed query as an associative array. * - * @return mixed The fetched object. + * @return array|null An array of fetched data or null if no data is available. */ - public function getAsObject() + public function fetch(): array|null { $this->run(); - $this->stmt->setFetchMode(PDO::FETCH_OBJ); - $data = $this->stmt->fetchAll(); - return $this->prettyData($data)[0] ?? []; + if ($this->hasStatement()) { + $this->getStatement()->setFetchMode(PDO::FETCH_ASSOC); + $data = $this->getStatement()->fetchAll(); + $this->setStatement(false); + return $data; + } + return null; } /** - * Set the data for binding parameters in the prepared statement. + * Fetch all rows from the executed query as an array of objects. * - * @param array $data The data to bind. - * @return DB The updated DB instance. + * @return array|null An array of fetched data as objects or null if no data is available. */ - public function data(array $data): self + public function fetchObject(): array|null { - $this->data = $data; - foreach ($data as $key => $value) { - if ($this->stmt !== false) { - $this->stmt->bindParam(":$key", (string) $value); - } + $this->run(); + if ($this->hasStatement()) { + $data = $this->getStatement()->fetchAll(); + $this->setStatement(false); + return $data; } - return $this; + return null; } /** - * Set the table name for the query. + * Get the data inserted during an insert operation. * - * @param string $name The table name. - * @return DB The updated DB instance. + * @return array The data inserted. */ - public function table(string $name): self + public function getDataInserted(): array { - $this->table = $name; - return $this; + return $this->dataInserted; } /** - * Insert data into the table. + * Set the data inserted during an insert operation. * - * @param mixed $data The data to insert. - * @return void + * @param array $dataInserted The data inserted. */ - public function insert($data): void + public function setDataInserted(array $dataInserted): void { - if ($this->isCollection($data)) { - $query = ""; - foreach ($data as $item) { - if (is_array($item)) { - $query .= Query::insert($this->table)->columns(array_keys($item))->values(array_values($item)); - } - } - } else { - $query = Query::insert($this->table)->columns(array_keys($data))->values(array_values($data)); - } - $this->withQuery((string) $query)->run(); + $this->dataInserted = $dataInserted; } /** - * Update data in the table. + * Validate and set data to be inserted into the database. * - * @param mixed $data The data to update. - * @param mixed $conditions The update conditions. - * @return void + * @param mixed $data The data to be inserted. + * + * @throws DataValidatorException If the data is not valid. */ - public function update($data, $conditions = null): void + public function data($data) { - if ($this->isCollection($data)) { - $query = ""; - foreach ($data as $item) { - if (is_array($item)) { - $query = Query::update($this->table) - ->columns(array_keys($item)) - ->values(array_values($item)) - ->where($conditions); - } - } - } else { - $query = Query::update($this->table) - ->columns(array_keys($data)) - ->values(array_values($data)) - ->where($conditions); + $validate = new DataValidator($data); + + if ($validate->isAssoc()) { + $this->setDataInserted([$data]); + return; + } + + if ($validate->isArrayOfAssoc()) { + $this->setDataInserted($data); + return; } - $this->withQuery((string) $query)->run($query->combineColumnsValues()); + + $validate->validate(); } /** - * Get the columns of the table. + * Validate the payload against table columns and apply data transformation rules. + * + * @param array $payload The payload data to validate. + * @param array $tableInfo The information about the database table columns. + * + * @return array The validated and transformed payload data. * - * @return array|false The columns of the table. + * @throws DataValidatorException If the payload is not valid. */ - public function getColumns(): array|false + public function validatePayload(array $payload, array $tableInfo): array { - $query = Query::describe($this->table); - return $this->withQuery($query)->get(); + $requiredColumns = $this->requiredColumns($tableInfo); + $payloadKeys = array_keys($payload); + + $missingColumns = array_diff($requiredColumns, $payloadKeys); + + if (!empty($missingColumns)) { + $diff = join(",", $missingColumns); + throw new DataValidatorException("Error Processing Data, required columns not found: '$diff'", 1); + } + foreach ($payload as $col => $value) { + + + $info = $this->getColumnInfo($col, $tableInfo); + + if ($info) { + + if (gettype($value) !== $info['type']) { + throw new DataValidatorException("Error Processing Data, type given for key '$col' not respect datatype column in database table, type must be an {$info['type']} at '$value (" . gettype($value) . ")'", 1); + } + + if (empty($value) && $info['default'] === null && $info['null'] === 'NO') { + throw new DataValidatorException("key $col must has a non-empty value"); + } + } + + $payload[$col] = $value; + + if (in_array($col, array_diff($payloadKeys, $requiredColumns))) { + unset($payload[$col]); + } + } + + return $payload; } /** - * Get the field names of the columns of the table. + * Pass data to the query and insert it into the database table. + * + * @param array $data The data to insert. + * @param Insert|Update $query The insert/update query object. + * + * @return bool True if the data was successfully inserted, false otherwise. * - * @return array|false The field names of the columns. + * @throws DatabaseException If there's an error during data insertion. */ - public function getColumnsField(): array|false + public function passData(array $data, $query) { - return array_map(fn ($col) => $col['Field'], $this->getColumns()); + $this->data($data); + + try { + + $this->getConnection()->beginTransaction(); + + $tableInfo = $this->getTableInfo(); + + foreach ($this->getDataInserted() as $item) { + + $validateItem = $this->validatePayload($item, $tableInfo); + + $query->data($validateItem); + + $query->insertValuesModeSafe(); + + $this->query((string) $query); + + $this->run($query->getParams()); + } + + return $this->getConnection()->commit(); + } catch (\Throwable $e) { + + if ($this->getConnection()->inTransaction()) { + $this->getConnection()->rollBack(); + } + + throw new DatabaseException($e->getMessage()); + } } /** - * Check if the data is a collection (multiple rows). + * Insert data into the database table. * - * @param mixed $data The data to check. - * @return bool True if the data is a collection, false otherwise. + * @param array|object $data The data to be inserted. + * @return bool True if the data was successfully inserted, false otherwise. */ - public function isCollection($data) + public function insert($data): bool { - return is_array($data) && count($data) > 1; + + $query = Query::insert($this->getTable(), Insert::INSERT_DATA); + + return $this->passData($data, $query); } /** - * Check if the string is a valid JSON. + * Update data in the database table based on specified conditions. * - * @param string $data The string to check. - * @return bool True if the string is valid JSON, false otherwise. + * @param array $data The data to be updated. + * @param mixed $conditions The conditions that determine which rows to update. + * @return bool True if the data was successfully updated, false otherwise. */ - public function isJson(string $data):bool + public function update(array $data, $conditions): bool { - json_decode($data); - return (json_last_error() === JSON_ERROR_NONE); + + $query = Query::update($this->getTable()); + + return $this->passData($data, $query); } /** - * Format the fetched data by decoding JSON strings. + * Fetch and optimize data using custom rules. * - * @param array $data The fetched data. - * @return array The formatted data. + * @param callable $rules A callback function to define data optimization rules. + * @return array|null The optimized data based on the provided rules. */ - public function prettyData($data) + public function fetchPretty(callable $rules): ?array { - if ($this->isCollection($data)) { - foreach ($data as &$item) { - foreach ($item as $key => $value) { - $decode = json_decode($value, true); - if ($this->isJson($value)) { - $item[$key] = $decode; - } - } - } + $data = $this->fetch(); + if (is_array($data)) { + return (new DataOptimizer($data))->optimize($rules); } - return $data; + return null; } } diff --git a/src/Data/DataOptimizer.php b/src/Data/DataOptimizer.php new file mode 100644 index 0000000..357a289 --- /dev/null +++ b/src/Data/DataOptimizer.php @@ -0,0 +1,151 @@ +data = $data; + + $this->data_rule = new DataRules(); + } + + /** + * Check if the string is valid JSON. + * + * @param string $value The string to check. + * @return bool True if the string is valid JSON, false otherwise. + */ + public function isJson(string $value): bool + { + json_decode($value); + return (json_last_error() === JSON_ERROR_NONE); + } + + /** + * Check if the data is a collection of multiple rows. + * + * @return bool True if the data is a collection, false otherwise. + */ + public function isMultipleRows(): bool + { + return is_array($this->data) && count($this->data) > 1; + } + + /** + * Convert text to a slug. + * + * @param string $text The text to convert. + * @param string $delimiter The character used as a delimiter in the slug. + * @return string The generated slug. + */ + public function textToSlug($text, $delimiter = '-') + { + $text = preg_replace('/[^a-zA-Z0-9\s]/', '', $text); + $text = strtolower($text); + $text = str_replace(' ', $delimiter, $text); + + return $text; + } + + /** + * Apply a validation rule to a value and transform it accordingly. + * + * @param string $rule The validation rule. + * @param mixed $value The value to validate and transform. + * @return mixed The transformed value. + */ + public function applyRule(string $rule, $value) + { + if ($rule === 'json' && $this->isJson($value)) { + return json_decode($value); + } + + if ($rule === 'integer' && is_numeric($value)) { + return (int) $value; + } + + if ($rule === 'float' && is_numeric($value)) { + return (float) $value; + } + + if ($rule === 'string') { + return (string) $value; + } + + if ($rule === 'slug') { + return $this->textToSlug($value, $this->data_rule->getAttribute('slug')); + } + + if ($rule === 'list' && $this->isJson($value)) { + return join(',', (array) json_decode($value)); + } + + if ($rule === 'date') { + $date = \DateTime::createFromFormat($this->data_rule->getAttribute('from_format'), $value); + if ($date) { + return $date->format($this->data_rule->getAttribute('to_format')); + } + } + + return $value; + } + + /** + * Optimize the data based on defined rules. + * + * @param callable $rules A callback function to define rules using DataRules. + * @return array The optimized data. + */ + public function optimize($rules): array + { + call_user_func($rules, $this->data_rule); + $this->data_rule->getRules(); + + if ($this->isMultipleRows($this->data)) { + foreach ($this->data as &$item) { + foreach ($item as $key => $value) { + if ($this->data_rule->hasRule($key)) { + $item[$key] = $this->applyRule($this->data_rule->getRule($key), $value); + } + } + } + } + + return $this->data; + } +} diff --git a/src/Data/DataRules.php b/src/Data/DataRules.php new file mode 100644 index 0000000..990ebc9 --- /dev/null +++ b/src/Data/DataRules.php @@ -0,0 +1,205 @@ +attributes[$attribute]; + } + + /** + * Get all defined attributes. + * + * @return array An array of attribute names. + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * Check if an attribute with the specified key exists. + * + * @param string $attribute The key to check. + * @return bool True if the attribute exists, false otherwise. + */ + public function hasAttribute(string $attribute): bool + { + return isset($this->attributes[$attribute]); + } + + /** + * Set an attribute for a specific key. + * + * @param string $key The key for which to set the attribute. + * @param string $attribute The attribute name to set. + * @return self + */ + public function setAttribute(string $key, string $attribute): self + { + $this->attributes[$key] = $attribute; + return $this; + } + + /** + * Check if a rule with the specified key exists. + * + * @param string $key The key to check. + * @return bool True if the rule exists, false otherwise. + */ + public function hasRule(string $key): bool + { + return isset($this->rules[$key]); + } + + /** + * Get all defined rules. + * + * @return array An array of rules. + */ + public function getRules(): array + { + return $this->rules; + } + + /** + * Get the rule for a specific key. + * + * @param string $key The key for which to retrieve the rule. + * @return string The rule. + */ + public function getRule(string $key): string + { + return $this->rules[$key]; + } + + /** + * Set an array of rules. + * + * @param array $rules An array of rules to set. + * @return self + */ + public function setRules(array $rules): self + { + $this->rules = $rules; + return $this; + } + + /** + * Set a rule for a specific key. + * + * @param string $key The key for which to set the rule. + * @param string $rule The rule to set. + * @return self + */ + public function setRule(string $key, string $rule): self + { + $this->rules[$key] = $rule; + return $this; + } + + /** + * Set a rule for a key to validate as JSON. + * + * @param string $key The key to validate as JSON. + * @return self + */ + public function json(string $key): self + { + return $this->setRule($key, 'json'); + } + + /** + * Set a rule for a key to validate as an integer. + * + * @param string $key The key to validate as an integer. + * @return self + */ + public function integer(string $key): self + { + return $this->setRule($key, 'integer'); + } + + /** + * Set a rule for a key to validate as a string. + * + * @param string $key The key to validate as a string. + * @return self + */ + public function string(string $key): self + { + return $this->setRule($key, 'string'); + } + + /** + * Set a rule for a key to validate as a double. + * + * @param string $key The key to validate as a double. + * @return self + */ + public function double(string $key): self + { + return $this->setRule($key, 'double'); + } + + /** + * Set a rule for a key to validate as a slug. + * + * @param string $key The key to validate as a slug. + * @param string $delimiter The character used as a delimiter in the slug. + * @return self + */ + public function slug(string $key, string $delimiter = '-'): self + { + $this->setAttribute('slug', $delimiter); + return $this->setRule($key, 'slug'); + } + + /** + * Set a rule for a key to validate as a list. + * + * @param string $key The key to validate as a list. + * @return self + */ + public function list(string $key): self + { + return $this->setRule($key, 'list'); + } + + /** + * Set a rule for a key to validate as a date. + * + * @param string $key The key to validate as a date. + * @param string $to_format The desired date format. + * @param string $from_format The source date format (default is 'Y-m-d H:i:s'). + * @return self + */ + public function date(string $key, string $to_format, string $from_format = 'Y-m-d H:i:s'): self + { + $this->setAttribute('from_format', $from_format); + $this->setAttribute('to_format', $to_format); + return $this->setRule($key, 'date'); + } +} diff --git a/src/Data/DataValidator.php b/src/Data/DataValidator.php new file mode 100644 index 0000000..cc82c7f --- /dev/null +++ b/src/Data/DataValidator.php @@ -0,0 +1,134 @@ +data = $data; + } + + /** + * Set an error message. + * + * @param string $error The error message to set. + */ + public function setError(string $error): void + { + $this->errors[] = $error; + } + + /** + * Check if the data is associative. + * + * @return bool True if the data is associative, false otherwise. + */ + public function isAssoc(): bool + { + foreach ($this->data as $key => $value) { + if ($key === '') { + $this->setError('key must be a non-empty string'); + return false; + } + if (!is_string($key)) { + $this->setError('key must be a string'); + return false; + } + } + return true; + } + + /** + * Check if the data is an array of associative arrays. + * + * @return bool True if the data is an array of associative arrays, false otherwise. + */ + public function isArrayOfAssoc(): bool + { + foreach ($this->data as $item) { + if (!is_array($item)) { + $this->setError('item must be array'); + return false; + } + if (empty($item)) { + $this->setError('key must be a non-empty item'); + return false; + } + if (!$this->isAssocArray($item)) { + $this->setError('item must be associative'); + return false; + } + } + return true; + } + + /** + * Check if an array is associative. + * + * @param array $array The array to check. + * + * @return bool True if the array is associative, false otherwise. + */ + private function isAssocArray(array $array): bool + { + foreach ($array as $key => $value) { + if ($key === '') { + $this->setError('key must be a non-empty string'); + return false; + } + if (!is_string($key)) { + $this->setError('key must be a string'); + return false; + } + } + return true; + } + + /** + * Validate the data and throw an exception if errors are present. + * + * @throws DataValidatorException If errors are present during validation. + * @return void + */ + public function validate():void + { + if (!empty($this->errors)) { + foreach ($this->errors as $error) { + throw new DataValidatorException("Error Processing Data. $error", 1); + } + } + } +} diff --git a/src/Exception/DataValidatorException.php b/src/Exception/DataValidatorException.php new file mode 100644 index 0000000..7d8b25b --- /dev/null +++ b/src/Exception/DataValidatorException.php @@ -0,0 +1,17 @@ +getTable()}"; + return $this->query((string) $query)->fetch(); + } + + /** + * Get information about the table's columns. + * + * @return array An array of column information. + */ + public function getTableInfo(): array + { + return array_map(function ($column) { + $sql_datatype = explode('(', $column['Type'])[0]; + return [ + $column['Field'] => [ + 'sql_datatype' => $sql_datatype, + 'type' => $this->convertDataType($sql_datatype), + 'default' => $column['Extra'] === 'auto_increment' ? 'auto_increment' : $column['Default'], + 'null' => $column['Null'] + ] + ]; + }, $this->describeTable()); + } + + /** + * Get information about a specific column. + * + * @param string $column_name The name of the column. + * @param array $tableInfo An array containing column information. + * + * @return array|null Column information if found, or null if not found. + */ + public function getColumnInfo(string $column_name, array $tableInfo): ?array + { + foreach ($tableInfo as $col) { + if (key($col) === $column_name) { + return current($col); + } + } + return null; + } + + /** + * Get an array of required columns. + * + * @param array $tableInfo An array containing column information. + * + * @return array An array of required column names. + */ + public function requiredColumns(array $tableInfo):array + { + $requiredColumns = []; + + foreach ($tableInfo as $col) { + $columnInfo = current($col); + + if ($columnInfo['default'] === null && $columnInfo['null'] === 'NO') { + $requiredColumns[] = key($col); + } + } + return $requiredColumns; + } + + /** + * Convert an SQL data type to a PHP data type. + * + * @param mixed $sql_datatype The SQL data type. + * + * @return string The corresponding PHP data type. + */ + public function convertDataType($sql_datatype): string + { + return match (strtolower($sql_datatype)) { + 'timestamp', 'date', 'year', 'time', 'datetime', 'varchar', 'char', 'text', 'tinytext', 'mediumtext', 'longtext', 'blob', 'tinyblob', 'mediumblob', 'longblob', 'binary', 'varbinary', 'uuid', 'enum', 'set' => 'string', + 'int', 'bigint', 'tinyint', 'smallint', 'mediumint', 'integer' => 'integer', + 'decimal', 'bit', 'double', 'doubleprecision', 'float' => 'double', + 'json' => 'array', + 'boolean' => 'boolean', + default => 'string' + }; + } +}