618 lines
16 KiB
PHP
618 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace mirzaev\ebaboba;
|
|
|
|
// Files of the project
|
|
use mirzaev\ebaboba\enumerations\encoding,
|
|
mirzaev\ebaboba\enumerations\type;
|
|
|
|
// Built-in libraries
|
|
use LogicException as exception_logic,
|
|
InvalidArgumentException as exception_invalid_argument,
|
|
RuntimeException as exception_runtime;
|
|
|
|
/**
|
|
* Database
|
|
*
|
|
* @package mirzaev\ebaboba
|
|
*
|
|
* @var string $database Path to the database file
|
|
* @var string $backups Path to the backups files directory
|
|
* @var encoding $encoding Encoding of records in the database file
|
|
* @var array $columns The database columns
|
|
* @var int $length Binary size of every record in the database file
|
|
*
|
|
* @method self encoding(encoding $encoding) Write encoding into the database instance property (fluent interface)
|
|
* @method self columns(column ...$columns) Write columns into the database instance property (fluent interface)
|
|
* @method self connect(string $database) Initialize the database files (fluent interface)
|
|
* @method record|null record(...$values) Initialize the record by the database columns
|
|
* @method string pack(record $record) Pack the record values
|
|
* @method record unpack(array $binaries) Unpack binary values and implement them as a `record` instance
|
|
* @method bool write(record $record) Write the record into the database file
|
|
* @method array read(?callable $filter, ?callable $update, bool $delete, int $amount, int $offset) Read records from the database file
|
|
* @method bool backups() Initialize the backups files directory
|
|
* @method int|false save() Create an unique backup file from the database file
|
|
* @method bool load(int $identifier) Restore the database file from the backup file
|
|
*
|
|
* @license http://www.wtfpl.net Do What The Fuck You Want To Public License
|
|
* @author Arsen Mirzaev Tatyano-Muradovich <arsen@mirzaev.sexy>
|
|
*/
|
|
class database
|
|
{
|
|
/**
|
|
* Database
|
|
*
|
|
* Path to the database file
|
|
*
|
|
* @var string $database Path to the database file
|
|
*/
|
|
public protected(set) string $database = __DIR__ . DIRECTORY_SEPARATOR . 'database.ba';
|
|
|
|
/**
|
|
* Backups
|
|
*
|
|
* Path to the backups files directory
|
|
*
|
|
* @var string $backups Path to the backups files directory
|
|
*/
|
|
public protected(set) string $backups = __DIR__ . DIRECTORY_SEPARATOR . 'backups';
|
|
|
|
/**
|
|
* Encoding
|
|
*
|
|
* @var encoding $encoding Encoding of records in the database file
|
|
*/
|
|
public protected(set) encoding $encoding;
|
|
|
|
/**
|
|
* Columns
|
|
*
|
|
* @var record[] $columns The database columns
|
|
*/
|
|
public protected(set) array $columns;
|
|
|
|
/**
|
|
* Length
|
|
*
|
|
* @var int $length Binary size of every record in the database file
|
|
*/
|
|
public protected(set) int $length;
|
|
|
|
/**
|
|
* Encoding
|
|
*
|
|
* Write encoding into the database instance property
|
|
*
|
|
* @see https://en.wikipedia.org/wiki/Fluent_interface#PHP Fluent Interface
|
|
*
|
|
* @param encoding $encoding The database file encoding
|
|
*
|
|
* @return self The database instance (fluent interface)
|
|
*/
|
|
public function encoding(encoding $encoding): self
|
|
{
|
|
// Writing into the database instance property
|
|
$this->encoding = $encoding;
|
|
|
|
// Exit (success)
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Columns
|
|
*
|
|
* Write columns into the database instance property
|
|
*
|
|
* @see https://en.wikipedia.org/wiki/Fluent_interface#PHP Fluent Interface
|
|
*
|
|
* @param column[] ...$columns The database columns
|
|
*
|
|
* @return self The database instance (fluent interface)
|
|
*/
|
|
public function columns(column ...$columns): self
|
|
{
|
|
// Writing into the database instance property
|
|
$this->columns = $columns;
|
|
|
|
// Initializing the database instance property
|
|
$this->length ??= 0;
|
|
|
|
foreach ($this->columns as $column) {
|
|
// Iterating over columns
|
|
|
|
if ($column->type === type::string) {
|
|
// String
|
|
|
|
// Adding the column string maximum length to the database instance property
|
|
$this->length += $column->length;
|
|
} else {
|
|
// Other types
|
|
|
|
// Adding the column type size to the database instance property
|
|
$this->length += $column->type->size();
|
|
}
|
|
}
|
|
|
|
// Exit (success)
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Connect
|
|
*
|
|
* Initialize the database files
|
|
*
|
|
* @see https://en.wikipedia.org/wiki/Fluent_interface#PHP Fluent Interface
|
|
*
|
|
* @param string $database Path to the database file
|
|
*
|
|
* @return self The database instance (fluent interface)
|
|
*/
|
|
public function connect(string $database): self
|
|
{
|
|
// Writing into the database instance property
|
|
$this->database = $database;
|
|
|
|
// Exit (success)
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Record
|
|
*
|
|
* Initialize the record by the database columns
|
|
*
|
|
* @param mixed[] $values Values of the record
|
|
*
|
|
* @throws exceptiin_invalid_argument if the balue type not matches the column values types
|
|
* @throws exception_logic if amount of columns not matches the amount of values
|
|
*
|
|
* @return record|null The record instance
|
|
*/
|
|
public function record(string|int|float ...$values): ?record
|
|
{
|
|
if (count($values) === count($this->columns)) {
|
|
// Amount of values matches amount of columns
|
|
|
|
// Declaring the buffer of combined values
|
|
$combined = [];
|
|
|
|
foreach ($this->columns as $index => $column) {
|
|
// Iterating over columns
|
|
|
|
if (gettype($values[$index]) === $column->type->type()) {
|
|
// The value type matches the column values type
|
|
|
|
// Writing named index value into the buffer of combined values
|
|
$combined[$column->name] = $values[$index];
|
|
} else {
|
|
// The value type not matches the column values type
|
|
|
|
// Exit (fail)
|
|
throw new exception_invalid_argument('The value type not matches the column values type');
|
|
}
|
|
}
|
|
|
|
// Initializing the record by the buffer of combined values
|
|
$record = new record(...$combined);
|
|
|
|
// Exit (success)
|
|
return $record;
|
|
} else {
|
|
// Amount of values not matches amount of columns
|
|
|
|
// Exit (fail)
|
|
throw new exception_logic('Amount of values not matches amount of columns');
|
|
}
|
|
|
|
// Exit (fail)
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Pack
|
|
*
|
|
* Pack the record values
|
|
*
|
|
* @param record $record The record
|
|
*
|
|
* @return string Packed values
|
|
*/
|
|
public function pack(record $record): string
|
|
{
|
|
// Declaring buffer of packed values
|
|
$packed = '';
|
|
|
|
foreach ($this->columns as $column) {
|
|
// Iterating over columns
|
|
|
|
if ($column->type === type::string) {
|
|
// String
|
|
|
|
// Converting to the database encoding
|
|
$value = mb_convert_encoding($record->values()[$column->name], $this->encoding->value);
|
|
|
|
// Packung the value and writing into the buffer of packed values
|
|
$packed .= pack($column->type->value . $column->length, $value);
|
|
} else {
|
|
// Other types
|
|
|
|
// Packung the value and writing into the buffer of packed values
|
|
$packed .= pack($column->type->value, $record->values()[$column->name]);
|
|
}
|
|
}
|
|
|
|
// Exit (success)
|
|
return $packed;
|
|
}
|
|
|
|
/**
|
|
* Unpack
|
|
*
|
|
* Unpack binary values and implement them as a `record` instance
|
|
*
|
|
* @param array $binaries Binary values in the same order as the columns
|
|
*
|
|
* @return record The unpacked record from binary values
|
|
*/
|
|
public function unpack(array $binaries): record
|
|
{
|
|
if (count($binaries) === count($this->columns)) {
|
|
// Amount of binery values matches amount of columns
|
|
|
|
// Declaring the buffer of unpacked values
|
|
$unpacked = [];
|
|
|
|
foreach (array_combine($binaries, $this->columns) as $binary => $column) {
|
|
// Iterating over columns
|
|
|
|
if ($column->type === type::string) {
|
|
// String
|
|
|
|
// Unpacking the value
|
|
$value = unpack($column->type->value . $column->length, $binary)[1];
|
|
|
|
// Deleting NULL-characters
|
|
$unnulled = str_replace("\0", '', $value);
|
|
|
|
// Encoding the unpacked value
|
|
$encoded = mb_convert_encoding($unnulled, $this->encoding->value);
|
|
|
|
// Writing into the buffer of readed values
|
|
$unpacked[] = $encoded;
|
|
} else {
|
|
// Other types
|
|
|
|
// Writing into the buffer of readed values
|
|
$unpacked[] = unpack($column->type->value, $binary)[1];
|
|
}
|
|
}
|
|
|
|
// Implementing the record
|
|
$record = $this->record(...$unpacked);
|
|
|
|
// Exit (success)
|
|
return $record;
|
|
} else {
|
|
// Amount of binery values not matches amount of columns
|
|
|
|
// Exit (fail)
|
|
throw new exception_invalid_argument('Amount of binary values not matches amount of columns');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write
|
|
*
|
|
* Write the record into the database file
|
|
*
|
|
* @param record $record The record
|
|
*
|
|
* @throws exception_runtime If failed to lock the file
|
|
* @throws exception_runtime If failed to unlock the file
|
|
*
|
|
* @return bool Is the record was writed into the end of the database file
|
|
*/
|
|
public function write(record $record): bool
|
|
{
|
|
try {
|
|
// Opening the database file
|
|
$file = fopen($this->database, 'ab');
|
|
|
|
if (flock($file, LOCK_EX)) {
|
|
// The file was locked
|
|
|
|
// Packing the record values
|
|
$packed = $this->pack($record);
|
|
|
|
// Writing the packed values to the database file
|
|
fwrite($file, $packed);
|
|
|
|
// Applying changes
|
|
fflush($file);
|
|
|
|
if (flock($file, LOCK_UN)) {
|
|
// The file was unlocked
|
|
|
|
// Exit (success)
|
|
return true;
|
|
} else {
|
|
// Failed to unlock the file
|
|
|
|
// Exit (fail)
|
|
throw new exception_runtime('Failed to unlock the file');
|
|
}
|
|
} else {
|
|
// Failed to lock the file
|
|
|
|
// Exit (fail)
|
|
throw new exception_runtime('Failed to lock the file');
|
|
}
|
|
} finally {
|
|
// Closing the database file
|
|
fclose($file);
|
|
}
|
|
|
|
// Exit (fail)
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Read
|
|
*
|
|
* Read records from the database file
|
|
*
|
|
* Order: `$filter` -> `$offset` -> (`$delete` -> read deleted || `$update` -> read updated || read) -> `$amount`
|
|
*
|
|
* @param callable|null $filter Filtering records `function($record, $records): bool`
|
|
* @param callable|null $update Updating records `function(&$record): void`
|
|
* @param callable|null $delete Deleting records
|
|
* @param int $amount Amount iterator
|
|
* @param int $offset Offset iterator
|
|
*
|
|
* @return array|null Readed records
|
|
*/
|
|
public function read(?callable $filter = null, ?callable $update = null, bool $delete = false, int $amount = 1, int $offset = 0): ?array
|
|
{
|
|
// Opening the database file
|
|
$file = fopen($this->database, 'r+b');
|
|
|
|
if (flock($file, LOCK_EX)) {
|
|
// The file was locked
|
|
|
|
// Declaring the buffer of readed records
|
|
$records = [];
|
|
|
|
// Declaring the buffer of failed to reading records
|
|
/* $failed = []; */
|
|
|
|
while ($amount > 0) {
|
|
// Reading records
|
|
|
|
// Declaring the buffer of binary values
|
|
$binaries = [];
|
|
|
|
foreach ($this->columns as $column) {
|
|
// Iterating over columns
|
|
|
|
if ($column->type === type::string) {
|
|
// String
|
|
|
|
// Reading the binary value from the database
|
|
$binaries[] = fread($file, $column->length);
|
|
} else {
|
|
// Other types
|
|
|
|
// Reading the binary value from the database
|
|
$binaries[] = fread($file, $column->type->size());
|
|
}
|
|
}
|
|
|
|
// Terminate loop when end of file is reached
|
|
if (feof($file)) break;
|
|
|
|
try {
|
|
// Unpacking the record
|
|
$record = $this->unpack($binaries);
|
|
|
|
if (is_null($filter) || $filter($record, $records)) {
|
|
// Passed the filter
|
|
|
|
if ($offset-- <= 0) {
|
|
// Offsetted
|
|
|
|
if ($delete) {
|
|
// Requested deleting
|
|
|
|
// Moving to the beginning of the row
|
|
fseek($file, -$this->length, SEEK_CUR);
|
|
|
|
// Writing NUL-characters instead of the record to the database file
|
|
fwrite($file, str_repeat("\0", $this->length));
|
|
|
|
// Moving to the end of the row
|
|
fseek($file, $this->length, SEEK_CUR);
|
|
} else if ($update) {
|
|
// Requested updating
|
|
|
|
// Updating the record
|
|
$update($record);
|
|
|
|
// Packing the updated record
|
|
$packed = $this->pack($record);
|
|
|
|
// Moving to the beginning of the row
|
|
fseek($file, -$this->length, SEEK_CUR);
|
|
|
|
// Writing to the database file
|
|
fwrite($file, $packed);
|
|
|
|
// Moving to the end of the row
|
|
fseek($file, $this->length, SEEK_CUR);
|
|
}
|
|
|
|
// Writing into the buffer of records
|
|
$records[] = $record;
|
|
|
|
// Decreasing the amount iterator
|
|
--$amount;
|
|
}
|
|
}
|
|
} catch (exception_logic | exception_invalid_argument $exception) {
|
|
// Writing into the buffer of failed to reading records
|
|
/* $failed[] = $record; */
|
|
}
|
|
}
|
|
|
|
// Unlocking the file
|
|
flock($file, LOCK_UN);
|
|
|
|
// Closing the database file
|
|
fclose($file);
|
|
|
|
// Exit (success)
|
|
return $records;
|
|
}
|
|
|
|
// Exit (fail)
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Backups
|
|
*
|
|
* Initialize the backups files directory
|
|
*
|
|
* @throws exception_runtime if failed to create the backups files directory
|
|
*
|
|
* @return bool Is the backups files directory created?
|
|
*/
|
|
public function backups(): bool
|
|
{
|
|
if (is_dir($this->backups) || is_writable($this->backups)) {
|
|
// The backups files directory exists
|
|
|
|
// Exit (success)
|
|
return true;
|
|
} else {
|
|
// The backups files directory is not exists
|
|
|
|
if (mkdir(directory: $this->backups, permissions: 0775, recursive: true)) {
|
|
// The backups files directory created
|
|
|
|
// Exit (success)
|
|
return true;
|
|
} else {
|
|
// The backups files directory is still not exists
|
|
|
|
// Exit (fail)
|
|
throw new exception_runtime('Failed to create the backups files directory: "' . $this->backups . '"');
|
|
}
|
|
}
|
|
|
|
// Exit (fail)
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Save
|
|
*
|
|
* Create an unique backup file from the database file
|
|
*
|
|
* @throws exception_runtime if failed to copying the database file to the backup file
|
|
* @throws exception_runtime if failed to initialize the backups files directory
|
|
*
|
|
* @return int|false Unique identifier of the created backup file
|
|
*/
|
|
public function save(): int|false
|
|
{
|
|
if ($this->backups()) {
|
|
// Initialized the backups files directory
|
|
|
|
// Generation of unique identifier
|
|
generate:
|
|
|
|
// Generating unique identifier for the backup file
|
|
$identifier = uniqid();
|
|
|
|
// Initializing path to the backup file with generated identifier
|
|
$file = $this->backups . DIRECTORY_SEPARATOR . $identifier;
|
|
|
|
if (file_exists($file)) {
|
|
// File with this identifier is already exists
|
|
|
|
// Repeating generation (entering into recursion)
|
|
goto generate;
|
|
} else {
|
|
// Generated unique identifier for the backup file
|
|
|
|
if (copy($this->database, $file)) {
|
|
// Copied the database file to the backup file
|
|
|
|
// Exit (success)
|
|
return $identifier;
|
|
} else {
|
|
// Not copied the database file to the backup file
|
|
|
|
// Exit (fail)
|
|
throw new exception_runtime('Failed to copying the database file to the backup file');
|
|
}
|
|
}
|
|
} else {
|
|
// Not initialized the backups files directory
|
|
|
|
// Exit (fail)
|
|
throw new exception_runtime('Failed to initialize the backups files directory');
|
|
}
|
|
|
|
// Exit (fail)
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Load
|
|
*
|
|
* Restore the database file from the backup file
|
|
*
|
|
* @throws exception_runtime if not found the backup file
|
|
* @throws exception_runtime if failed to initialize the backups files directory
|
|
*
|
|
* @return int|false Unique identifier of the created backup file
|
|
*/
|
|
public function load(int $identifier): bool
|
|
{
|
|
if ($this->backups()) {
|
|
// Initialized the backups files directory
|
|
|
|
// Initializing path to the backup file
|
|
$file = $this->backups . DIRECTORY_SEPARATOR . $identifier;
|
|
|
|
if (file_exists($file)) {
|
|
// Initialized the backup file
|
|
|
|
if (rename($file, $this->database)) {
|
|
// Loaded the database file from the backup file
|
|
|
|
// Exit (success)
|
|
return true;
|
|
}
|
|
} else {
|
|
// Not initialized the backup file
|
|
|
|
// Exit (fail)
|
|
throw new exception_runtime('Not found the backup file');
|
|
}
|
|
} else {
|
|
// Not initialized the backups files directory
|
|
|
|
// Exit (fail)
|
|
throw new exception_runtime('Failed to initialize the backups files directory');
|
|
}
|
|
|
|
// Exit (fail)
|
|
return false;
|
|
}
|
|
}
|