diff --git a/.gitignore b/.gitignore index 98e1c2c..69be138 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /vendor/* /vendor/ .phpunit.result.cache +.phpunit.cache .phpunit composer.lock auth.json diff --git a/README.md b/README.md index bb9ea07..d592d3a 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,12 @@ This framework for PHP applications delivers a vastly different approach to writing PHP applications for web and CLI. The core principles and concepts are + - platform/runtime agnostic approach (cloud, local, containerized) - abstraction and modularization - vendor-lock-in avoidance - interacting with data without committing to a specific storage technology -- reducing amount of time used for administering and managing databases +- reducing the amount of time used for administering and managing databases - giving guide rails to keep application components meaningful - inheritance and extension, even on cross-project-/application-level @@ -15,16 +16,17 @@ The core principles and concepts are While applications solely based on this package (`codename/core`) do run on their own, you might take advantage of several additional packages (sometimes even 'apps' on their own): + - **architect**: [Repository](https://github.com/codename-hub/architect), [Package](https://packagist.org/packages/codename/architect) - - creating and migrating database/table schemas - - executing deployment and migration tasks + - creating and migrating database/table schemas + - executing deployment and migration tasks - **core-ui**: [Repository](https://github.com/codename-hub/core-ui), [Package](https://packagist.org/packages/codename/core-ui) - - Generic CRUD and Form components - - UI-related components (web UI and CLI 'ui') + - Generic CRUD and Form components + - UI-related components (web UI and CLI 'ui') - **core-io**: [Repository](https://github.com/codename-hub/core-io), [Package](https://packagist.org/packages/codename/core-io) - - data handling (load, transform, output), esp. for mass data + - data handling (load, transform, output), esp. for mass data - **rest**: [Repository](https://github.com/codename-hub/rest), [Package](https://packagist.org/packages/codename/rest) - - supporting components for writing REST-endpoints and REST-style apps (or mixed ones) + - supporting components for writing REST-endpoints and REST-style apps (or mixed ones) They're all installable and auto-loadable via Composer. @@ -51,4 +53,4 @@ For Web-Apps, this might be the first part of the URI (if you're rewriting the U A **model** defines a data structure and can be backed by a RDBMS (like MySQL/MariaDB, SQLite, etc.), pure JSON data, an abstract data source or even a mapped ER-model. A model allows you to query data, filter it, change it (create, modify, delete) or build more complex 'queries' by adding models upon each other. -A **bucket** is an abstraction layer for using data storages (like a local directory, connections like FTP(S) and SFTP, S3 buckets, etc). +A **bucket** is an abstraction layer for using data storages (like a local directory, connections like FTP(S) and SFTP, S3 buckets, etc.). diff --git a/TUTORIAL_BASIC_IO.md b/TUTORIAL_BASIC_IO.md index a651e1d..04e0ff0 100644 --- a/TUTORIAL_BASIC_IO.md +++ b/TUTORIAL_BASIC_IO.md @@ -1,6 +1,6 @@ # TUTORIAL - BASIC IO # -This Tutorial is for giving you a quick overview about the most important components of the core framework. +This Tutorial is for giving you a quick overview about the most important parts of the core framework. Requires the following knowledge: @@ -51,7 +51,7 @@ class mycontext extends \codename\core\context { _NOTES:_ * Make sure you're using the correct namespace (e.g. _namespace codename\demo\context_ if your app is called "_demo_") -* Make sure your class is inheriting from a context class (e.g. _\codename\core\context_ ) +* Make sure your class is inherited from a context class (e.g. _\codename\core\context_ ) * Make sure you prefix all your view functions with view_ * You may omit the PHP closing tag (some modern-stylish PHP programming stuff...) * Don't you ever dare to use __camelCasing__ for context backing class files @@ -63,8 +63,7 @@ __Step 2: Create your view code__ Create a new PHP file (depending on your preferred templating engine) at frontend/view/__your-context__/__your-view__.php This may be the raw HTML/PHP-Inline code of your view. -If you're using __Twig__ for templating/writing views, it may be called __your-view__.twig - +If you're using __Twig__ for templating/writing views, it may be called "__your-view__.twig" ~~~php @@ -75,45 +74,41 @@ If you're using __Twig__ for templating/writing views, it may be called __your-v _NOTES:_ * The example is a bare inline PHP code -* Don't forget to namespace the code if you're using *.php files (irrelevant if you're using Twig) -* Then, you can access the response constainer via app::getResponse()->getData( ... ); +* Remember to namespace the code if you're using *.php files (irrelevant if you're using Twig) +* Then, you can access the response container via app::getResponse()->getData( ... ); - - - - __Step 3: Allow your view to be accessed__ Open your app configuration at __config/app.json__. -Under the key "__context__" create a json object declaring your context outline: - +Under the key "__context__" create a JSON object declaring your context outline: ~~~json { - ... - "context": { - "mycontext": { - "defaultview": "myview", - "view": { - "myview": { - "public": true - } - } - }, - ... + "context": { + "mycontext": { + "defaultview": "myview", + "view": { + "myview": { + "public": true + } + } } + } } ~~~ _NOTES:_ * Required keys for each context: - defaultview - view -* Set __"public" : true__ for a view to be accessed without authentication. This is fine for testing purposes. + defaultview + view +* Set __"public": true__ for a view to be accessed without authentication. This is fine for testing purposes. * Optional keys: - "type": optionally, you can define "crud" or another inheritable context type. Then, you might not need to define stuff that is already present in the base context type. - "template": explicitly use a template here - "defaulttemplateengine": explicitly use a template engine defined in environment.json here - + "type": optionally, you can define "crud" or another inheritable context type. Then, you might not need to define stuff that is already present in the base context type. + "template": explicitly use a template here + "defaulttemplateengine": explicitly use a template engine defined in environment.json here __Step 3.5: Test!__ diff --git a/TUTORIAL_DATA.md b/TUTORIAL_DATA.md index 879f525..8153b32 100644 --- a/TUTORIAL_DATA.md +++ b/TUTORIAL_DATA.md @@ -9,7 +9,7 @@ __Requirements__ * Model Definition * Model Backing Class -* Your imagination - e.g. a blank context to work in +* Your imagination—e.g., a blank context to work in __Files to be touched__ @@ -21,55 +21,57 @@ __Files to be touched__ __Step 1: Create your model definition__ -Create a new json file in config/model/, e.g. demo_stuff.json. -It is always _.json. Schema MAY be "demo", but it can also be "abc" or whatever you want it to become. -The schema translates to DB Schemata (or, if you're using MySQL, it matches up with a "database"). And yes, you can cross-join them. +Create a new JSON file in config/model/, e.g., demo_stuff.json. +It is always _.json. +The Schema MAY be "demo", but it can also be "abc" or whatever you want it to become. +The schema translates to DB Schemata (or, if you're using MySQL, it matches up with a "database"). +And yes, you can cross-join them. ~~~json { - "field" : [ + "field": [ "stuff_id", "stuff_created", "stuff_modified", "stuff_name" ], - "primary" : [ + "primary": [ "stuff_id" ], - "datatype" : { - "stuff_id" : "number_natural", - "stuff_created" : "text_timestamp", - "stuff_modified" : "text_timestamp", - "stuff_name" : "text" + "datatype": { + "stuff_id": "number_natural", + "stuff_created": "text_timestamp", + "stuff_modified": "text_timestamp", + "stuff_name": "text" }, - "options" : { - "stuff_name" : { - length: 64 + "options": { + "stuff_name": { + "length": 64 } }, - "connection" : "myconnection" + "connection": "myconnection" } ~~~ __NOTES:__ * Required keys: - field (array of field names / column names) - primary (array of fields to be the primary key - should be only 1 (!) at this time) - datatype (the core-framework-specific datatypes - one of those: text, number, number_natural, boolean, text, structure, ... - you can even use text_email) + field (array of field names / column names) + primary (array of fields to be the primary key - should be only 1 (!) at this time) + datatype (the core-framework-specific datatypes - one of those: text, number, number_natural, boolean, text, structure, ... - you can even use text_email) * Optional keys: - foreign (for foreign key definitions) - db_column_type (see below) - connection (for using a specific db connection defined in environment.json) + foreign (for foreign key definitions) + db_column_type (see below) + connection (for using a specific db connection defined in environment.json) * You may use "db_column_type" to specify the exact database-specific datatype. If undefined for a given key, the default values are used. - - - - __Step 2: Build__ -If you don't want to create the databases, tables, fields and constraints yourself, you should use the __Architect__ to build you definition. +If you don't want to create databases, tables, fields and constraints yourself, you should use the __Architect__ to build your definition. You have to have architect installed as a composer package. -If you're using the default prepared docker-compose environment, you can open up http://architect.localhost:8080/ to view pending changes. Click on Details of your app. +If you're using the default prepared docker-compose environment, you can open up http://architect.localhost:8080/ to view pending changes. Click on the Details of your app. To execute the pending tasks, append the GET-Parameter exec=1 to the url. - - - - @@ -84,7 +86,7 @@ namespace codename\demo\model; /** * this is an example model - * without function + * without a function */ class stuff extends \codename\core\model\schematic\mysql { @@ -92,8 +94,8 @@ class stuff extends \codename\core\model\schematic\mysql { * * {@inheritDoc} */ - public function __CONSTRUCT(array $modeldata) { - parent::__CONSTRUCT($modeldata); + public function __construct(array $modeldata) { + parent::__construct($modeldata); $this->setConfig(null, 'demo', 'stuff'); } @@ -116,7 +118,7 @@ $model = app::getModel('stuff'); // do your stuff. __NOTES:__ -* If you're using app::getModel(), you have to be in the namespace codename\core; (or even your own app namespace). Otherwise, require it using "use \codename\core\app;". -* If you're working inside a context, you can also use $this->getModel( ... ), as the base context class provides you this method. +* If you're using app::getModel(), you have to be in the namespace codename\core; (or even your own app namespace). Otherwise, it requires it using "use \codename\core\app;". +* If you're working inside a context, you can also use $this->getModel(...), as the base context class provides you with this method. - - - - \ No newline at end of file diff --git a/backend/class/api.php b/backend/class/api.php index a58b7d1..ede4417 100755 --- a/backend/class/api.php +++ b/backend/class/api.php @@ -1,4 +1,5 @@ errorstack = new \codename\core\errorstack($this->type); - // $this->application = $application; + public function __construct() + { + $this->errorstack = new errorstack($this->type); return $this; } @@ -36,24 +37,26 @@ public function __construct() { * Return the version string * NOTE: this relies on URI-based API-Versioning! * - * @example "v1" * @return string + * @example "v1" */ - public static function getVersion() : string { + public static function getVersion(): string + { return explode('/', $_SERVER['REQUEST_URI'])[1]; } /** * Return the endpoint of the request - * @example $host/v1/user/disable?... will return the endpoint "UserDisable" * @return string + * @example $host/v1/user/disable?... will return the endpoint "UserDisable" */ - public static function getEndpoint() : string { - $endpoints = explode('/', explode('?', $_SERVER['REQUEST_URI'])[0]); + public static function getEndpoint(): string + { + $endpoints = explode('/', explode('?', $_SERVER['REQUEST_URI'])[0]); unset($endpoints[1]); $ret = ''; - foreach($endpoints as $endpoint) { + foreach ($endpoints as $endpoint) { $ret .= ucfirst($endpoint); } return $ret; @@ -63,39 +66,45 @@ public static function getEndpoint() : string { * Is a helper for the printAnswer function that fills the data * @param mixed $data * @return void + * @throws exception */ - protected function printSuccess($data) { - return $this->printAnswer(array( - 'success' => 1, - 'data' => $data - ) + protected function printSuccess(mixed $data): void + { + $this->printAnswer( + [ + 'success' => 1, + 'data' => $data, + ] ); } /** - * Is a helper for the printAnswer function that adds the 'error' key and fills it with the errorstack's content + * Outputs the JSON answer and ends the execution * @param array $data * @return void + * @throws exception */ - protected function printError($data = array()) { - return $this->printAnswer(array( - 'success' => 0, - 'data' => $data, - 'errors' => $this->errorstack->getErrors() - ) - ); + protected function printAnswer(array $data): void + { + app::getResponse()->setHeader('Content-Type: application/json'); + echo json_encode($data); + exit; } /** - * Outputs the JSON answer and ends the execution + * Is a helper for the printAnswer function that adds the 'error' key and fills it with the errorstack content * @param array $data * @return void + * @throws exception */ - protected function printAnswer(array $data) { - app::getResponse()->setHeader('Content-Type: application/json'); - echo json_encode($data); - exit; - return; + protected function printError(array $data = []): void + { + $this->printAnswer( + [ + 'success' => 0, + 'data' => $data, + 'errors' => $this->errorstack->getErrors(), + ] + ); } - } diff --git a/backend/class/api/codename.php b/backend/class/api/codename.php index a0bec66..85745c4 100755 --- a/backend/class/api/codename.php +++ b/backend/class/api/codename.php @@ -1,227 +1,130 @@ Also @see \codename\core\context\api for our own API Service provider's structure + * Also @see \codename\core\context\api for our own API Service provider's structure * @package core * @since 2016-04-05 */ -class codename extends \codename\core\api { - +class codename extends api +{ /** - * Contains the application data - * @var \codename\core\datacontainer + * Contains a list of fields that must not be sent via the POST request + * Most of the given fields may irritate the foreign service as they are based on the core + * @internal This is since these fields are request arguments responsible for app routing. + * @var array */ - protected $authentication; - + public $forbiddenpostfields = ['app', 'context', 'view', 'action', 'callback', 'template', 'lang']; /** - * Contains configuration of the service provider (host, port, etc) - * @var \codename\core\value\structure\api\codename\serviceprovider + * Contains the application data + * @var datacontainer */ - protected $serviceprovider; - + protected datacontainer $authentication; /** - * Contains an instance of Errorstack to provide deep information in case of errors - * @var \codename\core\errorstack + * Contains configuration of the service provider (host, port, etc.) + * @var serviceprovider */ - protected $errorstack; - + protected serviceprovider $serviceprovider; /** * Contains the CURL Handler for the next request - *
Is used to handle HTTP(s) requests for retrieving and sending data from the foreign service - * @todo: as of PHP8 \CurlHandle instead of regular resource - * @var resource + * Is used to handle HTTP(s) requests for retrieving and sending data from the foreign service + * @var CurlHandle */ - protected $curlHandler; - + protected CurlHandle $curlHandler; /** * What is the request's special secret string? - *
Many codename API services are relying on a second authentication factor. - *
By definition the second factor is dependent from the concret topic of the request - *
The given salt is filled with the requested key's name. - *
So every different key has a different salt. + * Many codename API services are relying on a second authentication factor. + * By definition, the second factor is dependent on the concrete topic of the request + * The given salt is filled with the requested key's name. + * So every different key has a different salt. * @internal Will not be transferred unencrypted * @var string */ - protected $salt = ''; - + protected string $salt = ''; /** - * Contains the API serive provider's response to the given request - *
After retrieving a response from the foreign host, it will be stored here. - * @var array + * Contains the API service provider's response to the given request + * After retrieving a response from the foreign host; it will be stored here. + * @var mixed */ - protected $response = ''; - + protected mixed $response = ''; /** * Contains the API type. - *
Typically is defined using the name of the foreign service in upper-case characters + * Typically, it is defined using the name of the foreign service in upper-case characters * @example YOURAPITYPE * @var string */ - protected $type = ''; - + protected string $type = ''; /** * Contains POST data for the request - *
Typically all headers for authentication and data retrieval + * Typically all headers for authentication and data retrieval * @var array */ - protected $data = array(); - + protected array $data = []; /** - * Contains a list of fields that must not be sent via the POST request - *
Most of the given fields may irritate the foreign service as they are based on the core core - * @internal This is since these fields are request arguments responsible for app routing. + * headers to send with the request * @var array */ - public $forbiddenpostfields = array('app', 'context', 'view', 'action', 'callback', 'template', 'lang'); + protected array $headers = []; /** * Create instance * @param array $data + * @throws ReflectionException + * @throws exception */ - public function __CONSTRUCT(array $data) { - $this->errorstack = new \codename\core\errorstack($this->type); - if(count($errors = app::getValidator('structure_api_codename')->validate($data)) > 0) { + public function __construct(array $data) + { + parent::__construct(); + if (count(app::getValidator('structure_api_codename')->validate($data)) > 0) { return false; } - $this->authentication = new \codename\core\datacontainer(array( - 'app_secret' => $data['secret'], - 'app_name' => $data['app_name'] - )); + $this->authentication = new datacontainer([ + 'app_secret' => $data['secret'], + 'app_name' => $data['app_name'], + ]); - $this->serviceprovider = new \codename\core\value\structure\api\codename\serviceprovider(array( - 'host' => $data['host'], - 'port' => $data['port'] - )); + $this->serviceprovider = new serviceprovider([ + 'host' => $data['host'], + 'port' => $data['port'], + ]); return $this; } - /** - * Returns the cacheGroup for this instance - * @return string - */ - protected function getCachegroup() : string { - return 'API_' . $this->type . '_' . $this->authentication->getData('app_name'); - } - /** * Mapper for the request function. - *
This method will concatenate the URL and return the (void) result of doRequest($url). + * This method will concatenate the URL and return the (void) result of doRequest($url). * @param string $url * @return mixed + * @throws ReflectionException + * @throws exception */ - public function request(string $url) { + public function request(string $url): mixed + { return $this->doRequest($this->serviceprovider->getUrl() . $url); } - /** - * Sets data for the request to be sent. - *
Will erialize arrays as JSON. - * @param array $data - * @return void - */ - public function setData(array $data) { - foreach($data as $key => $value) { - if(is_array($value)) { - if((count($value) > 0) && (reset($value) instanceof \CURLFile) ) { - // add the CURLFile as a POST content - $this->addData($key, $value); - continue; - } else { - $value = json_encode($value); - } - } - $this->addData($key, $value); - } - return; - } - - /** - * Adds another key to the data array of this instance. - *
Will check for the forbiddenpostfields here and do nothing if the field's $name is forbidden - * @param string $name - * @param mixed|null $value - * @return void - */ - public function addData(string $name, $value) { - if(in_array($name, $this->forbiddenpostfields)) { - return; - } - $this->data[$name] = $value; - return; - } - - /** - * Resets internal data storage - * e.g. after firing a request - * or on purpose. This WONT reset prepared headers - */ - public function resetData(): void { - $this->data = []; - } - - /** - * Returns the errorstack of the API instance - * @return \codename\core\errorstack - */ - public function getErrorstack() : \codename\core\errorstack { - return $this->errorstack; - } - - /** - * Hashes the type, app, secret and salt of this instance and returns the hash value - * @return string - **/ - protected function makeHash() : string { - if(strlen($this->salt) == 0) { - $this->errorstack->addError('setup', 'SERVICE_SALT_NOT_FOUND'); - print_r($this->errorstack->getErrors()); - } - if(strlen($this->type) == 0) { - $this->errorstack->addError('setup', 'TYPE_NOT_FOUND'); - print_r($this->errorstack->getErrors()); - } - return hash('sha512', $this->type . $this->authentication->getData('app_name') . $this->authentication->getData('app_secret') . $this->salt); - } - - /** - * Uses the given $version and $endpoint to request the correct API host and endpoint URL - * @param string $version - * @param string $endpoint - * @return bool - */ - protected function doAPIRequest(string $version, string $endpoint) : bool { - return $this->doRequest($this->serviceprovider->getUrl() . '/' . $version . '/' . $endpoint); - } - - /** - * headers to send with the request - * @var array - */ - protected $headers = []; - - /** - * [setHeader description] - * @param string $key [description] - * @param [type] $value [description] - */ - protected function setHeader(string $key, $value) { - if($value === null) { - unset($this->headers[$key]); - } - $this->headers[$key] = $value; - } - /** * Performs the request - * @param string $type - * @return mixed|bool + * @param string $url + * @return mixed + * @throws ReflectionException + * @throws exception */ - protected function doRequest(string $url) { + protected function doRequest(string $url): mixed + { $this->curlHandler = curl_init(); curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYHOST, 0); @@ -229,42 +132,29 @@ protected function doRequest(string $url) { curl_setopt($this->curlHandler, CURLOPT_URL, $url); curl_setopt($this->curlHandler, CURLOPT_RETURNTRANSFER, true); - // curl_setopt($this->curlHandler, CURLOPT_HTTPHEADER, array( - // "X-App: " . $this->authentication->getData('app_name'), - // "X-Auth: " . $this->makeHash() - // )); - $preparedHeaders = []; - foreach($this->headers as $k => $v) { - $preparedHeaders[] = $k.": ".$v; + foreach ($this->headers as $k => $v) { + $preparedHeaders[] = $k . ": " . $v; } - // curl_setopt($this->curlHandler, CURLINFO_HEADER_OUT, true); - - // // NOTE: doRequest() might be overwritten/re-implemented // in derived classes. Don't forget to set headers there. // curl_setopt($this->curlHandler, CURLOPT_HTTPHEADER, $preparedHeaders); - // \codename\core\app::getResponse()->setData('curl_headers', $preparedHeaders); - $this->sendData(); - // app::getLog('codenameapi')->debug(serialize($this)); $response = curl_exec($this->curlHandler); $res = $this->decodeResponse($response); - // \codename\core\app::getResponse()->setData('curl_response', $response); - curl_close($this->curlHandler); - // WARNING: reset data after request is needed - // to prevent information leakage to following requests. + // WARNING: reset data after request is necessary + // to prevent information leakage to the following requests. $this->resetData(); - if(is_bool($res) && !$res) { + if (is_bool($res) && !$res) { return false; } @@ -275,38 +165,41 @@ protected function doRequest(string $url) { * If data exist, this function will write the data as POST fields to the curlHandler * @return void */ - protected function sendData() { - if(count($this->data) > 0) { + protected function sendData(): void + { + if (count($this->data) > 0) { curl_setopt($this->curlHandler, CURLOPT_POST, 1); - foreach($this->data as $key => &$value) { - if(is_array($value)) { - if(count($value) > 0 && !(reset($value) instanceof \CURLFile)) { - $value = json_encode($value); + foreach ($this->data as &$value) { + if (is_array($value)) { + if (count($value) > 0 && !(reset($value) instanceof CURLFile)) { + $value = json_encode($value); } } } curl_setopt($this->curlHandler, CURLOPT_POST, count($this->data)); curl_setopt($this->curlHandler, CURLOPT_POSTFIELDS, $this->data); } - return; } /** * Decodes the response and validates it - *
Uses validators (\codename\core\validator\structure\api\response) to check the response content - *
Will return false on any error. - *
Will output cURL errors on development environments + * Uses validators (\codename\core\validator\structure\api\response) to check the response content + * Will return false on any error. + * Will output cURL errors on development environments * @param string $response * @return mixed + * @throws ReflectionException + * @throws exception */ - protected function decodeResponse(string $response) { + protected function decodeResponse(string $response): mixed + { app::getLog('debug')->debug('CORE_BACKEND_CLASS_API_CODENAME_DECODERESPONSE::START ($response = ' . $response . ')'); - if(defined('CORE_ENVIRONMENT') && CORE_ENVIRONMENT == 'dev') { + if (defined('CORE_ENVIRONMENT') && CORE_ENVIRONMENT == 'dev') { print_r(curl_error($this->curlHandler)); } - if(strlen($response) == 0) { + if (strlen($response) == 0) { $this->response = null; app::getLog('errormessage')->warning('CORE_BACKEND_CLASS_API_CODENAME_DECODERESPONSE::RESPONSE_EMPTY ($response = ' . $response . ')'); return false; @@ -314,18 +207,13 @@ protected function decodeResponse(string $response) { $response = app::object2array(json_decode($response)); - if(is_null($response)) { - $this->response = null; - return false; - } - - if(count($errors = app::getValidator('structure_api_codename_response')->validate($response)) > 0) { + if (count(app::getValidator('structure_api_codename_response')->validate($response)) > 0) { app::getLog('errormessage')->warning('CORE_BACKEND_CLASS_API_CODENAME_DECODERESPONSE::RESPONSE_INVALID ($response = ' . json_encode($response) . ')'); return false; } $this->response = $response; - if(array_key_exists('errors', $response)) { + if (array_key_exists('errors', $response)) { app::getLog('errormessage')->warning('CORE_BACKEND_CLASS_API_CODENAME_DECODERESPONSE::RESPONSE_CONTAINS_ERRORS ($response = ' . json_encode($response) . ')'); // @@ -338,4 +226,111 @@ protected function decodeResponse(string $response) { return $response; } + /** + * Resets internal data storage + * e.g., after firing a request + * or on purpose. This WON'T reset prepared headers + */ + public function resetData(): void + { + $this->data = []; + } + + /** + * Sets data for the request to be sent. + * Will serialize arrays as JSON. + * @param array $data + * @return void + */ + public function setData(array $data): void + { + foreach ($data as $key => $value) { + if (is_array($value)) { + if ((count($value) > 0) && (reset($value) instanceof CURLFile)) { + // add the CURLFile as a POST content + $this->addData($key, $value); + continue; + } else { + $value = json_encode($value); + } + } + $this->addData($key, $value); + } + } + + /** + * Adds another key to the data array of this instance. + * Will check for the forbiddenpostfields here and do nothing if the field's $name is forbidden + * @param string $name + * @param mixed $value + * @return void + */ + public function addData(string $name, mixed $value): void + { + if (in_array($name, $this->forbiddenpostfields)) { + return; + } + $this->data[$name] = $value; + } + + /** + * Returns the errorstack of the API instance + * @return errorstack + */ + public function getErrorstack(): errorstack + { + return $this->errorstack; + } + + /** + * Returns the cacheGroup for this instance + * @return string + */ + protected function getCacheGroup(): string + { + return 'API_' . $this->type . '_' . $this->authentication->getData('app_name'); + } + + /** + * Hashes the type, app, secret and salt of this instance and returns the hash value + * @return string + **/ + protected function makeHash(): string + { + if (strlen($this->salt) == 0) { + $this->errorstack->addError('setup', 'SERVICE_SALT_NOT_FOUND'); + print_r($this->errorstack->getErrors()); + } + if (strlen($this->type) == 0) { + $this->errorstack->addError('setup', 'TYPE_NOT_FOUND'); + print_r($this->errorstack->getErrors()); + } + return hash('sha512', $this->type . $this->authentication->getData('app_name') . $this->authentication->getData('app_secret') . $this->salt); + } + + /** + * Uses the given $version and $endpoint to request the correct API host and endpoint URL + * @param string $version + * @param string $endpoint + * @return bool + * @throws ReflectionException + * @throws exception + */ + protected function doAPIRequest(string $version, string $endpoint): bool + { + return $this->doRequest($this->serviceprovider->getUrl() . '/' . $version . '/' . $endpoint); + } + + /** + * [setHeader description] + * @param string $key [description] + * @param [type] $value [description] + */ + protected function setHeader(string $key, $value): void + { + if ($value === null) { + unset($this->headers[$key]); + } + $this->headers[$key] = $value; + } } diff --git a/backend/class/api/loggly.php b/backend/class/api/loggly.php index e91d0c6..71788df 100644 --- a/backend/class/api/loggly.php +++ b/backend/class/api/loggly.php @@ -1,37 +1,43 @@ token . '/tag/http/'; - - $data = array( - 'app' => \codename\core\app::getApp(), - 'server' => gethostname(), - 'client' => $_SERVER['REMOTE_ADDR'], - 'level' => $level, - 'data' => $data - ); + public function send(array $data, int $level): void + { + $url = 'https://logs-01.loggly.com/inputs/' . $this->token . '/tag/http/'; + + $data = [ + 'app' => app::getApp(), + 'server' => gethostname(), + 'client' => $_SERVER['REMOTE_ADDR'], + 'level' => $level, + 'data' => $data, + ]; // create CURL instance $ch = curl_init($url); @@ -39,15 +45,12 @@ public function send(array $data, int $level) { // Configure CURL instance curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); - curl_setopt($ch, CURLOPT_HTTPHEADER, array( - 'content-type:application/x-www-form-urlencoded', - 'Content-Length: ' . strlen(json_encode($data)) - )); - $test = curl_exec($ch); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'content-type:application/x-www-form-urlencoded', + 'Content-Length: ' . strlen(json_encode($data)), + ]); + curl_exec($ch); print_r(curl_error($ch)); curl_close($ch); - - return; } - } diff --git a/backend/class/api/rest.php b/backend/class/api/rest.php index 9a8454e..e8486a1 100644 --- a/backend/class/api/rest.php +++ b/backend/class/api/rest.php @@ -1,157 +1,108 @@ curlHandler = curl_init(); - - curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYHOST, 0); - - if(!in_array($method, ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS' ])) { - throw new exception('EXCEPTION_CORE_API_REST_INVALID_METHOD', exception::$ERRORLEVEL_ERROR, $method); +class rest extends codename +{ + /** + * {@inheritDoc} + * @param array $data [data array] + */ + public function __construct(array $data) + { + return parent::__construct($data); } - if($method == 'POST') { - curl_setopt($this->curlHandler, CURLOPT_POST, 1); - $this->setData($params); - } else { - curl_setopt($this->curlHandler, CURLOPT_POST, 0); - if($method != 'GET') { - // custom method, either 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS' ... ? - curl_setopt($this->curlHandler, CURLOPT_CUSTOMREQUEST, $method); - } else { - // - // HTTP Method GET - // handle URL-based params - // as it is 'GET' and not POST. - // - // so, we merge-in the params into the url - // + /** + * {@inheritDoc} + */ + protected function doRequest(string $url, string $method = '', array $params = []): mixed + { + $this->prepareRequest($url, $method, $params); - if(count($params) > 0) { - // NOTE: \http\Url is some of the worst PECL exts and class constructs I've ever seen - // hardly documented, but similar behaviour to the old parse_url and comparable stuff. - $url = (new \http\Url($url))->mod([ - 'query' => http_build_query($params, null, '&', PHP_QUERY_RFC3986) - ])->toString(); + $preparedHeaders = []; + foreach ($this->headers as $k => $v) { + $preparedHeaders[] = $k . ": " . $v; } - // $url = $url . '/?'.http_build_query($params); - } - } - - // this may be done in sendData() - /* if(count($params) > 0) { - curl_setopt($this->curlHandler, CURLOPT_POSTFIELDS, $params); - }*/ - - curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYHOST, 0); - curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYPEER, 0); - curl_setopt($this->curlHandler, CURLOPT_URL, $url); - curl_setopt($this->curlHandler, CURLOPT_RETURNTRANSFER, true); - } - - /** - * @inheritDoc - */ - protected function doRequest(string $url, string $method = '', array $params = []) - { - $this->prepareRequest($url, $method, $params); - - /* - $this->curlHandler = curl_init(); - - curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYHOST, 0); - - if(!in_array($method, ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS' ])) { - throw new exception('EXCEPTION_CORE_API_REST_INVALID_METHOD', exception::$ERRORLEVEL_ERROR, $method); - } + // + // NOTE: doRequest() might be overwritten/re-implemented + // in derived classes. Don't forget to set headers there. + // + curl_setopt($this->curlHandler, CURLOPT_HTTPHEADER, $preparedHeaders); - if($method == 'POST') { - curl_setopt($this->curlHandler, CURLOPT_POST, 1); - } else { - curl_setopt($this->curlHandler, CURLOPT_POST, 0); - if($method != 'GET') { - // custom method, either 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS' ... ? - curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method); - } - } + $this->sendData(); - curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYHOST, 0); - curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYPEER, 0); - curl_setopt($this->curlHandler, CURLOPT_URL, $url); - curl_setopt($this->curlHandler, CURLOPT_RETURNTRANSFER, true); - */ - // echo "$url"; - - /* curl_setopt($this->curlHandler, CURLOPT_HTTPHEADER, array( - "X-App: " . $this->authentication->getData('app_name'), - "X-Auth: " . $this->makeHash() - )); */ - - $preparedHeaders = []; - foreach($this->headers as $k => $v) { - $preparedHeaders[] = $k.": ".$v; - } + $response = curl_exec($this->curlHandler); + $res = $this->decodeResponse($response); - // - // NOTE: doRequest() might be overwritten/re-implemented - // in derived classes. Don't forget to set headers there. - // - curl_setopt($this->curlHandler, CURLOPT_HTTPHEADER, $preparedHeaders); - $this->sendData(); + if (!$res) { + $err = curl_error($this->curlHandler); + if ($err !== '') { + $this->errorstack->addError('curl', 0, $err); + } + } - // app::getLog('codenameapi')->debug(serialize($this)); + curl_close($this->curlHandler); - $response = curl_exec($this->curlHandler); - $res = $this->decodeResponse($response); + // WARNING: reset data after request is necessary + // to prevent information leakage to the following requests. + $this->resetData(); + if (is_bool($res) && !$res) { + // we may throw an exception here + return false; + } - if(!$res) { - $err = curl_error($this->curlHandler); - if($err !== '') { - $this->errorstack->addError('curl', 0, $err); - } + return $res; } - curl_close($this->curlHandler); + /** + * [prepareRequest description] + * @param string $url [description] + * @param string $method [description] + * @param array $params [description] + * @return void [type] [description] + * @throws exception + */ + protected function prepareRequest(string $url, string $method, array $params = []): void + { + $this->curlHandler = curl_init(); + + curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYHOST, 0); + + if (!in_array($method, ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS'])) { + throw new exception('EXCEPTION_CORE_API_REST_INVALID_METHOD', exception::$ERRORLEVEL_ERROR, $method); + } - // WARNING: reset data after request is needed - // to prevent information leakage to following requests. - $this->resetData(); + if ($method == 'POST') { + curl_setopt($this->curlHandler, CURLOPT_POST, 1); + $this->setData($params); + } else { + curl_setopt($this->curlHandler, CURLOPT_POST, 0); + if ($method != 'GET') { + // custom method, either 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS' ... ? + curl_setopt($this->curlHandler, CURLOPT_CUSTOMREQUEST, $method); + } elseif (count($params) > 0) { + // NOTE: \http\Url is some of the worst PECL texts and class constructs I've ever seen + // hardly documented, but similar behavior to the old parse_url and comparable stuff. + $url = (new Url($url))->mod([ + 'query' => http_build_query($params, null, '&', PHP_QUERY_RFC3986), + ])->toString(); + } + } - if(is_bool($res) && !$res) { - // we may throw an exception here - return false; + curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYHOST, 0); + curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYPEER, 0); + curl_setopt($this->curlHandler, CURLOPT_URL, $url); + curl_setopt($this->curlHandler, CURLOPT_RETURNTRANSFER, true); } - - return $res; - } - } diff --git a/backend/class/api/simple.php b/backend/class/api/simple.php index 66a91ab..9844c93 100644 --- a/backend/class/api/simple.php +++ b/backend/class/api/simple.php @@ -1,62 +1,61 @@ data['version'] = $version; - $this->data['endpoint'] = $endpoint; - // Just main page/endpoint without any flimflam. - return $this->doRequest($this->serviceprovider->getHost(). ':' . $this->serviceprovider->getPort()); - } - - /** - * @inheritDoc - */ - protected function doRequest(string $url): bool - { - $this->curlHandler = curl_init(); - - curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYHOST, 0); - curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYPEER, 0); - curl_setopt($this->curlHandler, CURLOPT_URL, $url); - curl_setopt($this->curlHandler, CURLOPT_RETURNTRANSFER, true); - - curl_setopt($this->curlHandler, CURLOPT_HTTPHEADER, $this->headers); - - $this->sendData(); - app::getLog('codenameapi')->debug(serialize($this)); - - $res = $this->decodeResponse(curl_exec($this->curlHandler)); - - curl_close($this->curlHandler); - - if(is_bool($res) && !$res) { - return false; +class simple extends codename +{ + /** + * {@inheritDoc} + */ + public function __construct(array $data) + { + return parent::__construct($data); + } + + /** + * {@inheritDoc} + */ + protected function doAPIRequest(string $version, string $endpoint): bool + { + // fill DATA with the to-be-used version and endpoint parameters + $this->data['version'] = $version; + $this->data['endpoint'] = $endpoint; + // Just main page/endpoint without any flimflam. + return $this->doRequest($this->serviceprovider->getHost() . ':' . $this->serviceprovider->getPort()); } - return true; - } + /** + * {@inheritDoc} + */ + protected function doRequest(string $url): bool + { + $this->curlHandler = curl_init(); + + curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYHOST, 0); + curl_setopt($this->curlHandler, CURLOPT_SSL_VERIFYPEER, 0); + curl_setopt($this->curlHandler, CURLOPT_URL, $url); + curl_setopt($this->curlHandler, CURLOPT_RETURNTRANSFER, true); + + curl_setopt($this->curlHandler, CURLOPT_HTTPHEADER, $this->headers); + $this->sendData(); + app::getLog('codenameapi')->debug(serialize($this)); + + $res = $this->decodeResponse(curl_exec($this->curlHandler)); + + curl_close($this->curlHandler); + + if (is_bool($res) && !$res) { + return false; + } + + return true; + } } diff --git a/backend/class/app.php b/backend/class/app.php index 6c41eae..f4f93b7 100644 --- a/backend/class/app.php +++ b/backend/class/app.php @@ -1,245 +1,429 @@ Contexts must contain at least one view. + * Contexts must contain at least one view. * @var string */ - CONST EXCEPTION_VIEWEXISTS_CONTEXTCONTAINSNOVIEWS = 'EXCEPTION_VIEWEXISTS_CONTEXTCONTAINSNOVIEWS'; + public const string EXCEPTION_VIEWEXISTS_CONTEXTCONTAINSNOVIEWS = 'EXCEPTION_VIEWEXISTS_CONTEXTCONTAINSNOVIEWS'; /** * The currently requested view does not exist in the requested context. * @var string */ - CONST EXCEPTION_MAKEREQUEST_REQUESTEDVIEWNOTINCONTEXT = 'EXCEPTION_MAKEREQUEST_REQUESTEDVIEWNOTINCONTEXT'; + public const string EXCEPTION_MAKEREQUEST_REQUESTEDVIEWNOTINCONTEXT = 'EXCEPTION_MAKEREQUEST_REQUESTEDVIEWNOTINCONTEXT'; /** * The desired application's base folder cannot be found. - *
It must exist in order to load the application's resources. + * It must exist to load the application's resources. * @var string */ - CONST EXCEPTION_GETAPP_APPFOLDERNOTFOUND = 'EXCEPTION_GETAPP_APPFOLDERNOTFOUND'; + public const string EXCEPTION_GETAPP_APPFOLDERNOTFOUND = 'EXCEPTION_GETAPP_APPFOLDERNOTFOUND'; /** * The desired application config does not exist at least one context. * @var string */ - CONST EXCEPTION_GETCONFIG_APPCONFIGCONTAINSNOCONTEXT = 'EXCEPTION_GETCONFIG_APPCONFIGCONTAINSNOCONTEXT'; + public const string EXCEPTION_GETCONFIG_APPCONFIGCONTAINSNOCONTEXT = 'EXCEPTION_GETCONFIG_APPCONFIGCONTAINSNOCONTEXT'; /** * The given type of the desired context is not valid - *
It either cannot be found or is an invalid configuration + * It either cannot be found or is an invalid configuration * @var string */ - CONST EXCEPTION_GETCONFIG_APPCONFIGCONTEXTTYPEINVALID = 'EXCEPTION_GETCONFIG_APPCONFIGCONTEXTTYPEINVALID'; + public const string EXCEPTION_GETCONFIG_APPCONFIGCONTEXTTYPEINVALID = 'EXCEPTION_GETCONFIG_APPCONFIGCONTEXTTYPEINVALID'; /** * The desired application's configuration file is not valid. * @var string */ - CONST EXCEPTION_GETCONFIG_APPCONFIGFILEINVALID = 'EXCEPTION_GETCONFIG_APPCONFIGFILEINVALID'; + public const string EXCEPTION_GETCONFIG_APPCONFIGFILEINVALID = 'EXCEPTION_GETCONFIG_APPCONFIGFILEINVALID'; /** * The current request's environment type cannot be found in the environment configuration. * @var string */ - CONST EXCEPTION_GETDATA_CURRENTENVIRONMENTNOTFOUND = 'EXCEPTION_GETDATA_CURRENTENVIRONMENTNOTFOUND'; + public const string EXCEPTION_GETDATA_CURRENTENVIRONMENTNOTFOUND = 'EXCEPTION_GETDATA_CURRENTENVIRONMENTNOTFOUND'; /** - * The requested environment type (e.g. "mail") cannot be found in the current request's environment config. - *
Maybe you missed to copy that key into your current environment. + * The requested environment type (e.g., "mail") cannot be found in the current request's environment config. + * Maybe you missed to copy that key into your current environment. * @var string */ - CONST EXCEPTION_GETDATA_REQUESTEDTYPENOTFOUND = 'EXCEPTION_GETDATA_REQUESTEDTYPENOTFOUND'; + public const string EXCEPTION_GETDATA_REQUESTEDTYPENOTFOUND = 'EXCEPTION_GETDATA_REQUESTEDTYPENOTFOUND'; /** * The current environment type configuration does not contain the desired key for the type. - *
May occur when you use multiple mail configurators. + * May occur when you use multiple mail configurators. * @var string */ - CONST EXCEPTION_GETDATA_REQUESTEDKEYINTYPENOTFOUND = 'EXCEPTION_GETDATA_REQUESTEDKEYINTYPENOTFOUND'; + public const string EXCEPTION_GETDATA_REQUESTEDKEYINTYPENOTFOUND = 'EXCEPTION_GETDATA_REQUESTEDKEYINTYPENOTFOUND'; /** * The desired file cannot be found in any appstack levels. * @var string */ - CONST EXCEPTION_GETINHERITEDPATH_FILENOTFOUND = 'EXCEPTION_GETINHERITEDPATH_FILENOTFOUND'; + public const string EXCEPTION_GETINHERITEDPATH_FILENOTFOUND = 'EXCEPTION_GETINHERITEDPATH_FILENOTFOUND'; /** * The desired class cannot be found in any appstack level. * @var string */ - CONST EXCEPTION_GETINHERITEDCLASS_CLASSFILENOTFOUND = 'EXCEPTION_GETINHERITEDCLASS_CLASSFILENOTFOUND'; + public const string EXCEPTION_GETINHERITEDCLASS_CLASSFILENOTFOUND = 'EXCEPTION_GETINHERITEDCLASS_CLASSFILENOTFOUND'; /** * The desired template cannot be found * @var string */ - CONST EXCEPTION_PARSEFILE_TEMPLATENOTFOUND = 'EXCEPTION_PARSEFILE_TEMPLATENOTFOUND'; + public const string EXCEPTION_PARSEFILE_TEMPLATENOTFOUND = 'EXCEPTION_PARSEFILE_TEMPLATENOTFOUND'; /** * The desired action name is not valid * @var string */ - CONST EXCEPTION_DOACTION_ACTIONNAMEISINVALID = 'EXCEPTION_DOACTION_ACTIONNAMEISINVALID'; + public const string EXCEPTION_DOACTION_ACTIONNAMEISINVALID = 'EXCEPTION_DOACTION_ACTIONNAMEISINVALID'; /** * The desired action is configured, but the action cannot be found in the current context * @var string */ - CONST EXCEPTION_DOACTION_ACTIONNOTFOUNDINCONTEXT = 'EXCEPTION_DOACTION_ACTIONNOTFOUNDINCONTEXT'; + public const string EXCEPTION_DOACTION_ACTIONNOTFOUNDINCONTEXT = 'EXCEPTION_DOACTION_ACTIONNOTFOUNDINCONTEXT'; /** * The requested action's corresponding method name (action_$actionname$) cannot be found in the current context * @var string */ - CONST EXCEPTION_DOACTION_REQUESTEDACTIONFUNCTIONNOTFOUND = 'EXCEPTION_DOACTION_REQUESTEDACTIONFUNCTIONNOTFOUND'; + public const string EXCEPTION_DOACTION_REQUESTEDACTIONFUNCTIONNOTFOUND = 'EXCEPTION_DOACTION_REQUESTEDACTIONFUNCTIONNOTFOUND'; /** * The requested view's name is not valid. * @var string */ - CONST EXCEPTION_DOVIEW_VIEWNAMEISINVALID = 'EXCEPTION_DOVIEW_VIEWNAMEISINVALID'; + public const string EXCEPTION_DOVIEW_VIEWNAMEISINVALID = 'EXCEPTION_DOVIEW_VIEWNAMEISINVALID'; /** * The requested view's corresponding method name (view_$viewname$) cannot be found in the current context * @var string */ - CONST EXCEPTION_DOVIEW_VIEWFUNCTIONNOTFOUNDINCONTEXT = 'EXCEPTION_DOVIEW_VIEWFUNCTIONNOTFOUNDINCONTEXT'; + public const string EXCEPTION_DOVIEW_VIEWFUNCTIONNOTFOUNDINCONTEXT = 'EXCEPTION_DOVIEW_VIEWFUNCTIONNOTFOUNDINCONTEXT'; /** * The requested view needs the user to have a specific usergroup, which he doesn't have. * @var string */ - CONST EXCEPTION_DOVIEW_VIEWDISALLOWED = 'EXCEPTION_DOVIEW_VIEWDISALLOWED'; + public const string EXCEPTION_DOVIEW_VIEWDISALLOWED = 'EXCEPTION_DOVIEW_VIEWDISALLOWED'; /** * The requested context name is not valid * @var string */ - CONST EXCEPTION_GETCONTEXT_CONTEXTNAMEISINVALID = 'EXCEPTION_GETCONTEXT_CONTEXTNAMEISINVALID'; + public const string EXCEPTION_GETCONTEXT_CONTEXTNAMEISINVALID = 'EXCEPTION_GETCONTEXT_CONTEXTNAMEISINVALID'; /** * The requested class file cannot be found * @var string */ - CONST EXCEPTION_GETCONTEXT_REQUESTEDCLASSFILENOTFOUND = 'EXCEPTION_GETCONTEXT_REQUESTEDCLASSFILENOTFOUND'; + public const string EXCEPTION_GETCONTEXT_REQUESTEDCLASSFILENOTFOUND = 'EXCEPTION_GETCONTEXT_REQUESTEDCLASSFILENOTFOUND'; /** * The requested client could not be found * @var string */ - CONST EXCEPTION_GETCLIENT_NOTFOUND = 'EXCEPTION_GETCLIENT_NOTFOUND'; - + public const string EXCEPTION_GETCLIENT_NOTFOUND = 'EXCEPTION_GETCLIENT_NOTFOUND'; + /** + * Exception thrown if the context configuration is missing in the app.json + * @var string + */ + public const string EXCEPTION_MAKEREQUEST_CONTEXT_CONFIGURATION_MISSING = 'EXCEPTION_MAKEREQUEST_CONTEXT_CONFIGURATION_MISSING'; + /** + * Event/Hook fired when the appstack has become available + * @var string + */ + public const string EVENT_APP_APPSTACK_AVAILABLE = 'EVENT_APP_APPSTACK_AVAILABLE'; + /** + * Injection mode for base apps + * (in-between core and extensions) + * @var int + */ + public const int INJECT_APP_BASE = -1; + /** + * Injection mode for extension apps + * (below the main app, but above base apps) + * @var int + */ + public const int INJECT_APP_EXTENSION = 1; + /** + * Injection mode for app overrides + * (above the main app!) + * @var int + */ + public const int INJECT_APP_OVERRIDE = 0; /** * Contains the apploader for this application - *
The apploader consists of the vendor name and the app's name - * @example coename_core - * @var \codename\core\value\text\apploader + * The apploader consists of the vendor name and the app's name + * @example codename_core + * @var null|apploader */ - protected static $apploader = null; - + protected static ?apploader $apploader = null; /** * Contains the vendor of the running app. It is automatically derived from the app's namespace - *
The vendor defines the folder the resources are retrieved from - *
A vendor must consist of lowercase alphabetic characters (a-z) - * @var \codename\core\value\text\methodname $vendor + * The vendor defines the folder the resources are retrieved from + * A vendor must consist of lowercase alphabetic characters (a-z) + * @var null|methodname $vendor */ - protected static $vendor = null; - + protected static ?methodname $vendor = null; /** * Contains the name of the running app. It is automatically derived from the app's namespace - *
The app name must consist of lowercase alphabetical characters (a-z) - * @var \codename\core\value\text\methodname $app + * The app name must consist of lowercase alphabetical characters (a-z) + * @var null|methodname $app */ - protected static $app = null; - + protected static ?methodname $app = null; /** * This contains the actual instance of the app class for singleton usage - *
Access it by app::getMyInstance() - * @var \codename\core\app + * Access it by app::getMyInstance() + * @var null|app */ - protected static $instance = null; - + protected static ?app $instance = null; /** * This contains an array of application names including the vendor names - *
The stack is created by searching the file ./config/parent.app in the app's directory - *
Example: array('codename_exampleapp', 'codename_someapp', 'coename_core') - * @var \codename\core\value\structure\appstack + * The stack is created by searching the file ./config/parent.app in the app's directory + * Example: array('codename_exampleapp', 'codename_someapp', 'codename_core') + * @var null|appstack */ - protected static $appstack = null; - + protected static ?appstack $appstack = null; /** * This contains an instance of \codename\core\config. It is used to store the app's configuration * It contains the contexts, actions, views and templates of an application - *
Basically it is the outline of the app - * @var \codename\core\config + * Basically it is the outline of the app + * @var null|config */ - protected static $config = null; - + protected static ?config $config = null; /** - * This contains the environment. The environment is configured in ./config/environment.json - *
You can either use the current app's environment file or the environment file of a parent - *
Environments can be created using the $appstack property of the app class - * @var \codename\core\config + * This contains the environment. + * The environment is configured in "./config/environment.json" + * You can either use the current app's environment file or the environment file of a parent + * Environments can be created using the $appstack property of the app class + * @var null|config */ - protected static $environment = null; - + protected static ?config $environment = null; /** - * This contains an instance of \codename\core\hook. The hook class is used for event based engineering. - *
Add listeners or callbacks by using the methods in the class returned by this method. - * @var \codename\core\hook + * This contains an instance of \codename\core\hook. + * The hook class is used for event-based engineering. + * Add listeners or callbacks by using the methods in the class returned by this method. + * @var null|hook */ - protected static $hook = null; - + protected static ?hook $hook = null; /** - * This is the file path that is used to load the application config. - *
It is always below the application folder. + * This is the file path used to load the application config. + * It is always below the application folder. * @var string */ - protected static $json_config = 'config/app.json'; - + protected static string $json_config = 'config/app.json'; /** * This is the file path used for loading the environment config - *
It is always below the application folder + * It is always below the application folder * @var string */ - protected static $json_environment = 'config/environment.json'; + protected static string $json_environment = 'config/environment.json'; + /** + * the current exit code to be sent after app's execution + * @var null|int + */ + protected static ?int $exitCode = 0; + /** + * overridden base namespace + * @var null|string + */ + protected static ?string $namespace = null; + /** + * App's home dir - null by default + * set to override. + * @var string|null + */ + protected static ?string $homedir = null; + /** + * @var objectidentifier[]] + */ + protected static array $dbValueObjectidentifierArray = []; + /** + * @var null|objecttype + */ + protected static ?value\text\objecttype $dbValueObjecttype = null; + /** + * @var objectidentifier[]] + */ + protected static array $translateValueObjectidentifierArray = []; + /** + * @var null|objecttype + */ + protected static ?value\text\objecttype $translateValueObjecttype = null; + /** + * @var objectidentifier[]] + */ + protected static array $cacheValueObjectidentifierArray = []; + /** + * @var null|objecttype + */ + protected static ?value\text\objecttype $cacheValueObjecttype = null; + /** + * @var objectidentifier[]] + */ + protected static array $sessionValueObjectidentifierArray = []; + /** + * @var null|objecttype + */ + protected static ?value\text\objecttype $sessionValueObjecttype = null; + /** + * [protected description] + * @var log[] + */ + protected static array $logInstance = []; + /** + * @var objectidentifier[]] + */ + protected static array $logValueObjectidentifierArray = []; + /** + * @var null|objecttype + */ + protected static ?value\text\objecttype $logValueObjecttype = null; + /** + * @var array + */ + protected static array $validatorCacheArray = []; + /** + * @var objectidentifier[]] + */ + protected static array $templateengineValueObjectidentifierArray = []; + /** + * @var null|objecttype + */ + protected static ?value\text\objecttype $templateengineValueObjecttype = null; + /** + * array of injected or to-be-injected apps during makeAppstack + * @var array[] + */ + protected static array $injectedApps = []; + /** + * [protected description] + * @var string[] + */ + protected static array $injectedAppIdentifiers = []; + /** + * Whether to register custom shutdown handler(s) + * @var bool + */ + protected bool $registerShutdownHandler = true; /** * This is the entry point for an application call. - *
Either pass $app, $context, $view and $action as arguments into the constructor or let these arguments be derived from the request container - *
This method configures the application instance completely and creates all properties that are NULL by default - * @return \codename\core\app + * Either pass $app, $context, $view and $action as arguments into the constructor or let these arguments be derived from the request container + * This method configures the application instance completely and creates all properties that are NULL by default + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws ErrorException + * @throws Throwable + * @throws exception */ - public function __CONSTRUCT() { - + public function __construct() + { // // register shutdown handler // and handle fatal PHP core runtime/execution errors like @@ -248,343 +432,502 @@ public function __CONSTRUCT() { // - function nesting level reached // ini_set('display_errors', 0); - if($this->registerShutdownHandler) { - register_shutdown_function(function() { - if($error = error_get_last()) { - $exc = new \codename\core\exception('EXCEPTION_RUNTIME_SHUTDOWN', exception::$ERRORLEVEL_FATAL, $error); - app::getResponse()->setStatus(\codename\core\response::STATUS_INTERNAL_ERROR); - app::getResponse()->displayException($exc); - exit(1); - } - }); + if ($this->registerShutdownHandler) { + register_shutdown_function(function () { + if ($error = error_get_last()) { + $exc = new exception('EXCEPTION_RUNTIME_SHUTDOWN', exception::$ERRORLEVEL_FATAL, $error); + app::getResponse()->setStatus(response::STATUS_INTERNAL_ERROR); + app::getResponse()->displayException($exc); + exit(1); + } + }); } // Make Exceptions out of PHP Errors set_error_handler(function ($err_severity, $err_msg, $err_file, $err_line) { - // - // https://www.php.net/manual/de/language.operators.errorcontrol.php - // This function simply exits, if we've got a suppressed error reporting - // (via @). This is more 'natural', as we also suppress exceptions - // when we suppress warnings, notices, errors or else. - // CHANGED 2021-09-24: as of PHP8, error suppression in this function has changed - // - if (!(error_reporting() & $err_severity)) { - // NOTE: returning FALSE here enforces regular error handling afterwards. - // We do not return a value to ensure core-specific behaviours stay the same. - return; - } - switch($err_severity) - { - case E_ERROR: throw new \ErrorException ($err_msg, 0, $err_severity, $err_file, $err_line); - case E_WARNING: throw new WarningException ($err_msg, 0, $err_severity, $err_file, $err_line); - case E_PARSE: throw new ParseException ($err_msg, 0, $err_severity, $err_file, $err_line); - // case E_NOTICE: throw new NoticeException ($err_msg, 0, $err_severity, $err_file, $err_line); - case E_CORE_ERROR: throw new CoreErrorException ($err_msg, 0, $err_severity, $err_file, $err_line); - case E_CORE_WARNING: throw new CoreWarningException ($err_msg, 0, $err_severity, $err_file, $err_line); - case E_COMPILE_ERROR: throw new CompileErrorException ($err_msg, 0, $err_severity, $err_file, $err_line); - case E_COMPILE_WARNING: throw new CoreWarningException ($err_msg, 0, $err_severity, $err_file, $err_line); - case E_USER_ERROR: throw new UserErrorException ($err_msg, 0, $err_severity, $err_file, $err_line); - case E_USER_WARNING: throw new UserWarningException ($err_msg, 0, $err_severity, $err_file, $err_line); - // case E_USER_NOTICE: throw new UserNoticeException ($err_msg, 0, $err_severity, $err_file, $err_line); - case E_STRICT: throw new StrictException ($err_msg, 0, $err_severity, $err_file, $err_line); - case E_RECOVERABLE_ERROR: throw new RecoverableErrorException ($err_msg, 0, $err_severity, $err_file, $err_line); - // case E_DEPRECATED: throw new DeprecatedException ($err_msg, 0, $err_severity, $err_file, $err_line); - // case E_USER_DEPRECATED: throw new UserDeprecatedException ($err_msg, 0, $err_severity, $err_file, $err_line); - } + // + // https://www.php.net/manual/de/language.operators.errorcontrol.php + // This function simply exits, if we've got a suppressed error reporting + // (via @). This is more 'natural', as we also suppress exceptions + // when we suppress warnings, notices, errors or else. + // CHANGED 2021-09-24: as of PHP8, error suppression in this function has changed + // + if (!(error_reporting() & $err_severity)) { + // NOTE: returning FALSE here enforces regular error handling afterwards. + // We do not return a value to ensure core-specific behaviors stay the same. + return; + } + switch ($err_severity) { + case E_ERROR: + throw new ErrorException($err_msg, 0, $err_severity, $err_file, $err_line); + case E_WARNING: + throw new warningException($err_msg, 0, $err_severity, $err_file, $err_line); + case E_PARSE: + throw new parseException($err_msg, 0, $err_severity, $err_file, $err_line); + // case E_NOTICE: throw new noticeException($err_msg, 0, $err_severity, $err_file, $err_line); + case E_CORE_ERROR: + throw new coreErrorException($err_msg, 0, $err_severity, $err_file, $err_line); + case E_CORE_WARNING: + throw new coreWarningException($err_msg, 0, $err_severity, $err_file, $err_line); + case E_COMPILE_ERROR: + throw new compileErrorException($err_msg, 0, $err_severity, $err_file, $err_line); + case E_COMPILE_WARNING: + throw new coreWarningException($err_msg, 0, $err_severity, $err_file, $err_line); + case E_USER_ERROR: + throw new userErrorException($err_msg, 0, $err_severity, $err_file, $err_line); + case E_USER_WARNING: + throw new userWarningException($err_msg, 0, $err_severity, $err_file, $err_line); + // case E_USER_NOTICE: throw new userNoticeException($err_msg, 0, $err_severity, $err_file, $err_line); + case E_STRICT: + throw new strictException($err_msg, 0, $err_severity, $err_file, $err_line); + case E_RECOVERABLE_ERROR: + throw new recoverableErrorException($err_msg, 0, $err_severity, $err_file, $err_line); + // case E_DEPRECATED: throw new deprecatedException($err_msg, 0, $err_severity, $err_file, $err_line); + // case E_USER_DEPRECATED: throw new userDeprecatedException($err_msg, 0, $err_severity, $err_file, $err_line); + } }); // Core Exception Handler - set_exception_handler(function(\Throwable $t) { - if(self::shouldThrowException()) { - throw $t; - } else { - $code = is_int($t->getCode()) ? $t->getCode() : 0; - app::getResponse()->displayException(new \Exception($t->getMessage(), $code, $t)); - } + set_exception_handler(function (Throwable $t) { + if (self::shouldThrowException()) { + throw $t; + } else { + $code = is_int($t->getCode()) ? $t->getCode() : 0; + app::getResponse()->displayException(new \Exception($t->getMessage(), $code, $t)); + } }); - self::getHook()->fire(\codename\core\hook::EVENT_APP_INITIALIZING); + self::getHook()->fire(hook::EVENT_APP_INITIALIZING); self::$instance = $this; - self::getHook()->fire(\codename\core\hook::EVENT_APP_INITIALIZED); - return; + self::getHook()->fire(hook::EVENT_APP_INITIALIZED); } - /** - * Whether to register custom shutdown handler(s) - * @var bool - */ - protected $registerShutdownHandler = true; - /** * [shouldThrowException description] * @return bool */ - protected static function shouldThrowException() : bool { - return self::getEnv() == 'dev' && extension_loaded('xdebug') && !isset($_REQUEST['XDEBUG_SESSION_STOP']) && (isset($_REQUEST['XDEBUG_SESSION_START']) || isset($_COOKIE['XDEBUG_SESSION'])); + protected static function shouldThrowException(): bool + { + return self::getEnv() == 'dev' && extension_loaded('xdebug') && !isset($_REQUEST['XDEBUG_SESSION_STOP']) && (isset($_REQUEST['XDEBUG_SESSION_START']) || isset($_COOKIE['XDEBUG_SESSION'])); } /** - * [initDebug description] - * @return [type] [description] - */ - protected function initDebug() { - if(self::getEnv() == 'dev' && (self::getRequest()->getData('template') !== 'json' && self::getRequest()->getData('template') !== 'blank')) { - $this->getHook()->add(\codename\core\hook::EVENT_APP_RUN_START, function() { - $_REQUEST['start'] = microtime(true); - })->add(\codename\core\hook::EVENT_APP_RUN_END, function() { - if($this->getRequest() instanceof \codename\core\request\cli) { - echo 'Generated in '.round(abs(($_REQUEST['start'] - microtime(true)) * 1000),2).'ms'.chr(10) - . ' ' . \codename\core\observer\database::$query_count . ' Queries'.chr(10) - . ' ' . \codename\core\observer\cache::$set . ' Cache SETs'.chr(10) - . ' ' . \codename\core\observer\cache::$get . ' Cache GETs'.chr(10) - . ' ' . \codename\core\observer\cache::$hit . ' Cache HITs'.chr(10) - . ' ' . \codename\core\observer\cache::$miss . ' Cache MISSes'.chr(10); - } else if($this->getRequest() instanceof \codename\core\request\json || $this->getRequest() instanceof \codename\rest\request\json) { - // - // NO DEBUG APPEND for this type of request/response - // - } else { - if(self::getRequest()->getData('template') !== 'json' && self::getRequest()->getData('template') !== 'blank') { - echo '
Generated in '.round(abs(($_REQUEST['start'] - microtime(true)) * 1000),2).'ms
-              '.\codename\core\observer\database::$query_count . ' Queries
-              '. \codename\core\observer\cache::$set . ' Cache SETs
-              '. \codename\core\observer\cache::$get . ' Cache GETs
-              '. \codename\core\observer\cache::$hit . ' Cache HITs
-              '. \codename\core\observer\cache::$miss . ' Cache MISSes
-              
'; + * Returns the current environment identifier + * @return string + */ + final public static function getEnv(): string + { + if (!defined("CORE_ENVIRONMENT")) { + $env = getenv('CORE_ENVIRONMENT'); + if (!$env) { + // + // We have to die() here + // as Exception Throwing+Displaying needs the environment to be defined. + // + echo("CORE_ENVIRONMENT not defined."); + die(); } - } - }); - } + define('CORE_ENVIRONMENT', $env); + } + return strtolower(CORE_ENVIRONMENT); } /** - * - * {@inheritDoc} - * @see \codename\core\app_interface::contextExists($context) + * Returns the app's instance of \codename\core\hook for event firing + * @return hook */ - public function contextExists(\codename\core\value\text\contextname $context) : bool { - return self::getConfig()->exists("context>".$context->get()); + final public static function getHook(): hook + { + if (self::$hook === null) { + self::$hook = hook::getInstance(); + } + return self::$hook; } /** - * - * {@inheritDoc} - * @see \codename\core\app_interface::viewExists($context, $view) - * @todo clearly context related. move to the context + * Returns an instance of $class. It will be cached to the current $_request scope to increase performance. + * @param string $class name of the class to load + * @param array|null $config config to be used [WARNING: if already initialized/used, config is not being overridden!] + * @return object + * @throws ReflectionException + * @throws exception */ - public function viewExists(\codename\core\value\text\contextname $context, \codename\core\value\text\viewname $view) : bool { - if (!$this->contextExists($context)) { - return false; + final public static function getInstance(string $class, ?array $config = null): object + { + $simplename = str_replace('\\', '', $class); + if (array_key_exists($simplename, self::$instances)) { + return self::$instances[$simplename]; + } + + $class = self::getInheritedClass($class); + + if (!is_null($config)) { + return self::$instances[$simplename] = new $class($config); + } + return self::$instances[$simplename] = new $class(); + } + + /** + * Returns the name of the given $class name from the lowest available application's source. + * @param string $classname + * @return string + * @throws ReflectionException + * @throws exception + */ + final public static function getInheritedClass(string $classname): string + { + $classname = str_replace('_', '\\', $classname); + + if (is_null(self::$appstack)) { + return "\\codename\\core\\" . $classname; } - if (!self::getConfig()->exists("context>".$context->get().">view")) { - throw new \codename\core\exception(self::EXCEPTION_VIEWEXISTS_CONTEXTCONTAINSNOVIEWS, \codename\core\exception::$ERRORLEVEL_FATAL, $context); + foreach (self::getAppstack() as $parentapp) { + // CHANGED 2021-08-16: purely rely on namespace/autoload for inherited classes + $namespace = $parentapp['namespace'] ?? ('\\' . $parentapp['vendor'] . '\\' . $parentapp['app']); + $class = $namespace . '\\' . $classname; + + if (class_exists($class)) { + return $class; + } } - return self::getConfig()->exists("context>".$context->get().">view>".$view->get()); + throw new exception(self::EXCEPTION_GETINHERITEDCLASS_CLASSFILENOTFOUND, exception::$ERRORLEVEL_FATAL, $classname); } /** - * - * {@inheritDoc} - * @see \codename\core\app_interface::actionExists($context, $action) - * @todo clearly context related. move to the context + * Returns the appstack of the instance. Can be used to load files by their existence (not my app? -> parent app? -> parent's parent...) + * @return array + * @throws ReflectionException + * @throws exception */ - public function actionExists(\codename\core\value\text\contextname $context, \codename\core\value\text\actionname $action) : bool { - return self::getConfig()->exists("context>".$context->get().">action>".$action->get()); + final public static function getAppstack(): array + { + if (self::$appstack == null) { + self::makeCurrentAppstack(); + } + return self::$appstack->get(); } /** - * [handleAccess description] - * @return bool [description] + * creates and sets the appstack for the current app + * @return array [description] + * @throws ReflectionException + * @throws exception */ - protected function handleAccess() : bool { - if($this->getContext() instanceof \codename\core\context\customContextInterface) { - if(!$this->getContext()->isAllowed()) { - self::getHook()->fire(\codename\core\hook::EVENT_APP_RUN_FORBIDDEN); - // TODO: redirect? - return false; - } - return true; - } else if(!$this->getContext()->isAllowed() && !self::getConfig()->get("context>{$this->getRequest()->getData('context')}>view>{$this->getRequest()->getData('view')}>public")) { - self::getHook()->fire(\codename\core\hook::EVENT_APP_RUN_FORBIDDEN); - $this->getResponse()->setRedirect($this->getApp(), 'login'); - $this->getResponse()->doRedirect(); - return false; - } else { - return true; - } + final protected static function makeCurrentAppstack(): array + { + $stack = self::makeAppstack(self::getVendor(), self::getApp()); + self::$appstack = new appstack($stack); + self::getHook()->fire(app::EVENT_APP_APPSTACK_AVAILABLE); + return $stack; } /** - * - * {@inheritDoc} - * @see \codename\core\app_interface::run() + * Generates an array of application names that depend on from each other. Lower array positions are lower priorities + * @param string $vendor [vendor] + * @param string $app [app] + * @return array + * @throws exception|ReflectionException */ - public function run() { - self::getHook()->fire(\codename\core\hook::EVENT_APP_RUN_START); - self::getLog('debug')->debug('CORE_BACKEND_CLASS_APP_RUN::START - ' . json_encode(self::getRequest()->getData())); - self::getLog('access')->info(json_encode($this->getRequest()->getData())); + final protected static function makeAppstack(string $vendor, string $app): array + { + $initialApp = [ + 'vendor' => $vendor, + 'app' => $app, + ]; + if ($vendor == static::getVendor() && $app == static::getApp()) { + // set namespace override, if we're in the current app + // may be null. + $initialApp['namespace'] = static::getNamespace(); + } - // Warning: - // "Chicken or the egg" problem. - // We have to call $this->makeRequest(); - // Before we're using app::getResponse(); - // -- - // originally, we set APP-SRV header here. - // this has been moved to the core response class. + // add initial app as starting point for stack + $stack = [$initialApp]; + $parentfile = self::getHomedir($vendor, $app) . 'config/parent.app'; - try { - $this->makeRequest(); - } catch (\Exception $e) { - if(self::shouldThrowException()) { - throw $e; - } else { - $this->getResponse()->displayException($e); - } - } + $current_vendor = ''; + $current_app = ''; + + while (self::getInstance('filesystem_local')->fileAvailable($parentfile)) { + $parentapp = app::getParentapp($current_vendor, $current_app); - self::getHook()->fire(\codename\core\hook::EVENT_APP_RUN_MAIN); + if (strlen($parentapp) == 0) { + break; + } - try { + $parentapp_data = explode('_', $parentapp); + $current_vendor = $parentapp_data[0]; + $current_app = $parentapp_data[1]; + $stack[] = [ + 'vendor' => $parentapp_data[0], + 'app' => $parentapp_data[1], + ]; - // if(!$this->getContext()->isAllowed() && !self::getConfig()->exists("context>{$this->getRequest()->getData('context')}>view>{$this->getRequest()->getData('view')}>public")) { - // self::getHook()->fire(\codename\core\hook::EVENT_APP_RUN_FORBIDDEN); - // $this->getResponse()->setRedirect($this->getApp(), 'login'); - // $this->getResponse()->doRedirect(); - // return; - // } + self::getHook()->fire(hook::EVENT_APP_MAKEAPPSTACK_ADDED_APP); - if(!$this->handleAccess()) { - return; - } + $parentfile = self::getHomedir($parentapp_data[0], $parentapp_data[1]) . 'config/parent.app'; + } - // perform the main application lifecycle calls - $this->mainRun(); + // one more step to execute - core app itself + $parentapp = app::getParentapp($current_vendor, $current_app); - } catch (\Exception $e) { - // display exception using the current response class - // which may be either http or even CLI ! - if(self::shouldThrowException()) { - throw $e; - } else { - $this->getResponse()->displayException($e); - } + if (strlen($parentapp) > 0) { + $parentapp_data = explode('_', $parentapp); + + $stack[] = [ + 'vendor' => $parentapp_data[0], + 'app' => $parentapp_data[1], + ]; + + self::getHook()->fire(hook::EVENT_APP_MAKEAPPSTACK_ADDED_APP); } - self::getHook()->fire(\codename\core\hook::EVENT_APP_RUN_END); + // we don't need to add the core framework explicitly + // as an 'app', as it is returned by app::getParentapp + // if there's no parent app defined - // fire exit code - if(self::$exitCode !== null) { - exit(self::$exitCode); + // First, we inject app extensions + foreach (self::getExtensions($vendor, $app) as $injectApp) { + array_splice($stack, -1, 0, [$injectApp]); } - return; - } + // Inject apps, if available. + // Those are injected dynamically, e.g., in-app constructor + foreach (self::$injectedApps as $injectApp) { + array_splice($stack, $injectApp['injection_mode'], 0, [$injectApp]); + } + // inject core-ui app before core app, if defined + if (class_exists("\\codename\\core\\ui\\app")) { + $uiApp = [ + 'vendor' => 'codename', + 'app' => 'core-ui', + 'namespace' => '\\codename\\core\\ui', + ]; + array_splice($stack, -1, 0, [$uiApp]); + } - /** - * the main run / main lifecycle (context, action, view, show - output) - */ - protected function mainRun() { - if($this->getContext() instanceof \codename\core\context\customContextInterface) { - $this->doContextRun(); - } else { - $this->doAction()->doView(); - } - $this->doShow()->doOutput(); + return $stack; } /** - * [doContextRun description] - * @return \codename\core\app + * I return the vendor of this app + * @return string + * @throws exception|ReflectionException */ - protected function doContextRun() : \codename\core\app { - $this->getContext()->run(); - return $this; + final public static function getVendor(): string + { + if (is_null(self::$vendor)) { + $appdata = explode('\\', self::getApploader()->get()); + self::$vendor = new methodname($appdata[0]); + } + return self::$vendor->get(); } /** - * the current exit code to be sent after app's execution - * @var int - */ - protected static $exitCode = 0; - - /** - * set an exitcode for the application (on exiting normally) - * @param int $exitCode [description] + * Returns the apploader of this app. + * @return apploader + * @throws ReflectionException + * @throws exception */ - public static function setExitCode(int $exitCode) { - self::$exitCode = $exitCode; + final protected static function getApploader(): apploader + { + if (is_null(self::$apploader)) { + self::$apploader = new apploader((new ReflectionClass(self::$instance))->getNamespaceName()); + } + return self::$apploader; } /** - * Executes the given $context->$view - * @param string $context - * @param string $view - * @return void + * Simple returns the app's name + * @return string + * @throws ReflectionException + * @throws exception */ - public function execute(string $context, string $view) { - $this->getRequest()->setData('context', $context); - $this->getRequest()->setData('view', $view); - $this->doView()->doShow(); - return; + final public static function getApp(): string + { + if (!is_null(self::$app)) { + return self::$app->get(); + } + $appdata = explode('\\', self::getApploader()->get()); + self::$app = new methodname($appdata[1]); + return self::$app->get(); } /** - * Exception thrown if the context configuration is missing in the app.json - * @var string + * returns a custom base namespace, if desired + * (as a starting point for the current app) + * @return string|null */ - const EXCEPTION_MAKEREQUEST_CONTEXT_CONFIGURATION_MISSING = 'EXCEPTION_MAKEREQUEST_CONTEXT_CONFIGURATION_MISSING'; + final public static function getNamespace(): ?string + { + return static::$namespace ?? null; + } /** - * Sets the request arguments - * @throws \codename\core\exception + * Returns the directory where the app must be stored in + * This method relies on the constant CORE_VENDORDIR + * @param string $vendor + * @param string $app + * @return string + * @throws ReflectionException + * @throws exception */ - protected function makeRequest() { - self::getRequest()->setData('context', self::getRequest()->isDefined('context') ? self::getRequest()->getData('context') : self::getConfig()->get('defaultcontext')); - self::getRequest()->setData('view', self::getRequest()->isDefined('view') ? self::getRequest()->getData('view') : self::getConfig()->get('context>' . self::getRequest()->getData('context') . '>defaultview')); - self::getRequest()->setData('action', self::getRequest()->isDefined('action') ? self::getRequest()->getData('action') : null); - - if(self::getConfig()->get('context>' . self::getRequest()->getData('context')) == null) { - throw new \codename\core\exception(self::EXCEPTION_MAKEREQUEST_CONTEXT_CONFIGURATION_MISSING, \codename\core\exception::$ERRORLEVEL_ERROR, self::getRequest()->getData('context')); + final public static function getHomedir(string $vendor = '', string $app = ''): string + { + if (strlen($vendor) == 0) { + $vendor = self::getVendor(); + } + if (strlen($app) == 0) { + $app = self::getApp(); } - if(!self::getConfig()->get('context>' . self::getRequest()->getData('context') . '>custom')) { - if (!$this->viewExists(new \codename\core\value\text\contextname(self::getRequest()->getData('context')), new \codename\core\value\text\viewname(self::getRequest()->getData('view')))) { - throw new \codename\core\exception(self::EXCEPTION_MAKEREQUEST_REQUESTEDVIEWNOTINCONTEXT, \codename\core\exception::$ERRORLEVEL_ERROR, self::getRequest()->getData('view')); - } + $dir = null; + if (($vendor == static::getVendor()) && ($app == static::getApp()) && (static::$homedir)) { + // NOTE: we have to rely on appstack for 'homedir' key... + // this only takes effect, if $homedir static var is explicitly set. + $dir = static::$homedir; + } elseif (static::$appstack !== null) { + // + // traverse appstack + // + foreach (static::getAppstack() as $appEntry) { + if (($appEntry['vendor'] == $vendor) && ($appEntry['app'] == $app)) { + $dir = $appEntry['homedir'] ?? ($appEntry['vendor'] . '/' . $appEntry['app']); + break; + } + } } - if (!$this->getRequest()->isDefined('template')) { - if(self::getConfig()->exists("context>".self::getRequest()->getData('context').">view>".self::getRequest()->getData('view').">template")) { - // view-level template config - $this->getRequest()->setData('template', self::getConfig()->get("context>".self::getRequest()->getData('context').">view>".self::getRequest()->getData('view').">template")); - } else if(self::getConfig()->exists("context>".self::getRequest()->getData('context').">template")) { - // context-level template config - $this->getRequest()->setData('template', self::getConfig()->get("context>".self::getRequest()->getData('context').">template")); - } else { - // app-level template config - $this->getRequest()->setData('template', self::getConfig()->get("defaulttemplate")); + if ($dir === null) { + // DEBUG: + // print_r([$vendor, $app, static::getAppstack()]); + + // NOTE/WARNING: + // We _should_ enable this in the future. + // At the moment, it breaks a lot of scenarios - + // but it might be security-relevant, + // as it prevents out-of-appdir file lookups + // + // Before, we returned a value that is fully based on VENDOR DIR, + // and it simply was "vendor/app". + // now, we really return NULL, just to make sure. + // But this won't be the end of the story. + // + // NOTE: changed back, crashes architect. + // + // throw new \codename\core\exception('EXCEPTION_APP_HOMEDIR_REQUEST_OUT_OF_SCOPE', \codename\core\exception::$ERRORLEVEL_ERROR, [ + // 'vendor' => $vendor, + // 'app' => $app, + // ]); + + // Legacy style: + // $dir = $vendor . '/' . $app; + + // Crashes Architect: + // if($nullFallback) { + // return null; + // } else { + // $dir = $vendor . '/' . $app; + // } + $dir = $vendor . '/' . $app; + } + + // + // Path normalization for comparison + // for multi-platform usage + // + if (DIRECTORY_SEPARATOR == '\\') { + $dirNormalized = str_replace('/', DIRECTORY_SEPARATOR, $dir); + } else { + // normalize vice-versa? + $dirNormalized = $dir; + } + $realpathNormalized = realpath($dir); + + if ($realpathNormalized === $dirNormalized) { + // assume $dir is an absolute path + // NOTE: this is a little hacky and should be improved somehow. + return $dirNormalized . '/'; + } else { + return CORE_VENDORDIR . $dirNormalized . '/'; + } + } + + /** + * Returns the name of the parent app if it was specified in the app configuration. Returns core otherwise + * @param string $vendor + * @param string $app + * @return string + * @throws ReflectionException + * @throws exception + */ + final public static function getParentapp(string $vendor = '', string $app = ''): string + { + $path = self::getHomedir($vendor, $app) . 'config/parent.app'; + + if (!self::getInstance('filesystem_local')->fileAvailable($path)) { + return 'codename_core'; + } + + return trim(self::getInstance('filesystem_local')->fileRead($path)); + } + + /** + * get extensions for a given vendor/app + * @param string $vendor [description] + * @param string $app [description] + * @return array + * @throws ReflectionException + * @throws exception + */ + protected static function getExtensions(string $vendor, string $app): array + { + $appJson = self::getHomedir($vendor, $app) . 'config/app.json'; + if (self::getInstance('filesystem_local')->fileAvailable($appJson)) { + $json = new json($appJson, false, false); + $extensions = $json->get('extensions'); + if ($extensions !== null) { + $extensionParameters = []; + foreach ($extensions as $ext) { + $class = '\\' . str_replace('_', '\\', $ext) . '\\extension'; + if (class_exists($class) && (new ReflectionClass($class))->isSubclassOf('\\codename\\core\\extension')) { + $extension = new $class(); + $extensionParameters[] = $extension->getInjectParameters(); + } else { + throw new exception('CORE_APP_EXTENSION_COULD_NOT_BE_LOADED', exception::$ERRORLEVEL_FATAL, $ext); + } + } + return $extensionParameters; } } - return; + return []; + } + + /** + * set an exitcode for the application (on exiting normally) + * @param int $exitCode [description] + */ + public static function setExitCode(int $exitCode): void + { + self::$exitCode = $exitCode; } /** * Convert an array to an object. Updated the original (LINK) to work recursively * @see http://stackoverflow.com/questions/1869091/how-to-convert-an-array-to-object-in-php - * @param mixed|object|null $object + * @param mixed|object|null $object * @return array */ - public static function object2array($object) : array { - if($object === null) { - return array(); + public static function object2array(mixed $object): array + { + if ($object === null) { + return []; } - $array = array(); + $array = []; foreach ($object as $key => $value) { - if(( (array) $value === $value ) || is_object($value)) { + if (((array)$value === $value) || is_object($value)) { $array[$key] = self::object2array($value); } else { $array[$key] = $value; @@ -597,1102 +940,938 @@ public static function object2array($object) : array { * returns true if the current php process is being run from a command line interface. * @return bool */ - public static function isCli() : bool { + public static function isCli(): bool + { return php_sapi_name() === 'cli'; } /** - * Transllates the given key. If there's no PERIOD in the key, the function will use APP.$CONTEXT_$VIEW as prefix automatically + * Translates the given key. If there's no PERIOD in the key, the function will use APP.$CONTEXT_$VIEW as prefix automatically * @param string $key * @return string + * @throws ReflectionException + * @throws exception */ - public static function translate(string $key) : string { - if(strpos($key, '.') == false) { + public static function translate(string $key): string + { + if (!strpos($key, '.')) { $key = strtoupper("APP." . self::getRequest()->getData('context') . '_' . self::getRequest()->getData('view') . '_' . $key); } return self::getTranslate()->translate($key); } - /** - * Simple returns the app's name - * @return string - */ - final public static function getApp() : string { - if(!is_null(self::$app)) { - return self::$app->get(); - } - $appdata = explode('\\', self::getApploader()->get()); - self::$app = new \codename\core\value\text\methodname($appdata[1]); - // CHANGED 2021-09-21: disabled availability checking - // as it enforces a race condition when overrides exist - // if (!self::getInstance('filesystem_local')->dirAvailable(self::getHomedir())) { - // throw new \codename\core\exception(self::EXCEPTION_GETAPP_APPFOLDERNOTFOUND, \codename\core\exception::$ERRORLEVEL_FATAL, self::getHomedir()); - // } - return self::$app->get(); - } - - /** - * Returns the current environment identifier - * @throws exception - * @return string - */ - final public static function getEnv() : string { - if(!defined("CORE_ENVIRONMENT")) { - $env = getenv('CORE_ENVIRONMENT'); - if(!$env) { - // - // We have to die() here - // as Exception Throwing+Displaying needs the environment to be defined. - // - echo("CORE_ENVIRONMENT not defined."); - die(); - } - define('CORE_ENVIRONMENT', $env); - } - return strtolower(CORE_ENVIRONMENT); - } - /** * - * @return \codename\core\config - */ - final public static function getEnvironment() : \codename\core\config { - if(is_null(self::$environment)) { - self::$environment = new \codename\core\config\json(self::$json_environment, true, true); - } - return self::$environment; - } - - /** - * Returns the configuration object of the application - * @return \codename\core\config + * {@inheritDoc} + * @param string $type + * @param string $key + * @return mixed + * @throws ReflectionException + * @throws exception + * @see app_interface::getData, $key) */ - final public static function getConfig() : \codename\core\config { - if(is_null(self::$config)) { - - // Pre-construct cachegroup - $cacheGroup = 'APPCONFIG'; - $cacheKey = self::getVendor().'_'.self::getApp(); - - if($finalConfig = self::getCache()->get($cacheGroup, $cacheKey)) { - self::$config = new \codename\core\config($finalConfig); - return self::$config; - } + final public static function getData(string $type, string $key): mixed + { + $env = self::getEnv(); - $config = (new \codename\core\config\json(self::$json_config))->get(); + // Get the value first, regardless of success. + $value = self::getEnvironment()->get("$env>" . $type . ">" . $key); - if(!array_key_exists('context', $config)) { - throw new \codename\core\exception(self::EXCEPTION_GETCONFIG_APPCONFIGCONTAINSNOCONTEXT, \codename\core\exception::$ERRORLEVEL_FATAL, self::$json_config); + // If we detect something irregular, dig deeper: + if ($value == null) { + if (!self::getEnvironment()->exists("$env")) { + throw new exception(self::EXCEPTION_GETDATA_CURRENTENVIRONMENTNOTFOUND, exception::$ERRORLEVEL_ERROR, $type); } - // - // NOTE: Extension injection moved to appstack building - // - // if(array_key_exists('extensions', $config)) { - // foreach($config['extensions'] as $ext) { - // $class = '\\' . str_replace('_', '\\', $ext) . '\\extension'; - // if(class_exists($class) && (new \ReflectionClass($class))->isSubclassOf('\\codename\\core\\extension')) { - // $extension = new $class(); - // self::injectApp($extension->getInjectParameters()); - // } else { - // throw new exception('CORE_APP_EXTENSION_COULD_NOT_BE_LOADED', exception::$ERRORLEVEL_FATAL, $ext); - // } - // } - // // re-build appstack? - // self::makeCurrentAppstack(); - // } - - // Testing: Adding the default (install) context - // TODO: Filepath-beautify - // Using appstack=true ! - $default = (new \codename\core\config\json("/config/app.json", true, true))->get(); - $config = utils::array_merge_recursive_ex($config,$default); - - - foreach ($config['context'] as $key => $value) { - if(!array_key_exists('type', $value)) { - continue; - } - $contexttype = (new \codename\core\config\json('config/context/' . $config['context'][$key]['type'] . '.json', true))->get(); - - if(count($errors = static::getValidator('structure_config_context')->reset()->validate($contexttype)) > 0) { - throw new \codename\core\exception(self::EXCEPTION_GETCONFIG_APPCONFIGCONTEXTTYPEINVALID, \codename\core\exception::$ERRORLEVEL_FATAL, $errors); - } - - $config['context'][$key] = utils::array_merge_recursive_ex($config['context'][$key], $contexttype); - - if(is_array($config['context'][$key]['defaultview']) && count($config['context'][$key]['defaultview']) > 1) { - $config['context'][$key]['defaultview'] = $config['context'][$key]['defaultview'][0]; - } + if (!self::getEnvironment()->exists("$env>" . $type)) { + throw new exception(self::EXCEPTION_GETDATA_REQUESTEDTYPENOTFOUND, exception::$ERRORLEVEL_ERROR, $type); } - if (count($errors = static::getValidator('structure_config_app')->reset()->validate($config)) > 0) { - throw new \codename\core\exception(self::EXCEPTION_GETCONFIG_APPCONFIGFILEINVALID, \codename\core\exception::$ERRORLEVEL_FATAL, $errors); + if (!self::getEnvironment()->exists("$env>" . $type . ">" . $key)) { + throw new exception(self::EXCEPTION_GETDATA_REQUESTEDKEYINTYPENOTFOUND, exception::$ERRORLEVEL_ERROR, ['type' => $type, 'key' => $key]); } - - self::$config = new \codename\core\config($config); - - self::getCache()->set($cacheGroup, $cacheKey, self::$config->get()); + } else { + return $value; } - return self::$config; + return null; } - /** - * I return the vendor of this app - * @return string + * + * @return config + * @throws ReflectionException + * @throws exception */ - final public static function getVendor() : string { - if(is_null(self::$vendor)) { - $appdata = explode('\\', self::getApploader()->get()); - self::$vendor = new \codename\core\value\text\methodname($appdata[0]); + final public static function getEnvironment(): config + { + if (is_null(self::$environment)) { + self::$environment = new json(self::$json_environment, true, true); } - return self::$vendor->get(); + return self::$environment; } /** - * returns a custom base namespace, if desired - * (as a starting point for the current app) - * @return string|null + * Returns the translator instance configured as $identifier + * @param string $identifier + * @return translate + * @throws ReflectionException + * @throws exception */ - final public static function getNamespace() : ?string { - return static::$namespace ?? null; + final public static function getTranslate(string $identifier = 'default'): translate + { + $object = self::getClient('translate', $identifier); + if ($object instanceof translate) { + return $object; + } + throw new exception('EXCEPTION_GETTRANSLATE_WRONG_OBJECT', exception::$ERRORLEVEL_FATAL); } /** - * overridden base namespace - * @var string - */ - protected static $namespace = null; - - /** - * App's home dir - null by default - * set to override. - * @var string|null + * Returns the (maybe cached) client stored as "driver" in $identifier (app.json) for the given $type. + * @param string $type + * @param string $identifier + * @param bool $store + * @return object + * @throws ReflectionException + * @throws exception */ - protected static $homedir = null; + final public static function getClient(string $type, string $identifier, bool $store = true): object + { + $simplename = $type . $identifier; - /** - * Returns the appstack of the instance. Can be used to load files by their existance (not my app? -> parent app? -> parent's parent...) - * @return array - */ - final public static function getAppstack() : array { - if(self::$appstack == null) { - self::makeCurrentAppstack(); + if ($store && array_key_exists($simplename, $_REQUEST['instances'])) { + return $_REQUEST['instances'][$simplename]; } - return self::$appstack->get(); - } - /** - * - * {@inheritDoc} - * @see \codename\core\app_interface::getData($type, $key) - */ - final public static function getData(string $type, string $identifier) { - $env = self::getenv(); + $config = self::getData($type, $identifier); - // Get the value first, regardless of success. - $value = self::getEnvironment()->get("$env>".$type.">".$identifier); + $app = array_key_exists('app', $config) ? $config['app'] : self::getApp(); + $vendor = self::getVendor(); - // If we detect something irregular, dig deeper: - if($value == NULL) { - if (!self::getEnvironment()->exists("$env")) { - throw new \codename\core\exception(self::EXCEPTION_GETDATA_CURRENTENVIRONMENTNOTFOUND, \codename\core\exception::$ERRORLEVEL_ERROR, $type); - } - - if (!self::getEnvironment()->exists("$env>".$type)) { - throw new \codename\core\exception(self::EXCEPTION_GETDATA_REQUESTEDTYPENOTFOUND, \codename\core\exception::$ERRORLEVEL_ERROR, $type); - } - - if (!self::getEnvironment()->exists("$env>".$type.">".$identifier)) { - throw new \codename\core\exception(self::EXCEPTION_GETDATA_REQUESTEDKEYINTYPENOTFOUND, \codename\core\exception::$ERRORLEVEL_ERROR, array('type' => $type, 'key' => $identifier)); - } - } else { - return $value; + if (is_array($config['driver'])) { + $config['driver'] = $config['driver'][0]; } - } - /** - * Returns the directory where the app must be stored in - *
This method relies on the constant CORE_VENDORDIR - * @param string $vendor - * @param string $app - * @return string - */ - final public static function getHomedir(string $vendor = '', string $app = '') : string { - if(strlen($vendor) == 0) {$vendor = self::getVendor();} - if(strlen($app) == 0) {$app = self::getApp(); } + $classname = "\\$vendor\\$app\\$type\\" . $config['driver']; - $dir = null; - if(($vendor == static::getVendor()) && ($app == static::getApp()) && (static::$homedir)) { - // NOTE: we have to rely on appstack for 'homedir' key... - // this only takes effect, if $homedir static var is explicitly set. - $dir = static::$homedir; // ?? ($vendor . '/' . $app); - } else { - // Check for appstack being set - // this prevents a recursion (during env init/config loading) - if(static::$appstack === null) { - $dir = null; - } else { - // - // traverse appstack - // - foreach(static::getAppstack() as $appEntry) { - if(($appEntry['vendor'] == $vendor) && ($appEntry['app'] == $app)) { - $dir = $appEntry['homedir'] ?? ($appEntry['vendor'] . '/' . $appEntry['app']); - break; - } + // if not found in app, traverse appstack + if (!class_exists($classname)) { + $found = false; + foreach (self::getAppstack() as $parentapp) { + $vendor = $parentapp['vendor']; + $app = $parentapp['app']; + $namespace = $parentapp['namespace'] ?? "\\$vendor\\$app"; + $classname = $namespace . "\\$type\\" . $config['driver']; + + if (class_exists($classname)) { + $found = true; + break; + } + } + + if ($found !== true) { + throw new exception(self::EXCEPTION_GETCLIENT_NOTFOUND, exception::$ERRORLEVEL_FATAL, [$type, $identifier]); } - } - - } - - if($dir === null) { - // DEBUG: - // print_r([$vendor, $app, static::getAppstack()]); - - // NOTE/WARNING: - // We _should_ enable this in the future. - // At the moment, it breaks a lot of scenarious - - // but it might be security-relevant, - // as it prevents out-of-appdir file lookups - // - // Before, we returned a value that is fully based on VENDOR DIR - // and it simply was "vendor/app". - // now, we really return NULL, just to make sure. - // But this won't be the end of the story. - // - // NOTE: changed back, crashes architect. - // - // throw new \codename\core\exception('EXCEPTION_APP_HOMEDIR_REQUEST_OUT_OF_SCOPE', \codename\core\exception::$ERRORLEVEL_ERROR, [ - // 'vendor' => $vendor, - // 'app' => $app, - // ]); - - // Legacy style: - // $dir = $vendor . '/' . $app; - - // Crashes Architect: - // if($nullFallback) { - // return null; - // } else { - // $dir = $vendor . '/' . $app; - // } - $dir = $vendor . '/' . $app; } - // - // Path normalization for comparison - // for multi-platform usage - // - if(DIRECTORY_SEPARATOR == '\\') { - $dirNormalized = str_replace('/', DIRECTORY_SEPARATOR, $dir); - $realpathNormalized = realpath($dir); - } else { - // normalize vice-versa? - $dirNormalized = $dir; - $realpathNormalized = realpath($dir); + // instance + $instance = new $classname($config); + + // make its own name public to the client itself + if ($instance instanceof clientInterface) { + $instance->setClientName($simplename); } - if($realpathNormalized === $dirNormalized) { - // assume $dir is an absolute path - // NOTE: this is a little bit hacky and should be improved somehow. - return $dirNormalized . '/'; + if ($store) { + return $_REQUEST['instances'][$simplename] = $instance; } else { - return CORE_VENDORDIR . $dirNormalized . '/'; + return $instance; } } /** - * Get path of file (in APP dir OR in core dir) if it exists there - throws error if neither - * @param string $file - * @param array|null $useAppstack [whether to use a specific appstack, defaults to the current one] + * Get a path of file (in APP dir OR in core dir) if it exists there - throws error if neither + * @param string $file + * @param array|null $useAppstack [whether to use a specific appstack, defaults to the current one] * @return string + * @throws ReflectionException + * @throws exception */ - final public static function getInheritedPath(string $file, ?array $useAppstack = null) : string { + final public static function getInheritedPath(string $file, ?array $useAppstack = null): string + { $filename = self::getHomedir() . $file; - if(self::getInstance('filesystem_local')->fileAvailable($filename)) { + if (self::getInstance('filesystem_local')->fileAvailable($filename)) { return $filename; } - if($useAppstack == null) { - $useAppstack = self::getAppstack(); + if ($useAppstack == null) { + $useAppstack = self::getAppstack(); } - foreach($useAppstack as $parentapp) { + foreach ($useAppstack as $parentapp) { $vendor = $parentapp['vendor']; $app = $parentapp['app']; $dir = static::getHomedir($vendor, $app); $filename = $dir . $file; - if(self::getInstance('filesystem_local')->fileAvailable($filename)) { + if (self::getInstance('filesystem_local')->fileAvailable($filename)) { return $filename; } } - throw new \codename\core\exception(self::EXCEPTION_GETINHERITEDPATH_FILENOTFOUND, \codename\core\exception::$ERRORLEVEL_FATAL, $file); + throw new exception(self::EXCEPTION_GETINHERITEDPATH_FILENOTFOUND, exception::$ERRORLEVEL_FATAL, $file); } /** - * Returns the name of the parent app if it was specified in the app configuration. Returns core otherwise - * @param string $vendor - * @param string $app - * @return string + * Returns a db instance configured as $identifier + * @param string $identifier + * @param bool $store [store the database connection] + * @return database + * @throws ReflectionException + * @throws exception */ - final public static function getParentapp(string $vendor = '', string $app = '') : string { - $path = self::getHomedir($vendor, $app) . 'config/parent.app'; - - if(!self::getInstance('filesystem_local')->fileAvailable($path)) { - return 'codename_core'; + final public static function getDb(string $identifier = 'default', bool $store = true): database + { + $object = self::getClient('database', $identifier, $store); + if ($object instanceof database) { + return $object; } - - return trim(self::getInstance('filesystem_local')->fileRead($path)); + throw new exception('EXCEPTION_GETDB_WRONG_OBJECT', exception::$ERRORLEVEL_FATAL); } /** - * Returns an the db instance that is configured as $identifier - * @param string $identifier - * @param bool $store [store the database connection] - * @return \codename\core\database + * Returns the session instance configured as $identifier + * @param string $identifier + * @return session + * @throws ReflectionException + * @throws exception */ - final public static function getDb(string $identifier = 'default', bool $store = true) : \codename\core\database { - return self::getClient('database', $identifier, $store); + final public static function getSession(string $identifier = 'default'): session + { + $object = self::getClient('session', $identifier); + if ($object instanceof session) { + return $object; + } + throw new exception('EXCEPTION_GETSESSION_WRONG_OBJECT', exception::$ERRORLEVEL_FATAL); } /** - * @var \codename\core\value\text\objectidentifier[]] - */ - protected static $dbValueObjectidentifierArray = array(); - - /** - * @var \codename\core\value\text\objecttype - */ - protected static $dbValueObjecttype = NULL; - - /** - * Returns the auth instance that is configured as $identifier + * Returns a mailer client. Uses the client identified by 'default' when $identifier is not passed in * @param string $identifier - * @return \codename\core\auth + * @return mail + * @throws ReflectionException + * @throws exception */ - final public static function getAuth(string $identifier = 'default') : \codename\core\auth { - return self::getClient('auth', $identifier); + final public static function getMailer(string $identifier = 'default'): mail + { + $object = self::getClient('mail', $identifier, false); + if ($object instanceof mail) { + return $object; + } + throw new exception('EXCEPTION_GETMAILER_WRONG_OBJECT', exception::$ERRORLEVEL_FATAL); } /** - * Returns the translator instance that is configured as $identifier + * Returns the bucket masked by the given $identifier * @param string $identifier - * @return \codename\core\translate + * @return bucket + * @throws ReflectionException + * @throws exception */ - final public static function getTranslate(string $identifier = 'default') : \codename\core\translate { - return self::getClient('translate', $identifier); + final public static function getBucket(string $identifier): bucket + { + $object = self::getClient('bucket', $identifier); + if ($object instanceof bucket) { + return $object; + } + throw new exception('EXCEPTION_GETBUCKET_WRONG_OBJECT', exception::$ERRORLEVEL_FATAL); } /** - * @var \codename\core\value\text\objectidentifier[]] + * Returns an instance of the requested queue + * @param string $identifier + * @return queue + * @throws ReflectionException + * @throws exception */ - protected static $translateValueObjectidentifierArray = array(); + final public static function getQueue(string $identifier = 'default'): queue + { + $object = self::getClient('queue', $identifier); + if ($object instanceof queue) { + return $object; + } + throw new exception('EXCEPTION_GETQUEUE_WRONG_OBJECT', exception::$ERRORLEVEL_FATAL); + } /** - * @var \codename\core\value\text\objecttype + * Returns the current app instance + * @return app */ - protected static $translateValueObjecttype = NULL; + final public static function getMyInstance(): app + { + return self::$instance; + } /** - * Returns the cache instance that is configured as $identifier - * @param string $identifier - * @return \codename\core\cache + * Creates a value object of a specific type and using the given value + * @param string $type [description] + * @param mixed|null $value [description] + * @return value [description] + * @throws ReflectionException + * @throws exception */ - final public static function getCache(string $identifier = 'default') : \codename\core\cache { - return self::getClient('cache', $identifier); + final public static function getValueobject(string $type, mixed $value): value + { + $classname = self::getInheritedClass('value\\' . $type); + return new $classname($value); } /** - * @var \codename\core\value\text\objectidentifier[]] + * Includes the requested $file in a separate output buffer and returns the content, after parsing $data to it + * @param string $file + * @param object|array|null $data + * @return string + * @throws ReflectionException + * @throws exception */ - protected static $cacheValueObjectidentifierArray = array(); + final public static function parseFile(string $file, object|array|null $data = null): string + { + if (!self::getInstance('filesystem_local')->fileAvailable($file)) { + throw new exception(self::EXCEPTION_PARSEFILE_TEMPLATENOTFOUND, exception::$ERRORLEVEL_ERROR, $file); + } - /** - * @var \codename\core\value\text\objecttype - */ - protected static $cacheValueObjecttype = NULL; + ob_start(); + require $file; + return ob_get_clean(); + } /** - * Returns the session instance that is configured as $identifier - * @param string $identifier - * @return \codename\core\session + * Injects an app, optionally with an injection mode (the place where it goes in the appstack) + * @param array $injectApp [array/object containing the app identifiers] + * @param int $injectionMode [defaults to INJECT_APP_BASE] + * @throws exception */ - final public static function getSession(string $identifier = 'default') : \codename\core\session { - return self::getClient('session', $identifier); + final protected static function injectApp(array $injectApp, int $injectionMode = self::INJECT_APP_BASE): void + { + if (isset($injectApp['vendor']) && isset($injectApp['app']) && isset($injectApp['namespace'])) { + $identifier = $injectApp['vendor'] . '#' . $injectApp['app'] . '#' . $injectApp['namespace']; + // Prevent double-injecting the apps + if (!in_array($identifier, self::$injectedAppIdentifiers)) { + $injectApp['injection_mode'] = $injectionMode; + self::$injectedApps[] = $injectApp; + self::$injectedAppIdentifiers[] = $identifier; + } + } else { + throw new exception("EXCEPTION_APP_INJECTAPP_CANNOT_INJECT_APP", exception::$ERRORLEVEL_FATAL, $injectApp); + } } /** - * @var \codename\core\value\text\objectidentifier[]] + * + * {@inheritDoc} + * @throws ReflectionException + * @throws exception + * @see app_interface::run */ - protected static $sessionValueObjectidentifierArray = array(); + public function run(): void + { + self::getHook()->fire(hook::EVENT_APP_RUN_START); + self::getLog('debug')->debug('CORE_BACKEND_CLASS_APP_RUN::START - ' . json_encode(self::getRequest()->getData())); + self::getLog('access')->info(json_encode(static::getRequest()->getData())); - /** - * @var \codename\core\value\text\objecttype - */ - protected static $sessionValueObjecttype = NULL; + // Warning: + // "Chicken or the egg" problem. + // We have to call $this->makeRequest(); + // Before we're using app::getResponse(); + // -- + // originally, we set APP-SRV header here. + // This has been moved to the core response class. - /** - * Returns a log client. Uses the client identified by 'default' when $identifier is not passed in - * @param string $identifier - * @return \codename\core\log - */ - final public static function getLog(string $identifier = 'default') : \codename\core\log { - return self::getSingletonClient('log', $identifier); - } + try { + $this->makeRequest(); + } catch (\Exception $e) { + if (self::shouldThrowException()) { + throw $e; + } else { + static::getResponse()->displayException($e); + } + } - /** - * [protected description] - * @var \codename\core\log[] - */ - protected static $logInstance = []; + self::getHook()->fire(hook::EVENT_APP_RUN_MAIN); - /** - * @var \codename\core\value\text\objectidentifier[]] - */ - protected static $logValueObjectidentifierArray = array(); + try { + if (!$this->handleAccess()) { + return; + } - /** - * @var \codename\core\value\text\objecttype - */ - protected static $logValueObjecttype = NULL; + // perform the main application lifecycle calls + $this->mainRun(); + } catch (\Exception $e) { + // display exception using the current response class + // which may be either http or even CLI! + if (self::shouldThrowException()) { + throw $e; + } else { + static::getResponse()->displayException($e); + } + } + self::getHook()->fire(hook::EVENT_APP_RUN_END); - /** - * Returns a filesystem client. Uses the client identified by 'default' when $identifier is not passed in - * @param string $identifier - * @return \codename\core\filesystem - */ - final public static function getFilesystem(string $identifier = 'local') : \codename\core\filesystem { - return self::getClient('filesystem', $identifier); + // fire exit code + if (self::$exitCode !== null) { + exit(self::$exitCode); + } } /** - * Returns a mailer client. Uses the client identified by 'default' when $identifier is not passed in + * Returns a log client. Uses the client identified by 'default' when $identifier is not passed in * @param string $identifier - * @return \codename\core\mail - */ - final public static function getMailer(string $identifier = 'default') : \codename\core\mail { - return self::getClient('mail', $identifier, false); - } - - /** - * Returns the app's instance of \codename\core\hook for event firing - * @return \codename\core\hook + * @return log + * @throws ReflectionException + * @throws exception */ - final public static function getHook() : \codename\core\hook { - if(self::$hook === null) { - self::$hook = \codename\core\hook::getInstance(); + final public static function getLog(string $identifier = 'default'): log + { + $object = self::getSingletonClient('log', $identifier); + if ($object instanceof log) { + return $object; } - return self::$hook; + throw new exception('EXCEPTION_GETLOG_WRONG_OBJECT', exception::$ERRORLEVEL_FATAL); } /** - * Returns the bucket masked by the given $identifier + * Returns the (maybe cached) client stored as "driver" in $identifier (app.json) for the given $type. + * @param string $type * @param string $identifier - * @return \codename\core\bucket + * @param bool $store [whether to try to retrieve instance, if already initialized/cached] + * @return object + * @throws ReflectionException + * @throws exception */ - final public static function getBucket(string $identifier) : \codename\core\bucket { - return self::getClient('bucket', $identifier); - } + final protected static function getSingletonClient(string $type, string $identifier, bool $store = true): object + { + // make simplename for storing instance + $simplename = $type . $identifier; - /** - * Returns an instance of the requested queue - * @param string $identifier - * @return \codename\core\queue - */ - final public static function getQueue(string $identifier = 'default') : \codename\core\queue { - return self::getClient('queue', $identifier); - } + // check if already instanced + if ($store && array_key_exists($simplename, $_REQUEST['instances'])) { + return $_REQUEST['instances'][$simplename]; + } - /** - * Returns the current app instance - * @return \codename\core\app - */ - final public static function getMyInstance() : \codename\core\app { - return self::$instance; - } + $config = self::getData($type, $identifier); - /** - * Returns an instance of $class. It will be cached to the current $_request scope to increase performance. - * @param string $class name of the class to load - * @param array|null $config config to be used [WARNING: if already initialized/used, config is not being overridden!] - * @return object - */ - final public static function getInstance(string $class, ?array $config = null ) { - $simplename = str_replace('\\', '', $class); - if(array_key_exists($simplename, self::$instances)) { - return self::$instances[$simplename]; - } + // Load client information - // $class = "\\codename\\core\\" . str_replace('_', '\\', $class); - $class = self::getInheritedClass($class); + // Maybe overwrite data + $app = self::getApp(); + $vendor = self::getVendor(); - if (!is_null($config)) { - return self::$instances[$simplename] = new $class($config); + if (array_key_exists('app', $config)) { + $app = $config['app']; } - return self::$instances[$simplename] = new $class(); - } - /** - * Creates a value object of a specific type and using the given value - * @param string $type [description] - * @param mixed|null $value [description] - * @return value [description] - */ - final public static function getValueobject(string $type, $value) : value { - $classname = self::getInheritedClass('value\\' . $type); - return new $classname($value); - } + // Check classpath and name in the current app + if (is_array($config['driver'])) { + $config['driver'] = $config['driver'][0]; + } - /** - * Loads an instance of the given validator type and returns it. - * @param string $type Type of the validator - * @return validator - * @todo validate if datatype exists - */ - final public static function getValidator(string $type) : validator { - if(!array_key_exists($type, self::$validatorCacheArray)) { - $classname = self::getInheritedClass('validator\\' . $type); - self::$validatorCacheArray[$type] = new $classname(); + $classpath = self::getHomedir($vendor, $app) . '/backend/class/' . $type . '/' . $config['driver'] . '.php'; + $classname = "\\$vendor\\$app\\$type\\" . $config['driver']; + + // if not found in app, use the core app + if (!self::getInstance('filesystem_local')->fileAvailable($classpath)) { + $app = 'core'; + $vendor = 'codename'; + $classname = "\\$vendor\\$app\\$type\\" . $config['driver']; } - return self::$validatorCacheArray[$type]; - } - /** - * @var array - */ - protected static $validatorCacheArray = array(); + // instance + $_REQUEST['instances'][$simplename] = call_user_func($classname . '::getInstance', $config); + return $_REQUEST['instances'][$simplename]; + } /** - * Returns the name of the given $class name from the lowest available application's source. - * @param string $classname - * @throws \codename\core\exception - * @return string + * Sets the request arguments + * @throws exception|ReflectionException */ - public final static function getInheritedClass(string $classname) : string { - $classname = str_replace('_', '\\', $classname); + protected function makeRequest(): void + { + self::getRequest()->setData('context', self::getRequest()->isDefined('context') ? self::getRequest()->getData('context') : self::getConfig()->get('defaultcontext')); + self::getRequest()->setData('view', self::getRequest()->isDefined('view') ? self::getRequest()->getData('view') : self::getConfig()->get('context>' . self::getRequest()->getData('context') . '>defaultview')); + self::getRequest()->setData('action', self::getRequest()->isDefined('action') ? self::getRequest()->getData('action') : null); - if(is_null(self::$appstack)) { - return "\\codename\\core\\" . $classname; + if (self::getConfig()->get('context>' . self::getRequest()->getData('context')) == null) { + throw new exception(self::EXCEPTION_MAKEREQUEST_CONTEXT_CONFIGURATION_MISSING, exception::$ERRORLEVEL_ERROR, self::getRequest()->getData('context')); } - foreach(self::getAppstack() as $parentapp) { - // CHANGED 2021-08-16: purely rely on namespace/autoloading for inherited classes - $namespace = $parentapp['namespace'] ?? ('\\' . $parentapp['vendor'] . '\\' . $parentapp['app']); - $class = $namespace . '\\' . $classname; - - if(class_exists($class)) { - return $class; + if (!self::getConfig()->get('context>' . self::getRequest()->getData('context') . '>custom')) { + if (!$this->viewExists(new contextname(self::getRequest()->getData('context')), new viewname(self::getRequest()->getData('view')))) { + throw new exception(self::EXCEPTION_MAKEREQUEST_REQUESTEDVIEWNOTINCONTEXT, exception::$ERRORLEVEL_ERROR, self::getRequest()->getData('view')); } } - throw new \codename\core\exception(self::EXCEPTION_GETINHERITEDCLASS_CLASSFILENOTFOUND, \codename\core\exception::$ERRORLEVEL_FATAL, $classname); - } - - /** - * Includes the requested $file into a separate output buffer and returns the content, after parsing $data to it - * @param string $file - * @param object $data - * @return string - */ - final public static function parseFile(string $file, $data = null) : string { - if (!self::getInstance('filesystem_local')->fileAvailable($file)) { - throw new \codename\core\exception(self::EXCEPTION_PARSEFILE_TEMPLATENOTFOUND, \codename\core\exception::$ERRORLEVEL_ERROR, $file); + if (!static::getRequest()->isDefined('template')) { + if (self::getConfig()->exists("context>" . self::getRequest()->getData('context') . ">view>" . self::getRequest()->getData('view') . ">template")) { + // view-level template config + static::getRequest()->setData('template', self::getConfig()->get("context>" . self::getRequest()->getData('context') . ">view>" . self::getRequest()->getData('view') . ">template")); + } elseif (self::getConfig()->exists("context>" . self::getRequest()->getData('context') . ">template")) { + // context-level template config + static::getRequest()->setData('template', self::getConfig()->get("context>" . self::getRequest()->getData('context') . ">template")); + } else { + // app-level template config + static::getRequest()->setData('template', self::getConfig()->get("defaulttemplate")); + } } - - ob_start(); - require $file; - return ob_get_clean(); } /** - * Writes a log entry into the activitystream model. - * @param string $action - * @param string|null $model - * @param array|null $info - * @param string $level - * @return void + * Returns the configuration object of the application + * @return config + * @throws ReflectionException + * @throws exception */ - final public static function writeActivity(string $action, ?string $model = null, $info = null, string $level = 'INFO') { - /* self::getModel('activitystream')->save(array( - 'entry_app' => self::getInstance('request')->getData('app'), - 'entry_userid' => app::getSession()->getData('user_id'), - 'entry_action' => $action, - 'entry_model' => $model, - 'entry_info' => json_encode($info), - 'entry_level' => $level - )); */ - return; - } + final public static function getConfig(): config + { + if (is_null(self::$config)) { + // Pre-construct cachegroup + $cacheGroup = 'APPCONFIG'; + $cacheKey = self::getVendor() . '_' . self::getApp(); - /** - * Tries to perform the action if it was set - * @return \codename\core\app - */ - protected function doAction() : \codename\core\app { - $action = $this->getRequest()->getData('action'); - if (is_null($action)) { - return $this; - } + if ($finalConfig = self::getCache()->get($cacheGroup, $cacheKey)) { + self::$config = new config($finalConfig); + return self::$config; + } - if(count($errors = static::getValidator('text_methodname')->reset()->validate($action)) > 0) { - throw new \codename\core\exception(self::EXCEPTION_DOACTION_ACTIONNAMEISINVALID, \codename\core\exception::$ERRORLEVEL_FATAL, $errors); - } + $config = (new json(self::$json_config))->get(); - if (!$this->actionExists(new \codename\core\value\text\contextname($this->getRequest()->getData('context')), new \codename\core\value\text\actionname($action))) { - throw new \codename\core\exception(self::EXCEPTION_DOACTION_ACTIONNOTFOUNDINCONTEXT, \codename\core\exception::$ERRORLEVEL_NORMAL, $action); - } + if (!array_key_exists('context', $config)) { + throw new exception(self::EXCEPTION_GETCONFIG_APPCONFIGCONTAINSNOCONTEXT, exception::$ERRORLEVEL_FATAL, self::$json_config); + } - $action = "action_{$action}"; + // Testing: Adding the default (install) context + // TODO: Filepath-beautify + // Using appstack=true ! + $default = (new json("/config/app.json", true, true))->get(); + $config = utils::array_merge_recursive_ex($config, $default); - if (!method_exists($this->getContext(), $action)) { - throw new \codename\core\exception(self::EXCEPTION_DOACTION_REQUESTEDACTIONFUNCTIONNOTFOUND, \codename\core\exception::$ERRORLEVEL_ERROR, $action); - } - $this->getContext()->$action(); + foreach ($config['context'] as $key => $value) { + if (!array_key_exists('type', $value)) { + continue; + } + $contexttype = (new json('config/context/' . $config['context'][$key]['type'] . '.json', true))->get(); - return $this; - } + if (count($errors = static::getValidator('structure_config_context')->reset()->validate($contexttype)) > 0) { + throw new exception(self::EXCEPTION_GETCONFIG_APPCONFIGCONTEXTTYPEINVALID, exception::$ERRORLEVEL_FATAL, $errors); + } - /** - * Tries to call the function that belongs to the view - * @return app - */ - protected function doView() : \codename\core\app { - $view = $this->getRequest()->getData('view'); + $config['context'][$key] = utils::array_merge_recursive_ex($config['context'][$key], $contexttype); - if(count($errors = static::getValidator('text_methodname')->reset()->validate($view)) > 0) { - throw new \codename\core\exception(self::EXCEPTION_DOVIEW_VIEWNAMEISINVALID, \codename\core\exception::$ERRORLEVEL_FATAL, $errors); - } + if (is_array($config['context'][$key]['defaultview']) && count($config['context'][$key]['defaultview']) > 1) { + $config['context'][$key]['defaultview'] = $config['context'][$key]['defaultview'][0]; + } + } - $viewMethod = "view_{$view}"; + if (count($errors = static::getValidator('structure_config_app')->reset()->validate($config)) > 0) { + throw new exception(self::EXCEPTION_GETCONFIG_APPCONFIGFILEINVALID, exception::$ERRORLEVEL_FATAL, $errors); + } - if (!method_exists($this->getContext(), $viewMethod)) { - throw new \codename\core\exception(self::EXCEPTION_DOVIEW_VIEWFUNCTIONNOTFOUNDINCONTEXT, \codename\core\exception::$ERRORLEVEL_ERROR, $viewMethod); - } + self::$config = new config($config); - if(app::getConfig()->exists('context>' . $this->getRequest()->getData('context') . '>view>'.$view.'>_security>group')) { - if(!app::getAuth()->memberOf(app::getConfig()->get('context>' . $this->getRequest()->getData('context') . '>view>'.$view.'>_security>group'))) { - throw new \codename\core\exception(self::EXCEPTION_DOVIEW_VIEWDISALLOWED, \codename\core\exception::$ERRORLEVEL_ERROR, array('context' => $this->getRequest()->getData('context'), 'view' => $view)); - } + self::getCache()->set($cacheGroup, $cacheKey, self::$config->get()); } - - $this->getContext()->$viewMethod(); - $this->getHook()->fire(\codename\core\hook::EVENT_APP_DOVIEW_FINISH); - return $this; + return self::$config; } /** - * Returns an instance of the context that is in the request container - * @return \codename\core\context + * Returns the cache instance configured as $identifier + * @param string $identifier + * @return cache + * @throws exception|ReflectionException */ - protected function getContext() : \codename\core\context { - $context = self::getRequest()->getData('context'); - - if(count($errors = static::getValidator('text_methodname')->reset()->validate($context)) > 0) { - throw new \codename\core\exception(self::EXCEPTION_GETCONTEXT_CONTEXTNAMEISINVALID, \codename\core\exception::$ERRORLEVEL_FATAL, $errors); + final public static function getCache(string $identifier = 'default'): cache + { + $object = self::getClient('cache', $identifier); + if ($object instanceof cache) { + return $object; } - - $simplename = self::getApp()."_{$context}"; - - if (!array_key_exists($simplename, $_REQUEST['instances'])) { - $filename = self::getHomedir() . "backend/class/context/{$context}.php"; - - if(!self::getFilesystem()->fileAvailable($filename)) { - // - // Check for existance in core (inherited) instead of CURRENT app - // Overriding the default behavior - // - $baseFilename = dirname(__DIR__, 2) . "/backend/class/context/{$context}.php"; - if(self::getFilesystem()->fileAvailable($baseFilename)) { - // TODO: check if this can be non-hardcoded! - $classname = "\\codename\\core\\context\\{$context}"; - $_REQUEST['instances'][$simplename] = new $classname(); - return $_REQUEST['instances'][$simplename]; - } else { - throw new \codename\core\exception(self::EXCEPTION_GETCONTEXT_REQUESTEDCLASSFILENOTFOUND, \codename\core\exception::$ERRORLEVEL_FATAL, $filename); - } - } - $classname = (static::getNamespace() ?? ("\\".self::getVendor()."\\".self::getApp()))."\\context\\{$context}"; - $_REQUEST['instances'][$simplename] = new $classname(); - } - return $_REQUEST['instances'][$simplename]; + throw new exception('EXCEPTION_GETCACHE_WRONG_OBJECT', exception::$ERRORLEVEL_FATAL); } /** - * Loads the view's output file - * @return \codename\core\app - */ - protected function doShow() : \codename\core\app { - if($this->getResponse()->isDefined('templateengine')) { - $templateengine = $this->getResponse()->getData('templateengine'); - } else { - // look in view - $templateengine = app::getConfig()->get('context>' . $this->getResponse()->getData('context') . '>view>'.$this->getResponse()->getData('view').'>templateengine'); - // look in context - if($templateengine == null) { - $templateengine = app::getConfig()->get('context>' . $this->getResponse()->getData('context') . '>templateengine'); - } - // fallback - if($templateengine == null) { - $templateengine = 'default'; - } - } - - $this->getResponse()->setData('content', app::getTemplateEngine($templateengine)->renderView($this->getResponse()->getData('context') . '/' . $this->getResponse()->getData('view'))); - return $this; + * Loads an instance of the given validator type and returns it. + * @param string $type Type of the validator + * @return validator + * @throws ReflectionException + * @throws exception + * @todo validate if datatype exists + */ + final public static function getValidator(string $type): validator + { + if (!array_key_exists($type, self::$validatorCacheArray)) { + $classname = self::getInheritedClass('validator\\' . $type); + self::$validatorCacheArray[$type] = new $classname(); + } + return self::$validatorCacheArray[$type]; } /** - * Outputs the current request's template - * @return null + * + * {@inheritDoc} + * @param contextname $context + * @param viewname $view + * @return bool + * @throws ReflectionException + * @throws exception + * @todo clearly context related. move to the context + * @see app_interface::viewExists, $view) */ - protected function doOutput() { - - if(!($this->getResponse() instanceof \codename\core\response\json)) { - if($this->getResponse()->isDefined('templateengine')) { - $templateengine = $this->getResponse()->getData('templateengine'); - } else { - $templateengine = app::getConfig()->get('defaulttemplateengine'); + public function viewExists(contextname $context, viewname $view): bool + { + if (!$this->contextExists($context)) { + return false; } - if($templateengine == null) { - $templateengine = 'default'; + + if (!self::getConfig()->exists("context>" . $context->get() . ">view")) { + throw new exception(self::EXCEPTION_VIEWEXISTS_CONTEXTCONTAINSNOVIEWS, exception::$ERRORLEVEL_FATAL, $context); } - // self::getResponse()->setOutput(self::parseFile(self::getInheritedPath("frontend/template/" . $this->getRequest()->getData('template') . "/template.php"))); - self::getResponse()->setOutput(app::getTemplateEngine($templateengine)->renderTemplate($this->getResponse()->getData('template'), $this->getResponse())); - } - self::getResponse()->pushOutput(); - return; + return self::getConfig()->exists("context>" . $context->get() . ">view>" . $view->get()); } /** - * Returns the templateengine instance that is configured as $identifier - * @param string $identifier - * @return \codename\core\templateengine + * + * {@inheritDoc} + * @param contextname $context + * @return bool + * @throws ReflectionException + * @throws exception + * @see app_interface::contextExists */ - final public static function getTemplateEngine(string $identifier = 'default') : \codename\core\templateengine { - return self::getClient('templateengine', $identifier); + public function contextExists(contextname $context): bool + { + return self::getConfig()->exists("context>" . $context->get()); } /** - * @var \codename\core\value\text\objectidentifier[]] - */ - protected static $templateengineValueObjectidentifierArray = array(); - - /** - * @var \codename\core\value\text\objecttype + * [handleAccess description] + * @return bool [description] + * @throws ReflectionException + * @throws exception */ - protected static $templateengineValueObjecttype = NULL; + protected function handleAccess(): bool + { + if ($this->getContext() instanceof customContextInterface) { + if (!$this->getContext()->isAllowed()) { + self::getHook()->fire(hook::EVENT_APP_RUN_FORBIDDEN); + // TODO: redirect? + return false; + } + return true; + } elseif (!$this->getContext()->isAllowed() && !self::getConfig()->get('context>' . static::getRequest()->getData('context') . '>view>' . static::getRequest()->getData('view') . '>public')) { + self::getHook()->fire(hook::EVENT_APP_RUN_FORBIDDEN); + static::getResponse()->setRedirect(static::getApp(), 'login'); + static::getResponse()->doRedirect(); + return false; + } else { + return true; + } + } /** - * Returns the (maybe cached) client that is stored as "driver" in $identifier (app.json) for the given $type. - * @param string $type - * @param string $identifier - * @param bool $store - * @return object + * Returns an instance of the context that is in the request container + * @return context + * @throws ReflectionException + * @throws exception */ - final public static function getClient(string $type, string $identifier, bool $store = true) { - $simplename = $type . $identifier; - - if ($store && array_key_exists($simplename, $_REQUEST['instances'])) { - return $_REQUEST['instances'][$simplename]; - } - - $config = self::getData($type, $identifier); - - $app = array_key_exists('app', $config) ? $config['app'] : self::getApp(); - $vendor = self::getVendor(); + protected function getContext(): context + { + $context = self::getRequest()->getData('context'); - if(is_array($config['driver'])) { - $config['driver'] = $config['driver'][0]; + if (count($errors = self::getValidator('text_methodname')->reset()->validate($context)) > 0) { + throw new exception(self::EXCEPTION_GETCONTEXT_CONTEXTNAMEISINVALID, exception::$ERRORLEVEL_FATAL, $errors); } - $classname = "\\{$vendor}\\{$app}\\{$type}\\" . $config['driver']; - - // if not found in app, traverse appstack - if(!class_exists($classname)) { - $found = false; - foreach(self::getAppstack() as $parentapp) { - $vendor = $parentapp['vendor']; - $app = $parentapp['app']; - $namespace = $parentapp['namespace'] ?? "\\{$vendor}\\{$app}"; - $classname = $namespace . "\\{$type}\\" . $config['driver']; + $simplename = self::getApp() . "_$context"; - if(class_exists($classname)) { - $found = true; - break; + if (!array_key_exists($simplename, $_REQUEST['instances'])) { + $filename = self::getHomedir() . "backend/class/context/$context.php"; + + if (!self::getFilesystem()->fileAvailable($filename)) { + // + // Check for existence in core (inherited) instead of CURRENT app + // Overriding the default behavior + // + $baseFilename = dirname(__DIR__, 2) . "/backend/class/context/$context.php"; + if (self::getFilesystem()->fileAvailable($baseFilename)) { + // TODO: check if this can be non-hardcoded! + $classname = "\\codename\\core\\context\\$context"; + $_REQUEST['instances'][$simplename] = new $classname(); + return $_REQUEST['instances'][$simplename]; + } else { + throw new exception(self::EXCEPTION_GETCONTEXT_REQUESTEDCLASSFILENOTFOUND, exception::$ERRORLEVEL_FATAL, $filename); + } } - } - - if($found !== true) { - throw new \codename\core\exception(self::EXCEPTION_GETCLIENT_NOTFOUND, \codename\core\exception::$ERRORLEVEL_FATAL, array($type, $identifier)); - } + $classname = (static::getNamespace() ?? ("\\" . self::getVendor() . "\\" . self::getApp())) . "\\context\\$context"; + $_REQUEST['instances'][$simplename] = new $classname(); } + return $_REQUEST['instances'][$simplename]; + } - // instanciate - $instance = new $classname($config); - - // make its own name public to the client itself - if($instance instanceof \codename\core\clientInterface) { - $instance->setClientName($simplename); + /** + * Returns a filesystem client. Uses the client identified by 'default' when $identifier is not passed in + * @param string $identifier + * @return filesystem + * @throws ReflectionException + * @throws exception + */ + final public static function getFilesystem(string $identifier = 'local'): filesystem + { + $object = self::getClient('filesystem', $identifier); + if ($object instanceof filesystem) { + return $object; } + throw new exception('EXCEPTION_GETFILESYSTEM_WRONG_OBJECT', exception::$ERRORLEVEL_FATAL); + } - if($store) { - return $_REQUEST['instances'][$simplename] = $instance; + /** + * the main run / main lifecycle (context, action, view, show - output) + * @throws ReflectionException + * @throws exception + */ + protected function mainRun(): void + { + if ($this->getContext() instanceof customContextInterface || $this->getContext() instanceof taskschedulerInterface) { + $this->doContextRun(); } else { - return $instance; + $this->doAction()->doView(); } + $this->doShow()->doOutput(); } /** - * Returns the (maybe cached) client that is stored as "driver" in $identifier (app.json) for the given $type. - * @param string $type - * @param string $identifier - * @param bool $store [whether to try to retrieve instance, if already initialized/cached] - * @return object + * [doContextRun description] + * @return app + * @throws ReflectionException + * @throws exception */ - final protected static function getSingletonClient(string $type, string $identifier, bool $store = true) { - - // make simplename for storing instance - $simplename = $type . $identifier; - - // check if already instanced - if ($store && array_key_exists($simplename, $_REQUEST['instances'])) { - return $_REQUEST['instances'][$simplename]; + protected function doContextRun(): app + { + $context = $this->getContext(); + if ($context instanceof customContextInterface || $context instanceof taskschedulerInterface) { + $context->run(); } + return $this; + } - $config = self::getData($type, $identifier); - - // Load client information - - // Maybe overwrite data - $app = self::getApp(); - $vendor = self::getVendor(); + /** + * Tries to call the function that belongs to the view + * @return app + * @throws ReflectionException + * @throws exception + */ + protected function doView(): app + { + $view = static::getRequest()->getData('view'); - if(array_key_exists('app', $config)) { - $app = $config['app']; + if (count($errors = static::getValidator('text_methodname')->reset()->validate($view)) > 0) { + throw new exception(self::EXCEPTION_DOVIEW_VIEWNAMEISINVALID, exception::$ERRORLEVEL_FATAL, $errors); } - // Check classpath and name in the current app - if(is_array($config['driver'])) { - $config['driver'] = $config['driver'][0]; - } + $viewMethod = "view_$view"; - $classpath = self::getHomedir($vendor, $app) . '/backend/class/' . $type . '/' . $config['driver'] . '.php'; - $classname = "\\{$vendor}\\{$app}\\{$type}\\" . $config['driver']; + if (!method_exists($this->getContext(), $viewMethod)) { + throw new exception(self::EXCEPTION_DOVIEW_VIEWFUNCTIONNOTFOUNDINCONTEXT, exception::$ERRORLEVEL_ERROR, $viewMethod); + } - // if not found in app, use the core app - if(!self::getInstance('filesystem_local')->fileAvailable($classpath)) { - $app = 'core'; - $vendor = 'codename'; - $classpath = self::getHomedir($app) . '/backend/class/' . $type . '/' . $config['driver'] . '.php'; - $classname = "\\{$vendor}\\{$app}\\{$type}\\" . $config['driver']; + if (app::getConfig()->exists('context>' . static::getRequest()->getData('context') . '>view>' . $view . '>_security>group')) { + if (!app::getAuth()->memberOf(app::getConfig()->get('context>' . static::getRequest()->getData('context') . '>view>' . $view . '>_security>group'))) { + throw new exception(self::EXCEPTION_DOVIEW_VIEWDISALLOWED, exception::$ERRORLEVEL_ERROR, ['context' => static::getRequest()->getData('context'), 'view' => $view]); + } } - // instanciate - $_REQUEST['instances'][$simplename] = $classname::getInstance($config); - return $_REQUEST['instances'][$simplename]; + $this->getContext()->$viewMethod(); + static::getHook()->fire(hook::EVENT_APP_DOVIEW_FINISH); + return $this; } /** - * Returns the apploader of this app. - * @return \codename\core\value\text\apploader + * Returns the auth instance that is configured as $identifier + * @param string $identifier + * @return auth + * @throws ReflectionException + * @throws exception */ - final protected static function getApploader() : \codename\core\value\text\apploader { - if(is_null(self::$apploader)) { - self::$apploader = new \codename\core\value\text\apploader((new \ReflectionClass(self::$instance))->getNamespaceName()); + final public static function getAuth(string $identifier = 'default'): auth + { + $object = self::getClient('auth', $identifier); + if ($object instanceof auth) { + return $object; } - return self::$apploader; + throw new exception('EXCEPTION_GETAUTH_WRONG_OBJECT', exception::$ERRORLEVEL_FATAL); } /** - * creates and sets the appstack for the current app - * @return array [description] - */ - final protected static function makeCurrentAppstack() : array { - $stack = self::makeAppstack(self::getVendor(), self::getApp()); - self::$appstack = new \codename\core\value\structure\appstack($stack); - self::getHook()->fire(\codename\core\app::EVENT_APP_APPSTACK_AVAILABLE); - return $stack; - } - - /** - * Event/Hook that is fired when the appstack has become available - * @var string - */ - const EVENT_APP_APPSTACK_AVAILABLE = 'EVENT_APP_APPSTACK_AVAILABLE'; - - /** - * Generates an array of application names that depend from each other. Lower array positions are lower priorities - * @param string $vendor [vendor] - * @param string $app [app] - * @return array + * Tries to perform the action if it was set + * @return app + * @throws ReflectionException + * @throws exception */ - final protected static function makeAppstack(string $vendor, string $app) : array { - $initialApp = [ - 'vendor' => $vendor, - 'app' => $app, - ]; - if($vendor == static::getVendor() && $app == static::getApp()) { - // set namespace override, if we're in the current app - // may be null. - $initialApp['namespace'] = static::getNamespace(); + protected function doAction(): app + { + $action = static::getRequest()->getData('action'); + if (is_null($action)) { + return $this; } - // add initial app as starting point for stack - $stack = [ $initialApp ]; - $parentfile = self::getHomedir($vendor, $app) . 'config/parent.app'; - - $current_vendor = ''; - $current_app = ''; - - while (self::getInstance('filesystem_local')->fileAvailable($parentfile)) { - $parentapp = app::getParentapp($current_vendor, $current_app); - - if(strlen($parentapp) == 0) { - break; - } - - $parentapp_data = explode('_', $parentapp); - $current_vendor = $parentapp_data[0]; - $current_app = $parentapp_data[1]; - $stack[] = array( - 'vendor' => $parentapp_data[0], - 'app' => $parentapp_data[1] - ); - - self::getHook()->fire(\codename\core\hook::EVENT_APP_MAKEAPPSTACK_ADDED_APP); - - $parentfile = self::getHomedir($parentapp_data[0], $parentapp_data[1]) . 'config/parent.app'; + if (count($errors = static::getValidator('text_methodname')->reset()->validate($action)) > 0) { + throw new exception(self::EXCEPTION_DOACTION_ACTIONNAMEISINVALID, exception::$ERRORLEVEL_FATAL, $errors); } - // one more step to execute - core app itself - $parentapp = app::getParentapp($current_vendor, $current_app); - - if(strlen($parentapp) > 0) { - $parentapp_data = explode('_', $parentapp); - $current_vendor = $parentapp_data[0]; - $current_app = $parentapp_data[1]; - - $stack[] = array( - 'vendor' => $parentapp_data[0], - 'app' => $parentapp_data[1], - ); - - self::getHook()->fire(\codename\core\hook::EVENT_APP_MAKEAPPSTACK_ADDED_APP); + if (!$this->actionExists(new contextname(static::getRequest()->getData('context')), new actionname($action))) { + throw new exception(self::EXCEPTION_DOACTION_ACTIONNOTFOUNDINCONTEXT, exception::$ERRORLEVEL_NORMAL, $action); } - // we don't need to add the core framework explicitly - // as an 'app', as it is returned by app::getParentapp - // if there's no parent app defined + $action = "action_$action"; - // First, we inject app extensions - foreach(self::getExtensions($vendor, $app) as $injectApp) { - array_splice($stack, -1, 0, array($injectApp)); + if (!method_exists($this->getContext(), $action)) { + throw new exception(self::EXCEPTION_DOACTION_REQUESTEDACTIONFUNCTIONNOTFOUND, exception::$ERRORLEVEL_ERROR, $action); } - // inject apps, if available. - // Those are injected dynamically, e.g. in app constructor - foreach(self::$injectedApps as $injectApp) { - array_splice($stack, $injectApp['injection_mode'], 0, array($injectApp)); - } + $this->getContext()->$action(); - // inject core-ui app before core app, if defined - if(class_exists("\\codename\\core\\ui\\app")) { - $uiApp = array( - 'vendor' => 'codename', - 'app' => 'core-ui', - 'namespace' => '\\codename\\core\\ui' - ); - array_splice($stack, -1, 0, array($uiApp)); - } + return $this; + } - return $stack; + /** + * + * {@inheritDoc} + * @param contextname $context + * @param actionname $action + * @return bool + * @throws ReflectionException + * @throws exception + * @todo clearly context related. move to the context + * @see app_interface::actionExists, $action) + */ + public function actionExists(contextname $context, actionname $action): bool + { + return self::getConfig()->exists("context>" . $context->get() . ">action>" . $action->get()); } /** - * get extensions for a given vendor/app - * @param string $vendor [description] - * @param string $app [description] - * @return array + * Outputs the current request's template + * @return void + * @throws ReflectionException + * @throws exception */ - protected static function getExtensions($vendor, $app) { - $appJson = self::getHomedir($vendor, $app) . 'config/app.json'; - if(self::getInstance('filesystem_local')->fileAvailable($appJson)) { - $json = new \codename\core\config\json($appJson, false, false); - $extensions = $json->get('extensions'); - if($extensions !== null) { - $extensionParameters = []; - foreach($extensions as $ext) { - $class = '\\' . str_replace('_', '\\', $ext) . '\\extension'; - if(class_exists($class) && (new \ReflectionClass($class))->isSubclassOf('\\codename\\core\\extension')) { - $extension = new $class(); - $extensionParameters[] = $extension->getInjectParameters(); + protected function doOutput(): void + { + if (!(static::getResponse() instanceof response\json)) { + if (static::getResponse()->isDefined('templateengine')) { + $templateengine = static::getResponse()->getData('templateengine'); } else { - throw new exception('CORE_APP_EXTENSION_COULD_NOT_BE_LOADED', exception::$ERRORLEVEL_FATAL, $ext); + $templateengine = app::getConfig()->get('defaulttemplateengine'); + } + if ($templateengine == null) { + $templateengine = 'default'; } - } - return $extensionParameters; + + self::getResponse()->setOutput(app::getTemplateEngine($templateengine)->renderTemplate(static::getResponse()->getData('template'), static::getResponse())); } - } - return []; + self::getResponse()->pushOutput(); } /** - * array of injected or to-be-injected apps during makeAppstack - * @var array[] - */ - protected static $injectedApps = []; - - /** - * [protected description] - * @var string[] + * Returns the templateengine instance configured as $identifier + * @param string $identifier + * @return templateengine + * @throws ReflectionException + * @throws exception */ - protected static $injectedAppIdentifiers = []; + final public static function getTemplateEngine(string $identifier = 'default'): templateengine + { + $object = self::getClient('templateengine', $identifier); + if ($object instanceof templateengine) { + return $object; + } + throw new exception('EXCEPTION_GETTEMPLATEENGINE_WRONG_OBJECT', exception::$ERRORLEVEL_FATAL); + } /** - * Injection mode for base apps - * (in-between core and extensions) - * @var int + * Loads the view's output file + * @return app + * @throws ReflectionException + * @throws exception */ - public const INJECT_APP_BASE = -1; + protected function doShow(): app + { + if (static::getResponse()->isDefined('templateengine')) { + $templateengine = static::getResponse()->getData('templateengine'); + } else { + // look in view + $templateengine = app::getConfig()->get('context>' . static::getResponse()->getData('context') . '>view>' . static::getResponse()->getData('view') . '>templateengine'); + // look in context + if ($templateengine == null) { + $templateengine = app::getConfig()->get('context>' . static::getResponse()->getData('context') . '>templateengine'); + } + // fallback + if ($templateengine == null) { + $templateengine = 'default'; + } + } - /** - * Injection mode for extension apps - * (below main app, but above base apps) - * @var int - */ - public const INJECT_APP_EXTENSION = 1; + static::getResponse()->setData('content', app::getTemplateEngine($templateengine)->renderView(static::getResponse()->getData('context') . '/' . static::getResponse()->getData('view'))); + return $this; + } /** - * Injection mode for app overrides - * (above main app!) - * @var int + * Executes the given $context->$view + * @param string $context + * @param string $view + * @return void + * @throws ReflectionException + * @throws exception */ - public const INJECT_APP_OVERRIDE = 0; + public function execute(string $context, string $view): void + { + static::getRequest()->setData('context', $context); + static::getRequest()->setData('view', $view); + $this->doView()->doShow(); + } /** - * Injects an app, optionally with an injection mode (the place where it goes in the appstack) - * @param array $injectApp [array/object containing the app identifiers] - * @param int $injectionMode [defaults to INJECT_APP_BASE] - */ - final protected static function injectApp(array $injectApp, int $injectionMode = self::INJECT_APP_BASE) { - if(isset($injectApp['vendor']) && isset($injectApp['app']) && isset($injectApp['namespace'])) { - $identifier = $injectApp['vendor'].'#'.$injectApp['app'].'#'.$injectApp['namespace']; - // Prevent double-injecting the apps - if(!in_array($identifier, self::$injectedAppIdentifiers)) { - $injectApp['injection_mode'] = $injectionMode; - self::$injectedApps[] = $injectApp; - self::$injectedAppIdentifiers[] = $identifier; - } - } else { - throw new exception("EXCEPTION_APP_INJECTAPP_CANNOT_INJECT_APP", exception::$ERRORLEVEL_FATAL, $injectApp); - } + * [initDebug description] + * @return void [type] [description] + */ + protected function initDebug(): void + { + if (self::getEnv() == 'dev' && (self::getRequest()->getData('template') !== 'json' && self::getRequest()->getData('template') !== 'blank')) { + static::getHook()->add(hook::EVENT_APP_RUN_START, function () { + $_REQUEST['start'] = microtime(true); + })->add(hook::EVENT_APP_RUN_END, function () { + if (static::getRequest() instanceof cli) { + echo 'Generated in ' . round(abs(($_REQUEST['start'] - microtime(true)) * 1000), 2) . 'ms' . chr(10) + . ' ' . \codename\core\observer\database::$query_count . ' Queries' . chr(10) + . ' ' . \codename\core\observer\cache::$set . ' Cache SETs' . chr(10) + . ' ' . \codename\core\observer\cache::$get . ' Cache GETs' . chr(10) + . ' ' . \codename\core\observer\cache::$hit . ' Cache HITs' . chr(10) + . ' ' . \codename\core\observer\cache::$miss . ' Cache MISSes' . chr(10); + } elseif (static::getRequest() instanceof request\json || static::getRequest() instanceof \codename\rest\request\json) { + // + // NO DEBUG APPEND for this type of request/response + // + } elseif (self::getRequest()->getData('template') !== 'json' && self::getRequest()->getData('template') !== 'blank') { + echo '
Generated in ' . round(abs(($_REQUEST['start'] - microtime(true)) * 1000), 2) . 'ms
+              ' . \codename\core\observer\database::$query_count . ' Queries
+              ' . \codename\core\observer\cache::$set . ' Cache SETs
+              ' . \codename\core\observer\cache::$get . ' Cache GETs
+              ' . \codename\core\observer\cache::$hit . ' Cache HITs
+              ' . \codename\core\observer\cache::$miss . ' Cache MISSes
+              
'; + } + }); + } } - } diff --git a/backend/class/app/appInterface.php b/backend/class/app/appInterface.php index e32899c..67af503 100755 --- a/backend/class/app/appInterface.php +++ b/backend/class/app/appInterface.php @@ -1,53 +1,57 @@ saveXML(); */ - -class array2xml { - - private static $xml = null; - private static $encoding = 'UTF-8'; - +class array2xml +{ /** - * Initialize the root XML node [optional] - * @param $version - * @param $encoding - * @param $format_output + * @var null|DOMDocument */ - public static function init($version = '1.0', $encoding = 'UTF-8', $format_output = true) { - self::$xml = new \DomDocument($version, $encoding); - self::$xml->formatOutput = $format_output; - self::$encoding = $encoding; - } + private static ?DOMDocument $xml = null; + /** + * @var string + */ + private static string $encoding = 'UTF-8'; /** * Convert an Array to XML * @param string $node_name - name of the root node to be converted - * @param array $arr - aray to be converterd - * @return \DomDocument + * @param array $arr - array to be converted + * @return null|DOMDocument + * @throws DOMException + * @throws exception */ - public static function &createXML($node_name, $arr=array()) { + public static function &createXML(string $node_name, array $arr = []): ?DOMDocument + { $xml = self::getXMLRoot(); $xml->appendChild(self::convert($node_name, $arr)); - self::$xml = null; // clear the xml node in the class for 2nd time use. + self::$xml = null; // clear the XML node in the class for 2nd time use. return $xml; } + /** + * @return DOMDocument|null + */ + private static function getXMLRoot(): ?DOMDocument + { + if (empty(self::$xml)) { + self::init(); + } + return self::$xml; + } + + /** + * Initialize the root XML node [optional] + * @param string $version + * @param string $encoding + * @param bool $format_output + */ + public static function init(string $version = '1.0', string $encoding = 'UTF-8', bool $format_output = true): void + { + self::$encoding = $encoding; + self::$xml = new DOMDocument($version, self::$encoding); + self::$xml->formatOutput = $format_output; + } + + /* + * Get the root XML node, if there isn't one, create it. + */ + /** * Convert an Array to XML * @param string $node_name - name of the root node to be converted - * @param array $arr - aray to be converterd - * @return \DOMNode + * @param array $arr - array to be converted + * @return DOMNode + * @throws DOMException + * @throws exception */ - private static function &convert($node_name, $arr=array()) { - + private static function &convert(string $node_name, array $arr = []): DOMNode + { //print_arr($node_name); $xml = self::getXMLRoot(); $node = $xml->createElement($node_name); - if(is_array($arr)){ + if (is_array($arr)) { // get the attributes first.; - if(isset($arr['@attributes'])) { - foreach($arr['@attributes'] as $key => $value) { - if(!self::isValidTagName($key)) { - throw new \Exception('[array2xml] Illegal character in attribute name. attribute: '.$key.' in node: '.$node_name); + if (isset($arr['@attributes'])) { + foreach ($arr['@attributes'] as $key => $value) { + if (!self::isValidTagName($key)) { + throw new \Exception('[array2xml] Illegal character in attribute name. attribute: ' . $key . ' in node: ' . $node_name); } $node->setAttribute($key, self::bool2str($value)); } @@ -88,13 +120,13 @@ private static function &convert($node_name, $arr=array()) { } // check if it has a value stored in @value, if yes store the value and return - // else check if its directly stored as string - if(isset($arr['@value'])) { + // else check if it's directly stored as string + if (isset($arr['@value'])) { $node->appendChild($xml->createTextNode(self::bool2str($arr['@value']))); unset($arr['@value']); //remove the key from the array once done. //return from recursion, as a note with value cannot have child nodes. return $node; - } else if(isset($arr['@cdata'])) { + } elseif (isset($arr['@cdata'])) { $node->appendChild($xml->createCDATASection(self::bool2str($arr['@cdata']))); unset($arr['@cdata']); //remove the key from the array once done. //return from recursion, as a note with cdata cannot have child nodes. @@ -103,17 +135,17 @@ private static function &convert($node_name, $arr=array()) { } //create subnodes using recursion - if(is_array($arr)){ + if (is_array($arr)) { // recurse to get the node for that key - foreach($arr as $key=>$value){ - if(!self::isValidTagName($key)) { - throw new exception('CODENAME_CORE_BACKEND_CLASS_ARRAY2XML_&CONVERT::ILLEGAL_CHARACTER', \codename\core\exception::$ERRORLEVEL_FATAL, "[array2xml] Illegal character in tag name. tag: '.$key.' in node: '.$node_name"); + foreach ($arr as $key => $value) { + if (!self::isValidTagName($key)) { + throw new exception('CODENAME_CORE_BACKEND_CLASS_ARRAY2XML_&CONVERT::ILLEGAL_CHARACTER', exception::$ERRORLEVEL_FATAL, "[array2xml] Illegal character in tag name. tag: '.$key.' in node: '.$node_name"); } - if(is_array($value) && is_numeric(key($value))) { + if (is_array($value) && is_numeric(key($value))) { // MORE THAN ONE NODE OF ITS KIND; - // if the new array is numeric index, means it is array of nodes of the same kind + // if the new array is numeric index, means it is an array of nodes of the same kind, // it should follow the parent key name - foreach($value as $k=>$v){ + foreach ($value as $v) { $node->appendChild(self::convert($key, $v)); } } else { @@ -124,42 +156,36 @@ private static function &convert($node_name, $arr=array()) { } } - // after we are done with all the keys in the array (if it is one) + // after we are done with all the keys in the array (if it is one), // we check if it has any text value, if yes, append it. - if(!is_array($arr)) { + if (!is_array($arr)) { $node->appendChild($xml->createTextNode(self::bool2str($arr))); } return $node; } - /* - * Get the root XML node, if there isn't one, create it. - */ - private static function getXMLRoot(){ - if(empty(self::$xml)) { - self::init(); - } - return self::$xml; - } - - /* + /** * Get string representation of boolean value + * @param string $tag + * @return bool */ - private static function bool2str($v){ - //convert boolean to text value. - $v = $v === true ? 'true' : $v; - $v = $v === false ? 'false' : $v; - return $v; + private static function isValidTagName(string $tag): bool + { + $pattern = '/^[a-z_]+[a-z0-9:\-._]*[^:]*$/i'; + return preg_match($pattern, $tag, $matches) && $matches[0] == $tag; } - /* + /** * Check if the tag name or attribute name contains illegal characters * Ref: http://www.w3.org/TR/xml/#sec-common-syn + * @param mixed $v + * @return mixed */ - private static function isValidTagName($tag){ - $pattern = '/^[a-z_]+[a-z0-9\:\-\.\_]*[^:]*$/i'; - return preg_match($pattern, $tag, $matches) && $matches[0] == $tag; + private static function bool2str(mixed $v): mixed + { + //convert boolean to text value. + $v = $v === true ? 'true' : $v; + return $v === false ? 'false' : $v; } } -?> diff --git a/backend/class/auth.php b/backend/class/auth.php index 1dbe150..08ffe46 100755 --- a/backend/class/auth.php +++ b/backend/class/auth.php @@ -1,11 +1,15 @@ Returns an empty array if authentication failed + * Returns an empty array if authentication failed * @param string $username Try authentication for this user... * @param string $password ... using this password * @return array Array of user information. Is EMPTY on authentication failure * @access public */ - public function authenticate(string $username, string $password) : array; + public function authenticate(string $username, string $password): array; /** * Returns the hashed password value - *
You may want to create your own hashing algo using this method. - *
This method is public to be accessible for contexts and models (e.g. automatic user creation, password resetting) + * You may want to create your own hashing algo using this method. + * This method is public to be accessible for contexts and models (e.g., automatic user creation, password resetting) * @param string $username The username to hash * @param string $password The password to hash * @return string The hashed combination of $username and $password * @access public */ - public function passwordMake(string $username, string $password) : string; + public function passwordMake(string $username, string $password): string; /** - * Returns true if the current user is member of the given usergroup. + * Returns true if the current user is a member of the given usergroup. * @param string $usergroup_name * @return bool */ - public function memberOf(string $usergroup_name) : bool; - + public function memberOf(string $usergroup_name): bool; } diff --git a/backend/class/auth/credentialAuthInterface.php b/backend/class/auth/credentialAuthInterface.php index e0a7b82..d2a8757 100644 --- a/backend/class/auth/credentialAuthInterface.php +++ b/backend/class/auth/credentialAuthInterface.php @@ -1,6 +1,9 @@ Returns an empty array if authentication failed + * Returns an empty array if authentication failed * @param string $username Try authentication for this user... * @param string $password ... using this password * @return array Array of user information. Is EMPTY on authentication failure @@ -21,18 +24,18 @@ interface credentialAuthInterface { /** * Authenticates using the given credential object - * returns a data array that is associated with this credential - * (e.g. session data, user/client data, etc.) + * returns a data array associated with this credential + * (e.g., session data, user/client data, etc.) * - * @param \codename\core\credential $credential [description] + * @param credential $credential [description] * @return array [description] */ - public function authenticate(\codename\core\credential $credential) : array; + public function authenticate(credential $credential): array; /** * Returns the hashed password value - *
You may want to create your own hashing algo using this method. - *
This method is public to be accessible for contexts and models (e.g. automatic user creation, password resetting) + * You may want to create your own hashing algo using this method. + * This method is public to be accessible for contexts and models (e.g., automatic user creation, password resetting) * @param string $username The username to hash * @param string $password The password to hash * @return string The hashed combination of $username and $password @@ -43,23 +46,23 @@ public function authenticate(\codename\core\credential $credential) : array; * returns a new credential object from the given parameters * note the type depends on the auth class used * - * @param array $parameters [parameter array] - * @return \codename\core\credential [credential object] + * @param array $parameters [parameter array] + * @return credential [credential object] */ - public function createCredential(array $parameters) : \codename\core\credential; + public function createCredential(array $parameters): credential; /** * creates a hash using the given credential object * - * @param \codename\core\credential $credential [description] + * @param credential $credential [description] * @return string [description] */ - public function makeHash(\codename\core\credential $credential) : string; + public function makeHash(credential $credential): string; /** * Returns true if we have a valid authentication * returns false otherwise * @return bool [authentication success] */ - public function isAuthenticated() : bool; + public function isAuthenticated(): bool; } diff --git a/backend/class/auth/groupInterface.php b/backend/class/auth/groupInterface.php index 1cf1d09..6c7dfde 100644 --- a/backend/class/auth/groupInterface.php +++ b/backend/class/auth/groupInterface.php @@ -1,4 +1,5 @@ validate($data)) > 0) { - return false; - } - $this->apiInst = new \codename\core\api\codename\ssis($data); - return $this; - } - - /** - * - * {@inheritDoc} - * @see \codename\core\auth_interface::authenticate($username, $password) - */ - public function authenticate(string $username, string $password) : array { - return $this->apiInst->authenticate($username, $password); - } - - /** - * - * {@inheritDoc} - * @see \codename\core\auth_interface::passwordMake($username, $password) - */ - public function passwordMake(string $username, string $password) : string { - return ''; - } - - /** - * Returns the URL to the login page - * @param \codename\core\request $request - * @return string - */ - public function getLoginurl(\codename\core\request $request) : string { - return $this->apiInst->getLoginurl($request); - } - - /** - * Returns the errorstack property of the API instance - * @return \codename\core\errorstack - */ - public function getErrorstack() : \codename\core\errorstack { - return $this->apiInst->getErrorstack(); - } - - /** - * I will send the given $sessionobject to the remote $app. Identification will be done using the $redirect array. - * @param array $redirect - * @param array $app - * @param \codename\core\value\structure\api\codename\ssis\sessionobject $sessionobject - */ - public function sendSessionToRemoteApplication(array $redirect, array $app, \codename\core\value\structure\api\codename\ssis\sessionobject $sessionobject) : string { - return $this->apiInst->sendSessionToRemoteApplication($redirect, $app, $sessionobject); - } - - - /** - * - * {@inheritDoc} - * @see \codename\core\auth\authInterface::memberOf() - */ - public function memberOf(string $usergroup_name) : bool { - if(!app::getSession()->identify()) { - return false; - } - - foreach(app::getSession()->getData('data>usergroups>0') as $usergroup) { - if($usergroup['usergroup_name'] == 'Mitarbeiter') { - return true; - } - } - return false; - } - -} diff --git a/backend/class/bootstrap.php b/backend/class/bootstrap.php index 8a9af1a..23fe283 100755 --- a/backend/class/bootstrap.php +++ b/backend/class/bootstrap.php @@ -1,54 +1,63 @@ $vendor, - 'app' => $app + 'vendor' => $vendor, + 'app' => $app, ]; // construct a virtual appstack - if($isForeignApp) { - array_splice($appstack, count($appstack)-1, 0, [[ - 'vendor' => $vendor, - 'app' => $app - ]]); - $initConfig['appstack'] = $appstack; + if ($isForeignApp) { + array_splice($appstack, count($appstack) - 1, 0, [ + [ + 'vendor' => $vendor, + 'app' => $app, + ], + ]); + $initConfig['appstack'] = $appstack; } // construct a FQCN to check for - $classname = "{$namespace}\\model\\{$model}"; + $classname = "$namespace\\model\\$model"; - // check for existance using autoloading capabilities - if(class_exists($classname)) { - return new $classname($initConfig); + // check for existence using autoload capabilities + if (class_exists($classname)) { + return new $classname($initConfig); } // This is a bit tricky. // As we already checked for class availability // (with a negative result) - // And we're already on the lowest level (core) + // And we're yet at the lowest level (core), // We cannot traverse the appstack any further. // Therefore: throw exception! - if($app == 'core') { - throw new \codename\core\exception(self::EXCEPTION_GETMODEL_MODELNOTFOUND, \codename\core\exception::$ERRORLEVEL_FATAL, array('model' => $model, 'app' => $app, 'vendor' => $vendor)); + if ($app == 'core') { + throw new exception(self::EXCEPTION_GETMODEL_MODELNOTFOUND, exception::$ERRORLEVEL_FATAL, ['model' => $model, 'app' => $app, 'vendor' => $vendor]); } $parentapp = app::getParentapp($vendor, $app); @@ -98,8 +109,8 @@ public static function getModel(string $model, string $app = '', string $vendor $vendor = $parentappdata[0]; $app = $parentappdata[1]; - if($parentapp == $app) { - throw new \codename\core\exception(self::EXCEPTION_GETMODEL_APPSTACKRECURSIVE, \codename\core\exception::$ERRORLEVEL_FATAL, array('model' => $model, 'app' => $app, 'vendor' => $vendor)); + if ($parentapp == $app) { + throw new exception(self::EXCEPTION_GETMODEL_APPSTACKRECURSIVE, exception::$ERRORLEVEL_FATAL, ['model' => $model, 'app' => $app, 'vendor' => $vendor]); } return self::getModel($model, $app, $vendor); @@ -107,24 +118,27 @@ public static function getModel(string $model, string $app = '', string $vendor /** * [array_find description] - * @param array $xs [description] - * @param callable $f [description] - * @return mixed|null [description] + * @param array $xs [description] + * @param callable $f [description] + * @return mixed [description] */ - protected static function array_find(array $xs, callable $f) { - foreach ($xs as $x) { - if (call_user_func($f, $x) === true) - return $x; - } - return null; + protected static function array_find(array $xs, callable $f): mixed + { + foreach ($xs as $x) { + if (call_user_func($f, $x) === true) { + return $x; + } + } + return null; } /** * Returns an instance of the current request container. * @return request */ - public static function getRequest() : \codename\core\request { - if(!(static::$instances['request'] ?? false)) { + public static function getRequest(): request + { + if (!(static::$instances['request'] ?? false)) { $classname = "\\codename\\core\\request\\" . self::getRequesttype(); static::$instances['request'] = new $classname(); } @@ -132,15 +146,16 @@ public static function getRequest() : \codename\core\request { } /** - * Returns the request type that is used for this request + * Returns the request type used for this request * @return string */ - protected static function getRequesttype() : string { - if(php_sapi_name() === 'cli') { + protected static function getRequesttype(): string + { + if (php_sapi_name() === 'cli') { return 'cli'; } // ? - if(strpos($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json') !== false) { + if (str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json')) { return 'json'; } return 'https'; @@ -149,24 +164,18 @@ protected static function getRequesttype() : string { /** * Returns an instance of the current response container * @return response + * @throws exception */ - public static function getResponse() : \codename\core\response { - if(!(static::$instances['response'] ?? false)) { - if((static::$instances['request'] ?? false)) { - $classname = "\\codename\\core\\response\\" . self::getRequesttype(); - static::$instances['response'] = new $classname(); - } else { - throw new exception(self::EXCEPTION_BOOTSTRAP_GETRESPONSE_REQUEST_INSTANCE_NOT_CREATED, exception::$ERRORLEVEL_FATAL); - } + public static function getResponse(): response + { + if (!(static::$instances['response'] ?? false)) { + if ((static::$instances['request'] ?? false)) { + $classname = "\\codename\\core\\response\\" . self::getRequesttype(); + static::$instances['response'] = new $classname(); + } else { + throw new exception(self::EXCEPTION_BOOTSTRAP_GETRESPONSE_REQUEST_INSTANCE_NOT_CREATED, exception::$ERRORLEVEL_FATAL); + } } return static::$instances['response']; } - - /** - * exception thrown, if getResponse called without existing request - * e.g. makeRequest hasn't been called - * @var string - */ - public const EXCEPTION_BOOTSTRAP_GETRESPONSE_REQUEST_INSTANCE_NOT_CREATED = 'EXCEPTION_BOOTSTRAP_GETRESPONSE_REQUEST_INSTANCE_NOT_CREATED'; - } diff --git a/backend/class/bootstrapInstance.php b/backend/class/bootstrapInstance.php index abfcfcc..f44444c 100644 --- a/backend/class/bootstrapInstance.php +++ b/backend/class/bootstrapInstance.php @@ -1,45 +1,48 @@ See the validator for more info + * The given config cannot be validated against structure_config_bucket_local. + * See the validator for more info * @var string */ - const EXCEPTION_CONSTRUCT_CONFIGURATIONINVALID = 'EXCEPTION_CONSTRUCT_CONFIGURATIONINVALID'; - - /** - * Contains the base directory where all files in this bucket will be stored - * @var string $basedir - */ - protected $basedir = null; - - /** - * Contains an instance of \codename\core\errorstack - * @var \codename\core\errorstack - */ - protected $errorstack = null; - - /** - * Creates the instance, establishes the connection and authenticates - * @param array $data - * @return \codename\core\bucket - */ - public function __construct(array $data) { - $this->errorstack = new \codename\core\errorstack('BUCKET'); - - return $this; - } - - /** - * This method normaliuzes the remote path by trying - *
to prepend the basepath if it is not prepended yet. - * @param string $path - * @return string - */ - public function normalizePath(string $path) : string { - if(substr($path, 0, strlen($this->basedir)) == $this->basedir) { - return $path; - } - return $this->basedir . $path; - } - - /** - * Normalizes a given path. By default, $strict is being used - * which denies usage of any . or .. and throws an exception, if found. - * @param string $path - * @param bool $strict [default: true] - * @return string - */ - protected function normalizeRelativePath(string $path, bool $strict = true): string { - // - // Make sure we also handle Windows-style backslash paths - // Though bucket convention is slashes only - // - $path = str_replace('\\', '/', $path); - $parts = []; - foreach (explode('/', $path) as $part) { - switch ($part) { - case '.': - // - // NOTE: we might make this thing more tolerant - // '.' might be just discarded w/o throwing an exception - // This just enforces a strict programming style and data handling. - // - if($strict) { - throw new exception(static::BUCKET_EXCEPTION_BAD_PATH, exception::$ERRORLEVEL_FATAL); - } - case '': // initial/starting slash or // - break; - - case '..': - if($strict) { - throw new exception(static::BUCKET_EXCEPTION_BAD_PATH, exception::$ERRORLEVEL_FATAL); - } - if (empty($parts)) { - throw new exception(static::BUCKET_EXCEPTION_FORBIDDEN_PATH_TRAVERSAL, exception::$ERRORLEVEL_FATAL); - } - array_pop($parts); - break; - - default: - $parts[] = $part; - break; - } - } - return implode('/', $parts); - } - + public const string EXCEPTION_CONSTRUCT_CONFIGURATIONINVALID = 'EXCEPTION_CONSTRUCT_CONFIGURATIONINVALID'; /** * Exception thrown if a bad path is passed as path parameter somewhere * @var string */ - const BUCKET_EXCEPTION_BAD_PATH = 'BUCKET_EXCEPTION_BAD_PATH'; - + public const string BUCKET_EXCEPTION_BAD_PATH = 'BUCKET_EXCEPTION_BAD_PATH'; /** * Exception thrown if there was a (possibly malicious) path traversal * in a given path parameter * @var string */ - const BUCKET_EXCEPTION_FORBIDDEN_PATH_TRAVERSAL = 'BUCKET_EXCEPTION_FORBIDDEN_PATH_TRAVERSAL'; - + public const string BUCKET_EXCEPTION_FORBIDDEN_PATH_TRAVERSAL = 'BUCKET_EXCEPTION_FORBIDDEN_PATH_TRAVERSAL'; /** - * Returns the errorstack of the bucket - * @return \codename\core\errorstack + * Contains the base directory where all files in this bucket will be stored + * @var string $basedir */ - public function getErrorstack() : \codename\core\errorstack { - return $this->errorstack; - } - + protected $basedir = null; /** - * - * {@inheritDoc} - * @see \codename\core\bucket\bucketInterface::downloadToClient() - * @todo errorhandling + * Contains an instance of \codename\core\errorstack + * @var errorstack */ - public function downloadToClient(\codename\core\value\text\filerelative $remotefile, \codename\core\value\text\filename $filename, array $option = array()) { - if(!$this->fileAvailable($remotefile->get())) { - // app::writeActivity('BUCKET_FILE_DOWNLOAD_FAIL', $remotefile->get()); - throw new exception('BUCKET_FILE_DOWNLOAD_UNAVAILABLE', exception::$ERRORLEVEL_ERROR, $remotefile->get()); - } - - $tempfile = '/tmp/' . md5($remotefile->get() . microtime() . $filename->get()); - - // evaluate return value from ::filePull() - if(!$this->filePull($remotefile->get(), $tempfile)) { - throw new exception('BUCKET_FILE_DOWNLOAD_FAIL', exception::$ERRORLEVEL_ERROR, $remotefile->get()); - } - - app::writeActivity('BUCKET_FILE_DOWNLOAD', $remotefile->get()); - - if(array_key_exists('inline', $option) === TRUE && $option['inline'] === TRUE) { + protected errorstack $errorstack; - // Determine Mime Type by extension. I know it's bad. - $path_parts = pathinfo($remotefile->get()); - $ext = strtolower($path_parts["extension"]); - - // Determine Content Type (only for inlining) - switch ($ext) { - case "pdf": $ctype="application/pdf"; break; - case "gif": $ctype="image/gif"; break; - case "png": $ctype="image/png"; break; - case "jpeg": - case "jpg": $ctype="image/jpg"; break; - default: $ctype="application/force-download"; - } - - app::getResponse()->setHeader('Content-Type: ' . $ctype); - app::getResponse()->setHeader("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); - app::getResponse()->setHeader("Cache-Control: post-check=0, pre-check=0", false); - app::getResponse()->setHeader("Pragma: no-cache"); - app::getResponse()->setHeader('Content-Disposition: inline; filename="' . $filename->get() . '"'); - app::getResponse()->setHeader('Content-Length: ' . filesize($tempfile)); - app::getResponse()->setHeader('Content-Transfer-Encoding: binary'); - - } else { + /** + * Creates the instance, establishes the connection and authenticates + * @param array $data + * @return bucket + */ + public function __construct(array $data) + { + $this->errorstack = new errorstack('BUCKET'); - app::getResponse()->setHeader('Content-Description: File Transfer'); - app::getResponse()->setHeader('Content-Type: application/octet-stream'); - app::getResponse()->setHeader('Content-Transfer-Encoding: binary'); - app::getResponse()->setHeader('Pragma: public'); - app::getResponse()->setHeader('Content-Length: ' . filesize($tempfile)); - app::getResponse()->setHeader('Content-Disposition: attachment; filename="' . $filename->get() . '"'); - } - if (ob_get_contents()) ob_clean(); - flush(); - readfile($tempfile); - unlink($tempfile); // delete the tempfile afterwards - exit; + return $this; } /** - * Normalizes a file name by it's given $filename + * Normalizes a file name by its given $filename * @param string $filename - * @todo CENTRAL METHOD STORAGE + * @return filename + * @throws ReflectionException + * @throws exception * @todo one day move to factory class structure - * @return \codename\core\value\text\filename + * @todo CENTRAL METHOD STORAGE */ - final public static function factoryFilename(string $filename) : \codename\core\value\text\filename { + final public static function factoryFilename(string $filename): filename + { $text = $filename; - $text = preg_replace("/[∂άαáàâãªä]/u", "a", $text); - $text = preg_replace("/[∆лДΛдАÁÀÂÃÄ]/u", "A", $text); - $text = preg_replace("/[ЂЪЬБъь]/u", "b", $text); - $text = preg_replace("/[βвВ]/u", "B", $text); - $text = preg_replace("/[çς©с]/u", "c", $text); - $text = preg_replace("/[ÇС]/u", "C", $text); - $text = preg_replace("/[δ]/u", "d", $text); + $text = preg_replace("/[∂άαáàâãªä]/u", "a", $text); + $text = preg_replace("/[∆лДΛдАÁÀÂÃÄ]/u", "A", $text); + $text = preg_replace("/[ЂЪЬБъь]/u", "b", $text); + $text = preg_replace("/[βвВ]/u", "B", $text); + $text = preg_replace("/[çς©с]/u", "c", $text); + $text = preg_replace("/[ÇС]/u", "C", $text); + $text = preg_replace("/[δ]/u", "d", $text); $text = preg_replace("/[éèêëέëèεе℮ёєэЭ]/u", "e", $text); - $text = preg_replace("/[ÉÈÊË€ξЄ€Е∑]/u", "E", $text); - $text = preg_replace("/[₣]/u", "F", $text); - $text = preg_replace("/[НнЊњ]/u", "H", $text); - $text = preg_replace("/[ђћЋ]/u", "h", $text); - $text = preg_replace("/[ÍÌÎÏ]/u", "I", $text); - $text = preg_replace("/[íìîïιίϊі]/u", "i", $text); - $text = preg_replace("/[Јј]/u", "j", $text); - $text = preg_replace("/[ΚЌК]/u", 'K', $text); - $text = preg_replace("/[ќк]/u", 'k', $text); - $text = preg_replace("/[ℓ∟]/u", 'l', $text); - $text = preg_replace("/[Мм]/u", "M", $text); - $text = preg_replace("/[ñηήηπⁿ]/u", "n", $text); - $text = preg_replace("/[Ñ∏пПИЙийΝЛ]/u", "N", $text); + $text = preg_replace("/[ÉÈÊË€ξЄ€Е∑]/u", "E", $text); + $text = preg_replace("/[₣]/u", "F", $text); + $text = preg_replace("/[НнЊњ]/u", "H", $text); + $text = preg_replace("/[ђћЋ]/u", "h", $text); + $text = preg_replace("/[ÍÌÎÏ]/u", "I", $text); + $text = preg_replace("/[íìîïιίϊі]/u", "i", $text); + $text = preg_replace("/[Јј]/u", "j", $text); + $text = preg_replace("/[ΚЌК]/u", 'K', $text); + $text = preg_replace("/[ќк]/u", 'k', $text); + $text = preg_replace("/[ℓ∟]/u", 'l', $text); + $text = preg_replace("/[Мм]/u", "M", $text); + $text = preg_replace("/[ñηήηπⁿ]/u", "n", $text); + $text = preg_replace("/[Ñ∏пПИЙийΝЛ]/u", "N", $text); $text = preg_replace("/[óòôõºöοФσόо]/u", "o", $text); - $text = preg_replace("/[ÓÒÔÕÖθΩθОΩ]/u", "O", $text); - $text = preg_replace("/[ρφрРф]/u", "p", $text); - $text = preg_replace("/[®яЯ]/u", "R", $text); - $text = preg_replace("/[ГЃгѓ]/u", "r", $text); - $text = preg_replace("/[Ѕ]/u", "S", $text); - $text = preg_replace("/[ѕ]/u", "s", $text); - $text = preg_replace("/[Тт]/u", "T", $text); - $text = preg_replace("/[τ†‡]/u", "t", $text); - $text = preg_replace("/[úùûüџμΰµυϋύ]/u", "u", $text); - $text = preg_replace("/[√]/u", "v", $text); - $text = preg_replace("/[ÚÙÛÜЏЦц]/u", "U", $text); - $text = preg_replace("/[Ψψωώẅẃẁщш]/u", "w", $text); - $text = preg_replace("/[ẀẄẂШЩ]/u", "W", $text); - $text = preg_replace("/[ΧχЖХж]/u", "x", $text); - $text = preg_replace("/[ỲΫ¥]/u", "Y", $text); - $text = preg_replace("/[ỳγўЎУуч]/u", "y", $text); - $text = preg_replace("/[ζ]/u", "Z", $text); + $text = preg_replace("/[ÓÒÔÕÖθΩθОΩ]/u", "O", $text); + $text = preg_replace("/[ρφрРф]/u", "p", $text); + $text = preg_replace("/[®яЯ]/u", "R", $text); + $text = preg_replace("/[ГЃгѓ]/u", "r", $text); + $text = preg_replace("/[Ѕ]/u", "S", $text); + $text = preg_replace("/[ѕ]/u", "s", $text); + $text = preg_replace("/[Тт]/u", "T", $text); + $text = preg_replace("/[τ†‡]/u", "t", $text); + $text = preg_replace("/[úùûüџμΰµυϋύ]/u", "u", $text); + $text = preg_replace("/[√]/u", "v", $text); + $text = preg_replace("/[ÚÙÛÜЏЦц]/u", "U", $text); + $text = preg_replace("/[Ψψωώẅẃẁщш]/u", "w", $text); + $text = preg_replace("/[ẀẄẂШЩ]/u", "W", $text); + $text = preg_replace("/[ΧχЖХж]/u", "x", $text); + $text = preg_replace("/[ỲΫ¥]/u", "Y", $text); + $text = preg_replace("/[ỳγўЎУуч]/u", "y", $text); + $text = preg_replace("/[ζ]/u", "Z", $text); $text = preg_replace("/[‚‚]/u", ",", $text); $text = preg_replace("/[`‛′’‘]/u", "'", $text); @@ -271,7 +149,139 @@ final public static function factoryFilename(string $filename) : \codename\core\ $text = str_replace('/', '', $text); $text = str_replace('\\/', '', $text); $text = str_replace(' ', '', $text); - return new \codename\core\value\text\filename($text); + return new filename($text); + } + + /** + * This method normalizes the remote path by trying + * to prepend the base path if it is not prepended yet. + * @param string $path + * @return string + */ + public function normalizePath(string $path): string + { + if (str_starts_with($path, $this->basedir)) { + return $path; + } + return $this->basedir . $path; + } + + /** + * Returns the errorstack of the bucket + * @return errorstack + */ + public function getErrorstack(): errorstack + { + return $this->errorstack; + } + + /** + * + * {@inheritDoc} + * @param filerelative $remotefile + * @param filename $filename + * @param array $option + * @throws exception + * @see bucketInterface::downloadToClient + * @todo error handling + */ + public function downloadToClient(filerelative $remotefile, filename $filename, array $option = []): void + { + if (!$this->fileAvailable($remotefile->get())) { + throw new exception('BUCKET_FILE_DOWNLOAD_UNAVAILABLE', exception::$ERRORLEVEL_ERROR, $remotefile->get()); + } + + $tempfile = '/tmp/' . md5($remotefile->get() . microtime() . $filename->get()); + + // evaluate return value from ::filePull() + if (!$this->filePull($remotefile->get(), $tempfile)) { + throw new exception('BUCKET_FILE_DOWNLOAD_FAIL', exception::$ERRORLEVEL_ERROR, $remotefile->get()); + } + + if (array_key_exists('inline', $option) === true && $option['inline'] === true) { + // Determine Mime Type by extension. I know it's bad. + $path_parts = pathinfo($remotefile->get()); + $ext = strtolower($path_parts["extension"]); + + // Determine Content Type (only for inlining) + $ctype = match ($ext) { + "pdf" => "application/pdf", + "gif" => "image/gif", + "png" => "image/png", + "jpeg", "jpg" => "image/jpg", + default => "application/force-download", + }; + + app::getResponse()->setHeader('Content-Type: ' . $ctype); + app::getResponse()->setHeader("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); + app::getResponse()->setHeader("Cache-Control: post-check=0, pre-check=0"); + app::getResponse()->setHeader("Pragma: no-cache"); + app::getResponse()->setHeader('Content-Disposition: inline; filename="' . $filename->get() . '"'); + app::getResponse()->setHeader('Content-Length: ' . filesize($tempfile)); + app::getResponse()->setHeader('Content-Transfer-Encoding: binary'); + } else { + app::getResponse()->setHeader('Content-Description: File Transfer'); + app::getResponse()->setHeader('Content-Type: application/octet-stream'); + app::getResponse()->setHeader('Content-Transfer-Encoding: binary'); + app::getResponse()->setHeader('Pragma: public'); + app::getResponse()->setHeader('Content-Length: ' . filesize($tempfile)); + app::getResponse()->setHeader('Content-Disposition: attachment; filename="' . $filename->get() . '"'); + } + if (ob_get_contents()) { + ob_clean(); + } + flush(); + readfile($tempfile); + unlink($tempfile); // delete the tempfile afterwards + exit; } + /** + * Normalizes a given path. By default, $strict is being used + * which denies usage of any '.' Or '..' And throws an exception, if found. + * @param string $path + * @param bool $strict [default: true] + * @return string + * @throws exception + */ + protected function normalizeRelativePath(string $path, bool $strict = true): string + { + // + // Make sure we also handle Windows-style backslash paths + // Though bucket convention is slashes only + // + $path = str_replace('\\', '/', $path); + $parts = []; + foreach (explode('/', $path) as $part) { + switch ($part) { + case '.': + // + // NOTE: we might make this thing more tolerant + // '.' might be just discarded w/o throwing an exception + // This just enforces a strict programming style and data handling. + // + if ($strict) { + throw new exception(static::BUCKET_EXCEPTION_BAD_PATH, exception::$ERRORLEVEL_FATAL); + } + break; + case '': // initial/starting slash or // + break; + + case '..': + if ($strict) { + throw new exception(static::BUCKET_EXCEPTION_BAD_PATH, exception::$ERRORLEVEL_FATAL); + } + if (empty($parts)) { + throw new exception(static::BUCKET_EXCEPTION_FORBIDDEN_PATH_TRAVERSAL, exception::$ERRORLEVEL_FATAL); + } + array_pop($parts); + break; + + default: + $parts[] = $part; + break; + } + } + return implode('/', $parts); + } } diff --git a/backend/class/bucket/bucketInterface.php b/backend/class/bucket/bucketInterface.php index 82fdc2e..ee21f7c 100755 --- a/backend/class/bucket/bucketInterface.php +++ b/backend/class/bucket/bucketInterface.php @@ -1,14 +1,17 @@ reset()->validate($data)) > 0) { + if (count($errors = app::getValidator('structure_config_bucket_ftp')->reset()->validate($data)) > 0) { $this->errorstack->addError('CONFIGURATION', 'CONFIGURATION_INVALID', $errors); - throw new \codename\core\exception(self::EXCEPTION_CONSTRUCT_CONFIGURATIONINVALID, \codename\core\exception::$ERRORLEVEL_ERROR, $errors); + throw new exception(self::EXCEPTION_CONSTRUCT_CONFIGURATIONINVALID, exception::$ERRORLEVEL_ERROR, $errors); } $this->basedir = $data['basedir']; + if (isset($data['public']) && $data['public']) { + $this->baseurl = $data['baseurl']; + } + // Default timeout fallback for FTP network operations $this->timeout = $data['timeout'] ?? 2; - if($data['ftpserver']['ssl'] ?? false) { - $this->connection = @ftp_ssl_connect($data['ftpserver']['host'], $data['ftpserver']['port'], $this->timeout); + if ($data['ftpserver']['ssl'] ?? false) { + $this->connection = @ftp_ssl_connect($data['ftpserver']['host'], $data['ftpserver']['port'], $this->timeout); } else { - $this->connection = @ftp_connect($data['ftpserver']['host'], $data['ftpserver']['port'], $this->timeout); - } - - if(isset($data['public']) && $data['public']) { - $this->baseurl = $data['baseurl']; + $this->connection = @ftp_connect($data['ftpserver']['host'], $data['ftpserver']['port'], $this->timeout); } - if(is_bool($this->connection) && !$this->connection) { - $this->errorstack->addError('FILE', 'CONNECTION_FAILED', null); - app::getLog('errormessage')->warning('CORE_BACKEND_CLASS_BUCKET_FTP_CONSTRUCT::CONNECTION_FAILED ($host = ' . $data['ftpserver']['host'] .')'); - throw new exception('EXCEPTION_BUCKET_FTP_CONNECTION_FAILED', exception::$ERRORLEVEL_ERROR, [ 'host' => $data['ftpserver']['host'] ]); - return $this; + if (!$this->connection) { + $this->errorstack->addError('FILE', 'CONNECTION_FAILED'); + app::getLog('errormessage')->warning('CORE_BACKEND_CLASS_BUCKET_FTP_CONSTRUCT::CONNECTION_FAILED ($host = ' . $data['ftpserver']['host'] . ')'); + throw new exception('EXCEPTION_BUCKET_FTP_CONNECTION_FAILED', exception::$ERRORLEVEL_ERROR, ['host' => $data['ftpserver']['host']]); } - if(!@ftp_login($this->connection, $data['ftpserver']['user'], $data['ftpserver']['pass'])) { - $this->errorstack->addError('FILE', 'LOGIN_FAILED', null); - app::getLog('errormessage')->warning('CORE_BACKEND_CLASS_BUCKET_FTP_CONSTRUCT::LOGIN_FAILED ($user = ' . $data['ftpserver']['user'] .')'); - throw new exception('EXCEPTION_BUCKET_FTP_LOGIN_FAILED', exception::$ERRORLEVEL_ERROR, [ 'user' => $data['ftpserver']['user'] ]); + if (!@ftp_login($this->connection, $data['ftpserver']['user'], $data['ftpserver']['pass'])) { + $this->errorstack->addError('FILE', 'LOGIN_FAILED'); + app::getLog('errormessage')->warning('CORE_BACKEND_CLASS_BUCKET_FTP_CONSTRUCT::LOGIN_FAILED ($user = ' . $data['ftpserver']['user'] . ')'); + throw new exception('EXCEPTION_BUCKET_FTP_LOGIN_FAILED', exception::$ERRORLEVEL_ERROR, ['user' => $data['ftpserver']['user']]); } // // Sometimes, the server reports his own IP address // which might be wrong or a local address - // advise the client to ignore it and instead connect + // advises the client to ignore it and instead connect // to the known endpoint directly // - if($data['ftpserver']['ignore_passive_address'] ?? false) { - @ftp_set_option($this->connection, FTP_USEPASVADDRESS, false); + if ($data['ftpserver']['ignore_passive_address'] ?? false) { + @ftp_set_option($this->connection, FTP_USEPASVADDRESS, false); } // passive mode setting from config - if($data['ftpserver']['passive_mode'] ?? false) { - $this->enablePassiveMode(true); + if ($data['ftpserver']['passive_mode'] ?? false) { + $this->enablePassiveMode(true); } return $this; @@ -91,45 +94,52 @@ public function __construct(array $data) { /** * [enablePassiveMode description] - * @param bool $state [description] - * @return [type] [description] + * @param bool $state [description] + * @return void [type] [description] */ - public function enablePassiveMode(bool $state) { - @ftp_pasv($this->connection, $state); + public function enablePassiveMode(bool $state): void + { + @ftp_pasv($this->connection, $state); } /** * * {@inheritDoc} + * @param string $localfile + * @param string $remotefile + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\bucket_interface::filePush($localfile, $remotefile) */ - public function filePush(string $localfile, string $remotefile) : bool { + public function filePush(string $localfile, string $remotefile): bool + { // Path sanitization $remotefile = $this->normalizeRelativePath($remotefile); - if(!app::getFilesystem()->fileAvailable($localfile)) { + if (!app::getFilesystem()->fileAvailable($localfile)) { $this->errorstack->addError('FILE', 'LOCAL_FILE_NOT_FOUND', $localfile); return false; } - if($this->fileAvailable($remotefile)) { + if ($this->fileAvailable($remotefile)) { $this->errorstack->addError('FILE', 'REMOTE_FILE_EXISTS', $remotefile); return false; } $directory = $this->extractDirectory($remotefile); - if($directory != '' && !$this->dirAvailable($directory)) { + if ($directory != '' && !$this->dirAvailable($directory)) { $this->dirCreate($directory); } try { - if(!@ftp_put($this->connection, $this->basedir . $remotefile, $localfile, FTP_BINARY)) { - $this->errorstack->addError('FILE', 'FTP_PUT_ERROR', $this->basedir . $remotefile); - return false; - } - } catch (\Exception $e) { - $this->errorstack->addError('FILE', 'FILE_PUSH_FAILED', $this->basedir . $remotefile); + if (!@ftp_put($this->connection, $this->basedir . $remotefile, $localfile)) { + $this->errorstack->addError('FILE', 'FTP_PUT_ERROR', $this->basedir . $remotefile); + return false; + } + } catch (\Exception) { + $this->errorstack->addError('FILE', 'FILE_PUSH_FAILED', $this->basedir . $remotefile); } return $this->fileAvailable($remotefile); @@ -138,277 +148,311 @@ public function filePush(string $localfile, string $remotefile) : bool { /** * * {@inheritDoc} - * @see \codename\core\bucket_interface::filePull($remotefile, $localfile) + * @see \codename\core\bucket_interface::fileAvailable($remotefile) */ - public function filePull(string $remotefile, string $localfile) : bool { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - - if(app::getFilesystem()->fileAvailable($localfile)) { - $this->errorstack->addError('FILE', 'LOCAL_FILE_EXISTS', $localfile); - return false; - } + public function fileAvailable(string $remotefile): bool + { + // + // Neat trick to check for existence - simply use ftp_size + // which returns -1 for a nonexisting file + // NOTE: directories will also return -1 + // - if(!$this->fileAvailable($remotefile)) { - $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); - return false; - } + return @ftp_size($this->connection, $this->basedir . $remotefile) !== -1; + } - // This might fail due to various reasons - // read error on remote - or write error on local target path - if(!@ftp_get($this->connection, $localfile, $this->basedir . $remotefile, FTP_BINARY)) { - $this->errorstack->addError('FILE', 'FTP_GET_ERROR', [$localfile, $remotefile]); - return false; + /** + * Extracts the directory path from $filename + * example: + * $name = extractDirectory('/path/to/file.mp3'); + * + * // $name is now '/path/to/' + * @param string $filename + * @return string + */ + protected function extractDirectory(string $filename): string + { + $directory = pathinfo($filename, PATHINFO_DIRNAME); + if ($directory == '.') { + return ''; + } else { + return $directory; } - - return app::getFilesystem()->fileAvailable($localfile); } /** * * {@inheritDoc} + * @param string $directory + * @return bool + * @throws exception * @see \codename\core\bucket_interface::dirAvailable($directory) */ - public function dirAvailable(string $directory) : bool { - // Path sanitization - $directory = $this->normalizeRelativePath($directory); + public function dirAvailable(string $directory): bool + { + // Path sanitization + $directory = $this->normalizeRelativePath($directory); - return static::ftp_isdir($this->connection, $directory); + return static::ftp_isdir($this->connection, $directory); } /** * [ftp_isdir description] - * @param [type] $conn_id [description] - * @param [type] $dir [description] - * @return [type] [description] + * @param $conn_id + * @param $dir + * @return bool [type] [description] */ - protected static function ftp_isdir($conn_id,$dir) + protected static function ftp_isdir($conn_id, $dir): bool { - // Try to change the directory - // and automatically go up, if it worked - if(@ftp_chdir($conn_id,$dir)) { - ftp_cdup($conn_id); - return true; - } else { - return false; - } + // Try to change the directory + // and automatically go up if it worked + if (@ftp_chdir($conn_id, $dir)) { + ftp_cdup($conn_id); + return true; + } else { + return false; + } } /** - * - * {@inheritDoc} - * @see \codename\core\bucket_interface::dirList($directory) + * Creates the given $directory on this instance's remote hostname + * @param string $directory + * @return bool + * @throws exception */ - public function dirList(string $directory) : array { + public function dirCreate(string $directory): bool + { // Path sanitization $directory = $this->normalizeRelativePath($directory); - if(!$this->dirAvailable($directory)) { - $this->errorstack->addError('DIRECTORY', 'REMOTE_DIRECTORY_NOT_FOUND', $directory); - return array(); + if ($this->dirAvailable($directory)) { + return true; } - $list = $this->getDirlist($directory); - $myList = array(); - if(!is_array($list)) { - return $myList; - } + // + // ftp_mkdir is not recursive, + // therefore, we have to traverse each directory/component + // of the given path + // - $prefix = $directory != '' ? $directory.'/' : ''; - foreach($list as $element) { - $myList[] = $prefix.$element; - // $myList[] = str_replace('/', '', str_replace(str_replace('//', '/', $this->basedir), '', $directory.'/'.$element)); + // Store current directory for later restoration + $prevDir = @ftp_pwd($this->connection); + $parts = explode('/', $directory); + foreach ($parts as $part) { + if (!@ftp_chdir($this->connection, $part)) { + @ftp_mkdir($this->connection, $part); + @ftp_chdir($this->connection, $part); + } } - return $myList; + + // revert to starting directory. + @ftp_chdir($this->connection, $prevDir); + + return $this->dirAvailable($directory); } /** * * {@inheritDoc} - * @see \codename\core\bucket_interface::fileAvailable($remotefile) + * @param string $remotefile + * @param string $localfile + * @return bool + * @throws ReflectionException + * @throws exception + * @see \codename\core\bucket_interface::filePull($remotefile, $localfile) */ - public function fileAvailable(string $remotefile) : bool { - // - // Neat trick to check for existance - simply use ftp_size - // which returns -1 for a nonexisting file - // NOTE: directories will also return -1 - // + public function filePull(string $remotefile, string $localfile): bool + { + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); - return @ftp_size($this->connection, $this->basedir . $remotefile) !== -1; + if (app::getFilesystem()->fileAvailable($localfile)) { + $this->errorstack->addError('FILE', 'LOCAL_FILE_EXISTS', $localfile); + return false; + } + + if (!$this->fileAvailable($remotefile)) { + $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); + return false; + } + + // This might fail due to various reasons + // read error on remote - or write error on a local target path + if (!@ftp_get($this->connection, $localfile, $this->basedir . $remotefile)) { + $this->errorstack->addError('FILE', 'FTP_GET_ERROR', [$localfile, $remotefile]); + return false; + } + + return app::getFilesystem()->fileAvailable($localfile); } /** * * {@inheritDoc} - * @see \codename\core\bucket_interface::fileDelete($remotefile) + * @param string $directory + * @return array + * @throws exception + * @see \codename\core\bucket_interface::dirList($directory) */ - public function fileDelete(string $remotefile) : bool { + public function dirList(string $directory): array + { // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); + $directory = $this->normalizeRelativePath($directory); - if(!$this->fileAvailable($remotefile)) { - $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); - return true; + if (!$this->dirAvailable($directory)) { + $this->errorstack->addError('DIRECTORY', 'REMOTE_DIRECTORY_NOT_FOUND', $directory); + return []; } + $list = $this->getDirlist($directory); + $myList = []; - if(!@ftp_delete($this->connection, $this->basedir . $remotefile)) { - $this->errorstack->addError('FILE', 'REMOTE_FILE_DELETE_FAILED', $remotefile); - return false; + if (!is_array($list)) { + return $myList; } - return !$this->fileAvailable($remotefile); + $prefix = $directory != '' ? $directory . '/' : ''; + foreach ($list as $element) { + $myList[] = $prefix . $element; + } + return $myList; } /** - * @inheritDoc - * @see \codename\core\bucket_interface::fileMove($remotefile, $newremotefile) + * Nested function to retrieve a directory List + * @param string $directory + * @return array | null */ - public function fileMove(string $remotefile, string $newremotefile): bool + protected function getDirlist(string $directory): ?array { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - $newremotefile = $this->normalizeRelativePath($newremotefile); - - if(!$this->fileAvailable($remotefile)) { - $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); - return false; - } - - // check for existance of the new file - if($this->fileAvailable($newremotefile)) { - $this->errorstack->addError('FILE', 'FILE_ALREADY_EXISTS', $newremotefile); - return false; - } - - $directory = $this->extractDirectory($newremotefile); - if($directory !== '' && !$this->dirAvailable($directory)) { - $this->dirCreate($directory); - } - @ftp_rename($this->connection, $this->basedir . $remotefile, $this->basedir . $newremotefile); - - return $this->fileAvailable($newremotefile); + return @ftp_nlist($this->connection, $this->basedir . $directory); } /** * * {@inheritDoc} - * @see \codename\core\bucket\bucketInterface::isFile() + * @param string $remotefile + * @return bool + * @throws exception + * @see \codename\core\bucket_interface::fileDelete($remotefile) */ - public function isFile(string $remotefile) : bool { - $remotefile = $this->normalizeRelativePath($remotefile); // Path sanitization - $list = $this->getRawlist($this->extractDirectory($remotefile)); - if(!is_array($list)) { - return false; + public function fileDelete(string $remotefile): bool + { + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); + + if (!$this->fileAvailable($remotefile)) { + $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); + return true; } - foreach($list as $file) { - if(strpos($file, $this->extractFilename($remotefile)) !== false) { - return substr($file, 0,1) == 'd' ? false : true; - } + + if (!@ftp_delete($this->connection, $this->basedir . $remotefile)) { + $this->errorstack->addError('FILE', 'REMOTE_FILE_DELETE_FAILED', $remotefile); + return false; } - return false; - } - /** - * - * {@inheritDoc} - * @see \codename\core\bucket_interface::fileGetUrl($remotefile) - */ - public function fileGetUrl(string $remotefile) : string { - return $this->baseurl . $remotefile; + return !$this->fileAvailable($remotefile); } /** - * * {@inheritDoc} - * @see \codename\core\bucket_interface::fileGetInfo($remotefile) - */ - public function fileGetInfo(string $remotefile) : array {} - - /** - * Creates the given $directory on this instance's remote hostname - * @param string $directory + * @param string $remotefile + * @param string $newremotefile * @return bool + * @throws exception + * @see \codename\core\bucket_interface::fileMove($remotefile, $newremotefile) */ - public function dirCreate(string $directory) { + public function fileMove(string $remotefile, string $newremotefile): bool + { // Path sanitization - $directory = $this->normalizeRelativePath($directory); + $remotefile = $this->normalizeRelativePath($remotefile); + $newremotefile = $this->normalizeRelativePath($newremotefile); - if($this->dirAvailable($directory)) { - return true; + if (!$this->fileAvailable($remotefile)) { + $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); + return false; } - // - // ftp_mkdir is not recursive - // therefore, we have to traverse each directory/component - // of the given path - // - - // Store current directory for later restoration - $prevDir = @ftp_pwd($this->connection); - - $parts = explode('/', $directory); - foreach ($parts as $part) { - if(!@ftp_chdir($this->connection, $part)){ - @ftp_mkdir($this->connection, $part); - @ftp_chdir($this->connection, $part); - } + // check for the existence of the new file + if ($this->fileAvailable($newremotefile)) { + $this->errorstack->addError('FILE', 'FILE_ALREADY_EXISTS', $newremotefile); + return false; } - // revert to starting directory. - @ftp_chdir($this->connection, $prevDir); + $directory = $this->extractDirectory($newremotefile); + if ($directory !== '' && !$this->dirAvailable($directory)) { + $this->dirCreate($directory); + } + @ftp_rename($this->connection, $this->basedir . $remotefile, $this->basedir . $newremotefile); - return $this->dirAvailable($directory); + return $this->fileAvailable($newremotefile); } /** - * Nested function to retrieve a directory List - * @param string $directory - * @return array | null + * + * {@inheritDoc} + * @param string $remotefile + * @return bool + * @throws exception + * @see bucketInterface::isFile */ - protected function getDirlist(string $directory) { - return @ftp_nlist($this->connection, $this->basedir . $directory); + public function isFile(string $remotefile): bool + { + $remotefile = $this->normalizeRelativePath($remotefile); // Path sanitization + $list = $this->getRawlist($this->extractDirectory($remotefile)); + if (!is_array($list)) { + return false; + } + foreach ($list as $file) { + if (str_contains($file, $this->extractFilename($remotefile))) { + return !(str_starts_with($file, 'd')); + } + } + return false; } /** - * Nested functino to retrieve a RAW directory list + * Nested function to retrieve a RAW directory list * @param string $directory * @return array | null */ - protected function getRawlist(string $directory) { + protected function getRawlist(string $directory): ?array + { return @ftp_rawlist($this->connection, $this->basedir . $directory); } /** - * Extracts the directory path from $filename - *
example: - *
$name = extractDirectory('/path/to/file.mp3'); - *
- *
// $name is now '/path/to/' + * Extracts the file name from $filename + * example: + * $name = extractDirectory('/path/to/file.mp3'); + * + * // $name is now 'file.mp3' * @param string $filename * @return string */ - protected function extractDirectory(string $filename) : string { - $directory = pathinfo($filename, PATHINFO_DIRNAME); - if($directory == '.') { - return ''; - } else { - return $directory; - } + protected function extractFilename(string $filename): string + { + $filenamedata = explode('/', $filename); + return $filenamedata[count($filenamedata) - 1]; } /** - * Extracts the file name from $filename - *
example: - *
$name = extractDirectory('/path/to/file.mp3'); - *
- *
// $name is now 'file.mp3' - * @param string $filename - * @return string + * + * {@inheritDoc} + * @see \codename\core\bucket_interface::fileGetUrl($remotefile) */ - protected function extractFilename(string $filename) : string { - $filenamedata = explode('/', $filename); - return $filenamedata[count($filenamedata) - 1]; + public function fileGetUrl(string $remotefile): string + { + return $this->baseurl . $remotefile; } + /** + * + * {@inheritDoc} + * @see \codename\core\bucket_interface::fileGetInfo($remotefile) + */ + public function fileGetInfo(string $remotefile): array + { + return []; + } } diff --git a/backend/class/bucket/local.php b/backend/class/bucket/local.php index a03af3e..0004b8f 100755 --- a/backend/class/bucket/local.php +++ b/backend/class/bucket/local.php @@ -1,44 +1,60 @@ At least I am just a compatable helper to \codename\core\filesystem\local + * At least I am just a compatible helper to \codename\core\filesystem\local * @package core * @since 2016-04-21 */ -class local extends \codename\core\bucket implements \codename\core\bucket\bucketInterface { - +class local extends bucket implements bucketInterface +{ /** - * is TRUE if the bucket's basedir is publically available via HTTP(s) - * @var bool + * File is not writable (permissions) + * @var string */ - protected $public = false; - + public const string EXCEPTION_FILEPUSH_FILENOTWRITABLE = 'EXCEPTION_FILEPUSH_FILENOTWRITABLE'; + /** + * File is writable, but unknown another issue + * @var string + */ + public const string EXCEPTION_FILEPUSH_FILEWRITABLE_UNKNOWN_ERROR = 'EXCEPTION_FILEPUSH_FILEWRITABLE_UNKNOWN_ERROR'; /** * If the bucket is $public, this contains the URL the bucket can be accessed via HTTP(s) * @var string $baseurl */ - public $baseurl = ''; + public string $baseurl = ''; + /** + * is TRUE if the bucket's basedir is publicly available via HTTP(s) + * @var bool + */ + protected bool $public = false; /** * * @param array $data + * @throws ReflectionException + * @throws exception */ - public function __construct(array $data) { - $this->errorstack = new \codename\core\errorstack('BUCKET'); + public function __construct(array $data) + { + parent::__construct($data); - if(count($errors = app::getValidator('structure_config_bucket_local')->reset()->validate($data)) > 0) { + if (count($errors = app::getValidator('structure_config_bucket_local')->reset()->validate($data)) > 0) { $this->errorstack->addError('CONFIGURATION', 'CONFIGURATION_INVALID', $errors); - throw new \codename\core\exception(self::EXCEPTION_CONSTRUCT_CONFIGURATIONINVALID, \codename\core\exception::$ERRORLEVEL_ERROR, $errors); + throw new exception(self::EXCEPTION_CONSTRUCT_CONFIGURATIONINVALID, exception::$ERRORLEVEL_ERROR, $errors); } $this->basedir = $data['basedir']; - $this->public = $data['public']; + $this->public = $data['public']; - if($this->public) { + if ($this->public) { $this->baseurl = $data['baseurl']; } @@ -48,58 +64,56 @@ public function __construct(array $data) { /** * * {@inheritDoc} + * @param string $localfile + * @param string $remotefile + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\bucket_interface::filePush($localfile, $remotefile) */ - public function filePush(string $localfile, string $remotefile) : bool { - + public function filePush(string $localfile, string $remotefile): bool + { // Path sanitization $remotefile = $this->normalizeRelativePath($remotefile); // filename just for usage with FS functions $normalizedRemotefile = $this->normalizePath($remotefile); - if(!app::getFilesystem()->fileAvailable($localfile)) { + if (!app::getFilesystem()->fileAvailable($localfile)) { $this->errorstack->addError('FILE', 'LOCAL_FILE_NOT_FOUND', $localfile); return false; } - if($this->fileAvailable($remotefile)) { + if ($this->fileAvailable($remotefile)) { $this->errorstack->addError('FILE', 'REMOTE_FILE_EXISTS', $remotefile); return false; } - if(!app::getFilesystem()->fileCopy($localfile, $normalizedRemotefile)) { - // If Copy not successful, check access rights: - if(!is_writable($normalizedRemotefile)) { - // Access rights/permissions error. directory/file not writable - throw new \codename\core\exception(self::EXCEPTION_FILEPUSH_FILENOTWRITABLE,\codename\core\exception::$ERRORLEVEL_ERROR, $remotefile); - } else { - // Unknown Error - throw new \codename\core\exception(self::EXCEPTION_FILEPUSH_FILEWRITABLE_UNKNOWN_ERROR,\codename\core\exception::$ERRORLEVEL_FATAL, $remotefile); - } + if (!app::getFilesystem()->fileCopy($localfile, $normalizedRemotefile)) { + // If Copy not successful, check access rights: + if (!is_writable($normalizedRemotefile)) { + // Access rights/permissions error. directory/file not writable + throw new exception(self::EXCEPTION_FILEPUSH_FILENOTWRITABLE, exception::$ERRORLEVEL_ERROR, $remotefile); + } else { + // Unknown Error + throw new exception(self::EXCEPTION_FILEPUSH_FILEWRITABLE_UNKNOWN_ERROR, exception::$ERRORLEVEL_FATAL, $remotefile); + } } return $this->fileAvailable($remotefile); } - /** - * File is not writable (permissions) - * @var string - */ - const EXCEPTION_FILEPUSH_FILENOTWRITABLE = 'EXCEPTION_FILEPUSH_FILENOTWRITABLE'; - - /** - * File is writable, but unkown other issue - * @var string - */ - const EXCEPTION_FILEPUSH_FILEWRITABLE_UNKNOWN_ERROR = 'EXCEPTION_FILEPUSH_FILEWRITABLE_UNKNOWN_ERROR'; - /** * * {@inheritDoc} + * @param string $remotefile + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\bucket_interface::fileAvailable($remotefile) */ - public function fileAvailable (string $remotefile) : bool { + public function fileAvailable(string $remotefile): bool + { // Path sanitization $remotefile = $this->normalizeRelativePath($remotefile); $normalizedRemotefile = $this->normalizePath($remotefile); @@ -109,25 +123,47 @@ public function fileAvailable (string $remotefile) : bool { /** * * {@inheritDoc} + * @param string $remotefile + * @return bool + * @throws ReflectionException + * @throws exception + * @see bucketInterface::isFile + */ + public function isFile(string $remotefile): bool + { + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); + return app::getFilesystem()->isFile($this->normalizePath($remotefile)); + } + + /** + * + * {@inheritDoc} + * @param string $remotefile + * @param string $localfile + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\bucket_interface::filePull($remotefile, $localfile) */ - public function filePull(string $remotefile, string $localfile) : bool { + public function filePull(string $remotefile, string $localfile): bool + { // Path sanitization $remotefile = $this->normalizeRelativePath($remotefile); - if(!$this->fileAvailable($remotefile)) { + if (!$this->fileAvailable($remotefile)) { $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); return false; } - if(app::getFilesystem()->fileAvailable($localfile)) { + if (app::getFilesystem()->fileAvailable($localfile)) { $this->errorstack->addError('FILE', 'LOCAL_FILE_EXISTS', $localfile); return false; } $normalizedRemotefile = $this->normalizePath($remotefile); - if(!app::getFilesystem()->fileCopy($normalizedRemotefile, $localfile)) { - return false; + if (!app::getFilesystem()->fileCopy($normalizedRemotefile, $localfile)) { + return false; } return app::getFilesystem()->fileAvailable($localfile); @@ -136,13 +172,18 @@ public function filePull(string $remotefile, string $localfile) : bool { /** * * {@inheritDoc} + * @param string $remotefile + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\bucket_interface::fileDelete($remotefile) */ - public function fileDelete(string $remotefile) : bool { + public function fileDelete(string $remotefile): bool + { // Path sanitization $remotefile = $this->normalizeRelativePath($remotefile); - if(!$this->fileAvailable($remotefile)) { + if (!$this->fileAvailable($remotefile)) { $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); return true; } @@ -152,32 +193,42 @@ public function fileDelete(string $remotefile) : bool { } /** - * @inheritDoc + * {@inheritDoc} + * @param string $remotefile + * @param string $newremotefile + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\bucket_interface::fileMove($remotefile, $newremotefile) */ public function fileMove(string $remotefile, string $newremotefile): bool { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - $newremotefile = $this->normalizeRelativePath($newremotefile); + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); + $newremotefile = $this->normalizeRelativePath($newremotefile); - $normalizedRemotefile = $this->normalizePath($remotefile); - $normalizedNewremotefile = $this->normalizePath($newremotefile); - return app::getFilesystem()->fileMove($normalizedRemotefile, $normalizedNewremotefile); + $normalizedRemotefile = $this->normalizePath($remotefile); + $normalizedNewremotefile = $this->normalizePath($newremotefile); + return app::getFilesystem()->fileMove($normalizedRemotefile, $normalizedNewremotefile); } /** * * {@inheritDoc} + * @param string $remotefile + * @return string + * @throws ReflectionException + * @throws exception * @see \codename\core\bucket_interface::fileGetUrl($remotefile) */ - public function fileGetUrl(string $remotefile) : string { - if(!$this->fileAvailable($remotefile)) { + public function fileGetUrl(string $remotefile): string + { + if (!$this->fileAvailable($remotefile)) { $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); return ''; } - if(!$this->public) { + if (!$this->public) { $this->errorstack->addError('FILE', 'BUCKET_NOT_PUBLIC'); return ''; } @@ -188,9 +239,14 @@ public function fileGetUrl(string $remotefile) : string { /** * * {@inheritDoc} + * @param string $remotefile + * @return array + * @throws ReflectionException + * @throws exception * @see \codename\core\bucket_interface::fileGetInfo($remotefile) */ - public function fileGetInfo(string $remotefile) : array { + public function fileGetInfo(string $remotefile): array + { // Path sanitization $remotefile = $this->normalizeRelativePath($remotefile); $normalizedRemotefile = $this->normalizePath($remotefile); @@ -200,45 +256,38 @@ public function fileGetInfo(string $remotefile) : array { /** * * {@inheritDoc} - * @see \codename\core\bucket_interface::dirAvailable($directory) - */ - public function dirAvailable(string $directory) : bool { - // Path sanitization - $directory = $this->normalizeRelativePath($directory); - $normalizedDirectory = $this->normalizePath($directory); - return app::getFilesystem()->dirAvailable($normalizedDirectory); - } - - /** - * - * {@inheritDoc} + * @param string $directory + * @return array + * @throws ReflectionException + * @throws exception * @see \codename\core\bucket_interface::dirList($directory) */ - public function dirList(string $directory) : array { + public function dirList(string $directory): array + { // Path sanitization $directory = $this->normalizeRelativePath($directory); - if(!$this->dirAvailable($directory)) { + if (!$this->dirAvailable($directory)) { $this->errorstack->addError('DIRECTORY', 'REMOTE_DIRECTORY_NOT_FOUND', $directory); - return array(); + return []; } $normalizedDirectory = $this->normalizePath($directory); // // HACK: - // change bucket_local::dirList() behaviour to be relative to $directory - // simply prepend $directory to each entry + // change bucket_local::dirList() behavior to be relative to $directory + // prepend $directory to each entry // $list = app::getFilesystem()->dirList($normalizedDirectory); // At this point, we use $directory from above as "helper" // but internally rely on data the FS-client gave us. - if($directory !== '' && substr($directory, strlen($directory)-1, 1) !== '/') { - $directory .= '/'; + if ($directory !== '' && !str_ends_with($directory, '/')) { + $directory .= '/'; } - foreach($list as &$entry) { - $entry = $directory.$entry; + foreach ($list as &$entry) { + $entry = $directory . $entry; } return $list; } @@ -246,12 +295,17 @@ public function dirList(string $directory) : array { /** * * {@inheritDoc} - * @see \codename\core\bucket\bucketInterface::isFile() + * @param string $directory + * @return bool + * @throws ReflectionException + * @throws exception + * @see \codename\core\bucket_interface::dirAvailable($directory) */ - public function isFile(string $remotefile) : bool { + public function dirAvailable(string $directory): bool + { // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - return app::getFilesystem()->isFile($this->normalizePath($remotefile)); + $directory = $this->normalizeRelativePath($directory); + $normalizedDirectory = $this->normalizePath($directory); + return app::getFilesystem()->dirAvailable($normalizedDirectory); } - } diff --git a/backend/class/bucket/s3.php b/backend/class/bucket/s3.php index 4737d44..79a79f4 100644 --- a/backend/class/bucket/s3.php +++ b/backend/class/bucket/s3.php @@ -1,597 +1,620 @@ * @since 2017-04-05 */ -class s3 extends \codename\core\bucket implements \codename\core\bucket\bucketInterface { - - /** - * the current S3 client from AWS SDK - * @var \Aws\S3\S3Client - */ - protected $client = null; - - /** - * the bucket used for this client instance - * @var string - */ - protected $bucket = null; - - /** - * s3 version used for this client instance - * @var string - */ - protected $version = '2006-03-01'; - /** - * AWS region for s3 - * defaults to eu-west-1 - * @var string - */ - protected $region = 'eu-west-1'; - - /** - * credentials. defaults to null (e.g. for IAM-bases auth with EC2 instances) - * @var array - */ - protected $credentials = null; - - /** - * current acl - * used for every upload/copy/... command - * @var string - */ - protected $acl = self::ACL_PRIVATE; - - /** - * ACL: Private - needs signed links in frontend - * @var string - */ - const ACL_PRIVATE = 'private'; - - /** - * ACL: Public Read - * @var string - */ - const ACL_PUBLIC_READ = 'public-read'; - - /** - * actually, there are more ACL options than those two: - * private | public-read | public-read-write | authenticated-read | bucket-owner-read | bucket-owner-full-control - */ - - - /** - * default prefix for limiting access to the bucket - * bucketname/prefix... - * @var string - */ - protected $prefix = ''; - - /** - * option to make this client fetch ALL - * available results, e.g. in dirList - * using internal handling of continuationTokens - * @var bool - */ - protected $useContinuationToken = false; - - /** - * returns the current prefixed path component - * @return string [description] - */ - protected function getPathPrefix() : string { - return $this->prefix != '' ? ($this->prefix . '/') : ''; - } - - /** - * returns the final prefixed path - * and omits prepending, if already present. - * @param string $path - * @return string - */ - public function getPrefixedPath(string $path) : string { - $prefix = $this->getPathPrefix(); - if($path != '') { - if($prefix == '' || strpos($path, $prefix) !== 0) { - return $prefix . $path; - } - } else { - $path = $prefix; +class s3 extends bucket implements bucketInterface +{ + /** + * ACL: Private - needs signed links in frontend + * @var string + */ + public const string ACL_PRIVATE = 'private'; + /** + * ACL: Public Read + * @var string + */ + public const string ACL_PUBLIC_READ = 'public-read'; + /** + * this is the official AWS public grant URI + * for determining public-read ACL using getAccessInfo/getObjectAcl + * @var string + */ + public const string PUBLIC_GRANT_URI = 'http://acs.amazonaws.com/groups/global/AllUsers'; + /** + * the current S3 client from AWS SDK + * @var null|S3Client + */ + protected ?S3Client $client = null; + /** + * the bucket used for this client instance + * @var null|string + */ + protected ?string $bucket = null; + /** + * s3 version used for this client instance + * @var string + */ + protected string $version = '2006-03-01'; + /** + * AWS region for s3 + * defaults to eu-west-1 + * @var string + */ + protected string $region = 'eu-west-1'; + /** + * credentials. defaults to null (e.g., for IAM bases auth with EC2 instances) + * @var null|array + */ + protected ?array $credentials = null; + + /** + * actually, there are more ACL options than those two: + * private | public-read | public-read-write | authenticated-read | bucket-owner-read | bucket-owner-full-control + */ + /** + * current acl + * used for every upload/copy/... command + * @var string + */ + protected string $acl = self::ACL_PRIVATE; + /** + * default prefix for limiting access to the bucket + * bucketname/prefix... + * @var string + */ + protected string $prefix = ''; + /** + * option to make this client fetch ALL + * available results, e.g., in dirList + * using internal handling of continuationTokens + * @var bool + */ + protected bool $useContinuationToken = false; + + /** + * + * @param array $data + * @throws ReflectionException + * @throws exception + */ + public function __construct(array $data) + { + parent::__construct($data); + if (count($errors = app::getValidator('structure_config_bucket_s3')->reset()->validate($data)) > 0) { + $this->errorstack->addError('CONFIGURATION', 'CONFIGURATION_INVALID', $errors); + throw new exception(self::EXCEPTION_CONSTRUCT_CONFIGURATIONINVALID, 4, $errors); + } + + $this->bucket = $data['bucket']; + $this->region = $data['region'] ?? $this->region; + $this->version = $data['version'] ?? $this->version; + $this->credentials = $data['credentials'] ?? $this->credentials; + $this->prefix = $data['prefix'] ?? $this->prefix; + $this->useContinuationToken = $data['use_continuation_token'] ?? false; + + $factoryConfig = [ + 'version' => $this->version, + 'region' => $this->region, + ]; + + // custom endpoint override + if ($data['bucket_endpoint'] ?? false) { + $factoryConfig['bucket_endpoint'] = $data['bucket_endpoint']; + } + + if ($data['endpoint'] ?? false) { + $factoryConfig['endpoint'] = $data['endpoint']; + } + + if ($this->credentials != null) { + $factoryConfig['credentials'] = $this->credentials; + } + + + $this->client = new S3Client($factoryConfig); + + return $this; } - return $path; - } - - /** - * sets the current path prefix used for all requests using this instance - * @param string $prefix [path component] without trailing slash - */ - public function setPathPrefix(string $prefix) { - $this->prefix = $prefix; - } - - /** - * - * @param array $data - */ - public function __construct(array $data) { - $this->errorstack = new \codename\core\errorstack('BUCKET'); - if(count($errors = app::getValidator('structure_config_bucket_s3')->reset()->validate($data)) > 0) { - $this->errorstack->addError('CONFIGURATION', 'CONFIGURATION_INVALID', $errors); - throw new \codename\core\exception(self::EXCEPTION_CONSTRUCT_CONFIGURATIONINVALID, 4, $errors); - } - - $this->bucket = $data['bucket']; - $this->region = $data['region'] ?? $this->region; - $this->version = $data['version'] ?? $this->version; - $this->credentials = $data['credentials'] ?? $this->credentials; - $this->prefix = $data['prefix'] ?? $this->prefix; - $this->useContinuationToken = $data['use_continuation_token'] ?? false; - - $factoryConfig = array( - 'version' => $this->version, - 'region' => $this->region - ); - - // custom endpoint override - if($data['bucket_endpoint'] ?? false) { - $factoryConfig['bucket_endpoint'] = $data['bucket_endpoint']; - } - - if($data['endpoint'] ?? false) { - $factoryConfig['endpoint'] = $data['endpoint']; - } - - if($this->credentials != null) { - $factoryConfig['credentials'] = $this->credentials; - } - - - $this->client = \Aws\S3\S3Client::factory($factoryConfig); - - return $this; - } - - /** - * sets the current default ACL for this bucket instance - * @param string $acl [description] - * @return bool [success of operation] - */ - public function setAcl(string $acl) : bool { - if(($acl == self::ACL_PRIVATE) || ($acl == self::ACL_PUBLIC_READ)) { - $this->acl = $acl; - return true; + + /** + * sets the current path prefix used for all requests using this instance + * @param string $prefix [path component] without trailing slash + */ + public function setPathPrefix(string $prefix): void + { + $this->prefix = $prefix; } - return false; - } - - /** - * @inheritDoc - */ - public function filePush(string $localfile, string $remotefile): bool - { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - - if(!app::getFilesystem()->fileAvailable($localfile)) { - $this->errorstack->addError('FILE', 'LOCAL_FILE_NOT_FOUND', $localfile); + + /** + * sets the current default ACL for this bucket instance + * @param string $acl [description] + * @return bool [success of operation] + */ + public function setAcl(string $acl): bool + { + if (($acl == self::ACL_PRIVATE) || ($acl == self::ACL_PUBLIC_READ)) { + $this->acl = $acl; + return true; + } return false; } - if($this->fileAvailable($remotefile)) { - $this->errorstack->addError('FILE', 'REMOTE_FILE_EXISTS', $remotefile); + /** + * {@inheritDoc} + * @param string $localfile + * @param string $remotefile + * @return bool + * @throws ReflectionException + * @throws exception + */ + public function filePush(string $localfile, string $remotefile): bool + { + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); + + if (!app::getFilesystem()->fileAvailable($localfile)) { + $this->errorstack->addError('FILE', 'LOCAL_FILE_NOT_FOUND', $localfile); + return false; + } + + if ($this->fileAvailable($remotefile)) { + $this->errorstack->addError('FILE', 'REMOTE_FILE_EXISTS', $remotefile); + return false; + } + + try { + $this->client->putObject([ + 'Bucket' => $this->bucket, + 'Key' => $this->getPrefixedPath($remotefile), + 'SourceFile' => $localfile, + 'ACL' => $this->acl, + ]); + return true; // ? + } catch (S3Exception $e) { + $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); + } return false; } - try{ - $result = $this->client->putObject([ - 'Bucket' => $this->bucket, - 'Key' => $this->getPrefixedPath($remotefile), - 'SourceFile' => $localfile, - 'ACL' => $this->acl - ]); - return true; // ? - } catch (S3Exception $e) { - $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); - } - return false; - } - - /** - * @inheritDoc - */ - public function filePull(string $remotefile, string $localfile): bool - { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - - if(app::getFilesystem()->fileAvailable($localfile)) { - $this->errorstack->addError('FILE', 'LOCAL_FILE_EXISTS', $localfile); - return false; + /** + * {@inheritDoc} + * @param string $remotefile + * @return bool + * @throws exception + */ + public function fileAvailable(string $remotefile): bool + { + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); + return $this->objectExists($this->getPrefixedPath($remotefile)); } - if(!$this->fileAvailable($remotefile)) { - $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); + /** + * [objectExists description] + * @param string $key [description] + * @return bool [description] + * @throws \Exception + */ + protected function objectExists(string $key): bool + { + try { + /** + * @see http://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.S3.S3ClientInterface.html#_doesObjectExist + */ + return $this->client->doesObjectExist( + $this->bucket, + $key + // optional: options + ); + } catch (S3Exception $e) { + $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); + echo($e->getMessage()); + } return false; } - try{ - $result = $this->client->getObject([ - 'Bucket' => $this->bucket, - 'Key' => $this->getPrefixedPath($remotefile), - 'SaveAs' => $localfile, - ]); - return true; - } catch (S3Exception $e) { - $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); - } - return false; - } - - /** - * @inheritDoc - */ - public function fileAvailable(string $remotefile): bool - { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - return $this->objectExists($this->getPrefixedPath($remotefile)); - } - - /** - * [objectExists description] - * @param string $key [description] - * @return bool [description] - */ - protected function objectExists(string $key) : bool - { - try{ - /** - * @see http://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.S3.S3ClientInterface.html#_doesObjectExist - */ - return $this->client->doesObjectExist( - $this->bucket, - $key - // optional: options - ); - } catch (S3Exception $e) { - $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); - echo($e->getMessage()); - } - return false; - } - - /** - * @inheritDoc - */ - public function fileDelete(string $remotefile): bool - { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - try{ - /** - * @see http://docs.aws.amazon.com/aws-sdk-php/v3/api/api-s3-2006-03-01.html#deleteobject - */ - $result = $this->client->deleteObject([ - 'Bucket' => $this->bucket, - 'Key' => $this->getPrefixedPath($remotefile), - ]); - return true; - } catch (S3Exception $e) { - $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); - } - return false; - } - - /** - * @inheritDoc - */ - public function fileMove(string $remotefile, string $newremotefile): bool - { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - $newremotefile = $this->normalizeRelativePath($newremotefile); - - if(!$this->fileAvailable($remotefile)) { - $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); - return false; + /** + * returns the final prefixed path + * and omits prepending, if already present. + * @param string $path + * @return string + */ + public function getPrefixedPath(string $path): string + { + $prefix = $this->getPathPrefix(); + if ($path != '') { + if ($prefix == '' || !str_starts_with($path, $prefix)) { + return $prefix . $path; + } + } else { + $path = $prefix; + } + return $path; } - // check for existance of the new file - if($this->fileAvailable($newremotefile)) { - $this->errorstack->addError('FILE', 'FILE_ALREADY_EXISTS', $newremotefile); - return false; + /** + * returns the current prefixed path component + * @return string [description] + */ + protected function getPathPrefix(): string + { + return $this->prefix != '' ? ($this->prefix . '/') : ''; } - try{ - /** - * @see http://docs.aws.amazon.com/AmazonS3/latest/dev/CopyingObjectUsingPHP.html - */ - $result = $this->client->copyObject([ - 'Bucket' => $this->bucket, - 'Key' => $this->getPrefixedPath($newremotefile), - 'CopySource' => "{$this->bucket}/". $this->getPrefixedPath($remotefile), - 'ACL' => $this->acl - ]); - // delete file afterwards - $fileDeleted = $this->fileDelete($this->getPrefixedPath($remotefile)); - return $fileDeleted; - } catch (S3Exception $e) { - $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); + /** + * {@inheritDoc} + * @param string $remotefile + * @param string $localfile + * @return bool + * @throws ReflectionException + * @throws exception + */ + public function filePull(string $remotefile, string $localfile): bool + { + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); + + if (app::getFilesystem()->fileAvailable($localfile)) { + $this->errorstack->addError('FILE', 'LOCAL_FILE_EXISTS', $localfile); + return false; + } + + if (!$this->fileAvailable($remotefile)) { + $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); + return false; + } + + try { + $this->client->getObject([ + 'Bucket' => $this->bucket, + 'Key' => $this->getPrefixedPath($remotefile), + 'SaveAs' => $localfile, + ]); + return true; + } catch (S3Exception $e) { + $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); + } + return false; } - return false; - } - - /** - * @inheritDoc - * @param mixed $option [integer|string|DateTime] - */ - public function fileGetUrl(string $remotefile, $option = '+10 minutes'): string - { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); /** - * we may use @see http://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.S3.S3Client.html#_getObjectUrl - * and @see https://docs.aws.amazon.com/aws-sdk-php/v3/guide/service/s3-presigned-url.html + * {@inheritDoc} + * @param string $remotefile + * @param string $newremotefile + * @return bool + * @throws exception */ + public function fileMove(string $remotefile, string $newremotefile): bool + { + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); + $newremotefile = $this->normalizeRelativePath($newremotefile); + + if (!$this->fileAvailable($remotefile)) { + $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); + return false; + } - if($this->getAccessInfo($remotefile) === self::ACL_PUBLIC_READ) { + // check for the existence of the new file + if ($this->fileAvailable($newremotefile)) { + $this->errorstack->addError('FILE', 'FILE_ALREADY_EXISTS', $newremotefile); + return false; + } - // we may use an alternative URL Provider here. - try{ - $result = $this->client->getObjectUrl($this->bucket, $this->getPrefixedPath($remotefile)); - return $result; - } catch (S3Exception $e) { - $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); - } + try { + /** + * @see http://docs.aws.amazon.com/AmazonS3/latest/dev/CopyingObjectUsingPHP.html + */ + $this->client->copyObject([ + 'Bucket' => $this->bucket, + 'Key' => $this->getPrefixedPath($newremotefile), + 'CopySource' => "$this->bucket/" . $this->getPrefixedPath($remotefile), + 'ACL' => $this->acl, + ]); + // delete file afterwards + return $this->fileDelete($this->getPrefixedPath($remotefile)); + } catch (S3Exception $e) { + $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); + } + return false; + } - } else { + /** + * {@inheritDoc} + * @param string $remotefile + * @return bool + * @throws exception + */ + public function fileDelete(string $remotefile): bool + { + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); + try { + /** + * @see http://docs.aws.amazon.com/aws-sdk-php/v3/api/api-s3-2006-03-01.html#deleteobject + */ + $this->client->deleteObject([ + 'Bucket' => $this->bucket, + 'Key' => $this->getPrefixedPath($remotefile), + ]); + return true; + } catch (S3Exception $e) { + $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); + } + return false; + } - // we may use an alternative URL Provider here. - try{ - $cmd = $this->client->getCommand('GetObject', [ - 'Bucket' => $this->bucket, - 'Key' => $this->getPrefixedPath($remotefile) - ]); + /** + * {@inheritDoc} + * @param string $remotefile + * @param mixed $option [integer|string|DateTime] + * @return string + * @throws exception + */ + public function fileGetUrl(string $remotefile, mixed $option = '+10 minutes'): string + { + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); /** - * $option defines +10 minutes access/request validity upon generation - * @see http://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.S3.S3Client.html#_createPresignedRequest + * we may use @see http://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.S3.S3Client.html#_getObjectUrl + * and @see https://docs.aws.amazon.com/aws-sdk-php/v3/guide/service/s3-presigned-url.html */ - $request = $this->client->createPresignedRequest($cmd, $option); - // Get the actual presigned-url - $presignedUrl = (string) $request->getUri(); - return $presignedUrl; + if ($this->getAccessInfo($remotefile) === self::ACL_PUBLIC_READ) { + // we may use an alternative URL Provider here. + try { + return $this->client->getObjectUrl($this->bucket, $this->getPrefixedPath($remotefile)); + } catch (S3Exception $e) { + $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); + } + } else { + // we may use an alternative URL Provider here. + try { + $cmd = $this->client->getCommand('GetObject', [ + 'Bucket' => $this->bucket, + 'Key' => $this->getPrefixedPath($remotefile), + ]); + + /** + * $option defines +10 minutes access/request validity upon generation + * @see http://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.S3.S3Client.html#_createPresignedRequest + */ + $request = $this->client->createPresignedRequest($cmd, $option); + + // Get the actual presigned-url + return (string)$request->getUri(); + } catch (S3Exception $e) { + $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); + } + } + return ''; // throw exception? + } - } catch (S3Exception $e) { - $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); - } + /** + * gets the object access parameters + * currently, only private (default) and public-read is returned + * @param string $remotefile + * @return string [class-defined ACL] + * @throws exception + */ + public function getAccessInfo(string $remotefile): string + { + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); + + // default/fallback + $access = self::ACL_PRIVATE; + try { + /** + * @see http://docs.aws.amazon.com/cli/latest/reference/s3api/get-object-acl.html + * @see https://github.com/thephpleague/flysystem-aws-s3-v3/blob/master/src/AwsS3Adapter.php + */ + $result = $this->client->getObjectAcl([ + 'Bucket' => $this->bucket, + 'Key' => $this->getPrefixedPath($remotefile), + ]); + foreach ($result['Grants'] as $grant) { + if ( + isset($grant['Grantee']['URI']) + && $grant['Grantee']['URI'] === self::PUBLIC_GRANT_URI + && $grant['Permission'] === 'READ' + ) { + $access = self::ACL_PUBLIC_READ; + break; + } + } + } catch (S3Exception $e) { + $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); + } + return $access; + } + + /** + * {@inheritDoc} + */ + public function fileGetInfo(string $remotefile): array + { + // @TODO: s3-specific fileGetInfo + return []; } - return ''; // throw exception? - } - - - /** - * @inheritDoc - */ - public function fileGetInfo(string $remotefile): array - { - // @TODO: s3-specific fileGetInfo - return array(); - } - - /** - * @inheritDoc - */ - public function dirList(string $directory): array - { - // Path sanitization - $directory = $this->normalizeRelativePath($directory); - - try{ - // - // @see http://stackoverflow.com/questions/18683206/list-objects-in-a-specific-folder-on-amazon-s3 - // - - // - // CHANGED 2020-07-31: allow usage of continuation tokens - // Background: - // S3 fetches up to 1000 results at a time, by default - // if there are more, we also get a continuationToken - // to get more results. This may lead to ultra-large resultsets - // and has to be explicitly specified in configuration - // - $continuationToken = null; - $objects = array(); - - $prefixedPath = $this->getPrefixedPath($directory); - if(strlen($prefixedPath) > 0 && strpos($prefixedPath, '/', -1) === false) { - $prefixedPath .= '/'; - } - - do { - $response = $this->client->listObjectsV2([ - "Bucket" => $this->bucket, - "Prefix" => $prefixedPath, // $this->getPrefixedPath($directory), - "Delimiter" => '/', - "ContinuationToken" => $continuationToken, - ]); - - - // - // The "Files" (objects) - // - $result = $response->get('Contents') ?? false; - if($result) { - foreach($result as $object) { + /** + * {@inheritDoc} + * @param string $directory + * @return array + * @throws exception + */ + public function dirList(string $directory): array + { + // Path sanitization + $directory = $this->normalizeRelativePath($directory); + + try { // - // HACK: - // filter out self - s3 outputs the starting (requested) folder, too. + // @see http://stackoverflow.com/questions/18683206/list-objects-in-a-specific-folder-on-amazon-s3 // - if($object['Key'] != $directory) { - $objects[] = $object['Key']; - } - } - } - if($response['CommonPrefixes'] ?? false) { - // - // The "Folders" - // - $commonPrefixes = $response->get('CommonPrefixes'); - foreach($commonPrefixes as $object) { // - // HACK: - // filter out self - s3 outputs also the starting (requested) folder in CommonPrefixes + // CHANGED 2020-07-31: allow usage of continuation tokens + // Background: + // S3 fetches up to 1000 results at a time, by default, + // if there are more, we also get a continuationToken + // to get more results. This may lead to ultra-large resultsets + // and has to be explicitly specified in the configuration // - if($object['Prefix'] != $directory) { - $objects[] = $object['Prefix']; + $continuationToken = null; + $objects = []; + + $prefixedPath = $this->getPrefixedPath($directory); + if (strlen($prefixedPath) > 0 && strpos($prefixedPath, '/', -1) === false) { + $prefixedPath .= '/'; } - } - } - // Handle response.NextContinuationToken - // Which indicates we have MORE objects in the listing - // as far as we have enabled it via config. - if(($token = $response['NextContinuationToken'] ?? null) && $this->useContinuationToken) { - $continuationToken = $token; - } else { - break; + do { + $response = $this->client->listObjectsV2([ + "Bucket" => $this->bucket, + "Prefix" => $prefixedPath, + "Delimiter" => '/', + "ContinuationToken" => $continuationToken, + ]); + + // + // The "Files" (objects) + // + $result = $response->get('Contents') ?? false; + if ($result) { + foreach ($result as $object) { + // + // HACK: + // filter out self - s3 outputs the starting (requested) folder, too. + // + if ($object['Key'] != $directory) { + $objects[] = $object['Key']; + } + } + } + + if ($response['CommonPrefixes'] ?? false) { + // + // The "Folders" + // + $commonPrefixes = $response->get('CommonPrefixes'); + foreach ($commonPrefixes as $object) { + // + // HACK: + // filter out self - s3 outputs also the starting (requested) folder in CommonPrefixes + // + if ($object['Prefix'] != $directory) { + $objects[] = $object['Prefix']; + } + } + } + + // Handle response.NextContinuationToken + // Which indicates we have MORE objects in the listing + // as far as we have enabled it via config. + if ($this->useContinuationToken) { + $continuationToken = $response['NextContinuationToken'] ?? null; + } else { + break; + } + + // + // Continue/repeat execution, as long as we have a continuation token. + // + } while ($continuationToken !== null); + + return $objects; + } catch (S3Exception $e) { + $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); } + return []; + } - // - // Continue/repeat execution, as long as we have a continuation token. - // - } while($continuationToken !== null); + /** + * {@inheritDoc} + * @param string $directory + * @return bool + * @throws exception + */ + public function dirAvailable(string $directory): bool + { + // Path sanitization + $directory = $this->normalizeRelativePath($directory); - return $objects; + /** + * NOTE: we may have to check for objects in this bucket path + * as s3 is a flat file system + */ - } catch (S3Exception $e) { - $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); - } - return array(); - } + try { + /** + * @see http://stackoverflow.com/questions/18683206/list-objects-in-a-specific-folder-on-amazon-s3 + */ - /** - * @inheritDoc - */ - public function dirAvailable(string $directory): bool - { - // Path sanitization - $directory = $this->normalizeRelativePath($directory); + $prefixedPath = $this->getPrefixedPath($directory); + if (strlen($prefixedPath) > 0 && strpos($prefixedPath, '/', -1) === false) { + // Add /, if missing to make it a real directory search + // for S3-compat + $prefixedPath .= '/'; + } - /** - * NOTE: we may have to check for objects in this bucket path - * as s3 is a flat file system - */ + $response = $this->client->listObjectsV2([ + "Bucket" => $this->bucket, + "Prefix" => $prefixedPath, + "Delimiter" => '/', + "MaxKeys" => 1, + ]); - try{ - /** - * @see http://stackoverflow.com/questions/18683206/list-objects-in-a-specific-folder-on-amazon-s3 - */ - - $prefixedPath = $this->getPrefixedPath($directory); - if(strlen($prefixedPath) > 0 && strpos($prefixedPath, '/', -1) === false) { - // Add /, if missing to make it a real directory search - // for S3-compat - $prefixedPath .= '/'; - } - - $response = $this->client->listObjectsV2([ - "Bucket" => $this->bucket, - "Prefix" => $prefixedPath, // $this->getPrefixedPath($directory), // explicitly look for '/' suffix - "Delimiter" => '/', - "MaxKeys" => 1 - ]); - - $result = $response->get('Contents') ?? false; - - // - // NOTE: we limit "MaxKeys" to 1 - // but I expect some S3 APIs to ignore that - // we MIGHT change that to a > 1 later. - // - if($result && count($result) === 1) { - return true; - } else { - return false; - } - } catch(S3Exception $e) { - $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); - } - - return false; - } - - /** - * @inheritDoc - */ - public function isFile(string $remotefile): bool - { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - - // workaround? - // path prefixing by overwriting var value - $remotefile = $this->getPrefixedPath($remotefile); - if(strpos($remotefile, '/') === 0) { - $remotefile = substr($remotefile, 1, strlen($remotefile)-1); - } - return $this->objectExists($remotefile) && substr($remotefile, strlen($remotefile)-1, 1) !== '/'; - } - - /** - * this is the official AWS public grant URI - * for determining public-read ACL using getAccessInfo/getObjectAcl - * @var string - */ - const PUBLIC_GRANT_URI = 'http://acs.amazonaws.com/groups/global/AllUsers'; - - /** - * gets the object access parameters - * currently, only private (default) and public-read are returned - * @param string $remotefile - * @return string [class-defined ACL] - */ - public function getAccessInfo(string $remotefile) : string { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - - // default/fallback - $access = self::ACL_PRIVATE; - try{ - /** - * @see http://docs.aws.amazon.com/cli/latest/reference/s3api/get-object-acl.html - * @see https://github.com/thephpleague/flysystem-aws-s3-v3/blob/master/src/AwsS3Adapter.php - */ - $result = $this->client->getObjectAcl([ - 'Bucket' => $this->bucket, - 'Key' => $this->getPrefixedPath($remotefile), - ]); - foreach ($result['Grants'] as $grant) { - if ( - isset($grant['Grantee']['URI']) - && $grant['Grantee']['URI'] === self::PUBLIC_GRANT_URI - && $grant['Permission'] === 'READ' - ) { - $access = self::ACL_PUBLIC_READ; - break; + $result = $response->get('Contents') ?? false; + + // + // NOTE: we limit "MaxKeys" to 1, + // but I expect some S3 APIs to ignore that + // we MIGHT change that to a > 1 later. + // + if ($result && count($result) === 1) { + return true; + } else { + return false; + } + } catch (S3Exception $e) { + $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); } - } - } catch (S3Exception $e) { - $this->errorstack->addError('BUCKET', 'S3_EXCEPTION', $e->getMessage()); + + return false; } - return $access; - } + /** + * {@inheritDoc} + * @param string $remotefile + * @return bool + * @throws exception + */ + public function isFile(string $remotefile): bool + { + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); + + // workaround? + // path prefixing by overwriting var value + $remotefile = $this->getPrefixedPath($remotefile); + if (str_starts_with($remotefile, '/')) { + $remotefile = substr($remotefile, 1, strlen($remotefile) - 1); + } + return $this->objectExists($remotefile) && !str_ends_with($remotefile, '/'); + } } diff --git a/backend/class/bucket/sftp.php b/backend/class/bucket/sftp.php index 8ee91e3..9e0b9c0 100644 --- a/backend/class/bucket/sftp.php +++ b/backend/class/bucket/sftp.php @@ -1,218 +1,227 @@ reset()->validate($data)) > 0) { + if (count($errors = app::getValidator('structure_config_bucket_sftp')->reset()->validate($data)) > 0) { $this->errorstack->addError('CONFIGURATION', 'CONFIGURATION_INVALID', $errors); - throw new \codename\core\exception(self::EXCEPTION_CONSTRUCT_CONFIGURATIONINVALID, \codename\core\exception::$ERRORLEVEL_ERROR, $errors); + throw new exception(self::EXCEPTION_CONSTRUCT_CONFIGURATIONINVALID, exception::$ERRORLEVEL_ERROR, $errors); } $this->basedir = $data['basedir']; $this->method = $data['sftp_method'] ?? self::METHOD_SFTP; + $this->bufferLength = array_key_exists('buffer_length', $data) && !is_null($data['buffer_length']) ? $data['buffer_length'] : $this->bufferLength; $host = $data['sftpserver']['host']; $port = $data['sftpserver']['port']; try { - $this->sshConnection = ssh2_connect($host, $port); + $this->sshConnection = ssh2_connect($host, $port); } catch (\Exception $e) { - throw new \codename\core\sensitiveException($e); + throw new sensitiveException($e); } - if(isset($data['public']) && $data['public']) { + if (isset($data['public']) && $data['public']) { $this->baseurl = $data['baseurl']; } - if(!$this->sshConnection) { - $this->errorstack->addError('FILE', 'CONNECTION_FAILED', null); - throw new exception('EXCEPTION_BUCKET_SFTP_SSH_CONNECTION_FAILED', exception::$ERRORLEVEL_ERROR, [ 'host' => $host ]); - return $this; + if (!$this->sshConnection) { + $this->errorstack->addError('FILE', 'CONNECTION_FAILED'); + throw new exception('EXCEPTION_BUCKET_SFTP_SSH_CONNECTION_FAILED', exception::$ERRORLEVEL_ERROR, ['host' => $host]); } - if($data['sftpserver']['auth_type'] === 'password') { - // TODO: key auth? - $username = $data['sftpserver']['user']; - $password = $data['sftpserver']['pass']; - - if (! @ssh2_auth_password($this->sshConnection, $username, $password)) { - throw new exception("EXCEPTION_BUCKET_SFTP_SSH_AUTH_FAILED", exception::$ERRORLEVEL_ERROR); - } - } else if($data['sftpserver']['auth_type'] === 'pubkey_file') { - $username = $data['sftpserver']['user']; - - $pubkeyString = $data['sftpserver']['pubkey']; - $privkeyString = $data['sftpserver']['privkey']; - - $pubkeyFile = null; - try { - $pubkeyFile = tempnam('secure', 'sftp_pub_'); - $handle = fopen($pubkeyFile, 'w'); - fwrite($handle, $pubkeyString); - fclose($handle); - } catch (\Exception $e) { - throw new exception('FILE_COULD_NOT_BE_WRITE', exception::$ERRORLEVEL_ERROR,array($pubkeyFile)); - } - - $privkeyFile = null; - try { - $privkeyFile = tempnam('secure', 'sftp_pri_'); - $handle = fopen($privkeyFile, 'w'); - fwrite($handle, $privkeyString); - fclose($handle); - } catch (\Exception $e) { - throw new exception('FILE_COULD_NOT_BE_WRITE', exception::$ERRORLEVEL_ERROR,array($privkeyFile)); - } - - // TODO: passphrase for privkey file - - $passphrase = $data['sftpserver']['privkey_passphrase'] ?? null; - if (! @ssh2_auth_pubkey_file($this->sshConnection, $username, $pubkeyFile, $privkeyFile, $passphrase)) { - throw new exception("EXCEPTION_BUCKET_SFTP_SSH_AUTH_FAILED", exception::$ERRORLEVEL_ERROR); - } - - // emulate "files" via streams? + if ($data['sftpserver']['auth_type'] === 'password') { + // TODO: key auth? + $username = $data['sftpserver']['user']; + $password = $data['sftpserver']['pass']; + + if (!@ssh2_auth_password($this->sshConnection, $username, $password)) { + throw new exception("EXCEPTION_BUCKET_SFTP_SSH_AUTH_FAILED", exception::$ERRORLEVEL_ERROR); + } + } elseif ($data['sftpserver']['auth_type'] === 'pubkey_file') { + $username = $data['sftpserver']['user']; + + $pubkeyString = $data['sftpserver']['pubkey']; + $privkeyString = $data['sftpserver']['privkey']; + + $pubkeyFile = null; + try { + $pubkeyFile = tempnam('secure', 'sftp_pub_'); + $handle = fopen($pubkeyFile, 'w'); + fwrite($handle, $pubkeyString); + fclose($handle); + } catch (\Exception) { + throw new exception('FILE_COULD_NOT_BE_WRITE', exception::$ERRORLEVEL_ERROR, [$pubkeyFile]); + } + + $privkeyFile = null; + try { + $privkeyFile = tempnam('secure', 'sftp_pri_'); + $handle = fopen($privkeyFile, 'w'); + fwrite($handle, $privkeyString); + fclose($handle); + } catch (\Exception) { + throw new exception('FILE_COULD_NOT_BE_WRITE', exception::$ERRORLEVEL_ERROR, [$privkeyFile]); + } + + // TODO: passphrase for privkey file + + $passphrase = $data['sftpserver']['privkey_passphrase'] ?? null; + if (!@ssh2_auth_pubkey_file($this->sshConnection, $username, $pubkeyFile, $privkeyFile, $passphrase)) { + throw new exception("EXCEPTION_BUCKET_SFTP_SSH_AUTH_FAILED", exception::$ERRORLEVEL_ERROR); + } + // emulate "files" via streams? } else { - throw new exception('EXCEPTION_BUCKET_SFTP_SSH_AUTH_TYPE_NOT_IMPLEMENTED', exception::$ERRORLEVEL_ERROR); + throw new exception('EXCEPTION_BUCKET_SFTP_SSH_AUTH_TYPE_NOT_IMPLEMENTED', exception::$ERRORLEVEL_ERROR); } // initialize sftp client $this->connection = @ssh2_sftp($this->sshConnection); - if(!$this->connection) { - throw new exception("EXCEPTION_BUCKET_SFTP_SFTP_MODE_FAILED", exception::$ERRORLEVEL_ERROR); + if (!$this->connection) { + throw new exception("EXCEPTION_BUCKET_SFTP_SFTP_MODE_FAILED", exception::$ERRORLEVEL_ERROR); } - // if(!@ftp_login($this->connection, $data['ftpserver']['user'], $data['ftpserver']['pass'])) { - // $this->errorstack->addError('FILE', 'LOGIN_FAILED', null); - // app::getLog('errormessage')->warning('CORE_BACKEND_CLASS_BUCKET_FTP_CONSTRUCT::LOGIN_FAILED ($user = ' . $data['ftpserver']['user'] .')'); - // throw new exception('EXCEPTION_BUCKET_FTP_LOGIN_FAILED', exception::$ERRORLEVEL_ERROR, [ 'user' => $data['ftpserver']['user'] ]); - // return $this; - // } - return $this; } /** * * {@inheritDoc} + * @param string $localfile + * @param string $remotefile + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\bucket_interface::filePush($localfile, $remotefile) */ - public function filePush(string $localfile, string $remotefile) : bool { + public function filePush(string $localfile, string $remotefile): bool + { // Path sanitization $remotefile = $this->normalizeRelativePath($remotefile); - if(!app::getFilesystem()->fileAvailable($localfile)) { + if (!app::getFilesystem()->fileAvailable($localfile)) { $this->errorstack->addError('FILE', 'LOCAL_FILE_NOT_FOUND', $localfile); return false; } - if($this->fileAvailable($remotefile)) { + if ($this->fileAvailable($remotefile)) { $this->errorstack->addError('FILE', 'REMOTE_FILE_EXISTS', $remotefile); return false; } $directory = $this->extractDirectory($remotefile); - if(!$this->dirAvailable($directory)) { + if (!$this->dirAvailable($directory)) { $this->dirCreate($directory); } - // @ftp_put($this->connection, $this->basedir . $remotefile, $localfile, FTP_BINARY); - // \codename\core\app::getResponse()->setData('bucket_sftp_debug', [ - // // $this->sshConnection, - // $localfile, - // $this->basedir, - // $remotefile - // ]); - - - if($this->method === self::METHOD_SFTP) { - // Local stream - if (!($localStream = @fopen($localfile, 'r'))) { - throw new exception("Unable to open local file for reading: {$localfile}", exception::$ERRORLEVEL_ERROR); - } - // Remote stream - if (!($remoteStream = fopen("ssh2.sftp://{$this->connection}/{$this->basedir}{$remotefile}", 'w'))) { - throw new exception("Unable to open remote file for writing: {$this->basedir}{$remotefile}", exception::$ERRORLEVEL_ERROR); - } - // Write from our remote stream to our local stream - $read = 0; - $fileSize = filesize($localfile); - while ($read < $fileSize && ($buffer = fread($localStream, $fileSize - $read))) { - // Increase our bytes read - $read += strlen($buffer); - // Write to our local file - if (fwrite($remoteStream, $buffer) === FALSE) { - throw new exception("Unable to write to local file: {$this->basedir}{$remotefile}", exception::$ERRORLEVEL_ERROR); - } - } - // Close our streams - fclose($localStream); - fclose($remoteStream); - - } else if($this->method === self::METHOD_SCP) { - - // TODO: provide create mode ? - // TODO: handle error ? FALSE return value? - @ssh2_scp_send($this->sshConnection , $localfile, $this->basedir . $remotefile, 0777); - + if ($this->method === self::METHOD_SFTP) { + // Local stream + if (!($localStream = @fopen($localfile, 'r'))) { + throw new exception("Unable to open local file for reading: $localfile", exception::$ERRORLEVEL_ERROR); + } + // Remote stream + if (!($remoteStream = fopen("ssh2.sftp://$this->connection/$this->basedir$remotefile", 'w'))) { + throw new exception("Unable to open remote file for writing: $this->basedir$remotefile", exception::$ERRORLEVEL_ERROR); + } + $bufferLength = $this->bufferLength; + if (empty($bufferLength)) { + $bufferLength = filesize($localfile); + } + // Write from our remote stream to our local stream + while (!feof($localStream)) { + if (($buffer = fread($localStream, $bufferLength)) === false) { + throw new exception("Unable to read to local file: $this->basedir$remotefile", exception::$ERRORLEVEL_ERROR); + } + if (($writtenBytes = fwrite($remoteStream, $buffer)) === false) { + throw new exception("Unable to write to remote file: $this->basedir$remotefile", exception::$ERRORLEVEL_ERROR); + } + if ($writtenBytes < strlen($buffer)) { + throw new exception("Writing not completely possible to remote file: $this->basedir$remotefile", exception::$ERRORLEVEL_ERROR); + } + } + // Close our streams + fclose($localStream); + fclose($remoteStream); + } elseif ($this->method === self::METHOD_SCP) { + if (!@ssh2_scp_send($this->sshConnection, $localfile, $this->basedir . $remotefile, 0777)) { + throw new exception("Unable to write remote file: $this->basedir$remotefile", exception::$ERRORLEVEL_ERROR); + } } else { - throw new exception('EXCEPTION_BUCKET_SFTP_INVALID_METHOD', exception::$ERRORLEVEL_ERROR, $this->method); + throw new exception('EXCEPTION_BUCKET_SFTP_INVALID_METHOD', exception::$ERRORLEVEL_ERROR, $this->method); } @@ -222,111 +231,231 @@ public function filePush(string $localfile, string $remotefile) : bool { /** * * {@inheritDoc} - * @see \codename\core\bucket_interface::filePull($remotefile, $localfile) + * @param string $remotefile + * @return bool + * @throws exception + * @see \codename\core\bucket_interface::fileAvailable($remotefile) */ - public function filePull(string $remotefile, string $localfile) : bool { + public function fileAvailable(string $remotefile): bool + { // Path sanitization $remotefile = $this->normalizeRelativePath($remotefile); - if(app::getFilesystem()->fileAvailable($localfile)) { - $this->errorstack->addError('FILE', 'LOCAL_FILE_EXISTS', $localfile); + // CHANGED 2021-03-30: improved and fixed internal SFTP bucket handling + return $this->isFile($remotefile); + } + + /** + * + * {@inheritDoc} + * @param string $remotefile + * @return bool + * @throws exception + * @see bucketInterface::isFile + */ + public function isFile(string $remotefile): bool + { + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); + + $statResult = @ssh2_sftp_stat($this->connection, $this->basedir . $remotefile); + if ($statResult === false) { return false; } - if(!$this->fileAvailable($remotefile)) { - $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); + // + // check for dir + // @see https://www.php.net/manual/en/function.stat.php#54999 + // + if (self::S_IFDIR == ($statResult['mode'] & self::S_IFMT)) { return false; } - // @ftp_get($this->connection, $localfile, $this->basedir . $remotefile, FTP_BINARY - - // \codename\core\app::getResponse()->setData('bucket_sftp_debug', [ - // // $this->sshConnection, - // $localfile, - // $this->basedir, - // $remotefile - // ]); - - if($this->method === self::METHOD_SFTP) { - // Remote stream - if (!($remoteStream = @fopen("ssh2.sftp://{$this->connection}/{$this->basedir}{$remotefile}", 'r'))) { - throw new exception("Unable to open remote file: {$this->basedir}{$remotefile}", exception::$ERRORLEVEL_ERROR); - } - // Local stream - if (!($localStream = @fopen($localfile, 'w'))) { - throw new exception("Unable to open local file for writing: {$localfile}", exception::$ERRORLEVEL_ERROR); - } - // Write from our remote stream to our local stream - $read = 0; - $fileSize = filesize("ssh2.sftp://{$this->connection}/{$this->basedir}{$remotefile}"); - while ($read < $fileSize && ($buffer = fread($remoteStream, $fileSize - $read))) { - $bufferSize = strlen($buffer); - // Increase our bytes read - $read += $bufferSize; - // Write to our local file - $writtenBytes = fwrite($localStream, $buffer); - // NOTE: fwrite does not return FALSE when not enough disk space - if ($writtenBytes === FALSE || ($writtenBytes < $bufferSize)) { - throw new exception("Unable to write to local file: {$localfile}", exception::$ERRORLEVEL_ERROR); - } - } - // Close our streams - fclose($localStream); - fclose($remoteStream); - } else if($this->method === self::METHOD_SCP) { - - // TODO: handle error ? FALSE return value? - @ssh2_scp_recv($this->sshConnection, $this->basedir . $remotefile, $localfile); + return true; // ?? + } - } else { - throw new exception('EXCEPTION_BUCKET_SFTP_INVALID_METHOD', exception::$ERRORLEVEL_ERROR, $this->method); + /** + * Extracts the directory path from $filename + * example: + * $name = extractDirectory('/path/to/file.mp3'); + * + * // $name is now '/path/to/' + * @param string $filename + * @return string + */ + protected function extractDirectory(string $filename): string + { + $filenamedata = explode('/', $filename); + unset($filenamedata[count($filenamedata) - 1]); + return implode('/', $filenamedata); + } + + /** + * + * {@inheritDoc} + * @param string $directory + * @return bool + * @throws exception + * @see \codename\core\bucket_interface::dirAvailable($directory) + */ + public function dirAvailable(string $directory): bool + { + // Path sanitization + $directory = $this->normalizeRelativePath($directory); + + return $this->isDirectory($directory); + } + + /** + * [isDirectory description] + * @param string $directory [description] + * @return bool [description] + * @throws exception + */ + public function isDirectory(string $directory): bool + { + // Path sanitization + $directory = $this->normalizeRelativePath($directory); + + $statResult = @ssh2_sftp_stat($this->connection, $this->basedir . $directory); + if ($statResult === false) { + return false; } - return app::getFilesystem()->fileAvailable($localfile); + // + // check for dir + // @see https://www.php.net/manual/en/function.stat.php#54999 + // + if (self::S_IFDIR == ($statResult['mode'] & self::S_IFMT)) { + return true; + } + + return false; + } + + /** + * Creates the given $directory on this instance's remote hostname + * @param string $directory + * @return bool + * @throws exception + */ + public function dirCreate(string $directory): bool + { + // Path sanitization + $directory = $this->normalizeRelativePath($directory); + + if ($this->dirAvailable($directory)) { + return true; + } + + // @ftp_mkdir($this->connection, $directory); + // TODO: handle errors (FALSE return value) and other params! + try { + @ssh2_sftp_mkdir($this->connection, $this->basedir . $directory, 0777, true); + } catch (\Exception) { + return false; + } + + return $this->dirAvailable($directory); } /** * * {@inheritDoc} - * @see \codename\core\bucket_interface::dirAvailable($directory) + * @param string $remotefile + * @param string $localfile + * @return bool + * @throws ReflectionException + * @throws exception + * @see \codename\core\bucket_interface::filePull($remotefile, $localfile) */ - public function dirAvailable(string $directory) : bool { - // Path sanitization - $directory = $this->normalizeRelativePath($directory); + public function filePull(string $remotefile, string $localfile): bool + { + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); + + if (app::getFilesystem()->fileAvailable($localfile)) { + $this->errorstack->addError('FILE', 'LOCAL_FILE_EXISTS', $localfile); + return false; + } + + if (!$this->fileAvailable($remotefile)) { + $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); + return false; + } - return $this->isDirectory($directory); + if ($this->method === self::METHOD_SFTP) { + // Remote stream + if (!($remoteStream = @fopen("ssh2.sftp://$this->connection/$this->basedir$remotefile", 'r'))) { + throw new exception("Unable to open remote file: $this->basedir$remotefile", exception::$ERRORLEVEL_ERROR); + } + // Local stream + if (!($localStream = @fopen($localfile, 'w'))) { + throw new exception("Unable to open local file for writing: $localfile", exception::$ERRORLEVEL_ERROR); + } + $bufferLength = $this->bufferLength; + if (empty($bufferLength)) { + $bufferLength = filesize("ssh2.sftp://$this->connection/$this->basedir$remotefile"); + } + // Write from our remote stream to our local stream + while (!feof($remoteStream)) { + if (($buffer = fread($remoteStream, $bufferLength)) === false) { + throw new exception("Unable to read to remote file: $this->basedir$remotefile", exception::$ERRORLEVEL_ERROR); + } + if (($writtenBytes = fwrite($localStream, $buffer)) === false) { + throw new exception("Unable to write to local file: $this->basedir$remotefile", exception::$ERRORLEVEL_ERROR); + } + if ($writtenBytes < strlen($buffer)) { + throw new exception("Writing not completely possible to local file: $this->basedir$remotefile", exception::$ERRORLEVEL_ERROR); + } + } + // Close our streams + fclose($localStream); + fclose($remoteStream); + } elseif ($this->method === self::METHOD_SCP) { + // TODO: handle error ? FALSE return value? + @ssh2_scp_recv($this->sshConnection, $this->basedir . $remotefile, $localfile); + } else { + throw new exception('EXCEPTION_BUCKET_SFTP_INVALID_METHOD', exception::$ERRORLEVEL_ERROR, $this->method); + } + + return app::getFilesystem()->fileAvailable($localfile); } /** * * {@inheritDoc} + * @param string $directory + * @return array + * @throws exception * @see \codename\core\bucket_interface::dirList($directory) */ - public function dirList(string $directory) : array { + public function dirList(string $directory): array + { // Path sanitization $directory = $this->normalizeRelativePath($directory); - if(!$this->dirAvailable($directory)) { + if (!$this->dirAvailable($directory)) { $this->errorstack->addError('DIRECTORY', 'REMOTE_DIRECTORY_NOT_FOUND', $directory); - return array(); + return []; } - $handle = @opendir("ssh2.sftp://{$this->connection}/{$this->basedir}{$directory}"); + $handle = @opendir("ssh2.sftp://$this->connection/$this->basedir$directory"); if ($handle === false) { - throw new exception("Unable to open remote directory", exception::$ERRORLEVEL_ERROR); + throw new exception("Unable to open remote directory", exception::$ERRORLEVEL_ERROR); } - $files = array(); + $files = []; - $prefix = $directory != '' ? $directory.'/' : ''; + $prefix = $directory != '' ? $directory . '/' : ''; while (false !== ($entry = readdir($handle))) { - // exclude current dir and parent - if($entry != '.' && $entry != '..') { - $files[] = $prefix.$entry; - } + // exclude current dir and parent + if ($entry != '.' && $entry != '..') { + $files[] = $prefix . $entry; + } } - // close handle. otherwise, bad things happen. + // close the handle. otherwise, bad things happen. @closedir($handle); return $files; @@ -335,26 +464,17 @@ public function dirList(string $directory) : array { /** * * {@inheritDoc} - * @see \codename\core\bucket_interface::fileAvailable($remotefile) - */ - public function fileAvailable(string $remotefile) : bool { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - - // CHANGED 2021-03-30: improved and fixed internal SFTP bucket handling - return $this->isFile($remotefile); - } - - /** - * - * {@inheritDoc} + * @param string $remotefile + * @return bool + * @throws exception * @see \codename\core\bucket_interface::fileDelete($remotefile) */ - public function fileDelete(string $remotefile) : bool { + public function fileDelete(string $remotefile): bool + { // Path sanitization $remotefile = $this->normalizeRelativePath($remotefile); - if(!$this->fileAvailable($remotefile)) { + if (!$this->fileAvailable($remotefile)) { $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); return true; } @@ -367,108 +487,49 @@ public function fileDelete(string $remotefile) : bool { } /** - * @inheritDoc + * {@inheritDoc} + * @param string $remotefile + * @param string $newremotefile + * @return bool + * @throws exception * @see \codename\core\bucket_interface::fileMove($remotefile, $newremotefile) */ public function fileMove(string $remotefile, string $newremotefile): bool { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - $newremotefile = $this->normalizeRelativePath($newremotefile); - - if(!$this->fileAvailable($remotefile)) { - $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); - return false; - } - - // check for existance of the new fileW - if($this->fileAvailable($newremotefile)) { - $this->errorstack->addError('FILE', 'FILE_ALREADY_EXISTS', $newremotefile); - return false; - } - - $targetDir = $this->extractDirectory($newremotefile); - if(!$this->dirAvailable($targetDir)) { - $this->dirCreate($targetDir); - } - - // @ftp_rename($this->connection, $this->basedir . $remotefile, $this->basedir . $newremotefile); - // TODO: handle FALSE return value? - $success = @ssh2_sftp_rename($this->connection, $this->basedir . $remotefile, $this->basedir . $newremotefile); - - return $success && $this->fileAvailable($newremotefile); - } - - /** - * - * {@inheritDoc} - * @see \codename\core\bucket\bucketInterface::isFile() - */ - public function isFile(string $remotefile) : bool { - // Path sanitization - $remotefile = $this->normalizeRelativePath($remotefile); - - $statResult = @ssh2_sftp_stat($this->connection, $this->basedir . $remotefile); - if ($statResult === false) { - return false; - } - - // - // check for dir - // @see https://www.php.net/manual/en/function.stat.php#54999 - // - if (self::S_IFDIR == ($statResult['mode'] & self::S_IFMT)) { - return false; - } + // Path sanitization + $remotefile = $this->normalizeRelativePath($remotefile); + $newremotefile = $this->normalizeRelativePath($newremotefile); - return true; // ?? - } + if (!$this->fileAvailable($remotefile)) { + $this->errorstack->addError('FILE', 'REMOTE_FILE_NOT_FOUND', $remotefile); + return false; + } - /** - * [isDirectory description] - * @param string $directory [description] - * @return bool [description] - */ - public function isDirectory(string $directory): bool { - // Path sanitization - $directory = $this->normalizeRelativePath($directory); + // check for the existence of the new fileW + if ($this->fileAvailable($newremotefile)) { + $this->errorstack->addError('FILE', 'FILE_ALREADY_EXISTS', $newremotefile); + return false; + } - $statResult = @ssh2_sftp_stat($this->connection, $this->basedir . $directory); - if ($statResult === false) { - return false; - } + $targetDir = $this->extractDirectory($newremotefile); + if (!$this->dirAvailable($targetDir)) { + $this->dirCreate($targetDir); + } - // - // check for dir - // @see https://www.php.net/manual/en/function.stat.php#54999 - // - if (self::S_IFDIR == ($statResult['mode'] & self::S_IFMT)) { - return true; - } + // @ftp_rename($this->connection, $this->basedir . $remotefile, $this->basedir . $newremotefile); + // TODO: handle FALSE return value? + $success = @ssh2_sftp_rename($this->connection, $this->basedir . $remotefile, $this->basedir . $newremotefile); - return false; + return $success && $this->fileAvailable($newremotefile); } - /** - * [S_IFMT description] - * @see https://www.php.net/manual/en/function.stat.php#54999 - * @var int - */ - const S_IFMT = 0170000; - - /** - * [S_IFDIR description] - * @see https://www.php.net/manual/en/function.stat.php#54999 - * @var int - */ - const S_IFDIR = 040000; - /** * * {@inheritDoc} * @see \codename\core\bucket_interface::fileGetUrl($remotefile) */ - public function fileGetUrl(string $remotefile) : string { + public function fileGetUrl(string $remotefile): string + { return $this->baseurl . $remotefile; } @@ -477,59 +538,23 @@ public function fileGetUrl(string $remotefile) : string { * {@inheritDoc} * @see \codename\core\bucket_interface::fileGetInfo($remotefile) */ - public function fileGetInfo(string $remotefile) : array {} - - /** - * Creates the given $directory on this instance's remote hostname - * @param string $directory - * @return bool - */ - public function dirCreate(string $directory) { - // Path sanitization - $directory = $this->normalizeRelativePath($directory); - - if($this->dirAvailable($directory)) { - return true; - } - - // @ftp_mkdir($this->connection, $directory); - // TODO: handle errors (FALSE return value) and other params! - try { - @ssh2_sftp_mkdir($this->connection, $this->basedir.$directory, 0777, true); - } catch (\Exception $e) { - return false; - } - - return $this->dirAvailable($directory); - } - - /** - * Extracts the directory path from $filename - *
example: - *
$name = extractDirectory('/path/to/file.mp3'); - *
- *
// $name is now '/path/to/' - * @param string $filename - * @return string - */ - protected function extractDirectory(string $filename) : string { - $filenamedata = explode('/', $filename); - unset($filenamedata[count($filenamedata) - 1]); - return implode('/', $filenamedata); + public function fileGetInfo(string $remotefile): array + { + return []; } /** * Extracts the file name from $filename - *
example: - *
$name = extractDirectory('/path/to/file.mp3'); - *
- *
// $name is now 'file.mp3' + * example: + * $name = extractDirectory('/path/to/file.mp3'); + * + * // $name is now 'file.mp3' * @param string $filename * @return string */ - protected function extractFilename(string $filename) : string { + protected function extractFilename(string $filename): string + { $filenamedata = explode('/', $filename); return $filenamedata[count($filenamedata) - 1]; } - } diff --git a/backend/class/cache.php b/backend/class/cache.php index 36066cf..242044b 100755 --- a/backend/class/cache.php +++ b/backend/class/cache.php @@ -1,29 +1,33 @@ memcached->setOption(\Memcached::OPT_CLIENT_MODE, \Memcached::DYNAMIC_CLIENT_MODE); - } - +class elasticache_memcached extends memcached +{ + /** + * {@inheritDoc} + */ + protected function setOptions(): void + { + parent::setOptions(); + $this->memcached->setOption(\Memcached::OPT_CLIENT_MODE, \Memcached::DYNAMIC_CLIENT_MODE); + } } diff --git a/backend/class/cache/memcached.php b/backend/class/cache/memcached.php index 04c45ad..d3e753c 100755 --- a/backend/class/cache/memcached.php +++ b/backend/class/cache/memcached.php @@ -1,175 +1,216 @@ memcached = new \Memcached(); - - // - // set some client options - // - $this->setOptions(); - - $this->memcached->addServer($host, $port); - - $this->log = $config['log'] ?? null; - $this->attach(new \codename\core\observer\cache()); - return $this; - } + public function __construct(array $config) + { + if (isset($config['env_host'])) { + $host = getenv($config['env_host']); + } elseif (isset($config['host'])) { + $host = $config['host']; + } else { + throw new exception('EXCEPTION_MEMCACHED_CONFIG_HOST_UNDEFINED', exception::$ERRORLEVEL_FATAL); + } - /** - * set options for the memcached clients - * may be overridden/extended by inherting from this class - */ - protected function setOptions() { - $this->memcached->setOption(\Memcached::OPT_BINARY_PROTOCOL,true); + if (isset($config['env_port'])) { + $port = getenv($config['env_port']); + } elseif (isset($config['port'])) { + $port = $config['port']; + } else { + throw new exception('EXCEPTION_MEMCACHED_CONFIG_PORT_UNDEFINED', exception::$ERRORLEVEL_FATAL); + } + + $this->memcached = new \Memcached(); + + // + // set some client options + // + $this->setOptions(); + + $this->memcached->addServer($host, $port); + + $this->log = $config['log'] ?? null; + $this->attach(new cache()); + return $this; } /** - * - * {@inheritDoc} - * @see \codename\core\cache_interface::get($group, $key) + * set options for the memcached clients + * may be overridden/extended by inheriting from this class */ - public function get(string $group, string $key) { - $data = $this->uncompress($this->memcached->get("{$group}_{$key}")); - - if($this->memcached->getResultCode() !== \Memcached::RES_SUCCESS) { - return null; - } - - $this->notify('CACHE_GET'); - if($this->log) { - app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_GET::GETTING($group = ' . $group . ', $key= ' . $key . ')'); - } - if(is_null($data) || (is_string($data) && strlen($data)==0) || $data === false) { - $this->notify('CACHE_MISS'); - app::getHook()->fire(\codename\core\hook::EVENT_CACHE_MISS, $key); - return $data; - } - $this->notify('CACHE_HIT'); - if(is_object($data) || is_array($data)) { - return app::object2array($data); - } - return $data; + protected function setOptions(): void + { + $this->memcached->setOption(\Memcached::OPT_BINARY_PROTOCOL, true); } /** * * {@inheritDoc} + * @param string $group + * @param string $key + * @param mixed|null $value + * @param int|null $timeout + * @throws ReflectionException + * @throws exception * @see \codename\core\cache_interface::set($group, $key, $value) */ - public function set(string $group, string $key, $value = null, int $timeout = null) { - if(is_null($value)) { - if($this->log) { - app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_SET::EMPTY VALUE ($group = ' . $group . ', $key= ' . $key . ')'); - } - return; + public function set(string $group, string $key, mixed $value = null, int $timeout = null): void + { + if (is_null($value)) { + if ($this->log) { + app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_SET::EMPTY VALUE ($group = ' . $group . ', $key= ' . $key . ')'); + } + return; } $this->notify('CACHE_SET'); if ($timeout == 0 || $timeout == null) { $timeout = 86400; } - if($this->log) { - app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_SET::SETTING ($group = ' . $group . ', $key= ' . $key . ')'); + if ($this->log) { + app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_SET::SETTING ($group = ' . $group . ', $key= ' . $key . ')'); } - $this->memcached->set("{$group}_{$key}", $this->compress($value), $timeout); - return; + $this->memcached->set("{$group}_$key", $this->compress($value), $timeout); } /** * * {@inheritDoc} + * @param string $group + * @param string $key + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\cache_interface::isDefined($group, $key) */ - public function isDefined(string $group, string $key) : bool { + public function isDefined(string $group, string $key): bool + { return !is_null($this->get($group, $key)); } /** * * {@inheritDoc} + * @param string $group + * @param string $key + * @return mixed + * @throws ReflectionException + * @throws exception + * @see \codename\core\cache_interface::get($group, $key) + */ + public function get(string $group, string $key): mixed + { + $data = $this->uncompress($this->memcached->get("{$group}_$key")); + + if ($this->memcached->getResultCode() !== \Memcached::RES_SUCCESS) { + return null; + } + + $this->notify('CACHE_GET'); + if ($this->log) { + app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_GET::GETTING($group = ' . $group . ', $key= ' . $key . ')'); + } + if (is_null($data) || (is_string($data) && strlen($data) == 0) || $data === false) { + $this->notify('CACHE_MISS'); + app::getHook()->fire(hook::EVENT_CACHE_MISS, $key); + return $data; + } + $this->notify('CACHE_HIT'); + if (is_object($data) || is_array($data)) { + return app::object2array($data); + } + return $data; + } + + /** + * + * {@inheritDoc} + * @param string $group + * @param string $key + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\cache_interface::clearKey($group, $key) */ - public function clearKey(string $group, string $key) { - if($this->log) { - app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_CLEARKEY::CLEARING ($group = ' . $group . ', $key= ' . $key . ')'); + public function clearKey(string $group, string $key): bool + { + if ($this->log) { + app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_CLEARKEY::CLEARING ($group = ' . $group . ', $key= ' . $key . ')'); } - return $this->clear("{$group}_{$key}"); + return $this->clear("{$group}_$key"); } /** * * {@inheritDoc} + * @param string $key + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\cache_interface::clear($key) */ - public function clear(string $key) { - if($this->log) { - app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_CLEAR::CLEARING ($key= ' . $key . ')'); + public function clear(string $key): bool + { + if ($this->log) { + app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_CLEAR::CLEARING ($key= ' . $key . ')'); } // - // Special handling for memcached delete - // If the key doesn't exist and we try to delete + // Special handling for memcached deletes + // If the key doesn't exist, and we try to delete // it returns FALSE and RES_NOTFOUND // => which more-or-less evaluates to TRUE // - if(!$this->memcached->delete($key) && $this->memcached->getResultCode() !== \Memcached::RES_NOTFOUND) { - return false; + if (!$this->memcached->delete($key) && $this->memcached->getResultCode() !== \Memcached::RES_NOTFOUND) { + return false; } else { - return true; + return true; } } /** * * {@inheritDoc} + * @param string $group + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\cache_interface::clearGroup($group) */ - public function clearGroup(string $group) { - if($this->log) { - app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_CLEARGROUP::CLEARING ($group = ' . $group . ')'); + public function clearGroup(string $group): bool + { + if ($this->log) { + app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_CLEARGROUP::CLEARING ($group = ' . $group . ')'); } // @@ -177,49 +218,43 @@ public function clearGroup(string $group) { // $keys = $this->memcached->getAllKeys(); - if(!is_array($keys)) { - // echo("Failed clearing {$group} sys".chr(10)); - // echo($this->memcached->getLastErrorMessage().chr(10)); - // print_r($keys); - return false; // some error + if (!is_array($keys)) { + return false; } $result = true; - foreach($keys as $key) { - if(substr($key, 0, strlen($group)) == $group) { + foreach ($keys as $key) { + if (str_starts_with($key, $group)) { $result &= $this->clear($key); - // if(!$result) { - // echo("Failed clearing {$key} ".chr(10)); - // } } } - // if(!$result) { - // echo($this->memcached->getLastErrorMessage().chr(10)); - // } return $result; } /** - * @inheritDoc + * I will return all cache keys from the cache server + * @return array */ - public function flush() + public function getAllKeys(): array { - if($this->log) { - app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_FLUSH::FLUSHING (ALL)'); - } - return $this->memcached->flush(); + // + // NOTE: getAllKeys doesn't work with BINARY PROTOCOL + // + return $this->memcached->getAllKeys(); } /** - * I will return all cache keys from the cacheserver - * @return array + * {@inheritDoc} + * @return bool + * @throws ReflectionException + * @throws exception */ - public function getAllKeys() { - // - // NOTE: getAllKeys doesn't work with BINARY PROTOCOL - // - return $this->memcached->getAllKeys(); + public function flush(): bool + { + if ($this->log) { + app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_FLUSH::FLUSHING (ALL)'); + } + return $this->memcached->flush(); } - } diff --git a/backend/class/cache/memory.php b/backend/class/cache/memory.php index 3a8137f..37aa360 100644 --- a/backend/class/cache/memory.php +++ b/backend/class/cache/memory.php @@ -1,145 +1,167 @@ data = []; + public function __construct(array $config) + { + $this->data = []; - $this->attach(new \codename\core\observer\cache()); - return $this; + $this->attach(new cache()); + return $this; } /** * * {@inheritDoc} - * @see \codename\core\cache_interface::get($group, $key) + * @param string $group + * @param string $key + * @param mixed|null $value + * @param int|null $timeout + * @throws ReflectionException + * @throws exception + * @see \codename\core\cache_interface::set($group, $key, $value) */ - public function get(string $group, string $key) { - - $data = null; - if(array_key_exists("{$group}_{$key}", $this->data)) { - $data = $this->uncompress($this->data["{$group}_{$key}"]); - } else { - return null; + public function set(string $group, string $key, mixed $value = null, int $timeout = null): void + { + if (is_null($value)) { + if ($this->log) { + app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_SET::EMPTY VALUE ($group = ' . $group . ', $key= ' . $key . ')'); + } + return; } - $this->notify('CACHE_GET'); - - if(is_null($data) || (is_string($data) && strlen($data)==0) || $data === false) { - $this->notify('CACHE_MISS'); - app::getHook()->fire(\codename\core\hook::EVENT_CACHE_MISS, $key); - return $data; + $this->notify('CACHE_SET'); + if ($this->log) { + app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_SET::SETTING ($group = ' . $group . ', $key= ' . $key . ')'); } - $this->notify('CACHE_HIT'); - if(is_object($data) || is_array($data)) { - return app::object2array($data); - } - return $data; + $this->data["{$group}_$key"] = $this->compress($value); } /** * * {@inheritDoc} - * @see \codename\core\cache_interface::set($group, $key, $value) + * @see \codename\core\cache_interface::isDefined($group, $key) */ - public function set(string $group, string $key, $value = null, int $timeout = null) { - if(is_null($value)) { - if($this->log) { - app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_SET::EMPTY VALUE ($group = ' . $group . ', $key= ' . $key . ')'); - } - return; - } - - $this->notify('CACHE_SET'); - if ($timeout == 0 || $timeout == null) { - $timeout = 86400; - } - if($this->log) { - app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_SET::SETTING ($group = ' . $group . ', $key= ' . $key . ')'); - } - - $this->data["{$group}_{$key}"] = $this->compress($value); - return; + public function isDefined(string $group, string $key): bool + { + return !is_null($this->get($group, $key)); } /** * * {@inheritDoc} - * @see \codename\core\cache_interface::isDefined($group, $key) + * @see \codename\core\cache_interface::get($group, $key) */ - public function isDefined(string $group, string $key) : bool { - return !is_null($this->get($group, $key)); + public function get(string $group, string $key): mixed + { + if (array_key_exists("{$group}_$key", $this->data)) { + $data = $this->uncompress($this->data["{$group}_$key"]); + } else { + return null; + } + + $this->notify('CACHE_GET'); + + if (is_null($data) || (is_string($data) && strlen($data) == 0) || $data === false) { + $this->notify('CACHE_MISS'); + app::getHook()->fire(hook::EVENT_CACHE_MISS, $key); + return $data; + } + + $this->notify('CACHE_HIT'); + if (is_object($data) || is_array($data)) { + return app::object2array($data); + } + return $data; } /** * * {@inheritDoc} + * @param string $group + * @param string $key + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\cache_interface::clearKey($group, $key) */ - public function clearKey(string $group, string $key) { - if($this->log) { - app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_CLEARKEY::CLEARING ($group = ' . $group . ', $key= ' . $key . ')'); + public function clearKey(string $group, string $key): bool + { + if ($this->log) { + app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_CLEARKEY::CLEARING ($group = ' . $group . ', $key= ' . $key . ')'); } - return $this->clear("{$group}_{$key}"); + return $this->clear("{$group}_$key"); } /** * * {@inheritDoc} + * @param string $key + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\cache_interface::clear($key) */ - public function clear(string $key) { - if($this->log) { - app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_CLEAR::CLEARING ($key= ' . $key . ')'); + public function clear(string $key): bool + { + if ($this->log) { + app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_CLEAR::CLEARING ($key= ' . $key . ')'); } // - // Special handling for memcached delete - // If the key doesn't exist and we try to delete + // Special handling for memcached deletes + // If the key doesn't exist, and we try to delete // it returns FALSE and RES_NOTFOUND // => which more-or-less evaluates to TRUE // unset($this->data[$key]); - // if(!$this->memcached->delete($key) && $this->memcached->getResultCode() !== \Memcached::RES_NOTFOUND) { - // return false; - // } else { - // return true; - // } + return true; } /** * * {@inheritDoc} + * @param string $group + * @return bool + * @throws ReflectionException + * @throws exception * @see \codename\core\cache_interface::clearGroup($group) */ - public function clearGroup(string $group) { - if($this->log) { - app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_CLEARGROUP::CLEARING ($group = ' . $group . ')'); + public function clearGroup(string $group): bool + { + if ($this->log) { + app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_CLEARGROUP::CLEARING ($group = ' . $group . ')'); } // @@ -147,51 +169,40 @@ public function clearGroup(string $group) { // $keys = $this->getAllKeys(); - if(!is_array($keys)) { - // echo("Failed clearing {$group} sys".chr(10)); - // echo($this->memcached->getLastErrorMessage().chr(10)); - // print_r($keys); - return false; // some error - } - $result = true; - foreach($keys as $key) { - if(substr($key, 0, strlen($group)) == $group) { + foreach ($keys as $key) { + if (str_starts_with($key, $group)) { $result &= $this->clear($key); - // if(!$result) { - // echo("Failed clearing {$key} ".chr(10)); - // } } } - - // if(!$result) { - // echo($this->memcached->getLastErrorMessage().chr(10)); - // } return $result; } /** - * @inheritDoc + * I will return all cache keys from the cache server + * @return array */ - public function flush() + public function getAllKeys(): array { - if($this->log) { - app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_FLUSH::FLUSHING (ALL)'); - } - - $this->data = []; - return true; + // + // NOTE: getAllKeys doesn't work with BINARY PROTOCOL + // + return array_keys($this->data); } /** - * I will return all cache keys from the cacheserver - * @return array + * {@inheritDoc} + * @return bool + * @throws ReflectionException + * @throws exception */ - public function getAllKeys() { - // - // NOTE: getAllKeys doesn't work with BINARY PROTOCOL - // - return array_keys($this->data); - } + public function flush(): bool + { + if ($this->log) { + app::getLog($this->log)->debug('CORE_BACKEND_CLASS_CACHE_MEMCACHED_FLUSH::FLUSHING (ALL)'); + } + $this->data = []; + return true; + } } diff --git a/backend/class/catchableException.php b/backend/class/catchableException.php deleted file mode 100644 index 78c3006..0000000 --- a/backend/class/catchableException.php +++ /dev/null @@ -1,26 +0,0 @@ -message = $this->translateExceptionCode($code); - $this->code = $code; - $this->info = $info; - app::getHook()->fire($code); - app::getHook()->fire('EXCEPTION'); - return $this; - } - - -} diff --git a/backend/class/clientInterface.php b/backend/class/clientInterface.php index 382e909..984062b 100644 --- a/backend/class/clientInterface.php +++ b/backend/class/clientInterface.php @@ -1,23 +1,23 @@ data = $data; return $this; } /** - * Return the value of the given key. Either pass a direct name, or use a tree to navigate through the data array - *
->get('my>config>key') + * Returns true if the $key (or tree) exists in this instance's data property + * @param string $key + * @return bool + */ + public function exists(string $key): bool + { + return !is_null($this->get($key)); + } + + /** + * Return the value of the given key. + * Either pass a direct name or use a tree to navigate through the data array + * ->get('my>config>key') * @param string $key - * @param mixed|null $default - * @return mixed|null + * @param mixed $default + * @return mixed */ - public function get(string $key = '', $default = null) { + public function get(string $key = '', mixed $default = null): mixed + { // Try returning the desired key - if(strlen($key) == 0) { + if (strlen($key) == 0) { return $this->data; } // straight text key - if(strpos($key, '>') === false) { - if(array_key_exists($key, $this->data)) { + if (!str_contains($key, '>')) { + if (array_key_exists($key, $this->data)) { return $this->data[$key]; } return $default; @@ -48,8 +62,8 @@ public function get(string $key = '', $default = null) { // tree key $myConfig = $this->data; - foreach(explode('>', $key) as $myKey) { - if(!array_key_exists($myKey, $myConfig)) { + foreach (explode('>', $key) as $myKey) { + if (!array_key_exists($myKey, $myConfig)) { return $default; } $myConfig = $myConfig[$myKey]; @@ -57,14 +71,4 @@ public function get(string $key = '', $default = null) { return $myConfig; } - - /** - * Returns true if the $key (or tree) exists in this instance's data property - * @param string $key - * @return bool - */ - public function exists(string $key) : bool { - return !is_null($this->get($key)); - } - } diff --git a/backend/class/config/json.php b/backend/class/config/json.php index a16814c..5c1f33a 100755 --- a/backend/class/config/json.php +++ b/backend/class/config/json.php @@ -1,185 +1,192 @@ Consider using the appstack reverse search (pass $appstack = TRUE into the method) + * Consider using the appstack reverse search (pass $appstack = TRUE into the method) * @var string */ - CONST EXCEPTION_GETFULLPATH_FILEMISSING = 'EXCEPTION_GETFULLPATH_FILEMISSING'; + public const string EXCEPTION_GETFULLPATH_FILEMISSING = 'EXCEPTION_GETFULLPATH_FILEMISSING'; /** - * You succeeded finding a configuration file that is matching the desired file name. - *
Anyhow, the file that I managed to find is empty + * You succeeded finding a configuration file matching the desired file name. + * Anyhow, the file that I managed to find is empty * @var string */ - CONST EXCEPTION_DECODEFILE_FILEISEMPTY = 'EXCEPTION_DECODEFILE_FILEISEMPTY'; + public const string EXCEPTION_DECODEFILE_FILEISEMPTY = 'EXCEPTION_DECODEFILE_FILEISEMPTY'; /** - * The file that I found is containing information. - *
Anyway, the given information cannot be resolved into a JSON object. + * The file that I found contains information. + * Anyway, the given information cannot be resolved into a JSON object. * @var string */ - CONST EXCEPTION_DECODEFILE_FILEISINVALID = 'EXCEPTION_DECODEFILE_FILEISINVALID'; + public const string EXCEPTION_DECODEFILE_FILEISINVALID = 'EXCEPTION_DECODEFILE_FILEISINVALID'; /** - * You told the json class to inherit it's content by using the appstack - *
But you missed to allow the constructor to access the appstack. + * You told the JSON class to inherit it's content by using the appstack, + * But you missed to allow the constructor to access the appstack. * @var string */ - CONST EXCEPTION_CONSTRUCT_INVALIDBEHAVIOR = 'EXCEPTION_CONSTRUCT_INVALIDBEHAVIOR'; + public const string EXCEPTION_CONSTRUCT_INVALIDBEHAVIOR = 'EXCEPTION_CONSTRUCT_INVALIDBEHAVIOR'; /** * Exception thrown when no files could be found to construct a config * based on inheritance * @var string */ - CONST EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND = 'EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND'; + public const string EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND = 'EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND'; + /** + * contains a list of elements (file paths) this config is composed of + * (if inheritance was allowed during construction of this object) + * @var string[] + */ + protected $inheritance = []; /** * Creates a config instance and loads the given JSON configuration file as content - *
If $appstack is true, I will try loading the configuration from a parent app, if it does not exist in the curreent app - *
If $inherit is true, I will load all the configurations from parents, and the lower children will always overwrite the parents - * @param string $file [relative path to file] - * @param bool $appstack [traverse appstack, if needed] - * @param bool $inherit [use inheritance] - * @param array|null $useAppstack [optional: custom appstack] - * @return \codename\core\config + * If $appstack is true, I will try loading the configuration from a parent app, if it does not exist in the current app + * If $inherit is true, I will load all the configurations from parents, and the lower children will always overwrite the parents + * @param string $file [relative path to file] + * @param bool $appstack [traverse appstack, if needed] + * @param bool $inherit [use inheritance] + * @param array|null $useAppstack [optional: custom appstack] + * @throws ReflectionException + * @throws exception */ - public function __CONSTRUCT(string $file, bool $appstack = false, bool $inherit = false, ?array $useAppstack = null) { - + public function __construct(string $file, bool $appstack = false, bool $inherit = false, ?array $useAppstack = null) + { // do NOT start with an empty array - // $config = array(); $config = null; - if(!$inherit && !$appstack) { + if (!$inherit && !$appstack) { $config = $this->decodeFile($this->getFullpath($file, $appstack)); $this->data = $config; return $this; } - if($inherit && !$appstack) { - throw new \codename\core\exception(self::EXCEPTION_CONSTRUCT_INVALIDBEHAVIOR, \codename\core\exception::$ERRORLEVEL_FATAL, array('file' => $file, 'info' => 'Need Appstack to inherit config!')); + if ($inherit && !$appstack) { + throw new exception(self::EXCEPTION_CONSTRUCT_INVALIDBEHAVIOR, exception::$ERRORLEVEL_FATAL, ['file' => $file, 'info' => 'Need Appstack to inherit config!']); } - if($useAppstack == null) { - $useAppstack = app::getAppstack(); + if ($useAppstack == null) { + $useAppstack = app::getAppstack(); } - foreach(array_reverse($useAppstack) as $app) { + foreach (array_reverse($useAppstack) as $app) { // NOTE: this was originally thought for absolute path checking // we now really check for equality // to avoid relative-to-absolute path conversion // if the file exists in CWD! - if((($realpath = realpath($file)) !== false) && ($realpath == $file)) { - $fullpath = $file; + if ((($realpath = realpath($file)) !== false) && ($realpath == $file)) { + $fullpath = $file; } else { - $fullpath = app::getHomedir($app['vendor'], $app['app']) . $file; + $fullpath = app::getHomedir($app['vendor'], $app['app']) . $file; } - if(!app::getInstance('filesystem_local')->fileAvailable($fullpath)) { + if (!app::getInstance('filesystem_local')->fileAvailable($fullpath)) { continue; } - // initialize config as empty array here + // initialize config as an empty array here // as this is the first found file in the hierarchy - if($config === null) { - $config = array(); + if ($config === null) { + $config = []; } $thisConf = $this->decodeFile($fullpath); $this->inheritance[] = $fullpath; - if($inherit) { - $config = array_replace_recursive($config, $thisConf); + if ($inherit) { + $config = array_replace_recursive($config, $thisConf); } else { - $config = $thisConf; - break; + $config = $thisConf; + break; } } - if($config === null) { - // config was not initialized during hierarchy traversal - throw new \codename\core\exception(self::EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND, \codename\core\exception::$ERRORLEVEL_FATAL, array('file' => $file, 'appstack' => $useAppstack)); + if ($config === null) { + // config was not initialized during hierarchy traversal + throw new exception(self::EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND, exception::$ERRORLEVEL_FATAL, ['file' => $file, 'appstack' => $useAppstack]); } - $this->data = $config; + parent::__construct($config); return $this; } - - /** - * contains a list of elements (file paths) this config is composed of - * (if inheritance was allowed during construction of this object) - * @var string[] + * I will decode the given file and return the array of configuration it holds. + * @param string $fullpath + * @return array + * @throws ReflectionException + * @throws exception */ - protected $inheritance = array(); + protected function decodeFile(string $fullpath): array + { + $text = app::getInstance('filesystem_local')->fileRead($fullpath); - /** - * returns an array of file paths this config is composed of - * ordered from base to top level app - * also contains the topmost app (e.g. the current app) - * @return string[] - */ - public function getInheritance() : array { - return $this->inheritance; + if (strlen($text) == 0) { + throw new exception(self::EXCEPTION_DECODEFILE_FILEISEMPTY, exception::$ERRORLEVEL_FATAL, $fullpath); + } + + $json = json_decode($text, true); + + if (is_null($json)) { + throw new exception(self::EXCEPTION_DECODEFILE_FILEISINVALID, exception::$ERRORLEVEL_FATAL, $fullpath); + } + + return app::object2array($json); } /** * I will give you the lowest level full path that exists in the appstack. - *
If I don't use the appstack ($appstack = false), then I only search in the current app. - *
I will throw an exception, if neither in the app nor the appstack I can find the file + * If I don't use the appstack ($appstack = false), then I only search in the current app. + * I will throw an exception, if neither in the app nor the appstack I can find the file * @param string $file * @param bool $appstack - * @throws \codename\core\exception + * @return string + * @throws ReflectionException + * @throws exception * @todo REFACTOR simplify */ - protected function getFullpath(string $file, bool $appstack) : string { + protected function getFullpath(string $file, bool $appstack): string + { // direct absolute file path - if(!$appstack && realpath($file) !== false) { - return $file; + if (!$appstack && realpath($file) !== false) { + return $file; } $fullpath = app::getHomedir() . $file; - if(app::getInstance('filesystem_local')->fileAvailable($fullpath)) { + if (app::getInstance('filesystem_local')->fileAvailable($fullpath)) { return $fullpath; } - if(!$appstack) { - throw new \codename\core\exception(self::EXCEPTION_GETFULLPATH_FILEMISSING, \codename\core\exception::$ERRORLEVEL_FATAL, array('file' => $fullpath, 'info' => 'use appstack?')); + if (!$appstack) { + throw new exception(self::EXCEPTION_GETFULLPATH_FILEMISSING, exception::$ERRORLEVEL_FATAL, ['file' => $fullpath, 'info' => 'use appstack?']); } return app::getInheritedPath($file); } /** - * I will decode the given file and return the array of configuration it holds. - * @param string $fullpath - * @throws \codename\core\exception + * returns an array of file paths this config is composed of + * ordered from base to top level app + * also contains the topmost app (e.g., the current app) + * @return string[] */ - protected function decodeFile(string $fullpath) : array { - $text = app::getInstance('filesystem_local')->fileRead($fullpath); - - if(strlen($text) == 0) { - throw new \codename\core\exception(self::EXCEPTION_DECODEFILE_FILEISEMPTY, \codename\core\exception::$ERRORLEVEL_FATAL, $fullpath); - } - - $json = json_decode($text, true); - - if(is_null($json)) { - throw new \codename\core\exception(self::EXCEPTION_DECODEFILE_FILEISINVALID, \codename\core\exception::$ERRORLEVEL_FATAL, $fullpath); - } - - return app::object2array($json); + public function getInheritance(): array + { + return $this->inheritance; } - } diff --git a/backend/class/config/json/extendable.php b/backend/class/config/json/extendable.php index 4b9feba..87a2ebc 100644 --- a/backend/class/config/json/extendable.php +++ b/backend/class/config/json/extendable.php @@ -1,112 +1,118 @@ decodeFile($this->getFullpath($file, $appstack)); + $config = $this->provideExtends($config, $appstack, $inherit, $useAppstack); + $this->data = $config; + return $this; + } - // do NOT start with an empty array - // $config = array(); - $config = null; + if ($inherit && !$appstack) { + throw new exception(self::EXCEPTION_CONSTRUCT_INVALIDBEHAVIOR, exception::$ERRORLEVEL_FATAL, ['file' => $file, 'info' => 'Need Appstack to inherit config!']); + } - if(!$inherit && !$appstack) { - $config = $this->decodeFile($this->getFullpath($file, $appstack)); - $config = $this->provideExtends($config, $appstack, $inherit, $useAppstack); - $this->data = $config; - return $this; - } + if ($useAppstack == null) { + $useAppstack = app::getAppstack(); + } - if($inherit && !$appstack) { - throw new \codename\core\exception(self::EXCEPTION_CONSTRUCT_INVALIDBEHAVIOR, \codename\core\exception::$ERRORLEVEL_FATAL, array('file' => $file, 'info' => 'Need Appstack to inherit config!')); - } + foreach (array_reverse($useAppstack) as $app) { + if (realpath($file) !== false) { + $fullpath = $file; + } else { + $fullpath = app::getHomedir($app['vendor'], $app['app']) . $file; + } - if($useAppstack == null) { - $useAppstack = app::getAppstack(); - } + if (!app::getInstance('filesystem_local')->fileAvailable($fullpath)) { + continue; + } - foreach(array_reverse($useAppstack) as $app) { - if(realpath($file) !== false) { - $fullpath = $file; - } else { - $fullpath = app::getHomedir($app['vendor'], $app['app']) . $file; - } - if(!app::getInstance('filesystem_local')->fileAvailable($fullpath)) { - continue; - } + // initialize config as an empty array here + // as this is the first found file in the hierarchy + if ($config === null) { + $config = []; + } - // initialize config as empty array here - // as this is the first found file in the hierarchy - if($config === null) { - $config = array(); + $thisConf = $this->decodeFile($fullpath); + $thisConf = $this->provideExtends($thisConf, $appstack, $inherit, $useAppstack); + $this->inheritance[] = $fullpath; + if ($inherit) { + $config = array_replace_recursive($config, $thisConf); + } else { + $config = $thisConf; + break; + } } - $thisConf = $this->decodeFile($fullpath); - $thisConf = $this->provideExtends($thisConf, $appstack, $inherit, $useAppstack); - $this->inheritance[] = $fullpath; - if($inherit) { - $config = array_replace_recursive($config, $thisConf); - } else { - $config = $thisConf; - break; + if ($config === null) { + // config was not initialized during hierarchy traversal + throw new exception(self::EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND, exception::$ERRORLEVEL_FATAL, ['file' => $file, 'appstack' => $useAppstack]); } - } - if($config === null) { - // config was not initialized during hierarchy traversal - throw new \codename\core\exception(self::EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND, \codename\core\exception::$ERRORLEVEL_FATAL, array('file' => $file, 'appstack' => $useAppstack)); + $this->data = $config; + return $this; } - $this->data = $config; - return $this; - - } - - /** - * [provideExtends description] - * @param array|null $config [description] - * @param bool $appstack [description] - * @param bool $inherit [description] - * @param array|null $useAppstack [description] - * @return array|null [description] - */ - protected function provideExtends(?array $config, bool $appstack = false, bool $inherit = false, ?array $useAppstack = null) : ?array - { - if($config !== null && ($config['extends'] ?? false)) { - $extends = is_array($config['extends']) ? $config['extends'] : [ $config['extends'] ]; - foreach($extends as $extend) { - $extendableJsonConfig = new \codename\core\config\json\extendable($extend, $appstack, $inherit, $useAppstack); - // - // NOTE: this is a recursive replace in an inhertance-like "extends"-manner - // this means: - // we inherit ('extend') another config and replace/add/extend keys with those from our current config - // base config: some $extendableJsonConfig (we loop through the "extends" array) - // config that replaces/adds keys: this one ($config) - // - $config = array_replace_recursive($extendableJsonConfig->get(), $config); - } - } - if($config !== null && ($config['mixins'] ?? false)) { - $mixins = is_array($config['mixins']) ? $config['mixins'] : [ $config['mixins'] ]; - foreach($mixins as $mixin) { - $extendableJsonConfig = new \codename\core\config\json\extendable($mixin, $appstack, $inherit, $useAppstack); - $config = array_merge_recursive($config, $extendableJsonConfig->get()); - } + /** + * [provideExtends description] + * @param array|null $config [description] + * @param bool $appstack [description] + * @param bool $inherit [description] + * @param array|null $useAppstack [description] + * @return array|null [description] + * @throws ReflectionException + * @throws exception + */ + protected function provideExtends(?array $config, bool $appstack = false, bool $inherit = false, ?array $useAppstack = null): ?array + { + if ($config !== null && ($config['extends'] ?? false)) { + $extends = is_array($config['extends']) ? $config['extends'] : [$config['extends']]; + foreach ($extends as $extend) { + $extendableJsonConfig = new extendable($extend, $appstack, $inherit, $useAppstack); + // + // NOTE: this is a recursive replacement in an inheritance-like "extends"-manner + // this means: + // we inherit ('extend') another config and replace/add/extend keys with those from our current config + // base config: some $extendableJsonConfig (we loop through the "extends" array) + // config that replaces/adds keys: this one ($config) + // + $config = array_replace_recursive($extendableJsonConfig->get(), $config); + } + } + if ($config !== null && ($config['mixins'] ?? false)) { + $mixins = is_array($config['mixins']) ? $config['mixins'] : [$config['mixins']]; + foreach ($mixins as $mixin) { + $extendableJsonConfig = new extendable($mixin, $appstack, $inherit, $useAppstack); + $config = array_merge_recursive($config, $extendableJsonConfig->get()); + } + } + return $config; } - return $config; - } } diff --git a/backend/class/context.php b/backend/class/context.php index a6e4a08..ebcdfb5 100755 --- a/backend/class/context.php +++ b/backend/class/context.php @@ -1,30 +1,37 @@ identify(); - if(!$identity) { + if (!$identity) { return false; } - if(app::getConfig()->exists('context>' . $this->getRequest()->getData('context') . '>_security>group')) { + if (app::getConfig()->exists('context>' . $this->getRequest()->getData('context') . '>_security>group')) { return app::getAuth()->memberOf(app::getConfig()->get('context>' . $this->getRequest()->getData('context') . '>_security>group')); } return $identity; } - } diff --git a/backend/class/context/contextInterface.php b/backend/class/context/contextInterface.php index 732840b..94ac527 100644 --- a/backend/class/context/contextInterface.php +++ b/backend/class/context/contextInterface.php @@ -1,4 +1,5 @@ validate($data)) > 0) { - throw new exception(self::EXCEPTION_CORE_CREDENTIAL_VALIDATION, exception::$ERRORLEVEL_FATAL, $errors); +abstract class credential extends config implements credentialInterface +{ + /** + * [EXCEPTION_REST_CREDENTIAL_VALIDATION description] + * @var string + */ + public const string EXCEPTION_CORE_CREDENTIAL_VALIDATION = 'EXCEPTION_REST_CREDENTIAL_VALIDATION'; + + /** + * validator name to be used for validating input data + * @var string|null + */ + protected static $validatorName = null; + + /** + * {@inheritDoc} + * @param array $data + * @throws ReflectionException + * @throws exception + */ + public function __construct(array $data) + { + // if validator is set, validate! + if (self::$validatorName != null && count($errors = app::getValidator(self::$validatorName)->validate($data)) > 0) { + throw new exception(self::EXCEPTION_CORE_CREDENTIAL_VALIDATION, exception::$ERRORLEVEL_FATAL, $errors); + } + parent::__construct($data); } - parent::__construct($data); - } - - /** - * @inheritDoc - */ - public abstract function getIdentifier(): string; - - /** - * @inheritDoc - */ - public abstract function getAuthentication(); - - /** - * [public description] - * @return string - */ - // public abstract function getAuthenticationHash() : string; + /** + * {@inheritDoc} + */ + abstract public function getIdentifier(): string; + + /** + * {@inheritDoc} + */ + abstract public function getAuthentication(): mixed; + + /** + * [public description] + * @return string + */ + // abstract public function getAuthenticationHash() : string; } diff --git a/backend/class/credential/credentialExpiryInterface.php b/backend/class/credential/credentialExpiryInterface.php index 8e717da..321ff17 100644 --- a/backend/class/credential/credentialExpiryInterface.php +++ b/backend/class/credential/credentialExpiryInterface.php @@ -1,4 +1,5 @@ The server may be offline, misconfigured or your configuration is wrong. + * The server may be offline, misconfigured, or your configuration is wrong. * @var string */ - CONST EXCEPTION_CONSTRUCT_CONNECTIONERROR = 'EXCEPTION_CONSTRUCT_CONNECTIONERROR'; + public const string EXCEPTION_CONSTRUCT_CONNECTIONERROR = 'EXCEPTION_CONSTRUCT_CONNECTIONERROR'; /** * The query that was being executed id not finis correctly. - *
It may contain errors + * It may contain errors * @var string */ - CONST EXCEPTION_QUERY_QUERYERROR = 'EXCEPTION_QUERY_QUERYERROR'; + public const string EXCEPTION_QUERY_QUERYERROR = 'EXCEPTION_QUERY_QUERYERROR'; /** * Contains the current driver name - * @var string + * @var null|string */ - public $driver = null; + public ?string $driver = null; /** * Contains the \PDO instance of this DB instance - * @var \PDO + * @var null|PDO */ - protected $connection = null; + protected ?PDO $connection = null; /** * Contains the \PDOStatement instance of this DB instance after performing a query - * @var \PDOStatement + * @var null|PDOStatement */ - protected $statement = null; + protected ?PDOStatement $statement = null; /** * log name for queries * null to disable * @var string|null */ - protected $queryLog = null; + protected ?string $queryLog = null; + /** + * [protected description] + * @var PDOStatement[] + */ + protected array $statements = []; + /** + * holds data about the amount/count of cached statements + * to avoid calls to count() as far as possible + * @var int + */ + protected int $statementsCount = 0; + /** + * limit of how many PDO Prepared Statement Instances are kept for this database + * @var int + */ + protected int $maximumCachedStatements = 100; + /** + * [protected description] + * @var int + */ + protected int $maximumCachedOptimizedStatements = 50; + /** + * Virtual Transaction Counter + * @var array + */ + protected array $virtualTransactions = []; + /** + * Global virtual transaction counter + * @var int + */ + protected int $aggregatedVirtualTransactions = 0; /** * Creates an instance with the given data * @param array $config - * @return \codename\core\database + * @throws exception + * @throws sensitiveException */ - public function __construct(array $config) { + public function __construct(array $config) + { try { if (isset($config['env_pass'])) { - $pass = getenv($config['env_pass']); - } else if(isset($config['pass'])) { - $pass = $config['pass']; + $pass = getenv($config['env_pass']); + } elseif (isset($config['pass'])) { + $pass = $config['pass']; } else { - throw new \codename\core\exception(self::EXCEPTION_CONSTRUCT_CONNECTIONERROR, \codename\core\exception::$ERRORLEVEL_FATAL, array('ENV_PASS_NOT_SET')); + throw new exception(self::EXCEPTION_CONSTRUCT_CONNECTIONERROR, exception::$ERRORLEVEL_FATAL, ['ENV_PASS_NOT_SET']); } if (isset($config['env_host'])) { - $host = getenv($config['env_host']); - } else if(isset($config['host'])) { - $host = $config['host']; + $host = getenv($config['env_host']); + } elseif (isset($config['host'])) { + $host = $config['host']; } else { - throw new \codename\core\exception(self::EXCEPTION_CONSTRUCT_CONNECTIONERROR, \codename\core\exception::$ERRORLEVEL_FATAL, array('ENV_HOST_NOT_SET')); + throw new exception(self::EXCEPTION_CONSTRUCT_CONNECTIONERROR, exception::$ERRORLEVEL_FATAL, ['ENV_HOST_NOT_SET']); } if (isset($config['env_user'])) { - $user = getenv($config['env_user']); - } else if(isset($config['user'])) { - $user = $config['user']; + $user = getenv($config['env_user']); + } elseif (isset($config['user'])) { + $user = $config['user']; } else { - throw new \codename\core\exception(self::EXCEPTION_CONSTRUCT_CONNECTIONERROR, \codename\core\exception::$ERRORLEVEL_FATAL, array('ENV_USER_NOT_SET')); + throw new exception(self::EXCEPTION_CONSTRUCT_CONNECTIONERROR, exception::$ERRORLEVEL_FATAL, ['ENV_USER_NOT_SET']); } // set query log @@ -84,265 +124,208 @@ public function __construct(array $config) { // allow connections without database name // just put in autoconnect_database = false $autoconnectDatabase = true; - if(isset($config['autoconnect_database'])) { - $autoconnectDatabase = $config['autoconnect_database']; + if (isset($config['autoconnect_database'])) { + $autoconnectDatabase = $config['autoconnect_database']; } try { - // CHANGED 2021-05-04: allow driver-specific default attrs, if any. - $attrs = $this->getDefaultAttributes(); - $this->connection = new \PDO($this->driver . ":" . ( $autoconnectDatabase ? "dbname=" . $config['database'] . ";" : '') . 'host=' . $host . (isset($config['port']) ? (';port='.$config['port']) : '') . (isset($config['charset']) ? (';charset='.$config['charset']) : ''), $user, $pass, $attrs); + // CHANGED 2021-05-04: allow driver-specific default attrs, if any. + $attrs = $this->getDefaultAttributes(); + // CHANGED 2023-05-25: fixed dns driver for postgresql + $driver = $this->driver; + if ($driver === 'postgresql') { + $driver = 'pgsql'; + } + $this->connection = new PDO($driver . ":" . ($autoconnectDatabase ? "dbname=" . $config['database'] . ";" : '') . 'host=' . $host . (isset($config['port']) ? (';port=' . $config['port']) : '') . (isset($config['charset']) ? (';charset=' . $config['charset']) : ''), $user, $pass, $attrs); } catch (\Exception $e) { - throw new sensitiveException($e); + throw new sensitiveException($e); } - $this->connection->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); - $this->connection->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $this->connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); + $this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // if using NON-PERSISTENT connections, we override the statement class with one of our own // WARNING/CHANGED 2021-09-23: added ctor arg as reference (&) - // as it prevents PHP's GC from collecting this properly + // as it prevents PHP's GC from collecting this properly, // and this may cause connection keepalive trouble, when re-connecting to the same server // in the same process. - $this->connection->setAttribute(\PDO::ATTR_STATEMENT_CLASS, [\codename\core\extendedPdoStatement::class, [ &$this->connection ] ]); - } - catch (\PDOException $e) { - throw new \codename\core\exception(self::EXCEPTION_CONSTRUCT_CONNECTIONERROR, \codename\core\exception::$ERRORLEVEL_FATAL, $e); + $this->connection->setAttribute(PDO::ATTR_STATEMENT_CLASS, [extendedPdoStatement::class, [&$this->connection]]); + } catch (PDOException $e) { + throw new exception(self::EXCEPTION_CONSTRUCT_CONNECTIONERROR, exception::$ERRORLEVEL_FATAL, $e); } - $this->attach(new \codename\core\observer\database()); + $this->attach(new observer\database()); return $this; } - /** - * [__destruct description] - */ - public function __destruct() { - // - // NOTE: if experiencing problems with the GC/refcounting - // you might optionally clear out any leftover references to the PDO instance: - // - // $this->connection->setAttribute(\PDO::ATTR_STATEMENT_CLASS, [ \PDOStatement::class ]); - // $this->statement = null; - // $this->statements = []; - - // - // WARNING/CHANGED 2021-09-23: added destructor removing PDO object ref - // see note in this classes' constructor. - // - $this->connection = null; - } - /** * Default attributes to use during connection creation * as some attributes have no effect otherwise * @return array */ - protected function getDefaultAttributes(): array { - return []; + protected function getDefaultAttributes(): array + { + return []; } /** - * [protected description] - * @var \PDOStatement[] - */ - protected $statements = []; - - /** - * holds data about the amount/count of cached statements - * to avoid calls to count() as far as possible - * @var int - */ - protected $statementsCount = 0; - - /** - * limit of how many PDO Prepared Statement Instances are kept for this database - * @var int - */ - protected $maximumCachedStatements = 100; - - /** - * [protected description] - * @var int + * [__destruct description] */ - protected $maximumCachedOptimizedStatements = 50; - - // DEBUG Statement preparation performance - // public $statementPreparedCount = 0; - // public $statementsClearCount = 0; - // public $statementReusageCount = 0; - // public $statementUsageStatistics = []; + public function __destruct() + { + // + // WARNING/CHANGED 2021-09-23: added destructor removing PDO object ref + // see note in this classes' constructor. + // + $this->connection = null; + } /** * Performs the given $query on the \PDO instance. - *
Stores the \PDOStatement to the instance for result management + * Stores the \PDOStatement to the instance for result management * @param string $query * @param array $params * @return void + * @throws ReflectionException + * @throws exception */ - public function query (string $query, array $params = array()) { - if($this->queryLog) { - app::getLog($this->queryLog)->debug($query); - } - - // NOTE: disabled firing hooks for now. - // app::getHook()->fire(\codename\core\hook::EVENT_DATABASE_QUERY_QUERY_BEFORE, array('query' => $query, 'params' => $params)); - - $this->statement = null; - foreach($this->statements as $statement) { - if($statement->queryString == $query) { - $this->statement = $statement; - - // DEBUG Statement preparation performance - // $this->statementReusageCount++; - // $this->statementUsageStatistics[$query]++; - break; + public function query(string $query, array $params = []): void + { + if ($this->queryLog) { + app::getLog($this->queryLog)->debug($query); } - } - if($this->statement === null) { - $this->statement = $this->connection->prepare($query); - // DEBUG Statement preparation performance - // $this->statementPreparedCount++; - - // - // Clear cached prepared PDO statements, if there're more than N of them - // - if($this->statementsCount > $this->maximumCachedStatements) { - if($this->maximumCachedOptimizedStatements) { - uasort($this->statements, function(extendedPdoStatement $a, extendedPdoStatement $b) { - return $a->getExecutionCount() <=> $b->getExecutionCount(); - }); - $this->statements = array_slice($this->statements, 0, $this->maximumCachedOptimizedStatements); - $this->statementsCount = count($this->statements); - } else { - $this->statements = []; - $this->statementsCount = 0; - } - - // DEBUG Statement preparation performance - // $this->statementsClearCount++; + $this->statement = null; + foreach ($this->statements as $statement) { + if ($statement->queryString == $query) { + $this->statement = $statement; + break; + } } + if ($this->statement === null) { + $this->statement = $this->connection->prepare($query); + + // + // Clear cached prepared PDO statements, if there are more than N of them + // + if ($this->statementsCount > $this->maximumCachedStatements) { + if ($this->maximumCachedOptimizedStatements) { + uasort($this->statements, function (extendedPdoStatement $a, extendedPdoStatement $b) { + return $a->getExecutionCount() <=> $b->getExecutionCount(); + }); + $this->statements = array_slice($this->statements, 0, $this->maximumCachedOptimizedStatements); + $this->statementsCount = count($this->statements); + } else { + $this->statements = []; + $this->statementsCount = 0; + } + } - $this->statements[] = $this->statement; - $this->statementsCount++; - } - - foreach($params as $key => $param) { - // use parameters set in getParametrizedValue - // 0 => value, 1 => \PDO::PARAM_... - $this->statement->bindValue($key, $param[0], $param[1]); - } + $this->statements[] = $this->statement; + $this->statementsCount++; + } - $res = $this->statement->execute(); + foreach ($params as $key => $param) { + // use parameters set in getParametrizedValue + // 0 => value, 1 => \PDO::PARAM_... + $this->statement->bindValue($key, $param[0], $param[1]); + } - // explicitly check for falseness identity, not only == (equality), which may evaluate a 0 to a false. - if ($res === false) { - throw new \codename\core\exception(self::EXCEPTION_QUERY_QUERYERROR, \codename\core\exception::$ERRORLEVEL_FATAL, array('errors' => $this->statement->errorInfo(), 'query' => $query, 'params' => $params)); - } + $res = $this->statement->execute(); - // NOTE: disabled firing hooks for now. - // app::getHook()->fire(\codename\core\hook::EVENT_DATABASE_QUERY_QUERY_AFTER); - // NOTE: disabled calling notify() (observer) - // $this->notify(); - return; + // explicitly check for falseness identity, not only == (equality), which may evaluate a 0 to a false. + if ($res === false) { + throw new exception(self::EXCEPTION_QUERY_QUERYERROR, exception::$ERRORLEVEL_FATAL, ['errors' => $this->statement->errorInfo(), 'query' => $query, 'params' => $params]); + } } /** * Returns the array of records in the result * @return array */ - public function getResult() : array { - if(is_null($this->statement)) { - return array(); + public function getResult(): array + { + if (is_null($this->statement)) { + return []; } - return $this->statement->fetchAll(\PDO::FETCH_NAMED); + return $this->statement->fetchAll(PDO::FETCH_NAMED); } /** - * Returns the last insterted ID for the past query + * Returns the last inserted ID for the past query * @return string */ - public function lastInsertId() : string { + public function lastInsertId(): string + { return $this->connection->lastInsertId(); } /** * Returns count of affected rows of last - * create, update or delete operation + * creation, update or delete operation * @return int|null */ - public function affectedRows(): ?int { - if($this->statement) { - return $this->statement->rowCount(); - } else { - return null; - } + public function affectedRows(): ?int + { + return $this->statement?->rowCount(); } /** * [getConnection description] - * @return \PDO [description] + * @return PDO [description] */ - public function getConnection() : \PDO { - return $this->connection; + public function getConnection(): PDO + { + return $this->connection; } - /** - * Virtual Transaction Counter - * @var array - */ - protected $virtualTransactions = []; - - /** - * Global virtual transaction counter - * @var int - */ - protected $aggregatedVirtualTransactions = 0; - /** * Starts a virtualized transaction - * that may handle multi-model transactions + * that may handle multimodal transactions * - * @param string $transactionName [description] + * @param string $transactionName [description] * @return void + * @throws exception */ - public function beginVirtualTransaction(string $transactionName = 'default') { - if(!isset($this->virtualTransactions[$transactionName])) { - $this->virtualTransactions[$transactionName] = 0; - } - if(($this->virtualTransactions[$transactionName] === 0) && ($this->aggregatedVirtualTransactions === 0)) { - // this may cause errors when using multiple transaction names... - if($this->connection->inTransaction()) { - throw new exception('EXCEPTION_DATABASE_VIRTUALTRANSACTION_UNTRACKED_TRANSACTION_RUNNING', exception::$ERRORLEVEL_FATAL); + public function beginVirtualTransaction(string $transactionName = 'default'): void + { + if (!isset($this->virtualTransactions[$transactionName])) { + $this->virtualTransactions[$transactionName] = 0; + } + if (($this->virtualTransactions[$transactionName] === 0) && ($this->aggregatedVirtualTransactions === 0)) { + // this may cause errors when using multiple transaction names... + if ($this->connection->inTransaction()) { + throw new exception('EXCEPTION_DATABASE_VIRTUALTRANSACTION_UNTRACKED_TRANSACTION_RUNNING', exception::$ERRORLEVEL_FATAL); + } + // We have no open transactions with the given name, open a new one + $this->connection->beginTransaction(); } - // We have no open transactions with the given name, open a new one - $this->connection->beginTransaction(); - } - $this->virtualTransactions[$transactionName]++; - $this->aggregatedVirtualTransactions++; + $this->virtualTransactions[$transactionName]++; + $this->aggregatedVirtualTransactions++; } /** * [endVirtualTransaction description] - * @param string $transactionName [description] - * @return [type] [description] + * @param string $transactionName [description] + * @return void [type] [description] + * @throws exception */ - public function endVirtualTransaction(string $transactionName = 'default') { - if(!isset($this->virtualTransactions[$transactionName]) || ($this->virtualTransactions[$transactionName] === 0)) { - throw new exception('EXCEPTION_DATABASE_VIRTUALTRANSACTION_END_DOESNOTEXIST', exception::$ERRORLEVEL_FATAL, $transactionName); - } - if(!$this->connection->inTransaction()) { - throw new exception('EXCEPTION_DATABASE_VIRTUALTRANSACTION_END_TRANSACTION_INTERRUPTED', exception::$ERRORLEVEL_FATAL, $transactionName); - } - - $this->virtualTransactions[$transactionName]--; - $this->aggregatedVirtualTransactions--; - - if(($this->virtualTransactions[$transactionName] === 0) && ($this->aggregatedVirtualTransactions === 0)) { - $this->connection->commit(); - } + public function endVirtualTransaction(string $transactionName = 'default'): void + { + if (!isset($this->virtualTransactions[$transactionName]) || ($this->virtualTransactions[$transactionName] === 0)) { + throw new exception('EXCEPTION_DATABASE_VIRTUALTRANSACTION_END_DOESNOTEXIST', exception::$ERRORLEVEL_FATAL, $transactionName); + } + if (!$this->connection->inTransaction()) { + throw new exception('EXCEPTION_DATABASE_VIRTUALTRANSACTION_END_TRANSACTION_INTERRUPTED', exception::$ERRORLEVEL_FATAL, $transactionName); + } + + $this->virtualTransactions[$transactionName]--; + $this->aggregatedVirtualTransactions--; + + if (($this->virtualTransactions[$transactionName] === 0) && ($this->aggregatedVirtualTransactions === 0)) { + $this->connection->commit(); + } } /** @@ -350,12 +333,12 @@ public function endVirtualTransaction(string $transactionName = 'default') { * NOTE: this kills _all_ applicable transactions. * @return void */ - public function rollback() { - $this->virtualTransactions = []; - $this->aggregatedVirtualTransactions = 0; - if($this->connection->inTransaction()) { - $this->connection->rollBack(); - } + public function rollback(): void + { + $this->virtualTransactions = []; + $this->aggregatedVirtualTransactions = 0; + if ($this->connection->inTransaction()) { + $this->connection->rollBack(); + } } - } diff --git a/backend/class/database/mysql.php b/backend/class/database/mysql.php index e97b4d6..017b08c 100644 --- a/backend/class/database/mysql.php +++ b/backend/class/database/mysql.php @@ -1,46 +1,36 @@ -connection->exec('SET NAMES ' . $config['charset'] . '; CHARACTER SET '.$config['charset'].';'); - // } - return $this; + $this->driver = 'mysql'; + parent::__construct($config); + return $this; } /** - * @inheritDoc + * {@inheritDoc} */ protected function getDefaultAttributes(): array { - return [ - // CHANGED 2021-05-04: this fixes invalid rowcount - // on UPDATE where nothing really changed - \PDO::MYSQL_ATTR_FOUND_ROWS => true, - ]; + return [ + // CHANGED 2021-05-04: this fixes invalid rowcount + // on UPDATE where nothing really changed + PDO::MYSQL_ATTR_FOUND_ROWS => true, + ]; } - } diff --git a/backend/class/database/postgresql.php b/backend/class/database/postgresql.php index 80381a8..711f571 100755 --- a/backend/class/database/postgresql.php +++ b/backend/class/database/postgresql.php @@ -1,17 +1,23 @@ driver = 'postgresql'; + parent::__construct($config); + return $this; + } } diff --git a/backend/class/database/sqlite.php b/backend/class/database/sqlite.php index f1e00b7..884b3c0 100644 --- a/backend/class/database/sqlite.php +++ b/backend/class/database/sqlite.php @@ -1,103 +1,87 @@ queryLog = $config['querylog'] ?? null; - - // allow connections without database name - // just put in autoconnect_database = false - $autoconnectDatabase = true; - if(isset($config['autoconnect_database'])) { - $autoconnectDatabase = $config['autoconnect_database']; - } + $this->driver = 'sqlite'; + try { + // set query log + $this->queryLog = $config['querylog'] ?? null; - if($config['emulation_mode'] ?? false) { - $this->emulationMode = true; - } + if ($config['emulation_mode'] ?? false) { + $this->emulationMode = true; + } - try { - $file = $config['database_file']; - if($config['database_file_path_relative'] ?? false) { - $file = \codename\core\app::getHomedir($config['database_home']['vendor'], $config['database_home']['app']).'/'.$file; + try { + $file = $config['database_file']; + if ($config['database_file_path_relative'] ?? false) { + $file = app::getHomedir($config['database_home']['vendor'], $config['database_home']['app']) . '/' . $file; + } + $this->connection = new PDO($this->driver . ":" . $file); + } catch (\Exception $e) { + throw new sensitiveException($e); } - $this->connection = new \PDO($this->driver . ":" . $file); - } catch (\Exception $e) { - throw new sensitiveException($e); - } - $this->connection->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); - $this->connection->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $this->connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); + $this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $this->connection->setAttribute(\PDO::ATTR_STATEMENT_CLASS, [\codename\core\extendedPdoStatement::class, [ $this->connection ] ]); - } - catch (\PDOException $e) { - throw new \codename\core\exception(self::EXCEPTION_CONSTRUCT_CONNECTIONERROR, \codename\core\exception::$ERRORLEVEL_FATAL, $e); - } + $this->connection->setAttribute(PDO::ATTR_STATEMENT_CLASS, [extendedPdoStatement::class, [$this->connection]]); + } catch (PDOException $e) { + throw new exception(self::EXCEPTION_CONSTRUCT_CONNECTIONERROR, exception::$ERRORLEVEL_FATAL, $e); + } - $this->attach(new \codename\core\observer\database()); - return $this; - // - // parent::__construct($config); - // // if(isset($config['charset'])) { - // // $exec = $this->connection->exec('SET NAMES ' . $config['charset'] . '; CHARACTER SET '.$config['charset'].';'); - // // } - // return $this; + $this->attach(new database()); + return $this; } /** - * @inheritDoc + * {@inheritDoc} */ - public function query(string $query, array $params = array()) + public function query(string $query, array $params = []): void { - if($this->emulationMode) { - // $query = preg_replace('/([A-Z_a-z0-9]+\.[A-Z_a-z0-9]+)/', '"$1"', $query); - $query = str_ireplace('NOW()', "strftime('%Y-%m-%d %H:%M:%S','now')", $query); - } - // $query = preg_replace('/(d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/', 'strftime(\'%s\', \'$1\')', $query); - $this->sqliteQueryLog[] = $query; - return parent::query($query, $params); + if ($this->emulationMode) { + $query = str_ireplace('NOW()', "strftime('%Y-%m-%d %H:%M:%S','now')", $query); + } + $this->sqliteQueryLog[] = $query; + parent::query($query, $params); } - /** - * [protected description] - * @var [type] - */ - protected $sqliteQueryLog = []; - /** * [getQueryLog description] * @return array [description] */ - public function getQueryLog() : array { - return $this->sqliteQueryLog; + public function getQueryLog(): array + { + return $this->sqliteQueryLog; } - - /** - * [protected description] - * @var bool - */ - protected $emulationMode = false; - } diff --git a/backend/class/datacontainer.php b/backend/class/datacontainer.php index 1c56f2b..73c72c5 100755 --- a/backend/class/datacontainer.php +++ b/backend/class/datacontainer.php @@ -1,90 +1,68 @@ data = $data; return $this; } - /** - * Stores the given $data value under the given $key in this instance's data property - * @param string $key - * @param mixed|null $data - */ - public function setData(string $key, $data) { - if(strlen($key) == 0) { - return; - } - if(strpos($key, '>') !== false) { - // NOTE/CHANGED 2020-12-07: ::setData no longer sets literal '>' keys - // As this caused a general functionality disadvantage - // ::isDefined returned true for already set sub-keys - // and therefore, continued to set literals. - $myConfig = &$this->data; - foreach(explode('>', $key) as $myKey) { - if($myConfig !== null && !array_key_exists($myKey, $myConfig)) { - $myConfig[$myKey] = null; - } - $myConfig = &$myConfig[$myKey]; - } - $myConfig = $data; - } else { - $this->data[$key] = $data; - } - return; - } - /** * Adds the given KEY => VALUE array to the current set of data * @param array $data * @return void */ - public function addData(array $data) { + public function addData(array $data): void + { foreach ($data as $key => $value) { $this->setData($key, $value); } - return; } /** * Return the value of the given key. Either pass a direct name, or use a tree to navigate through the data set - *
->get('my>config>key') + * ->get('my>config>key') * @param string $key - * @return mixed|null + * @return mixed */ - public function getData(string $key = '') { - if(\strlen($key) == 0) { + public function getData(string $key = ''): mixed + { + if (strlen($key) == 0) { return $this->data; } - if(\strpos($key, '>') === false) { - if($this->isDefined($key)) { + if (!str_contains($key, '>')) { + if ($this->isDefined($key)) { return $this->data[$key]; } return null; } $myConfig = $this->data; - foreach(explode('>', $key) as $myKey) { - if(!\array_key_exists($myKey, $myConfig)) { + foreach (explode('>', $key) as $myKey) { + if (!array_key_exists($myKey, $myConfig)) { return null; } $myConfig = $myConfig[$myKey]; @@ -93,23 +71,52 @@ public function getData(string $key = '') { return $myConfig; } + /** + * Stores the given $data value under the given $key in this instance's data property + * @param string $key + * @param mixed|null $data + */ + public function setData(string $key, mixed $data): void + { + if (strlen($key) == 0) { + return; + } + if (str_contains($key, '>')) { + // NOTE/CHANGED 2020-12-07: ::setData no longer sets literal '>' keys + // As this caused a general functionality disadvantage + // ::isDefined returned true for already set sub-keys + // and therefore, continued to set literals. + $myConfig = &$this->data; + foreach (explode('>', $key) as $myKey) { + if ($myConfig !== null && !array_key_exists($myKey, $myConfig)) { + $myConfig[$myKey] = null; + } + $myConfig = &$myConfig[$myKey]; + } + $myConfig = $data; + } else { + $this->data[$key] = $data; + } + } + /** * Returns true if there is a value with name $key in this instance's data set * @param string $key * @return bool */ - public function isDefined(string $key) : bool { - if(\strpos($key, '>') === false) { - return \array_key_exists($key, $this->data); + public function isDefined(string $key): bool + { + if (!str_contains($key, '>')) { + return array_key_exists($key, $this->data); } else { - $myConfig = $this->data; - foreach(explode('>', $key) as $myKey) { - if(!\array_key_exists($myKey, $myConfig)) { - return false; - } - $myConfig = $myConfig[$myKey]; - } - return true; + $myConfig = $this->data; + foreach (explode('>', $key) as $myKey) { + if (!array_key_exists($myKey, $myConfig)) { + return false; + } + $myConfig = $myConfig[$myKey]; + } + return true; } } @@ -117,16 +124,15 @@ public function isDefined(string $key) : bool { * Removes the given $key from this instance's data set * @param string $key */ - public function unsetData(string $key) { - if(strlen($key) == 0) { - return $this->data; + public function unsetData(string $key): void + { + if (strlen($key) == 0) { + return; } - if(!$this->isDefined($key)) { + if (!$this->isDefined($key)) { return; } unset($this->data[$key]); - return; } - } diff --git a/backend/class/errorstack.php b/backend/class/errorstack.php index 0cb5e34..0bad205 100755 --- a/backend/class/errorstack.php +++ b/backend/class/errorstack.php @@ -1,25 +1,29 @@ type = strtoupper($type); return $this; } @@ -39,92 +44,96 @@ public function __construct(string $type) { /** * * {@inheritDoc} - * @see \codename\core\errorstack_interface::addError($identifier, $code, $detail) + * @see errorstack_interface::addError, $code, $detail) */ - final public function addError(string $identifier, string $code, $detail = null) : \codename\core\errorstack { - array_push($this->errors, array( - '__IDENTIFIER' => $identifier, - '__CODE' => "{$this->type}.{$code}", - '__TYPE' => $this->type, - '__DETAILS' => $detail - )); - - if(is_array($this->callback)) { - call_user_func(array($this->callback['object'], $this->callback['function']), $this->getErrors()); + final public function addError(string $identifier, string $code, mixed $detail = null): errorstack + { + $this->errors[] = [ + '__IDENTIFIER' => $identifier, + '__CODE' => $this->type . '.' . $code, + '__TYPE' => $this->type, + '__DETAILS' => $detail, + ]; + + if (is_array($this->callback)) { + call_user_func([$this->callback['object'], $this->callback['function']], $this->getErrors()); } return $this; } /** - * @inheritDoc + * + * {@inheritDoc} + * @see errorstack_interface::getErrors */ - public function addErrors(array $errors): \codename\core\errorstack + final public function getErrors(): array { - foreach($errors as $error) { - array_push($this->errors, $error); - } - if(is_array($this->callback)) { - call_user_func(array($this->callback['object'], $this->callback['function']), $this->getErrors()); - } - return $this; + return $this->errors; } + /** - * @inheritDoc + * {@inheritDoc} */ - public function addErrorstack(\codename\core\errorstack $errorstack): \codename\core\errorstack { - $this->addErrors($errorstack->getErrors()); - return $this; + public function addErrorstack(errorstack $errorstack): errorstack + { + $this->addErrors($errorstack->getErrors()); + return $this; } /** - * * {@inheritDoc} - * @see \codename\core\errorstack_interface::isSuccess() */ - final public function isSuccess() : bool { - return (count($this->getErrors()) == 0); + public function addErrors(array $errors): errorstack + { + foreach ($errors as $error) { + $this->errors[] = $error; + } + if (is_array($this->callback)) { + call_user_func([$this->callback['object'], $this->callback['function']], $this->getErrors()); + } + return $this; } /** * * {@inheritDoc} - * @see \codename\core\errorstack_interface::getErrors() + * @see errorstack_interface::isSuccess */ - final public function getErrors() : array { - return $this->errors; + final public function isSuccess(): bool + { + return (count($this->getErrors()) == 0); } /** * Adds a callback for errors. - *
Add the $object and the $function of the object that will be called + * Add the $object and the $function of the object that will be called * @param object $object * @param string $function */ - final public function setCallback($object, string $function) { - $this->callback = array( - 'object' => $object, - 'function' => $function - ); - return; + final public function setCallback(object $object, string $function): void + { + $this->callback = [ + 'object' => $object, + 'function' => $function, + ]; } /** - * @inheritDoc + * {@inheritDoc} */ - public function reset() : \codename\core\errorstack + public function reset(): errorstack { - $this->errors = array(); - return $this; + $this->errors = []; + return $this; } /** - * @inheritDoc + * {@inheritDoc} * custom serialization */ - public function jsonSerialize() + public function jsonSerialize(): mixed { - return $this->getErrors(); + return $this->getErrors(); } - } diff --git a/backend/class/errorstack/errorstackInterface.php b/backend/class/errorstack/errorstackInterface.php index a041adc..4ba685b 100755 --- a/backend/class/errorstack/errorstackInterface.php +++ b/backend/class/errorstack/errorstackInterface.php @@ -1,58 +1,58 @@ name; - } - - /** - * [__construct description] - * @param string $name [description] - */ - public function __construct(string $name) - { - $this->name = $name; - } + /** + * [protected description] + * @var string + */ + protected string $name; + /** + * [protected description] + * @var eventHandler[] + */ + protected array $eventHandlers = []; - /** - * invoke all event handlers without return value - * @param [type] $sender [description] - * @param [type] $eventArgs [description] - * @return [type] [description] - */ - public function invoke($sender, $eventArgs) { - foreach($this->eventHandlers as $eventHandler) { - $eventHandler->invoke($sender, $eventArgs); + /** + * [__construct description] + * @param string $name [description] + */ + public function __construct(string $name) + { + $this->name = $name; } - } - /** - * [invokeWithResult description] - * only the last eventHandler gets to return his return value - * @param [type] $sender [description] - * @param [type] $eventArgs [description] - * @return [type] [description] - */ - public function invokeWithResult($sender, $eventArgs) { - $ret = null; - foreach($this->eventHandlers as $eventHandler) { - $ret = $eventHandler->invoke($sender, $eventArgs); + /** + * [getName description] + * @return string [type] [description] + */ + public function getName(): string + { + return $this->name; } - return $ret; - } - /** - * [invokeWithAllResults description] - * @param [type] $sender [description] - * @param [type] $eventArgs [description] - * @return array - */ - public function invokeWithAllResults($sender, $eventArgs) : array { - $ret = array(); - foreach($this->eventHandlers as $eventHandler) { - $ret[] = $eventHandler->invoke($sender, $eventArgs); + /** + * [invokeWithResult description] + * only the last eventHandler gets to return his return value + * @param $sender + * @param $eventArgs + * @return mixed [type] [description] + */ + public function invokeWithResult($sender, $eventArgs): mixed + { + $ret = null; + foreach ($this->eventHandlers as $eventHandler) { + $ret = $eventHandler->invoke($sender, $eventArgs); + } + return $ret; } - return $ret; - } - /** - * [protected description] - * @var eventHandler[] - */ - protected $eventHandlers = array(); + /** + * invoke all event handlers without return value + * @param $sender + * @param $eventArgs + * @return void [type] [description] + */ + public function invoke($sender, $eventArgs): void + { + foreach ($this->eventHandlers as $eventHandler) { + $eventHandler->invoke($sender, $eventArgs); + } + } - /** - * [addEventHandler description] - * @param eventHandler $eventHandler [description] - * @return event [description] - */ - public function addEventHandler(eventHandler $eventHandler) : event { - $this->eventHandlers[] = $eventHandler; - return $this; - } + /** + * [invokeWithAllResults description] + * @param [type] $sender [description] + * @param [type] $eventArgs [description] + * @return array + */ + public function invokeWithAllResults($sender, $eventArgs): array + { + $ret = []; + foreach ($this->eventHandlers as $eventHandler) { + $ret[] = $eventHandler->invoke($sender, $eventArgs); + } + return $ret; + } -} \ No newline at end of file + /** + * [addEventHandler description] + * @param eventHandler $eventHandler [description] + * @return event [description] + */ + public function addEventHandler(eventHandler $eventHandler): event + { + $this->eventHandlers[] = $eventHandler; + return $this; + } +} diff --git a/backend/class/eventHandler.php b/backend/class/eventHandler.php index 42bd7fb..63919df 100644 --- a/backend/class/eventHandler.php +++ b/backend/class/eventHandler.php @@ -1,4 +1,5 @@ callable = $function; - } + /** + * the internal callable (function) + * @var callable + */ + protected $callable; - /** - * invoke the stored function in this eventhandler - * @param mixed|null $sender [description] - * @param mixed|null $arguments [description] - * @return mixed|null [description] - */ - public function invoke($sender, $arguments) { - return call_user_func($this->callable, $arguments); - } + /** + * [__construct description] + * @param callable $function [description] + */ + public function __construct(callable $function) + { + $this->callable = $function; + } + /** + * invoke the stored function in this eventhandler + * @param mixed $sender [description] + * @param mixed $arguments [description] + * @return mixed [description] + */ + public function invoke(mixed $sender, mixed $arguments): mixed + { + return call_user_func($this->callable, $arguments); + } } diff --git a/backend/class/exception.php b/backend/class/exception.php index 1f2cc02..025ef50 100755 --- a/backend/class/exception.php +++ b/backend/class/exception.php @@ -1,4 +1,5 @@ message = $this->translateExceptionCode($code); - $this->code = $code; - $this->info = $info; - - app::getHook()->fire($code); - app::getHook()->fire('EXCEPTION'); - - /* - app::getResponse()->setStatuscode(500, "Internal Server Error"); + public function __construct(string $code, int $level, $info = null) + { + $this->message = $this->translateExceptionCode($code); + $this->code = $code; + $this->info = $info; - if(defined('CORE_ENVIRONMENT') && CORE_ENVIRONMENT != 'production') { - echo "

Hicks!

"; - echo "
{$code}
"; - - if(!is_null($info)) { - echo "
Information:
"; - echo "
";
-            print_r($info);
-            echo "
"; - } - - echo "
Stacktrace:
"; - echo "
";
-        print_r($this->getTrace());
-        echo "
"; - die(); - } - - // (new \codename\core\api\loggly())->send(array('exception' => array( 'code'=>$code, 'level' => $level, 'info' => $info, 'stack' => $this->getTrace())), 1); - - app::getResponse()->pushOutput(); - return $this; - */ + app::getHook()->fire($code); + app::getHook()->fire('EXCEPTION'); } /** * [translateExceptionCode description] - * @param string $code [description] + * @param string $code [description] * @return string [description] */ - protected function translateExceptionCode(string $code) : string { - return $code; - // return app::getTranslate('exception')->translate('EXCEPTION.' . $code); + protected function translateExceptionCode(string $code): string + { + return $code; } - - } diff --git a/backend/class/exception/catchableException.php b/backend/class/exception/catchableException.php new file mode 100644 index 0000000..cef6819 --- /dev/null +++ b/backend/class/exception/catchableException.php @@ -0,0 +1,30 @@ +message = $this->translateExceptionCode($code); + $this->code = $code; + $this->info = $info; + app::getHook()->fire($code); + app::getHook()->fire('EXCEPTION'); + return $this; + } +} diff --git a/backend/class/exception/compileErrorException.php b/backend/class/exception/compileErrorException.php new file mode 100644 index 0000000..53e1e89 --- /dev/null +++ b/backend/class/exception/compileErrorException.php @@ -0,0 +1,9 @@ +encapsulatedException = $encapsulatedException; + $this->message = $encapsulatedException->getMessage(); + $this->line = $encapsulatedException->getLine(); + $this->file = $encapsulatedException->getFile(); + if ($encapsulatedException instanceof exception) { + $this->info = $encapsulatedException->info; + } + } + + /** + * [getEncapsulatedException description] + * @return \Exception [description] + */ + public function getEncapsulatedException(): \Exception + { + return $this->encapsulatedException; + } +} diff --git a/backend/class/exception/strictException.php b/backend/class/exception/strictException.php new file mode 100644 index 0000000..233844c --- /dev/null +++ b/backend/class/exception/strictException.php @@ -0,0 +1,9 @@ +filename = new \codename\core\value\text\fileabsolute($filename); + public function setFilename(string $filename): export + { + $this->filename = new fileabsolute($filename); return $this; } /** * * {@inheritDoc} - * @see \codename\core\export\exportInterface::export() + * @return bool + * @throws ReflectionException + * @throws exception + * @see exportInterface::export */ - public function export() : bool { + public function export(): bool + { $_ret = ''; - foreach($this->fields as $field) { + foreach ($this->fields as $field) { $_ret .= $field->get(); $_ret .= $this->separator_field; } $_ret .= $this->separator_row; - foreach($this->data as $row) { - foreach($this->fields as $field) { + foreach ($this->data as $row) { + foreach ($this->fields as $field) { $_ret .= $row->getData($field->get()); $_ret .= $this->separator_field; } $_ret .= $this->separator_row; } - + app::getFilesystem()->fileDelete($this->filename->get()); - app::getFilesystem('local')->fileWrite($this->filename->get(), mb_convert_encoding($_ret, $this->encoding, 'UTF-8')); - + app::getFilesystem()->fileWrite($this->filename->get(), mb_convert_encoding($_ret, $this->encoding, 'UTF-8')); + return true; } /** * * {@inheritDoc} - * @see \codename\core\export\exportInterface::addField() + * @see exportInterface::addFields */ - public function addField(\codename\core\value\text $field) : \codename\core\export { - $this->fields[] = $field; + public function addFields(array $fields): export + { + foreach ($fields as $field) { + $this->addField($field); + } return $this; } /** * * {@inheritDoc} - * @see \codename\core\export\exportInterface::addRow() + * @see exportInterface::addField */ - public function addRow(\codename\core\datacontainer $data) : \codename\core\export { - $this->data[] = $data; + public function addField(text $field): export + { + $this->fields[] = $field; return $this; } /** * * {@inheritDoc} - * @see \codename\core\export\exportInterface::addFields() + * @see exportInterface::addRows */ - public function addFields(array $fields) : \codename\core\export { - foreach($fields as $field) { - $this->addField($field); + public function addRows(array $rows): export + { + foreach ($rows as $row) { + $this->addRow($row); } return $this; } @@ -107,13 +126,11 @@ public function addFields(array $fields) : \codename\core\export { /** * * {@inheritDoc} - * @see \codename\core\export\exportInterface::addRows() + * @see exportInterface::addRow */ - public function addRows(array $rows) : \codename\core\export { - foreach($rows as $row) { - $this->addRow($row); - } + public function addRow(datacontainer $data): export + { + $this->data[] = $data; return $this; } - } diff --git a/backend/class/export/csv/excel.php b/backend/class/export/csv/excel.php index cc3cb14..0809ced 100644 --- a/backend/class/export/csv/excel.php +++ b/backend/class/export/csv/excel.php @@ -1,13 +1,17 @@ filename = new \codename\core\value\text\fileabsolute($filename); + public function setFilename(string $filename): export + { + $this->filename = new fileabsolute($filename); return $this; } /** * * {@inheritDoc} - * @see \codename\core\export\exportInterface::export() + * @return bool + * @throws ReflectionException + * @throws exception + * @see exportInterface::export */ - public function export() : bool { - + public function export(): bool + { $data = []; - foreach($this->data as $row) { - $rowData = []; - foreach($this->fields as $field) { - $rowData[$field->get()] = $row->getData($field->get()); - } - $data[] = $rowData; + foreach ($this->data as $row) { + $rowData = []; + foreach ($this->fields as $field) { + $rowData[$field->get()] = $row->getData($field->get()); + } + $data[] = $rowData; } $json = json_encode($data); app::getFilesystem()->fileDelete($this->filename->get()); - app::getFilesystem('local')->fileWrite($this->filename->get(), $json); + app::getFilesystem()->fileWrite($this->filename->get(), $json); return true; } @@ -59,31 +72,36 @@ public function export() : bool { /** * * {@inheritDoc} - * @see \codename\core\export\exportInterface::addField() + * @see exportInterface::addFields */ - public function addField(\codename\core\value\text $field) : \codename\core\export { - $this->fields[] = $field; + public function addFields(array $fields): export + { + foreach ($fields as $field) { + $this->addField($field); + } return $this; } /** * * {@inheritDoc} - * @see \codename\core\export\exportInterface::addRow() + * @see exportInterface::addField */ - public function addRow(\codename\core\datacontainer $data) : \codename\core\export { - $this->data[] = $data; + public function addField(text $field): export + { + $this->fields[] = $field; return $this; } /** * * {@inheritDoc} - * @see \codename\core\export\exportInterface::addFields() + * @see exportInterface::addRows */ - public function addFields(array $fields) : \codename\core\export { - foreach($fields as $field) { - $this->addField($field); + public function addRows(array $rows): export + { + foreach ($rows as $row) { + $this->addRow($row); } return $this; } @@ -91,13 +109,11 @@ public function addFields(array $fields) : \codename\core\export { /** * * {@inheritDoc} - * @see \codename\core\export\exportInterface::addRows() + * @see exportInterface::addRow */ - public function addRows(array $rows) : \codename\core\export { - foreach($rows as $row) { - $this->addRow($row); - } + public function addRow(datacontainer $data): export + { + $this->data[] = $data; return $this; } - } diff --git a/backend/class/extendedPdoStatement.php b/backend/class/extendedPdoStatement.php index 82c8883..edeead5 100644 --- a/backend/class/extendedPdoStatement.php +++ b/backend/class/extendedPdoStatement.php @@ -1,48 +1,53 @@ pdoInstance = $pdo; - } + /** + * [__construct description] + * @param PDO $pdo [description] + */ + protected function __construct(PDO $pdo) + { + $this->pdoInstance = $pdo; + } - /** - * @inheritDoc - */ - public function execute($input_parameters = null) - { - $this->executionCount++; - return parent::execute($input_parameters); - } + /** + * {@inheritDoc} + */ + public function execute($params = null): bool + { + $this->executionCount++; + return parent::execute($params); + } - /** - * [getExecutionCount description] - * @return int [description] - */ - public function getExecutionCount() : int { - return $this->executionCount; - } + /** + * [getExecutionCount description] + * @return int [description] + */ + public function getExecutionCount(): int + { + return $this->executionCount; + } } diff --git a/backend/class/extension.php b/backend/class/extension.php index dd0da33..96bfdc4 100644 --- a/backend/class/extension.php +++ b/backend/class/extension.php @@ -1,35 +1,38 @@ $this->getExtensionVendor(), + 'app' => $this->getExtensionName(), + 'namespace' => '\\' . (new ReflectionClass($this))->getNamespaceName(), + ]; + } - /** - * Returns parameters used for injecting the extension - * into an appstack - * - * @return array [injection parameters] - */ - final public function getInjectParameters() : array { - return [ - 'vendor' => $this->getExtensionVendor(), - 'app' => $this->getExtensionName(), - 'namespace' => '\\'.(new \ReflectionClass($this))->getNamespaceName() - ]; - } + /** + * [getExtensionVendor description] + * @return string [description] + */ + abstract public function getExtensionVendor(): string; + /** + * [getExtensionName description] + * @return string [description] + */ + abstract public function getExtensionName(): string; } diff --git a/backend/class/factory.php b/backend/class/factory.php index 6fa7bef..556aa30 100644 --- a/backend/class/factory.php +++ b/backend/class/factory.php @@ -1,4 +1,5 @@ (FTP, SMB, local Filesystem, Amazon S3, Dropbox, etc.) + * (FTP, SMB, local Filesystem, Amazon S3, Dropbox, etc.), * So this is our abstract filesystem class. * @package core * @since 2016-01-06 */ -abstract class filesystem implements \codename\core\filesystem\filesystemInterface { - +abstract class filesystem implements filesystemInterface +{ /** * Contains an instance of the errorstack class - * @var \codename\core\errorstack + * @var null|errorstack */ - protected $errorstack = null; - + protected ?errorstack $errorstack = null; /** - * Returns the error message - * @return string + * @var string */ - public function getError() : string { - return $this->errormessage; - } - + protected string $errormessage; + /** * Creates the errorstack instance - * @return \codename\core\filesystem + * @return filesystem */ - public function __construct() { - $this->errorstack = new \codename\core\errorstack('FILESYSTEM'); + public function __construct() + { + $this->errorstack = new errorstack('FILESYSTEM'); return $this; } - + + /** + * Returns the error message + * @return string + */ + public function getError(): string + { + return $this->errormessage; + } } diff --git a/backend/class/filesystem/filesystemInterface.php b/backend/class/filesystem/filesystemInterface.php index 1656d22..42cedd0 100755 --- a/backend/class/filesystem/filesystemInterface.php +++ b/backend/class/filesystem/filesystemInterface.php @@ -1,4 +1,5 @@ fileAvailable($file)) { + return false; + } + + if ($this->isDirectory($file)) { + return false; + } + + @unlink($file); + + return !$this->fileAvailable($file); + } /** * * {@inheritDoc} * @see \codename\core\filesystem_interface::fileAvailable($file) */ - public function fileAvailable(string $file) : bool { + public function fileAvailable(string $file): bool + { return file_exists($file); } /** * * {@inheritDoc} - * @see \codename\core\filesystem_interface::fileDelete($file) + * @see \codename\core\filesystem_interface::isDirectory($path) */ - public function fileDelete(string $file) : bool { - if(!$this->fileAvailable($file)) { - return false; - } - - if($this->isDirectory($file)) { + public function isDirectory(string $path): bool + { + if (!$this->fileAvailable($path)) { return false; } - - @unlink($file); - - return !$this->fileAvailable($file); + return @is_dir($path); } /** @@ -41,33 +60,34 @@ public function fileDelete(string $file) : bool { * {@inheritDoc} * @see \codename\core\filesystem_interface::fileMove($source, $destination) */ - public function fileMove(string $source, string $destination) : bool { - if(!$this->fileAvailable($source)) { + public function fileMove(string $source, string $destination): bool + { + if (!$this->fileAvailable($source)) { $this->errorstack->addError('FILE', 'SOURCE_NOT_FOUND', $source); return false; } - if($this->isDirectory($source)) { + if ($this->isDirectory($source)) { $this->errorstack->addError('FILE', 'SOURCE_IS_A_DIRECTORY', $source); return false; } - if($this->fileAvailable($destination)) { + if ($this->fileAvailable($destination)) { $this->errorstack->addError('FILE', 'DESTINATION_ALREADY_EXISTS', $source); return false; } - if(!$this->makePath($destination)) { + if (!$this->makePath($destination)) { $this->errorstack->addError('FILE', 'DESTINATION_PATH_NOT_CREATED', $destination); return false; } - if(!$this->dirAvailable(dirname($destination))) { + if (!$this->dirAvailable(dirname($destination))) { $this->errorstack->addError('FILE', 'DESTINATION_PATH_NOT_FOUND', $destination); return false; } - if(!is_writable(dirname($destination))) { + if (!is_writable(dirname($destination))) { $this->errorstack->addError('FILE', 'DESTINATION_PATH_NOT_WRITABLE', $destination); return false; } @@ -77,36 +97,70 @@ public function fileMove(string $source, string $destination) : bool { return $this->fileAvailable($destination); } + /** + * My creation several directories until the complete path is available. + * @param string $directory + * @return bool + */ + protected function makePath(string $directory): bool + { + // CHANGED 2021-03-25 to improve Windows support + // We can't rely on '/'-splitting + // Extract directory from a path + $basedir = pathinfo($directory, PATHINFO_DIRNAME); + + try { + @mkdir($basedir, 0777, true); + } catch (Exception) { + } + + return $this->dirAvailable($basedir); + } + + /** + * + * {@inheritDoc} + * @see \codename\core\filesystem_interface::dirAvailable($directory) + */ + public function dirAvailable(string $directory): bool + { + if (!$this->fileAvailable($directory)) { + return false; + } + return $this->isDirectory($directory); + } + /** * * {@inheritDoc} * @see \codename\core\filesystem_interface::fileCopy($source, $destination) */ - public function fileCopy(string $source, string $destination) : bool { - if(!$this->fileAvailable($source)) { + public function fileCopy(string $source, string $destination): bool + { + if (!$this->fileAvailable($source)) { $this->errorstack->addError('FILE', 'SOURCE_NOT_FOUND', $source); return false; } - if($this->isDirectory($source)) { + if ($this->isDirectory($source)) { $this->errorstack->addError('FILE', 'SOURCE_IS_A_DIRECTORY', $source); return false; } - if($this->fileAvailable($destination)) { + if ($this->fileAvailable($destination)) { $this->errorstack->addError('FILE', 'DESTINATION_ALREADY_EXISTS', $source); return false; } - if(!$this->makePath($destination)) { + if (!$this->makePath($destination)) { $this->errorstack->addError('FILE', 'DESTINATION_PATH_NOT_CREATED', $destination); return false; } // copying might fail due to quota, permissions or write errors - if(!@copy($source, $destination)) { - $this->errorstack->addError('FILE', 'COPY_FAILURE', [$source, $destination]); - return false; + if (!@copy($source, $destination)) { + $this->errorstack->addError('FILE', 'COPY_FAILURE', [$source, $destination]); + return false; } return $this->fileAvailable($destination); @@ -117,12 +171,13 @@ public function fileCopy(string $source, string $destination) : bool { * {@inheritDoc} * @see \codename\core\filesystem_interface::fileRead($file) */ - public function fileRead(string $file) : string { - if(!$this->fileAvailable($file)) { + public function fileRead(string $file): string + { + if (!$this->fileAvailable($file)) { return false; } - if($this->isDirectory($file)) { + if ($this->isDirectory($file)) { $this->errorstack->addError('FILE', 'DESTINATION_IS_A_DIRECTORY', $file); return ''; } @@ -135,13 +190,14 @@ public function fileRead(string $file) : string { * {@inheritDoc} * @see \codename\core\filesystem_interface::fileWrite($file, $content) */ - public function fileWrite(string $file, string $content=null) : bool { - if(!$this->makePath($file)) { + public function fileWrite(string $file, string $content = null): bool + { + if (!$this->makePath($file)) { $this->errorstack->addError('FILE', 'DESTINATION_PATH_NOT_CREATED', $file); return false; } - if($this->fileAvailable($file)) { + if ($this->fileAvailable($file)) { $this->errorstack->addError('FILE', 'FILE_ALREADY_EXISTS', $file); return false; } @@ -156,44 +212,22 @@ public function fileWrite(string $file, string $content=null) : bool { * {@inheritDoc} * @see \codename\core\filesystem_interface::getInfo($file) */ - public function getInfo(string $file) : array { - return array( - 'filesize' => filesize($file), - 'filectime' => filectime($file), - ); - } - - /** - * - * {@inheritDoc} - * @see \codename\core\filesystem_interface::dirAvailable($directory) - */ - public function dirAvailable(string $directory) : bool { - if(!$this->fileAvailable($directory)) { - return false; - } - return $this->isDirectory($directory); + public function getInfo(string $file): array + { + return [ + 'filesize' => filesize($file), + 'filectime' => filectime($file), + ]; } /** * * {@inheritDoc} - * @see \codename\core\filesystem_interface::isDirectory($path) + * @see filesystemInterface::isFile */ - public function isDirectory(string $path) : bool { - if(!$this->fileAvailable($path)) { - return false; - } - return (bool) @is_dir($path); - } - - /** - * - * {@inheritDoc} - * @see \codename\core\filesystem\filesystemInterface::isFile() - */ - public function isFile(string $file) : bool { - if(!$this->fileAvailable($file)) { + public function isFile(string $file): bool + { + if (!$this->fileAvailable($file)) { return false; } return @is_file($file); @@ -204,20 +238,20 @@ public function isFile(string $file) : bool { * {@inheritDoc} * @see \codename\core\filesystem_interface::dirCreate($directory) */ - public function dirCreate(string $directory) : bool { - if($this->fileAvailable($directory)) { + public function dirCreate(string $directory): bool + { + if ($this->fileAvailable($directory)) { return false; } // - // In some situations, mkdir may throw an exception, + // In some situations, mkdir may throw an exception // if the directory had been created in the meantime - // Either way, we check for the dir being available, below. + // Either way, we check, for the dir being available, below. // try { - @mkdir($directory); - } catch (\Exception $e) { - + @mkdir($directory); + } catch (Exception) { } return $this->fileAvailable($directory); @@ -228,15 +262,16 @@ public function dirCreate(string $directory) : bool { * {@inheritDoc} * @see \codename\core\filesystem_interface::dirList($directory) */ - public function dirList(string $directory) : array { - if(!$this->isDirectory($directory)) { - return array(); + public function dirList(string $directory): array + { + if (!$this->isDirectory($directory)) { + return []; } $list = scandir($directory); - $myList = array(); - foreach($list as $object) { - if($object == '.' || $object == '..') { + $myList = []; + foreach ($list as $object) { + if ($object == '.' || $object == '..') { continue; } $myList[] = $object; @@ -244,25 +279,4 @@ public function dirList(string $directory) : array { return $myList; } - - /** - * My create several directories until the complete path is available. - * @param string $directory - * @return bool - */ - protected function makePath(string $directory) : bool { - - // CHANGED 2021-03-25 to improve Windows support - // We can't rely on '/'-splitting - // Extract directory from path - $basedir = pathinfo($directory, PATHINFO_DIRNAME); - - try { - @mkdir($basedir, 0777, true); - } catch (\Exception $e) { - } - - return $this->dirAvailable($basedir); - } - } diff --git a/backend/class/frontend/buttonGroup.php b/backend/class/frontend/buttonGroup.php index 871d60d..2dcf674 100644 --- a/backend/class/frontend/buttonGroup.php +++ b/backend/class/frontend/buttonGroup.php @@ -1,56 +1,68 @@ items = $items; - $configArray = array(); - $value = parent::__construct($configArray); - return $value; - } - - /** - * @inheritDoc - */ - public function output(): string - { - $html = "
"; - foreach($this->items as $item) { - $html .= $item->output(); + /** + * {@inheritDoc} + */ + public function __construct(array $items) + { + $this->items = $items; + $configArray = []; + return parent::__construct($configArray); } - $html .= "
"; - return $html; - } - public function addItem(element $ele) { - $this->items[] = $ele; - } + /** + * @param array $items + * @return string + */ + public static function getHtml(array $items): string + { + $element = new self($items); + return $element->output(); + } - public static function getHtml(array $items) : string { - $element = new self($items); - return $element->output(); - } + /** + * @return string + */ + public function output(): string + { + $html = "
"; + foreach ($this->items as $item) { + $html .= $item->output(); + } + $html .= "
"; + return $html; + } - public static function create(array $items) : element { - $element = new self($items); - return $element; - } + /** + * @param array $items + * @return element + */ + public static function create(array $items): element + { + return new self($items); + } + /** + * @param element $ele + * @return void + */ + public function addItem(element $ele): void + { + $this->items[] = $ele; + } } diff --git a/backend/class/frontend/buttonLink.php b/backend/class/frontend/buttonLink.php index 915e97a..b47943d 100644 --- a/backend/class/frontend/buttonLink.php +++ b/backend/class/frontend/buttonLink.php @@ -1,32 +1,49 @@ " . ($content != '' ? ' ' . $content : ''); - $value = parent::__construct($urlParams,$content,$title,$cssClasses,$attributes); - return $value; - } - - public static function getHtml(array $urlParams, $iconCss = '', $title = '', array $cssClasses = array(), array $attributes = array(), $content = '') : string { - $link = new self($urlParams,$iconCss,$title,$cssClasses,$attributes,$content); - return $link->output(); - } +class buttonLink extends link +{ + /** + * {@inheritDoc} + */ + public function __construct(array $urlParams, string $content = '', string $title = '', array $cssClasses = [], array $attributes = [], string $iconCss = '') + { + $content = "" . ($content != '' ? ' ' . $content : ''); + return parent::__construct($urlParams, $content, $title, $cssClasses, $attributes); + } - public static function create(array $urlParams, $iconCss = '', $title = '', array $cssClasses = array(), array $attributes = array(), $content = '') : element { - $link = new self($urlParams,$iconCss,$title,$cssClasses,$attributes,$content); - return $link; - } + /** + * @param array $urlParams + * @param string $content + * @param string $title + * @param array $cssClasses + * @param array $attributes + * @param string $iconCss + * @return string + */ + public static function getHtml(array $urlParams, string $content = '', string $title = '', array $cssClasses = [], array $attributes = [], string $iconCss = ''): string + { + $link = new self($urlParams, $content, $title, $cssClasses, $attributes, $iconCss); + return $link->output(); + } + /** + * @param array $urlParams + * @param string $content + * @param string $title + * @param array $cssClasses + * @param array $attributes + * @param string $iconCss + * @return element + */ + public static function create(array $urlParams, string $content = '', string $title = '', array $cssClasses = [], array $attributes = [], string $iconCss = ''): element + { + return new self($urlParams, $content, $title, $cssClasses, $attributes, $iconCss); + } } diff --git a/backend/class/frontend/buttonMenu.php b/backend/class/frontend/buttonMenu.php index 0738dbe..06e2100 100644 --- a/backend/class/frontend/buttonMenu.php +++ b/backend/class/frontend/buttonMenu.php @@ -1,65 +1,85 @@ items = $items; + $configArray = [ + 'title' => $title, + 'css' => $cssClasses, + 'attributes' => $attributes, + ]; + return parent::__construct($configArray); + } - /** - * @inheritDoc - */ - public function __construct(array $items, $iconCss = '', $title = '', array $cssClasses = array(), array $attributes = array()) - { - $attributes['title'] = $title; - $this->items = $items; - $configArray = array( - 'title' => $title, - 'css' => $cssClasses, - 'attributes' => $attributes, - ); - $value = parent::__construct($configArray); - return $value; - } + /** + * @param array $items + * @param string $iconCss + * @param string $title + * @param array $cssClasses + * @param array $attributes + * @return string + */ + public static function getHtml(array $items, string $iconCss = '', string $title = '', array $cssClasses = [], array $attributes = []): string + { + $link = new self($items, $iconCss, $title, $cssClasses, $attributes); + return $link->output(); + } - /** - * @inheritDoc - */ - public function output(): string - { - $title = $this->config->get('title'); - $html = "
    "; - foreach($this->items as $item) { - $html .= "
  • " . $item->output() . "
  • "; + foreach ($this->items as $item) { + $html .= "
  • " . $item->output() . "
  • "; + } + $html .= "
"; + return $html; } - $html .= ""; - return $html; - } - - public function addItem(element $ele) { - $this->items[] = $ele; - } - public static function getHtml(array $items, $iconCss = '', $title = '', array $cssClasses = array(), array $attributes = array()) : string { - $link = new self($items,$iconCss,$title,$cssClasses,$attributes); - return $link->output(); - } - - public static function create(array $items, $iconCss = '', $title = '', array $cssClasses = array(), array $attributes = array()) : element { - $element = new self($items,$iconCss,$title,$cssClasses,$attributes); - return $element; - } + /** + * @param array $items + * @param string $iconCss + * @param string $title + * @param array $cssClasses + * @param array $attributes + * @return element + */ + public static function create(array $items, string $iconCss = '', string $title = '', array $cssClasses = [], array $attributes = []): element + { + return new self($items, $iconCss, $title, $cssClasses, $attributes); + } + /** + * @param element $element + * @return void + */ + public function addItem(element $element): void + { + $this->items[] = $element; + } } diff --git a/backend/class/frontend/element.php b/backend/class/frontend/element.php index f98730d..0b738e0 100644 --- a/backend/class/frontend/element.php +++ b/backend/class/frontend/element.php @@ -1,33 +1,35 @@ config = new \codename\core\config($configArray); - } - - public function output() : string { - throw new LogicException("Method not implemented."); - } - +class element +{ + /** + * @var config + */ + protected config $config; + /** + * + */ + public function __construct(array $configArray = []) + { + $this->config = new config($configArray); + } + /** + * @return string + */ + public function output(): string + { + throw new LogicException("Method not implemented."); + } } diff --git a/backend/class/frontend/link.php b/backend/class/frontend/link.php index 9187966..ba5bd56 100644 --- a/backend/class/frontend/link.php +++ b/backend/class/frontend/link.php @@ -1,66 +1,78 @@ config->get('params')); - $css = implode(' ', $this->config->get('css')); - $content = $this->config->get('content'); - $title = $this->config->get('title'); - - $tempAttributes = array(); - foreach($this->config->get('attributes') as $attribute => $value) { - $tempAttributes[] = "{$attribute}=\"{$value}\""; - } - $attributes = implode(' ', $tempAttributes); +class link extends element +{ + /** + * {@inheritDoc} + */ + public function __construct(array $urlParams, $content = '', $title = '', array $cssClasses = [], array $attributes = []) + { + $attributes['title'] = $title; - // @TODO: add # or other hosts? - if($href != '') { $href = "href=\"?{$href}\""; } - if($css != '') { $css = "class=\"{$css}\""; } - if($title != '') { $title = "title=\"{$title}\""; } + // temporary workaround for changing templates when clicking on a link. + if (app::getRequest()->getData('template') == 'coreadminempty') { + $urlParams['template'] = 'coreadminempty'; + } - // @TODO escaping and urlencoding? - return "{$content}"; - } - - /** - * @inheritDoc - */ - public function __construct(array $urlParams, $content = '', $title = '', array $cssClasses = array(), array $attributes = array()) - { - $attributes['title'] = $title; + $configArray = [ + 'params' => $urlParams, + 'css' => $cssClasses, + 'attributes' => $attributes, + 'content' => $content, + ]; + return parent::__construct($configArray); + } - // temporary workaround for changing templates when clicking on a link. - if(\codename\core\app::getRequest()->getData('template') == 'coreadminempty') { - $urlParams['template'] = 'coreadminempty'; + /** + * @param array $urlParams + * @param string $content + * @param string $title + * @param array $cssClasses + * @param array $attributes + * @return element + */ + public static function create(array $urlParams, string $content = '', string $title = '', array $cssClasses = [], array $attributes = []): element + { + return new self($urlParams, $content, $title, $cssClasses, $attributes); } - $configArray = array( - 'params' => $urlParams, - 'css' => $cssClasses, - 'attributes' => $attributes, - 'content' => $content - ); - $value = parent::__construct($configArray); - return $value; - } + /** + * @return string + */ + public function output(): string + { + $href = http_build_query($this->config->get('params')); + $css = implode(' ', $this->config->get('css')); + $content = $this->config->get('content'); + $title = $this->config->get('title'); - /** - * @return \codename\core\frontend\link - */ - public static function create(array $urlParams, $content = '', $title = '', array $cssClasses = array(), array $attributes = array()) : element { - $link = new self($urlParams,$content,$title,$cssClasses,$attributes); - return $link; - } + $tempAttributes = []; + foreach ($this->config->get('attributes') as $attribute => $value) { + $tempAttributes[] = "$attribute=\"$value\""; + } + $attributes = implode(' ', $tempAttributes); + // @TODO: add # or other hosts? + if ($href != '') { + $href = "href=\"?$href\""; + } + if ($css != '') { + $css = "class=\"$css\""; + } + if ($title != '') { + $title = "title=\"$title\""; + } + + // @TODO escaping and urlencoding? + return "$content"; + } } diff --git a/backend/class/frontend/table.php b/backend/class/frontend/table.php index 35b641e..4adcfac 100644 --- a/backend/class/frontend/table.php +++ b/backend/class/frontend/table.php @@ -1,162 +1,171 @@ 'table table-normal responsive', - 'table_style' => '', - 'tr_class' => '', - 'tr_style' => '', - 'td_class' => '', - 'td_style' => '' - ); - - /** - * if true, adds the /column captions - * @var bool - */ - public $showTableHeader = true; - - /** - * @inheritDoc - */ - public function __construct(array $configArray = array()) - { - // providing default config. - $configArray = array_merge($this->defaultConfig, $configArray); - $value = parent::__construct($configArray); - return $value; - } - - /** - * columns to be visible, also virtual/calculated columns - */ - protected $columns = array(); - - /** - * add a normal column to be displayed that contains a key from the data row. - */ - public function addColumn(string $columnName, string $columnKey) { - $this->columns[$columnName] = $columnKey; - } - - /** - * add a virtual/calculated column with a callback/callable function - */ - public function addVirtualColumn(string $columnName, callable $callback) { - $this->columns[$columnName] = $callback; - } - - /** - * add multiple columns, also virtual ones in an assoc array (!) - */ - public function addColumns(array $columnConfig) { - foreach($columnConfig as $n => $k) { - if(is_callable($k) && !is_string($k)) { - $this->addVirtualColumn($n, $k); - } else { - $this->addColumn($n, $k); - } +class table extends element +{ + /** + * if true, adds the /column captions + * @var bool + */ + public bool $showTableHeader = true; + /** + * providing a nice default config + */ + protected array $defaultConfig = [ + 'table_class' => 'table table-normal responsive', + 'table_style' => '', + 'tr_class' => '', + 'tr_style' => '', + 'td_class' => '', + 'td_style' => '', + ]; + /** + * columns to be visible, also virtual/calculated columns + */ + protected array $columns = []; + /** + * internal data storage array + */ + protected array $data = []; + + /** + * {@inheritDoc} + */ + public function __construct(array $configArray = []) + { + // providing default config. + $configArray = array_merge($this->defaultConfig, $configArray); + return parent::__construct($configArray); } - } - - /** - * internal data storage array - */ - protected $data = array(); - - - public function addRow(array $row) { - $data[] = $row; - } - - /** - * use a specific dataset, additionally with a column config - * provide an empty array as columnConfig to use NO default display columns - */ - public function useDataset(array $dataset, $columnConfig = null) { - if(is_array($columnConfig)) { - foreach($columnConfig as $n => $k) { - if(is_string($n) && is_string($k)) { - $this->addColumn($n, $k); // name, key - } else { - $this->addColumn($k, $k); // key, key. "name" is an int index here. + /** + * add multiple columns, also virtual ones in an assoc array (!) + */ + public function addColumns(array $columnConfig): void + { + foreach ($columnConfig as $n => $k) { + if (is_callable($k) && !is_string($k)) { + $this->addVirtualColumn($n, $k); + } else { + $this->addColumn($n, $k); + } } - } - } else { - foreach($dataset as $row) { - foreach($row as $k => $v) { - // if key exists in the defined array values (not keys!) - if(!in_array($k, array_values($this->columns))) { - $this->addColumn($k, $k); // use key as column name - } - } - } } - $this->data = $dataset; - } - - /** - * @inheritDoc - */ - public function output(): string - { - \codename\core\app::getResponse()->requireResource('js', '/assets/plugins/jquery.bsmodal/jquery.bsmodal.js'); - \codename\core\app::getResponse()->requireResource('js', '/assets/plugins/jquery.bsmodal/jquery.bsmodal.init.js'); - - $attributes = ''; - if($this->config->exists('table_attributes')) { - foreach($this->config->get('table_attributes') as $key => $value) { - $attributes .= " {$key}=\"{$value}\""; - } + + /** + * add a virtual/calculated column with a callback/callable function + */ + public function addVirtualColumn(string $columnName, callable $callback): void + { + $this->columns[$columnName] = $callback; } - $html = "config->get('table_class')}\" style=\"{$this->config->get('table_style')}\" {$attributes}>"; + /** + * add a normal column to be displayed that contains a key from the data row. + */ + public function addColumn(string $columnName, string $columnKey): void + { + $this->columns[$columnName] = $columnKey; + } - if($this->showTableHeader) { - // add table header - $html .= ''; - foreach($this->columns as $name => $key) { - $html .= ""; - } - $html .= ''; + /** + * @param array $row + * @return void + */ + public function addRow(array $row): void + { + $this->data[] = $row; } - // add table body - $html .= ''; - foreach($this->data as $row) { - $html .= ''; - foreach($this->columns as $colName => $colKey) { - $html .= ''; - } - $html .= ''; + $this->data = $dataset; } - $html .= ''; + /** + * @return string + * @throws ReflectionException + * @throws exception + */ + public function output(): string + { + $response = app::getResponse(); + if ($response instanceof http) { + $response->requireResource('js', '/assets/plugins/jquery.bsmodal/jquery.bsmodal.js'); + $response->requireResource('js', '/assets/plugins/jquery.bsmodal/jquery.bsmodal.init.js'); + } - // close it. - $html .= '
{$name}
'; - if(is_callable($colKey) && !is_string($colKey)) { - $html .= $colKey($row); // colKey is a callable + /** + * use a specific dataset, additionally with a column config + * provides an empty array as columnConfig to use NO default display columns + */ + public function useDataset(array $dataset, $columnConfig = null): void + { + if (is_array($columnConfig)) { + foreach ($columnConfig as $n => $k) { + if (is_string($n) && is_string($k)) { + $this->addColumn($n, $k); // name, key + } else { + $this->addColumn($k, $k); // key, key. "name" is an int index here. + } + } } else { - if(isset($row[$colKey])) { - $html .= $row[$colKey]; - } + foreach ($dataset as $row) { + foreach ($row as $k => $v) { + // if key exists in the defined array values (not keys!) + if (!in_array($k, array_values($this->columns))) { + $this->addColumn($k, $k); // use key as column name + } + } + } } - $html .= '
'; + $attributes = ''; + if ($this->config->exists('table_attributes')) { + foreach ($this->config->get('table_attributes') as $key => $value) { + $attributes .= " $key=\"$value\""; + } + } + $html = "config->get('table_class')}\" style=\"{$this->config->get('table_style')}\" $attributes>"; + + if ($this->showTableHeader) { + // add table header + $html .= ''; + foreach ($this->columns as $name => $key) { + $html .= ""; + } + $html .= ''; + } - return $html; - } + // add table body + $html .= ''; + foreach ($this->data as $row) { + $html .= ''; + foreach ($this->columns as $colKey) { + $html .= ''; + } + $html .= ''; + } + $html .= ''; + + // close it. + $html .= '
$name
'; + if (is_callable($colKey) && !is_string($colKey)) { + $html .= $colKey($row); // colKey is a callable + } elseif (isset($row[$colKey])) { + $html .= $row[$colKey]; + } + $html .= '
'; + + return $html; + } } diff --git a/backend/class/generator/restUrlGenerator.php b/backend/class/generator/restUrlGenerator.php index e1c6c56..c024890 100644 --- a/backend/class/generator/restUrlGenerator.php +++ b/backend/class/generator/restUrlGenerator.php @@ -1,58 +1,59 @@ generateFromParamters(array_merge( - array( - 'context' => $context, - 'view' => $view, - 'action' => $action - ), - $parameters - )); + public function generateFromRoute(string $name, $parameters = [], int $referenceType = self::ABSOLUTE_PATH): string + { + $routePartials = explode('/', $name); + $context = $routePartials[0] ?? null; + $view = $routePartials[1] ?? null; + $action = $routePartials[2] ?? null; + + // for now, we're justin doing the basic stuff + return $this->generateFromParameters( + array_merge( + [ + 'context' => $context, + 'view' => $view, + 'action' => $action, + ], + $parameters + ) + ); } /** - * @inheritDoc + * {@inheritDoc} */ - public function generateFromParameters($parameters = array(), $referenceType = self::ABSOLUTE_PATH) { - - $components = []; - - if(!empty($parameters['context'])) { - $components[] = $parameters['context']; - if(!empty($parameters['view'])) { - $components[] = $parameters['view']; - if(!empty($parameters['action'])) { - $components[] = $parameters['action']; - } + public function generateFromParameters($parameters = [], int $referenceType = self::ABSOLUTE_PATH): string + { + $components = []; + + if (!empty($parameters['context'])) { + $components[] = $parameters['context']; + if (!empty($parameters['view'])) { + $components[] = $parameters['view']; + if (!empty($parameters['action'])) { + $components[] = $parameters['action']; + } + } } - } - unset($parameters['context']); - unset($parameters['view']); - unset($parameters['action']); + unset($parameters['context']); + unset($parameters['view']); + unset($parameters['action']); - $baseUri = implode('/', $components); - $params = count($parameters) > 0 ? '?'.http_build_query($parameters) : ''; - return "/{$baseUri}{$params}"; + $baseUri = implode('/', $components); + $params = count($parameters) > 0 ? '?' . http_build_query($parameters) : ''; + return "/$baseUri$params"; } - -} \ No newline at end of file +} diff --git a/backend/class/generator/urlGenerator.php b/backend/class/generator/urlGenerator.php index f788941..bbc4064 100644 --- a/backend/class/generator/urlGenerator.php +++ b/backend/class/generator/urlGenerator.php @@ -1,42 +1,44 @@ generateFromParameters(array_merge( - array( - 'context' => $context, - 'view' => $view, - 'action' => $action - ), - $parameters - )); + // for now, we're justin doing the basic stuff + return $this->generateFromParameters( + array_merge( + [ + 'context' => $context, + 'view' => $view, + 'action' => $action, + ], + $parameters + ) + ); } /** - * @inheritDoc + * {@inheritDoc} */ - public function generateFromParameters($parameters = array(), $referenceType = self::ABSOLUTE_PATH) { - // for now, we're justing doing the basic stuff - return '/?' . http_build_query( - $parameters - ); + public function generateFromParameters($parameters = [], int $referenceType = self::ABSOLUTE_PATH): string + { + // for now, we're justin doing the basic stuff + return '/?' . http_build_query( + $parameters + ); } - } diff --git a/backend/class/generator/urlGeneratorInterface.php b/backend/class/generator/urlGeneratorInterface.php index 1cdf46b..e1292a4 100644 --- a/backend/class/generator/urlGeneratorInterface.php +++ b/backend/class/generator/urlGeneratorInterface.php @@ -1,34 +1,35 @@ config = new \codename\core\config($config); - } +abstract class handler +{ + /** + * [protected description] + * @var null|config + */ + protected ?config $config = null; - /** - * handles an incoming value - * and transforms it on need - * - * @param [type] $data [description] - * @param array $context [description] - * @return [type] [description] - */ - public abstract function handleValue($data, array $context); + /** + * initialize a new handler using a given config + * @param array $config [description] + */ + public function __construct(array $config) + { + $this->config = new config($config); + } - /** - * handle output value - * transform on need - * - * @param [type] $data [description] - * @param array $context [description] - * @return [type] [description] - */ - public abstract function getOutput($data, array $context); + /** + * handles an incoming value + * and transforms it on a need + * + * @param [type] $data [description] + * @param array $context [description] + * @return mixed [type] [description] + */ + abstract public function handleValue($data, array $context): mixed; + /** + * handle output value + * transform on a need + * + * @param [type] $data [description] + * @param array $context [description] + * @return mixed [type] [description] + */ + abstract public function getOutput($data, array $context): mixed; } diff --git a/backend/class/helper.php b/backend/class/helper.php index 432b523..c59e4f2 100644 --- a/backend/class/helper.php +++ b/backend/class/helper.php @@ -1,4 +1,5 @@ setData('getImplementationsInNamespace_appstack', $appstack); - - foreach($appstack as $app) { - - $dir = app::getHomedir($app['vendor'], $app['app']).$basedir; - - // - // NOTE: either the app specifies a differing namespace - // or we fallback to \vendor\app - // - $appNamespace = ($app['namespace'] ?? "\\{$app['vendor']}\\{$app['app']}") . '\\'; - - // - // If a relative namespace is provided, - // use this for searching in appstack - // - $lookupNamespace = $relativeNamespace ? $appNamespace.$namespace : $namespace; - - // DEBUG: - // \codename\core\app::getResponse()->setData( - // 'getImplementationsInNamespace_app_'.$app['app'].'_'.$baseClass, - // [ - // "baseClass" => $baseClass, - // "namespace" => $namespace, - // "appNamespace" => $appNamespace, - // "lookupNamespace" => $lookupNamespace, - // "relativeNamespace" => $relativeNamespace - // ] - // ); - - // DEBUG: - // \codename\core\app::getResponse()->setData('getImplementationsInNamespace_appNamespace', array_merge( - // \codename\core\app::getResponse()->getData('getImplementationsInNamespace_appNamespace') ?? [], - // [ - // $appNamespace - // ] - // )); - - if(!is_dir($dir)) { - continue; - } - - $Directory = new \RecursiveDirectoryIterator($dir); - $Iterator = new \RecursiveIteratorIterator($Directory); - $Regex = new \RegexIterator($Iterator, '/^.+\.php$/i', \RecursiveRegexIterator::GET_MATCH); - - - $all = []; - foreach($Regex as $match) { - $file = $match[0]; - - $pathinfo = pathinfo($file); - // strip basedir from dirname - $stripped = str_replace($dir, '', $pathinfo['dirname']); - - $class = $lookupNamespace . str_replace('/', '\\', $stripped) .'\\'. $pathinfo['filename']; - - // // DEBUG: - // \codename\core\app::getResponse()->setData('getImplementationsInNamespace', array_merge( - // \codename\core\app::getResponse()->getData('getImplementationsInNamespace') ?? [], - // [ - // [ - // 'dir' => $dir, - // 'file' => $file, - // // 'file_pathinfo_dirname' => $pathinfo['dirname'], - // 'file_pathinfo' => $pathinfo, - // 'stripped' => $stripped, - // 'lookup_namespace' => $lookupNamespace, - // 'class' => $class - // ] - // ] - // )); - - $all[] = $class; - if(class_exists($class)) { - $reflectionClass = (new \ReflectionClass($class)); - - // DEBUG: - // \codename\core\app::getResponse()->setData('classes_compare', array_merge( - // \codename\core\app::getResponse()->getData('classes_compare') ?? [], - // [ - // "$baseClass <=> ".'\\'.$reflectionClass->getNamespaceName() - // ] - // )); - - // - // NOTE: ReflectionClass::getName() (or similar methods) - // DO NOT return a leading backslash ('\') - // - if($reflectionClass->isAbstract() === false && ($reflectionClass->isSubclassOf($baseClass) || ('\\'.$reflectionClass->getName() === $baseClass))) { - $name = substr(str_replace('\\', '_', str_replace($lookupNamespace, '', $class)), 1); - $results[] = [ - 'name' => $name, - 'value' => $name - ]; - } + /** + * returns classes that + * - implement a specific base class + * - are found in a specific namespace (and sub-namespaces) + * - are found in a specific folder structure + * + * SKIP: + * - abstract classes + * - classes that do not inherit from the specific base class + * + * oh, this is so messy. + * + * @param $baseClass + * @param $namespace + * @param $basedir + * @return array [type] [description] + * @throws ReflectionException + * @throws exception + */ + public static function getImplementationsInNamespace($baseClass, $namespace, $basedir): array + { + $appstack = app::getAppstack(); + $results = []; + + $relativeNamespace = null; + // relative namespace + if (!str_starts_with($namespace, '\\')) { + $relativeNamespace = $namespace; } - } - } - // DEBUG: - // \codename\core\app::getResponse()->setData('getImplementationsInNamespace_modules', $results); + foreach ($appstack as $app) { + $dir = app::getHomedir($app['vendor'], $app['app']) . $basedir; + + // + // NOTE: either the app specifies a differing namespace + // or we fall back to \vendor\app + // + $appNamespace = ($app['namespace'] ?? "\\{$app['vendor']}\\{$app['app']}") . '\\'; + + // + // If a relative namespace is provided, + // use this for searching in appstack + // + $lookupNamespace = $relativeNamespace ? $appNamespace . $namespace : $namespace; + + if (!is_dir($dir)) { + continue; + } + + $Directory = new RecursiveDirectoryIterator($dir); + $Iterator = new RecursiveIteratorIterator($Directory); + $Regex = new RegexIterator($Iterator, '/^.+\.php$/i', RegexIterator::GET_MATCH); + + foreach ($Regex as $match) { + $file = $match[0]; + + $pathinfo = pathinfo($file); + // strip basedir from dirname + $stripped = str_replace($dir, '', $pathinfo['dirname']); + + $class = $lookupNamespace . str_replace('/', '\\', $stripped) . '\\' . $pathinfo['filename']; + if (class_exists($class)) { + $reflectionClass = (new ReflectionClass($class)); + + // + // NOTE: ReflectionClass::getName() (or similar methods) + // DO NOT return a leading backslash ('\') + // + if ($reflectionClass->isAbstract() === false && ($reflectionClass->isSubclassOf($baseClass) || ('\\' . $reflectionClass->getName() === $baseClass))) { + $name = substr(str_replace('\\', '_', str_replace($lookupNamespace, '', $class)), 1); + $results[] = [ + 'name' => $name, + 'value' => $name, + ]; + } + } + } + } - return $results; - } + return $results; + } } diff --git a/backend/class/helper/clicolors.php b/backend/class/helper/clicolors.php index 55de5a8..a71ce34 100644 --- a/backend/class/helper/clicolors.php +++ b/backend/class/helper/clicolors.php @@ -1,4 +1,5 @@ foreground_colors['black'] = '0;30'; + $this->foreground_colors['dark_gray'] = '1;30'; + $this->foreground_colors['blue'] = '0;34'; + $this->foreground_colors['light_blue'] = '1;34'; + $this->foreground_colors['green'] = '0;32'; + $this->foreground_colors['light_green'] = '1;32'; + $this->foreground_colors['cyan'] = '0;36'; + $this->foreground_colors['light_cyan'] = '1;36'; + $this->foreground_colors['red'] = '0;31'; + $this->foreground_colors['light_red'] = '1;31'; + $this->foreground_colors['purple'] = '0;35'; + $this->foreground_colors['light_purple'] = '1;35'; + $this->foreground_colors['brown'] = '0;33'; + $this->foreground_colors['yellow'] = '1;33'; + $this->foreground_colors['light_gray'] = '0;37'; + $this->foreground_colors['white'] = '1;37'; - public function __construct() { - // Set up shell colors - $this->foreground_colors['black'] = '0;30'; - $this->foreground_colors['dark_gray'] = '1;30'; - $this->foreground_colors['blue'] = '0;34'; - $this->foreground_colors['light_blue'] = '1;34'; - $this->foreground_colors['green'] = '0;32'; - $this->foreground_colors['light_green'] = '1;32'; - $this->foreground_colors['cyan'] = '0;36'; - $this->foreground_colors['light_cyan'] = '1;36'; - $this->foreground_colors['red'] = '0;31'; - $this->foreground_colors['light_red'] = '1;31'; - $this->foreground_colors['purple'] = '0;35'; - $this->foreground_colors['light_purple'] = '1;35'; - $this->foreground_colors['brown'] = '0;33'; - $this->foreground_colors['yellow'] = '1;33'; - $this->foreground_colors['light_gray'] = '0;37'; - $this->foreground_colors['white'] = '1;37'; + $this->background_colors['black'] = '40'; + $this->background_colors['red'] = '41'; + $this->background_colors['green'] = '42'; + $this->background_colors['yellow'] = '43'; + $this->background_colors['blue'] = '44'; + $this->background_colors['magenta'] = '45'; + $this->background_colors['cyan'] = '46'; + $this->background_colors['light_gray'] = '47'; + } - $this->background_colors['black'] = '40'; - $this->background_colors['red'] = '41'; - $this->background_colors['green'] = '42'; - $this->background_colors['yellow'] = '43'; - $this->background_colors['blue'] = '44'; - $this->background_colors['magenta'] = '45'; - $this->background_colors['cyan'] = '46'; - $this->background_colors['light_gray'] = '47'; - } + /** + * [getInstance description] + * @return self [description] + */ + public static function getInstance(): self + { + if (self::$instance == null) { + self::$instance = new self(); + } + return self::$instance; + } - // Returns colored string - public function getColoredString($string, $foreground_color = null, $background_color = null) { - $colored_string = ""; + // Returns colored string - // Check if given foreground color found - if (isset($this->foreground_colors[$foreground_color])) { - $colored_string .= "\033[" . $this->foreground_colors[$foreground_color] . "m"; - } - // Check if given background color found - if (isset($this->background_colors[$background_color])) { - $colored_string .= "\033[" . $this->background_colors[$background_color] . "m"; - } + /** + * @param string $string + * @param string|null $foreground_color + * @param string|null $background_color + * @return string + */ + public function getColoredString(string $string, string $foreground_color = null, string $background_color = null): string + { + $colored_string = ""; - // Add string and end coloring - $colored_string .= $string . "\033[0m"; + // Check if given foreground color found + if (($foreground_color ?? false) && isset($this->foreground_colors[$foreground_color])) { + $colored_string .= "\033[" . $this->foreground_colors[$foreground_color] . "m"; + } + // Check if given background color found + if (($background_color ?? false) && isset($this->background_colors[$background_color])) { + $colored_string .= "\033[" . $this->background_colors[$background_color] . "m"; + } - return $colored_string; - } + // Add string and end coloring + $colored_string .= $string . "\033[0m"; - // Returns all foreground color names - public function getForegroundColors() { - return array_keys($this->foreground_colors); - } + return $colored_string; + } - // Returns all background color names - public function getBackgroundColors() { - return array_keys($this->background_colors); - } + /** + * Returns all foreground color names + * @return array + */ + public function getForegroundColors(): array + { + return array_keys($this->foreground_colors); + } - } \ No newline at end of file + /** + * Returns all background color names + * @return array + */ + public function getBackgroundColors(): array + { + return array_keys($this->background_colors); + } +} diff --git a/backend/class/helper/date.php b/backend/class/helper/date.php index 5b77fa3..eaafb68 100644 --- a/backend/class/helper/date.php +++ b/backend/class/helper/date.php @@ -1,69 +1,93 @@ Uses the translation key DATETIME.FORMAT_DATE of your current localisation + * This method will return the given $timestamp's + * @param int $year + * @param int $month * @return string */ - public static function getCurrentDateAsReadible() : string { - return date(app::translate('DATETIME.FORMAT_DATE'), self::getCurrentTimestamp()); + public static function getLastDayOfMonthByYearAndMonthAsDate(int $year, int $month): string + { + return date('Y-m-d', self::getLastDayOfMonthByYearAndMonthAsTimestamp($year, $month)); } /** - * This method will return the given $timestamp's last day of month as a timestamp. - * @param int $timestamp + * This method will return the given $timestamp's month's last day as timestamp + * @param int $year + * @param int $month * @return int */ - public static function getLastDayOfMonthByTimestampAsTimestamp(int $timestamp) : int { - return strtotime(date('Y-m-t', $timestamp)); + public static function getLastDayOfMonthByYearAndMonthAsTimestamp(int $year, int $month): int + { + return self::getLastDayOfMonthByTimestampAsTimestamp(strtotime("$year-$month-01")); } /** - * This method will return the given $timestamp's month's last day as timestamp - * @param int $year - * @param int $month + * This method will return the given $timestamp's last day of month as a timestamp. + * @param int $timestamp * @return int */ - public static function getLastDayOfMonthByYearAndMonthAsTimestamp(int $year, int $month) : int { - return self::getLastDayOfMonthByTimestampAsTimestamp(strtotime("{$year}-{$month}-01")); + public static function getLastDayOfMonthByTimestampAsTimestamp(int $timestamp): int + { + return strtotime(date('Y-m-t', $timestamp)); } /** @@ -72,18 +96,20 @@ public static function getLastDayOfMonthByYearAndMonthAsTimestamp(int $year, int * @param int $month * @return string */ - public static function getLastDayOfMonthByYearAndMonthAsDate(int $year, int $month) : string { - return date('Y-m-d', self::getLastDayOfMonthByYearAndMonthAsTimestamp($year, $month)); + public static function getLastDayOfMonthByYearAndMonthAsDay(int $year, int $month): string + { + return date('d', self::getLastDayOfMonthByYearAndMonthAsTimestamp($year, $month)); } /** - * This method will return the given $timestamp's - * @param int $year - * @param int $month - * @return string + * This function returns an array of unix timestamps when an article shall be invoiced and provisioned again + * @param int $start + * @param string $interval + * @return array */ - public static function getLastDayOfMonthByYearAndMonthAsDay(int $year, int $month) : string { - return date('d', self::getLastDayOfMonthByYearAndMonthAsTimestamp($year, $month)); + public static function getIntervalsFromStartUntilNow(int $start, string $interval): array + { + return self::getIntervalsFromStartUntilEnd($start, self::getCurrentTimestamp(), $interval); } /** @@ -93,18 +119,19 @@ public static function getLastDayOfMonthByYearAndMonthAsDay(int $year, int $mont * @param string $interval * @return array */ - public static function getIntervalsFromStartUntilEnd(int $start, int $end, string $interval) : array { - if($start > $end) { - // do not allow a start date greater than end. - return array(); + public static function getIntervalsFromStartUntilEnd(int $start, int $end, string $interval): array + { + if ($start > $end) { + // do not allow a start date greater than the end. + return []; } - $intervals = array($start); - if(!in_array($interval, array(self::INTERVAL_MONTH, self::INTERVAL_YEAR))) { + $intervals = [$start]; + if (!in_array($interval, [self::INTERVAL_MONTH, self::INTERVAL_YEAR])) { return $intervals; } while ($start < $end) { $start = strtotime(date('Y-m-d', $start) . ' +1 ' . $interval); - if($start > $end) { + if ($start > $end) { break; } $intervals[] = $start; @@ -112,77 +139,67 @@ public static function getIntervalsFromStartUntilEnd(int $start, int $end, strin return $intervals; } + /** + * This function returns an array of unix timestamps + * as an array ('start' => ..., 'end' => ...) + * @param int $start + * @param string $interval + * @return array + */ + public static function getIntervalArrayFromStartUntilNow(int $start, string $interval): array + { + return self::getIntervalArrayFromStartUntilEnd($start, self::getCurrentTimestamp(), $interval); + } + /** * This method will return an array of timestamps that will differ from each other by the given $interval * This function returns an array of unix timestamps - * as an array( 'start' => ..., 'end' => ... ) - * @author Kevin Dargel + * as an array ('start' => ..., 'end' => ...) * @param int $start * @param int $end - * @param string $interval [e.g. self::INTERVAL_MONTH or self::INTERVAL_YEAR] + * @param string $interval [e.g., self::INTERVAL_MONTH or self::INTERVAL_YEAR] * @return array */ - public static function getIntervalArrayFromStartUntilEnd(int $start, int $end, string $interval) : array { - if($start > $end) { - // do not allow a start date greater than end. - return array(); + public static function getIntervalArrayFromStartUntilEnd(int $start, int $end, string $interval): array + { + if ($start > $end) { + // do not allow a start date greater than the end. + return []; } - $intervals = array(); - if(!in_array($interval, array(self::INTERVAL_MONTH, self::INTERVAL_YEAR))) { + $intervals = []; + if (!in_array($interval, [self::INTERVAL_MONTH, self::INTERVAL_YEAR])) { return $intervals; } $laststart = $start; while ($start < $end) { $start = strtotime(date('Y-m-d', $start) . ' +1 ' . $interval); - if($start > $end) { + if ($start > $end) { break; } - $intervals[] = array('start' => $laststart, 'end' => $start); + $intervals[] = ['start' => $laststart, 'end' => $start]; $laststart = $start; } return $intervals; } - /** - * This function returns an array of unix timestamps when an article shall be invoiced & provisioned again - * @param int $start - * @param string $interval - * @return array - */ - public static function getIntervalsFromStartUntilNow(int $start, string $interval) : array { - return self::getIntervalsFromStartUntilEnd($start, self::getCurrentTimestamp(), $interval); - } - - /** - * This function returns an array of unix timestamps - * as an array( 'start' => ..., 'end' => ... ) - * @param int $start - * @param string $interval - * @return array - */ - public static function getIntervalArrayFromStartUntilNow(int $start, string $interval) : array { - return self::getIntervalArrayFromStartUntilEnd($start, self::getCurrentTimestamp(), $interval); - } - - /** * Returns the current date as a DB-conform '2016-11-25 13:57:12' Format - * @author Kevin Dargelk - * @example 2016-11-25 * @return string + * @example 2016-11-25k */ - public static function getCurrentDateTimeAsDbdate() : string { + public static function getCurrentDateTimeAsDbDate(): string + { return date('Y-m-d H:i:s', self::getCurrentTimestamp()); } /** * Returns the current date as a DB-conform '2016-11-25 13:57:12' Format - * @author Kevin Dargel - * @example 2016-12-09 * @param int $timestamp [unix timestamp] * @return string + * @example 2016-12-09 */ - public static function getTimestampAsDbdate(int $timestamp) : string { + public static function getTimestampAsDbdate(int $timestamp): string + { return date('Y-m-d H:i:s', $timestamp); } @@ -190,12 +207,14 @@ public static function getTimestampAsDbdate(int $timestamp) : string { * returns a DateInterval object for two given points in time * @param string $start [date time as string] * @param string $end [date time as string] - * @return \DateInterval + * @return DateInterval + * @throws \Exception */ - public static function getDateInterval(string $start, string $end) : \DateInterval { - $start = new \DateTime($start); - $end = new \DateTime($end); - return $end->diff($start); + public static function getDateInterval(string $start, string $end): DateInterval + { + $start = new DateTime($start); + $end = new DateTime($end); + return $end->diff($start); } /** @@ -205,56 +224,56 @@ public static function getDateInterval(string $start, string $end) : \DateInterv * @param string $useInterval [interval, PHP parseable] * @param string $format * @return string[] [array of string date(times)] + * @throws \Exception */ - public static function getDateIntervalArray(string $start, string $end, string $useInterval = '+1 day', $format = 'Y-m-d') : array { - // we may protect against backwards-jumping... or forward, if we're going backwards?? - $interval = \DateInterval::createFromDateString($useInterval); - $startDate = new \DateTime($start); - $endDate = new \DateTime($end); + public static function getDateIntervalArray(string $start, string $end, string $useInterval = '+1 day', string $format = 'Y-m-d'): array + { + // we may protect against backwards-jumping... or forward, if we're going backwards?? + $interval = DateInterval::createFromDateString($useInterval); + $startDate = new DateTime($start); + $endDate = new DateTime($end); - $result = array(); - $result[] = $startDate->format($format); + $result = []; + $result[] = $startDate->format($format); - $currentDate = $startDate->add($interval); - while($currentDate <= $endDate) { - $result[] = $currentDate->format($format); - $currentDate = $currentDate->add($interval); - } - return $result; + $currentDate = $startDate->add($interval); + while ($currentDate <= $endDate) { + $result[] = $currentDate->format($format); + $currentDate = $currentDate->add($interval); + } + return $result; } /** * [getISO8601FromRelativeDatetimeString description] - * @param string $relativeDatetime [description] + * @param string $relativeDatetime [description] * @return string [description] */ - public static function getISO8601FromRelativeDatetimeString(string $relativeDatetime) : string { - return self::getISO8601FromDateInterval(\DateInterval::createFromDateString($relativeDatetime)); + public static function getISO8601FromRelativeDatetimeString(string $relativeDatetime): string + { + return self::getISO8601FromDateInterval(DateInterval::createFromDateString($relativeDatetime)); } /** * [getISO8601FromDateInterval description] - * @param \DateInterval $dateInterval [description] + * @param DateInterval $dateInterval [description] * @return string [description] */ - public static function getISO8601FromDateInterval(\DateInterval $dateInterval) : string { - // - // @see https://stackoverflow.com/questions/33787039/format-dateinterval-as-iso8601 - // + public static function getISO8601FromDateInterval(DateInterval $dateInterval): string + { + // + // @see https://stackoverflow.com/questions/33787039/format-dateinterval-as-iso8601 + // - // Incomplete variant ignoring "T": - // $format = $dateInterval->format("P%yY%mM%dD%hH%iM%sS"); - // $format = str_replace(["M0S", "H0M", "D0H", "M0D", "Y0M", "P0Y"], ["M", "H", "D", "M", "Y0M", "P"], $format); - // return $format; - - list($date,$time) = explode("T",$dateInterval->format("P%yY%mM%dDT%hH%iM%sS")); - // now, we need to remove anything that is a zero, but make sure to not remove - // something like 10D or 20D - $res = - str_replace([ 'M0D', 'Y0M', 'P0Y' ], [ 'M', 'Y', 'P' ], $date) . - rtrim(str_replace([ 'M0S', 'H0M', 'T0H'], [ 'M', 'H', 'T' ], "T$time"),"T"); - if ($res == 'P') // edge case - if we remove everything, DateInterval will hate us later - return 'PT0S'; - return $res; + [$date, $time] = explode("T", $dateInterval->format("P%yY%mM%dDT%hH%iM%sS")); + // now, we need to remove anything that is a zero, but make sure to not remove + // something like 10D or 20D + $res = + str_replace(['M0D', 'Y0M', 'P0Y'], ['M', 'Y', 'P'], $date) . + rtrim(str_replace(['M0S', 'H0M', 'T0H'], ['M', 'H', 'T'], "T$time"), "T"); + if ($res == 'P') { // edge case - if we remove everything, DateInterval will hate us later + return 'PT0S'; + } + return $res; } } diff --git a/backend/class/helper/deepaccess.php b/backend/class/helper/deepaccess.php index fe59b99..4a3e46e 100644 --- a/backend/class/helper/deepaccess.php +++ b/backend/class/helper/deepaccess.php @@ -1,63 +1,55 @@ true ]; - } - if(isset($dive[$key])) { - $dive = &$dive[$key]; - } else { - $dive[$key] = true; - $dive = &$dive[$key]; - } + /** + * [set description] + * @param mixed $obj [description] + * @param array $keys [description] + * @param mixed $value [description] + */ + public static function set(mixed $obj, array $keys, mixed $value) + { + $dive = &$obj; + foreach ($keys as $key) { + if (!is_array($dive)) { + $dive = [$key => true]; + } + if (!isset($dive[$key])) { + $dive[$key] = true; + } + $dive = &$dive[$key]; + } + // finally, set value at a path + $dive = $value; + return $obj; } - // finally set value at path - $dive = $value; - return $obj; - } - } diff --git a/backend/class/helper/file.php b/backend/class/helper/file.php index bcec66f..51c3deb 100644 --- a/backend/class/helper/file.php +++ b/backend/class/helper/file.php @@ -1,4 +1,5 @@ true] to perform inline pushing] - * @return [type] [description] - */ - public static function downloadToClient(string $filepath, string $filename, array $option = []) { - - if(!file_exists($filepath)) { - throw new exception('HELPER_FILE_DOWNLOADFILE_DOES_NOT_EXIST', exception::$ERRORLEVEL_ERROR, $filepath); - } - - if(array_key_exists('inline', $option) === TRUE && $option['inline'] === TRUE) { - - // Determine Mime Type by extension. I know it's bad. - $path_parts = pathinfo($filepath); - $ext = strtolower($path_parts["extension"]); - - // Determine Content Type (only for inlining) - switch ($ext) { - case "pdf": $ctype="application/pdf"; break; - case "gif": $ctype="image/gif"; break; - case "png": $ctype="image/png"; break; - case "jpeg": - case "jpg": $ctype="image/jpg"; break; - default: $ctype="application/force-download"; - } + /** + * [downloadToClient description] + * @param string $filepath [local filepath] + * @param string $filename [target filename] + * @param array $option [array of options, provide ['inline' => true] to perform inline pushing] + * @return void [type] [description] + * @throws exception + */ + public static function downloadToClient(string $filepath, string $filename, array $option = []): void + { + if (!file_exists($filepath)) { + throw new exception('HELPER_FILE_DOWNLOADER_DOES_NOT_EXIST', exception::$ERRORLEVEL_ERROR, $filepath); + } - app::getResponse()->setHeader('Content-Type: ' . $ctype); - app::getResponse()->setHeader("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); - app::getResponse()->setHeader("Cache-Control: post-check=0, pre-check=0", false); - app::getResponse()->setHeader("Pragma: no-cache"); - app::getResponse()->setHeader('Content-Disposition: inline; filename="' . $filename . '"'); - app::getResponse()->setHeader('Content-Length: ' . filesize($filepath)); - app::getResponse()->setHeader('Content-Transfer-Encoding: binary'); + if (array_key_exists('inline', $option) === true && $option['inline'] === true) { + // Determine Mime Type by extension. I know it's bad. + $path_parts = pathinfo($filepath); + $ext = strtolower($path_parts["extension"]); - } else { + // Determine Content Type (only for inlining) + $ctype = match ($ext) { + "pdf" => "application/pdf", + "gif" => "image/gif", + "png" => "image/png", + "jpeg", "jpg" => "image/jpg", + default => "application/force-download", + }; - app::getResponse()->setHeader('Content-Description: File Transfer'); - app::getResponse()->setHeader('Content-Type: application/octet-stream'); - app::getResponse()->setHeader('Content-Transfer-Encoding: binary'); - app::getResponse()->setHeader('Pragma: public'); - app::getResponse()->setHeader('Content-Length: ' . filesize($filepath)); - app::getResponse()->setHeader('Content-Disposition: attachment; filename="' . $filename . '"'); + app::getResponse()->setHeader('Content-Type: ' . $ctype); + app::getResponse()->setHeader("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); + app::getResponse()->setHeader("Cache-Control: post-check=0, pre-check=0"); + app::getResponse()->setHeader("Pragma: no-cache"); + app::getResponse()->setHeader('Content-Disposition: inline; filename="' . $filename . '"'); + app::getResponse()->setHeader('Content-Length: ' . filesize($filepath)); + app::getResponse()->setHeader('Content-Transfer-Encoding: binary'); + } else { + app::getResponse()->setHeader('Content-Description: File Transfer'); + app::getResponse()->setHeader('Content-Type: application/octet-stream'); + app::getResponse()->setHeader('Content-Transfer-Encoding: binary'); + app::getResponse()->setHeader('Pragma: public'); + app::getResponse()->setHeader('Content-Length: ' . filesize($filepath)); + app::getResponse()->setHeader('Content-Disposition: attachment; filename="' . $filename . '"'); - // add needed headers for CORS compat - app::getResponse()->setHeader('access-control-expose-headers: content-disposition, content-type'); + // add needed headers for CORS compat + app::getResponse()->setHeader('access-control-expose-headers: content-disposition, content-type'); + } + if (ob_get_contents()) { + ob_clean(); + } + flush(); + readfile($filepath); + unlink($filepath); + exit(0); } - if (ob_get_contents()) ob_clean(); - flush(); - readfile($filepath); - exit(0); - } } diff --git a/backend/class/helper/time.php b/backend/class/helper/time.php index c951866..28ca015 100644 --- a/backend/class/helper/time.php +++ b/backend/class/helper/time.php @@ -1,82 +1,108 @@ reset(); + if (count($timeValidator->validate($start)) > 0) { + return []; + } + $timeValidator->reset(); + if (count($timeValidator->validate($end)) > 0) { + return []; + } - $timeValidator = app::getValidator('text_time'); - $timeValidator->reset(); - if(count($timeValidator->validate($start)) > 0) { - return array(); - } - $timeValidator->reset(); - if(count($timeValidator->validate($end)) > 0) { - return array(); - } + $rangeStart = explode(':', $start); + $rangeEnd = explode(':', $end); + $times = []; + if (count($rangeStart) >= 2 && count($rangeStart) <= 3) { + $rangeStartSeconds = self::getSecondsFromTimeArray($rangeStart); + if (count($rangeEnd) >= 2 && count($rangeEnd) <= 3) { + $rangeEndSeconds = self::getSecondsFromTimeArray($rangeEnd); + $steps = floor(($rangeEndSeconds - $rangeStartSeconds) / $stepSeconds); + for ($i = 0; $i <= $steps; $i++) { + $times[] = self::getTimeArrayFromSeconds($rangeStartSeconds + ($stepSeconds * $i)); + } + } + } - $rangeStart = explode(':', $start); - $rangeEnd = explode(':', $end); - $times = array(); - if(count($rangeStart) >= 2 && count($rangeStart) <= 3) { - $rangeStartSeconds = self::getSecondsFromTimeArray($rangeStart); - if(count($rangeEnd) >= 2 && count($rangeEnd) <= 3) { - $rangeEndSeconds = self::getSecondsFromTimeArray($rangeEnd); - $steps = floor( ($rangeEndSeconds - $rangeStartSeconds) / $stepSeconds ); - for ($i = 0; $i <= $steps; $i++) { - $times[] = self::getTimeArrayFromSeconds($rangeStartSeconds + ($stepSeconds * $i)); + $formattedTimes = []; + foreach ($times as $t) { + foreach ($t as &$c) { + $c = str_pad($c, 2, '0', STR_PAD_LEFT); + } + $formattedTimes[] = implode(':', array_slice($t, 0, $showSeconds ? 3 : 2)); } - } - } - $formattedTimes = array(); - foreach($times as $t) { - foreach($t as &$c) { - $c = str_pad($c, 2, '0', STR_PAD_LEFT); - } - $formattedTimes[] = implode(':', array_slice($t, 0, $showSeconds ? 3 : 2)); + return $formattedTimes; } - return $formattedTimes; - } - - /** - * returns time in seconds from a 2- or 3-element array - */ - public static function getSecondsFromTimeArray(array $time) : int { - return self::getSecondsFromHours(intval($time[0])) + self::getSecondsFromMinutes($time[1]) + ((isset($time[2]) ? intval($time[2]) : 0)); - } - - public static function getSecondsFromMinutes(int $minutes) : int { - return $minutes * 60; - } - - public static function getSecondsFromHours(int $hours) : int { - return $hours * 60 * 60; - } + /** + * returns time in seconds from a two- or 3-element array + */ + public static function getSecondsFromTimeArray(array $time): int + { + return self::getSecondsFromHours(intval($time[0])) + self::getSecondsFromMinutes($time[1]) + ((isset($time[2]) ? intval($time[2]) : 0)); + } - public static function getTimeArrayFromSeconds(int $seconds) : array { - $hours = floor($seconds / (60*60)); - $minutes = floor(($seconds - self::getSecondsFromHours($hours)) / (60)); - $seconds = $seconds - (self::getSecondsFromHours($hours) + self::getSecondsFromMinutes($minutes)); - return array( - $hours, $minutes, $seconds - ); - } + /** + * @param int $hours + * @return int + */ + public static function getSecondsFromHours(int $hours): int + { + return $hours * 60 * 60; + } + /** + * @param int $minutes + * @return int + */ + public static function getSecondsFromMinutes(int $minutes): int + { + return $minutes * 60; + } + /** + * @param int $seconds + * @return array + */ + public static function getTimeArrayFromSeconds(int $seconds): array + { + $hours = floor($seconds / (60 * 60)); + $minutes = floor(($seconds - self::getSecondsFromHours($hours)) / (60)); + $seconds = $seconds - (self::getSecondsFromHours($hours) + self::getSecondsFromMinutes($minutes)); + return [ + $hours, + $minutes, + $seconds, + ]; + } } diff --git a/backend/class/hook.php b/backend/class/hook.php index 12583e4..418e45f 100755 --- a/backend/class/hook.php +++ b/backend/class/hook.php @@ -1,260 +1,273 @@ Is based on the singleton design pattern. + * Is based on the singleton design pattern. * @package core * @since 2016-04-11 */ -class hook { - +class hook +{ /** * This event will be fired whenever a translation key cannot be resolved into translated text * @var string */ - const EVENT_TRANSLATE_TRANSLATION_KEY_MISSING = 'EVENT_TRANSLATE_TRANSLATION_KEY_MISSING'; + public const string EVENT_TRANSLATE_TRANSLATION_KEY_MISSING = 'EVENT_TRANSLATE_TRANSLATION_KEY_MISSING'; /** * This event will be fired when the view method has completed and the wrapping method is about to finish * @var string */ - const EVENT_APP_DOVIEW_FINISH = 'EVENT_APP_DOVIEW_FINISH'; + public const string EVENT_APP_DOVIEW_FINISH = 'EVENT_APP_DOVIEW_FINISH'; /** * This event will be fired whenever the method adds an application to the current appstack. * @var string */ - const EVENT_APP_MAKEAPPSTACK_ADDED_APP = 'EVENT_APP_MAKEAPPSTACK_ADDED_APP'; + public const string EVENT_APP_MAKEAPPSTACK_ADDED_APP = 'EVENT_APP_MAKEAPPSTACK_ADDED_APP'; /** * This event is fired in the app class constructor. - *
Use it to prepend actions before any action of the framework + * Use it to prepend actions before any action of the framework * @var string */ - const EVENT_APP_INITIALIZING = 'EVENT_APP_INITIALIZING'; + public const string EVENT_APP_INITIALIZING = 'EVENT_APP_INITIALIZING'; /** - * This event is fired after the app class constructor finishes it's actions. + * This event is fired after the app class constructor finishes its actions. * @var string */ - const EVENT_APP_INITIALIZED = 'EVENT_APP_INITIALIZED'; + public const string EVENT_APP_INITIALIZED = 'EVENT_APP_INITIALIZED'; /** * This event is fired when the app class's >run method is executed. - *
Use it to prepend actions before the page generation + * Use it to prepend actions before the page generation * @var string */ - const EVENT_APP_RUN_START = 'EVENT_APP_RUN_START'; + public const string EVENT_APP_RUN_START = 'EVENT_APP_RUN_START'; /** * This event will be fired whenever a user tries to open a view/action/context - *
and the context's isAllowed method returns false. - *
You might use this to intervene for different redirection or storing of the request. + * and the context's isAllowed method returns false. + * You might use this to intervene for different redirection or storing of the request. * @var string */ - const EVENT_APP_RUN_FORBIDDEN = 'EVENT_APP_RUN_FORBIDDEN'; + public const string EVENT_APP_RUN_FORBIDDEN = 'EVENT_APP_RUN_FORBIDDEN'; /** * fired when the app enters the main app routine */ - const EVENT_APP_RUN_MAIN = 'EVENT_APP_RUN_MAIN'; + public const string EVENT_APP_RUN_MAIN = 'EVENT_APP_RUN_MAIN'; /** * This event is fired whenever the run method finishes all it's actions * @var string */ - const EVENT_APP_RUN_END = 'EVENT_APP_RUN_END'; + public const string EVENT_APP_RUN_END = 'EVENT_APP_RUN_END'; /** * This event is fired, when you try loading an object from a model - *
but the model's primarykey is missing in the request container + * but the model's primarykey is missing in the request container * @var string */ - const EVENT_APP_GETMODELOBJET_ARGUMENT_NOT_FOUND = 'EVENT_APP_GETMODELOBJET_ARGUMENT_NOT_FOUND'; + public const string EVENT_APP_GETMODELOBJET_ARGUMENT_NOT_FOUND = 'EVENT_APP_GETMODELOBJET_ARGUMENT_NOT_FOUND'; /** * This event is fired, when you try loading an object from a model - *
but the primary key (contained in the request container) cannot be found in the model + * but the primary key (contained in the request container) cannot be found in the model * @var string */ - const EVENT_APP_GETMODELOBJET_ENTRY_NOT_FOUND = 'EVENT_APP_GETMODELOBJET_ENTRY_NOT_FOUND'; + public const string EVENT_APP_GETMODELOBJET_ENTRY_NOT_FOUND = 'EVENT_APP_GETMODELOBJET_ENTRY_NOT_FOUND'; /** * This event will be fired when a cache object is requested but not found. * @param string $key * @var string */ - const EVENT_CACHE_MISS = 'EVENT_CACHE_MISS'; + public const string EVENT_CACHE_MISS = 'EVENT_CACHE_MISS'; /** * This event will be fired when the CRUD class creates a form in the credit method. - *
You can use this to alter the given form instance for asking more fields + * You can use this to alter the given form instance for asking more fields * @var string */ - const EVENT_CRUD_CREATE_FORM_INIT = 'EVENT_CRUD_CREATE_FORM_INIT'; + public const string EVENT_CRUD_CREATE_FORM_INIT = 'EVENT_CRUD_CREATE_FORM_INIT'; /** * This event will be fired whenever the edit method of any CRUD instance generates a form instance. - *
Use this event to alter the current form of the CRUD instance (e.g. for asking for more fields) + * Use this event to alter the current form of the CRUD instance (e.g., for asking for more fields) * @var string */ - const EVENT_CRUD_EDIT_FORM_INIT = 'EVENT_CRUD_EDIT_FORM_INIT'; + public const string EVENT_CRUD_EDIT_FORM_INIT = 'EVENT_CRUD_EDIT_FORM_INIT'; /** * This event is fired whenever the CRUD generator wants to create an entry - *
to a model. It is given the $data and must return the $data. - * @example Imagine cases where you don't want a user to input data but you must - *
add it to the entry, because the missing fields would violate the model's - *
constraints. Here you can do anything you want with the entry array. + * to a model. It is given the $data and must return the $data. + * @example Imagine cases where you don't want a user to input data, but you must + * add it to the entry because the missing fields would violate the model's + * constraints. Here you can do anything you want with the entry array. * @var string */ - const EVENT_CRUD_CREATE_BEFORE_VALIDATION = 'EVENT_CRUD_CREATE_BEFORE_VALIDATION'; + public const string EVENT_CRUD_CREATE_BEFORE_VALIDATION = 'EVENT_CRUD_CREATE_BEFORE_VALIDATION'; /** * This event is fired after validation has been successful. * @var string */ - const EVENT_CRUD_CREATE_AFTER_VALIDATION = 'EVENT_CRUD_CREATE_AFTER_VALIDATION'; + public const string EVENT_CRUD_CREATE_AFTER_VALIDATION = 'EVENT_CRUD_CREATE_AFTER_VALIDATION'; /** * This event is fired after validation has been successful. * We might run additional validators here. - * output must be either null, empty array or errors found in additional validators + * Output must be either null, empty array or errors found in additional validators * @var string */ - const EVENT_CRUD_CREATE_VALIDATION = 'EVENT_CRUD_CREATE_VALIDATION'; + public const string EVENT_CRUD_CREATE_VALIDATION = 'EVENT_CRUD_CREATE_VALIDATION'; /** * This event is fired whenever the CRUD generator wants to create an entry - *
to a model. It is given the $data and must return the $data. + * to a model. It is given the $data and must return the $data. * @example Imagine you want to manipulate entries on a model when saving the entry - *
from the CRUD generator. This is version will happen after the validation. + * from the CRUD generator. This is versioned will happen after the validation. * @var string */ - const EVENT_CRUD_CREATE_BEFORE_SAVE = 'EVENT_CRUD_CREATE_BEFORE_SAVE'; + public const string EVENT_CRUD_CREATE_BEFORE_SAVE = 'EVENT_CRUD_CREATE_BEFORE_SAVE'; /** * This event is fired whenever the CRUD generator successfully creates an entry - *
to a model. It is given the $data. + * to a model. It is given the $data. * @example Think of Creating an email using the complete entry after saving. * @var string */ - const EVENT_CRUD_CREATE_SUCCESS = 'EVENT_CRUD_CREATE_SUCCESS'; + public const string EVENT_CRUD_CREATE_SUCCESS = 'EVENT_CRUD_CREATE_SUCCESS'; /** * This event is fired whenever the CRUD generator wants to edit an entry - *
to a model. It is given the $data and must return the $data. - * @example Imagine cases where you don't want a user to input data but you must - *
add it to the entry, because the missing fields would violate the model's - *
constraints. Here you can do anything you want with the entry array. + * to a model. It is given the $data and must return the $data. + * @example Imagine cases where you don't want a user to input data, but you must + * add it to the entry because the missing fields would violate the model's + * constraints. Here you can do anything you want with the entry array. * @var string */ - const EVENT_CRUD_EDIT_BEFORE_VALIDATION = 'EVENT_CRUD_EDIT_BEFORE_VALIDATION'; + public const string EVENT_CRUD_EDIT_BEFORE_VALIDATION = 'EVENT_CRUD_EDIT_BEFORE_VALIDATION'; /** * This event is fired after validation has been successful. * @var string */ - const EVENT_CRUD_EDIT_AFTER_VALIDATION = 'EVENT_CRUD_EDIT_AFTER_VALIDATION'; + public const string EVENT_CRUD_EDIT_AFTER_VALIDATION = 'EVENT_CRUD_EDIT_AFTER_VALIDATION'; /** * This event is fired after validation has been successful. * We might run additional validators here. - * output must be either null, empty array or errors found in additional validators + * Output must be either null, empty array or errors found in additional validators * @var string */ - const EVENT_CRUD_EDIT_VALIDATION = 'EVENT_CRUD_EDIT_VALIDATION'; + public const string EVENT_CRUD_EDIT_VALIDATION = 'EVENT_CRUD_EDIT_VALIDATION'; /** * This event is fired whenever the CRUD generator wants to edit/update an entry - *
in a model. It is given the $data and must return the $data. + * in a model. It is given the $data and must return the $data. * @example Imagine you want to manipulate entries on a model when saving the entry - *
from the CRUD generator. This is will happen after the validation. + * from the CRUD generator. This happened after the validation. * @var string */ - const EVENT_CRUD_EDIT_BEFORE_SAVE = 'EVENT_CRUD_EDIT_BEFORE_SAVE'; + public const string EVENT_CRUD_EDIT_BEFORE_SAVE = 'EVENT_CRUD_EDIT_BEFORE_SAVE'; /** * This event is fired whenever the CRUD generator successfully edited an entry - *
to a model. It is given the $data. + * to a model. It is given the $data. * @example Think of Creating an email using the updated entry after saving. * @var string */ - const EVENT_CRUD_EDIT_SUCCESS = 'EVENT_CRUD_EDIT_SUCCESS'; + public const string EVENT_CRUD_EDIT_SUCCESS = 'EVENT_CRUD_EDIT_SUCCESS'; /** * This event will be fired when the database controller tries executing a query. - *
It will be fired before the actual query is executed. + * It will be fired before the actual query is executed. * @var string */ - const EVENT_DATABASE_QUERY_QUERY_BEFORE = 'EVENT_DATABASE_QUERY_QUERY_BEFORE'; + public const string EVENT_DATABASE_QUERY_QUERY_BEFORE = 'EVENT_DATABASE_QUERY_QUERY_BEFORE'; /** - * This event will be fired when the database controller successfully executed a database query - *
It will be fired after the actual query is executed. + * This event will be fired when the database controller successfully executed a database query, + * It will be fired after the actual query is executed. * @var string */ - const EVENT_DATABASE_QUERY_QUERY_AFTER = 'EVENT_DATABASE_QUERY_QUERY_AFTER'; + public const string EVENT_DATABASE_QUERY_QUERY_AFTER = 'EVENT_DATABASE_QUERY_QUERY_AFTER'; /** * This event is fired after the API Service provider validated that the - *
external application's authentication data was sent as headers. - *
Use it to modify the headers before validating the header match. + * external application's authentication data was sent as headers. + * Use it to modify the headers before validating the header match. * @var string */ - const EVENT_API_AUTHENTICATE_HEADER_MODIFY = 'EVENT_API_AUTHENTICATE_HEADER_MODIFY'; + public const string EVENT_API_AUTHENTICATE_HEADER_MODIFY = 'EVENT_API_AUTHENTICATE_HEADER_MODIFY'; /** * This event is fired in the authenticate method of an API service provider. - *
Use it to modify the salt value during runtime. - *
It will be given the current request instance. - *
If it returns a string, this string will be used as the salt + * Use it to modify the salt value during runtime. + * It will be given the current request instance. + * If it returns a string, this string will be used as salt * @var string */ - const EVENT_API_AUTHENTICATE_SALT_MODIFY = 'EVENT_API_AUTHENTICATE_SALT_MODIFY'; - + public const string EVENT_API_AUTHENTICATE_SALT_MODIFY = 'EVENT_API_AUTHENTICATE_SALT_MODIFY'; + /** + * Contains the actual instance + * @var null|hook + */ + private static ?hook $instance = null; /** * Contains a list of hooks * @var array of callables */ - private $hooks = array(); + private array $hooks = []; /** - * Contains the actual instance - * @var \codename\core\hook + * Access denied from outside this class + * @see https://en.wikipedia.org/wiki/Singleton_pattern */ - private static $instance = null; + protected function __construct() + { + } /** - * Adds the $callable function to the hook $name - * @param string $name - * @param callable $callback - * @return \codename\core\hook + * Returns the instance of the hook + * @return hook */ - public function add(string $name, callable $callback) : \codename\core\hook { - $this->hooks[$name][] = $callback; - return $this; + public static function getInstance(): hook + { + if (is_null(self::$instance)) { + self::$instance = new hook(); + } + return self::$instance; } /** - * Returns all the callbacks that are stored under the given $name + * Adds the $callable function to the hook $name * @param string $name - * @return array + * @param callable $callback + * @return hook */ - public function get(string $name) : array { - return isset($this->hooks[$name]) ? $this->hooks[$name] : array(); + public function add(string $name, callable $callback): hook + { + $this->hooks[$name][] = $callback; + return $this; } /** * Fires all the callbacks that are stored under the given $name * @param string $name - * @return mixed|null + * @param null $arguments + * @return mixed */ - public function fire(string $name, $arguments = null) { + public function fire(string $name, $arguments = null): mixed + { $ret = null; - foreach($this->get($name) as $callback) { - if(!is_callable($callback)) { + foreach ($this->get($name) as $callback) { + if (!is_callable($callback)) { continue; } $ret = call_user_func($callback, $arguments); @@ -263,30 +276,20 @@ public function fire(string $name, $arguments = null) { } /** - * Returns the instance of the hook - * @return \codename\core\hook - */ - public static function getInstance() : \codename\core\hook { - if(is_null(self::$instance)) { - self::$instance = new \codename\core\hook(); - } - return self::$instance; - } - - /** - * Access denied from outside this class - * @see https://en.wikipedia.org/wiki/Singleton_pattern + * Returns all the callbacks that are stored under the given $name + * @param string $name + * @return array */ - protected function __construct() { - return; + public function get(string $name): array + { + return $this->hooks[$name] ?? []; } /** * Access denied from outside this class * @see https://en.wikipedia.org/wiki/Singleton_pattern */ - protected function __clone() { - return; + protected function __clone() + { } - } diff --git a/backend/class/log.php b/backend/class/log.php index b00fc0c..4d05d8a 100755 --- a/backend/class/log.php +++ b/backend/class/log.php @@ -1,166 +1,176 @@ maskwrite($text, static::EMERGENCY); + protected function __construct(array $config = []) + { } /** * * {@inheritDoc} - * @see \codename\core\log_interface::alert($text) + * @see log_interface::emergency */ - public function alert(string $text) { - return $this->maskwrite($text, static::ALERT); + public function emergency(string $text): void + { + $this->maskwrite($text, static::EMERGENCY); } /** - * - * {@inheritDoc} - * @see \codename\core\log_interface::critical($text) + * Decides whether to log or not to log this entry by checking it's level. + * @param string $text + * @param int $level + * @return void */ - public function critical(string $text) { - return $this->maskwrite($text, static::CRITICAL); + protected function maskwrite(string $text, int $level): void + { + if ($level < $this->minlevel) { + return; + } + $this->write($text, $level); } /** * * {@inheritDoc} - * @see \codename\core\log_interface::error($text) + * @see log_interface::alert */ - public function error(string $text) { - return $this->maskwrite($text, static::ERROR); + public function alert(string $text): void + { + $this->maskwrite($text, static::ALERT); } /** * * {@inheritDoc} - * @see \codename\core\log_interface::warning($text) + * @see log_interface::critical */ - public function warning(string $text) { - return $this->maskwrite($text, static::WARNING); + public function critical(string $text): void + { + $this->maskwrite($text, static::CRITICAL); } /** * * {@inheritDoc} - * @see \codename\core\log_interface::notice($text) + * @see log_interface::error */ - public function notice(string $text) { - return $this->maskwrite($text, static::NOTICE); + public function error(string $text): void + { + $this->maskwrite($text, static::ERROR); } /** * * {@inheritDoc} - * @see \codename\core\log_interface::info($text) + * @see log_interface::warning */ - public function info(string $text) { - return $this->maskwrite($text, static::INFO); + public function warning(string $text): void + { + $this->maskwrite($text, static::WARNING); } /** * * {@inheritDoc} - * @see \codename\core\log_interface::debug($text) + * @see log_interface::notice */ - public function debug(string $text) { - $text = round((microtime(true) - ($_REQUEST['start'] ?? $_SERVER['REQUEST_TIME_FLOAT'])) * 1000, 4) . 'ms ' . $text; - return $this->maskwrite($text, static::DEBUG); + public function notice(string $text): void + { + $this->maskwrite($text, static::NOTICE); } /** - * Decides whether to log or not to log this entry by checking it's level. - * @param string $text - * @param int $level + * + * {@inheritDoc} + * @see log_interface::info */ - protected function maskwrite(string $text, int $level) { - if($level < $this->minlevel) { - return; - } - return $this->write($text, $level); + public function info(string $text): void + { + $this->maskwrite($text, static::INFO); } /** - * Access denied from outside this class - * @see https://en.wikipedia.org/wiki/Singleton_pattern + * @see log_interface::debug */ - protected function __construct() { - return; + public function debug(string $text): void + { + $text = round((microtime(true) - ($_REQUEST['start'] ?? $_SERVER['REQUEST_TIME_FLOAT'])) * 1000, 4) . 'ms ' . $text; + $this->maskwrite($text, static::DEBUG); } /** * Access denied from outside this class * @see https://en.wikipedia.org/wiki/Singleton_pattern */ - protected function __clone() { - return; + protected function __clone() + { } - } diff --git a/backend/class/log/dummy.php b/backend/class/log/dummy.php index 1ec72dc..909ab7e 100644 --- a/backend/class/log/dummy.php +++ b/backend/class/log/dummy.php @@ -1,35 +1,38 @@ minlevel = $config['data']['minlevel']; } $this->file = "/var/log/honeycomb/" . $config['data']['name'] . ".log"; return $this; } - + /** - * Returns the current instance by it's name + * Returns the current instance by its name * @param array $config - * @return \codename\core\log + * @return log */ - public static function getInstance(array $config) : \codename\core\log { - if(!array_key_exists($config['data']['name'], self::$instances)) { - self::$instances[$config['data']['name']] = new \codename\core\log\file($config); + public static function getInstance(array $config): log + { + if (!array_key_exists($config['data']['name'], self::$instances)) { + self::$instances[$config['data']['name']] = new file($config); } return self::$instances[$config['data']['name']]; } @@ -51,15 +56,13 @@ public static function getInstance(array $config) : \codename\core\log { * {@inheritDoc} * @see \codename\core\log_interface::write($text, $level) */ - public function write(string $text, int $level) { - $ip = null; + public function write(string $text, int $level): void + { $ip = (array_key_exists('HTTP_X_FORWARDED_FOR', $_SERVER) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : null); - if(is_null($ip)) { + if (is_null($ip)) { $ip = (array_key_exists('REMOTE_ADDR', $_SERVER) ? $_SERVER['REMOTE_ADDR'] : "127.0.0.1"); } - $text = date("Y-m-d H:i:s") . ' - ' . gethostname() . ' - ' . $level . ' - ' . $ip . ' - ' . trim(str_replace(CHR(13), '', str_replace(CHR(10), '', $text))) . CHR(10); + $text = date("Y-m-d H:i:s") . ' - ' . gethostname() . ' - ' . $level . ' - ' . $ip . ' - ' . trim(str_replace(chr(13), '', str_replace(chr(10), '', $text))) . chr(10); @file_put_contents($this->file, $text, FILE_APPEND); - return; } - } diff --git a/backend/class/log/logInterface.php b/backend/class/log/logInterface.php index 48f0450..4a530d0 100755 --- a/backend/class/log/logInterface.php +++ b/backend/class/log/logInterface.php @@ -1,4 +1,5 @@ token = $config['data']['token']; - if(array_key_exists('minlevel', $config['data'])) { + if (array_key_exists('minlevel', $config['data'])) { $this->minlevel = $config['data']['minlevel']; } return $this; } - + /** - * Returns the current instance by it's name + * Returns the current instance by its name * @param array $config - * @return \codename\core\log + * @return log */ - public static function getInstance(array $config) : \codename\core\log { - if(!array_key_exists($config['data']['token'], self::$instances)) { - self::$instances[$config['data']['token']] = new \codename\core\log\loggly($config); + public static function getInstance(array $config): log + { + if (!array_key_exists($config['data']['token'], self::$instances)) { + self::$instances[$config['data']['token']] = new loggly($config); } return self::$instances[$config['data']['token']]; } @@ -48,37 +56,39 @@ public static function getInstance(array $config) : \codename\core\log { /** * * {@inheritDoc} + * @param string $text + * @param int $level + * @throws ReflectionException + * @throws exception * @see \codename\core\log_interface::write($text, $level) */ - public function write(string $text, int $level) { - $url = 'http://logs-01.loggly.com/inputs/' . $this->token . '/tag/http/'; - - $data = array( - 'app' => \codename\core\app::getApp(), - 'server' => gethostname(), - 'client' => $_SERVER['REMOTE_ADDR'], - 'level' => $level, - 'text' => $text - ); - - $data_string = json_encode($data); - + public function write(string $text, int $level): void + { + $url = 'https://logs-01.loggly.com/inputs/' . $this->token . '/tag/http/'; + + $data = [ + 'app' => app::getApp(), + 'server' => gethostname(), + 'client' => $_SERVER['REMOTE_ADDR'], + 'level' => $level, + 'text' => $text, + ]; + + $data_string = json_encode($data); + // create CURL instance $ch = curl_init(); - - // Configure CURL instance - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); - curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string); - curl_setopt($ch, CURLOPT_HTTPHEADER, array( - 'content-type:application/x-www-form-urlencoded', - 'Content-Length: ' . strlen($data_string) - )); - $test = curl_exec($ch); + + // Configure CURL instance + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); + curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'content-type:application/x-www-form-urlencoded', + 'Content-Length: ' . strlen($data_string), + ]); + curl_exec($ch); print_r(curl_error($ch)); curl_close($ch); - - return; } - } diff --git a/backend/class/log/system.php b/backend/class/log/system.php index f5dc000..68f9e11 100644 --- a/backend/class/log/system.php +++ b/backend/class/log/system.php @@ -1,45 +1,49 @@ minlevel = $config['data']['minlevel']; + /** + * {@inheritDoc} + */ + protected function __construct(array $config) + { + parent::__construct($config); + if (array_key_exists('minlevel', $config['data'])) { + $this->minlevel = $config['data']['minlevel']; + } } - } - /** - * @inheritDoc - */ - public function write(string $text, int $level) - { - // only write, if ... you know. - if($level >= $this->minlevel) { - error_log("[LOGDRIVER:SYSTEM] ".$text, 0); + /** + * Returns the current instance by its name + * @param array $config + * @return log + */ + public static function getInstance(array $config): log + { + if (!array_key_exists($config['data']['name'], self::$instances)) { + self::$instances[$config['data']['name']] = new self($config); + } + return self::$instances[$config['data']['name']]; } - } - /** - * Returns the current instance by it's name - * @param array $config - * @return \codename\core\log - */ - public static function getInstance(array $config) : \codename\core\log { - if(!array_key_exists($config['data']['name'], self::$instances)) { - self::$instances[$config['data']['name']] = new self($config); - } - return self::$instances[$config['data']['name']]; - } + /** + * {@inheritDoc} + */ + public function write(string $text, int $level): void + { + // only write, if ... you know. + if ($level >= $this->minlevel) { + error_log("[LOGDRIVER:SYSTEM] " . $text); + } + } } diff --git a/backend/class/mail.php b/backend/class/mail.php index ffe7104..f2b7f48 100755 --- a/backend/class/mail.php +++ b/backend/class/mail.php @@ -1,25 +1,28 @@ client; } - } diff --git a/backend/class/mail/PHPMailer.php b/backend/class/mail/PHPMailer.php index 72a6e6c..df1d011 100755 --- a/backend/class/mail/PHPMailer.php +++ b/backend/class/mail/PHPMailer.php @@ -1,32 +1,30 @@ client = new \PHPMailer\PHPMailer\PHPMailer(true); // Use SMTP Mode - $this->client->IsSMTP(); + $this->client->isSMTP(); $this->client->Host = $config['host']; $this->client->Port = $config['port']; $this->client->SMTPSecure = $config['secure']; @@ -37,15 +35,15 @@ public function __CONSTRUCT(array $config) { //disable ssl verification // http://stackoverflow.com/questions/26827192/phpmailer-ssl3-get-server-certificatecertificate-verify-failed - $this->client->SMTPOptions = array( - 'ssl' => array( - 'verify_peer' => false, - 'verify_peer_name' => false, - 'allow_self_signed' => true - ) - ); - - if($this->client->SMTPAuth) { + $this->client->SMTPOptions = [ + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ], + ]; + + if ($this->client->SMTPAuth) { $this->client->Username = $config['user']; $this->client->Password = $config['pass']; } @@ -55,17 +53,27 @@ public function __CONSTRUCT(array $config) { /** * * {@inheritDoc} + * @param string $email + * @param string $name + * @return mail + * @throws Exception * @see \codename\core\mail_interface::setFrom($email, $name) */ - public function setFrom(string $email, string $name = '') : \codename\core\mail { + public function setFrom(string $email, string $name = ''): mail + { $this->client->setFrom($email, $name); return $this; } /** - * @inheritDoc + * {@inheritDoc} + * @param string $email + * @param string $name + * @return mail + * @throws Exception */ - public function addReplyTo(string $email, string $name = '') : \codename\core\mail { + public function addReplyTo(string $email, string $name = ''): mail + { $this->client->addReplyTo($email, $name); return $this; } @@ -73,9 +81,14 @@ public function addReplyTo(string $email, string $name = '') : \codename\core\ma /** * * {@inheritDoc} + * @param string $email + * @param string $name + * @return mail + * @throws Exception * @see \codename\core\mail_interface::addTo($email, $name) */ - public function addTo(string $email, string $name = '') : \codename\core\mail { + public function addTo(string $email, string $name = ''): mail + { $this->client->addAddress($email, $name); return $this; } @@ -83,9 +96,14 @@ public function addTo(string $email, string $name = '') : \codename\core\mail { /** * * {@inheritDoc} + * @param string $email + * @param string $name + * @return mail + * @throws Exception * @see \codename\core\mail_interface::addCc($email, $name) */ - public function addCc(string $email, string $name = '') : \codename\core\mail { + public function addCc(string $email, string $name = ''): mail + { $this->client->addCC($email, $name); return $this; } @@ -93,9 +111,14 @@ public function addCc(string $email, string $name = '') : \codename\core\mail { /** * * {@inheritDoc} + * @param string $email + * @param string $name + * @return mail + * @throws Exception * @see \codename\core\mail_interface::addBcc($email, $name) */ - public function addBcc(string $email, string $name = '') : \codename\core\mail { + public function addBcc(string $email, string $name = ''): mail + { $this->client->addBCC($email, $name); return $this; } @@ -103,9 +126,14 @@ public function addBcc(string $email, string $name = '') : \codename\core\mail { /** * * {@inheritDoc} + * @param string $file + * @param string $newname + * @return mail + * @throws Exception * @see \codename\core\mail_interface::addAttachment($file, $newname) */ - public function addAttachment(string $file, string $newname = '') : \codename\core\mail { + public function addAttachment(string $file, string $newname = ''): mail + { $this->client->addAttachment($file, $newname); return $this; } @@ -115,7 +143,8 @@ public function addAttachment(string $file, string $newname = '') : \codename\co * {@inheritDoc} * @see \codename\core\mail_interface::setHtml($status) */ - public function setHtml (bool $status = true) : \codename\core\mail { + public function setHtml(bool $status = true): mail + { $this->client->isHTML($status); return $this; } @@ -125,7 +154,8 @@ public function setHtml (bool $status = true) : \codename\core\mail { * {@inheritDoc} * @see \codename\core\mail_interface::setSubject($subject) */ - public function setSubject (string $subject) : \codename\core\mail { + public function setSubject(string $subject): mail + { $this->client->Subject = $subject; return $this; } @@ -135,7 +165,8 @@ public function setSubject (string $subject) : \codename\core\mail { * {@inheritDoc} * @see \codename\core\mail_interface::setBody($body) */ - public function setBody (string $body) : \codename\core\mail { + public function setBody(string $body): mail + { $this->client->Body = $body; return $this; } @@ -145,7 +176,8 @@ public function setBody (string $body) : \codename\core\mail { * {@inheritDoc} * @see \codename\core\mail_interface::setAltbody($altbody) */ - public function setAltbody (string $altbody) : \codename\core\mail { + public function setAltbody(string $altbody): mail + { $this->client->AltBody = $altbody; return $this; } @@ -153,9 +185,12 @@ public function setAltbody (string $altbody) : \codename\core\mail { /** * * {@inheritDoc} + * @return bool + * @throws Exception * @see \codename\core\mail_interface::send() */ - public function send() : bool { + public function send(): bool + { return $this->client->send(); } @@ -164,8 +199,8 @@ public function send() : bool { * {@inheritDoc} * @see \codename\core\mail_interface::getError() */ - public function getError() { + public function getError(): mixed + { return $this->client->ErrorInfo; } - } diff --git a/backend/class/mail/mailInterface.php b/backend/class/mail/mailInterface.php index d8bb9b9..f8e9204 100755 --- a/backend/class/mail/mailInterface.php +++ b/backend/class/mail/mailInterface.php @@ -1,98 +1,101 @@ config; - } - + protected bool $forceVirtualJoin = false; /** - * loads a new config file (uncached) - * implement me! - * @return \codename\core\config + * [protected description] + * @var bool */ - protected abstract function loadConfig() : \codename\core\config; - + protected bool $recursive = false; /** - * @inheritDoc + * [protected description] + * @var null|value\text\modelfield */ - public function getCount(): int - { - // - // NOTE: this has to be implemented per DB technology - // - throw new \LogicException('Not implemented'); // TODO - } - + protected ?value\text\modelfield $recursiveSelfReferenceField = null; /** - * [getNestedJoins description] - * @param string|null $model name of a model to look for - * @param string|null $modelField name of a field the model is joined upon - * @return \codename\core\model\plugin\join[] [array of joins, may be empty] + * [protected description] + * @var null|value\text\modelfield */ - public function getNestedJoins(string $model = null, string $modelField = null) : array { - if($model || $modelField) { - return array_values(array_filter($this->getNestedJoins(), function(\codename\core\model\plugin\join $join) use ($model, $modelField){ - return ($model === null || $join->model->getIdentifier() === $model) && ($modelField === null || $join->modelField === $modelField); - })); - } else { - return $this->nestedModels; - } - } - + protected ?value\text\modelfield $recursiveAnchorField = null; /** - * [getNestedCollections description] - * @return \codename\core\model\plugin\collection[] [description] + * [protected description] + * @var filter[] */ - public function getNestedCollections() : array { - return $this->collectionPlugins; - } - + protected array $recursiveAnchorConditions = []; /** - * determines if the model is joinable - * in the same run (e.g. DB compatibility and stuff) - * @param \codename\core\model $model [the model to check direct join compatibility with] - * @return bool + * contains configured join plugin instances for nested models + * @var join[] */ - protected function compatibleJoin(\codename\core\model $model) : bool { - // - // NOTE/CHANGED 2020-07-21: Feature 'force_virtual_join' is checked right here - // to allow virtually joining a table via already available features of the framework. - // This overcomes the problem of join count limitations - // While preserving ORM capabilities - // - // If $model has the force_virtual_join feature enabled, - // this method will return false, no matter if its mysql==mysql or else. - // - return $this->getType() == $model->getType() && !$model->getForceVirtualJoin(); - } - + protected array $nestedModels = []; /** - * Whether model is discrete/self-contained - * and/or performs its work as a subquery - * @return bool [description] + * [protected description] + * @var null|model\servicing\sql */ - protected function isDiscreteModel() : bool { - return false; - } - + protected ?model\servicing\sql $servicingInstance = null; /** - * I will set the given $field's value to $value of the previously loaded dataset / entry. - * @param \codename\core\value\text\modelfield $field - * @param mixed|null $value - * @throws \codename\core\exception - * @return \codename\core\model + * model data passed during initialization + * @var null|config */ - public function fieldSet(\codename\core\value\text\modelfield $field, $value) : \codename\core\model { - if(!$this->fieldExists($field)) { - throw new \codename\core\exception(self::EXCEPTION_FIELDSET_FIELDNOTFOUNDINMODEL, \codename\core\exception::$ERRORLEVEL_FATAL, $field); - } - if($this->data === null || empty($this->data->getData())) { - throw new \codename\core\exception(self::EXCEPTION_FIELDSET_NOOBJECTLOADED, \codename\core\exception::$ERRORLEVEL_FATAL); - } - $this->data->setData($field->get(), $value); - return $this; - } - + protected ?config $modeldata = null; /** - * I will return the given $field's value of the previously loaded dataset. - * @param \codename\core\value\text\modelfield $field - * @throws \codename\core\exception - * @return mixed|null + * Provides an additional collection of filter arrays + * to be used in queries. + * Like [0] => { + * operator => 'AND' + * filters => { filter1, filter2 } + * } + * ... + * which are chained as groups with the default operator (AND - or other, if defined) + * and chained internally as defined via joinMethod + * @var array */ - public function fieldGet(\codename\core\value\text\modelfield $field) { - if(!$this->fieldExists($field)) { - throw new \codename\core\exception(self::EXCEPTION_FIELDGET_FIELDNOTFOUNDINMODEL, \codename\core\exception::$ERRORLEVEL_FATAL, $field); - } - if($this->data === null || empty($this->data->getData())) { - throw new \codename\core\exception(self::EXCEPTION_FIELDGET_NOOBJECTLOADED, \codename\core\exception::$ERRORLEVEL_FATAL); - } - return $this->data->getData($field->get()); - } + protected array $filterCollections = []; + /** + * Contains instances of the filters that will be used again after resetting the model + * @var array + */ + protected array $defaultfilterCollections = []; + /** + * virtual field functions + * @var callable[] + */ + protected array $virtualFields = []; + /** + * groupBy fields + * @var group[] + */ + protected array $group = []; + /** + * internal in-mem caching of fieldtypes + * @var array + */ + protected array $cachedFieldtype = []; + /** + * primarykey cache field + * @var null|string + */ + protected ?string $primarykey = null; + /** + * internal caching variable containing the list of fields in the model + * @var null|array + */ + protected ?array $normalizeDataFieldCache = null; + /** + * contains the last query performed with this model instance + * @var string + */ + protected string $lastQuery = ''; + /** + * Temporary model field cache during normalizeResult / normalizeRow + * This is being reset each time normalizeResult is going to call normalizeRow + * @var modelfield[] + */ + protected array $normalizeModelFieldCache = []; + /** + * Temporary model field type cache during normalizeResult / normalizeRow + * This is being reset each time normalizeResult is going to call normalizeRow + * @var modelfield[] + */ + protected array $normalizeModelFieldTypeCache = []; + /** + * [protected description] + * @var bool[] + */ + protected array $normalizeModelFieldTypeStructureCache = []; + /** + * [protected description] + * @var bool[] + */ + protected array $normalizeModelFieldTypeVirtualCache = []; + /** + * internal variable containing field types for a given field + * to improve performance of ":: importField" + * @var array [type] + */ + protected array $importFieldTypeCache = []; + /** + * @var array + */ + protected array $fieldTypeCache = []; /** - * I am capable of creating a new entry for the current model by the given array $data. - * @param array $data - * @return \codename\core\model + * Creates an instance + * @param array $modeldata + * @return model + * @todo refactor the constructor for no method args */ - public function entryMake(array $data = array()) : \codename\core\model { - $this->data = new \codename\core\datacontainer($data); + public function __construct(array $modeldata = []) + { + $this->errorstack = new errorstack('VALIDATION'); + $this->modeldata = new config($modeldata); return $this; } /** - * I will validate the currently loaded dataset of the current model and return the array of errors that might have occured - * @return array + * returns the config object + * @return config [description] */ - public function entryValidate() : array { - return $this->validate($this->data->getData())->getErrors(); + public function getConfig(): config + { + return $this->config; } /** - * I will delete the previously loaded entry. - * @throws \codename\core\exception - * @return \codename\core\model + * {@inheritDoc} */ - public function entryDelete() : \codename\core\model { - if($this->data === null || empty($this->data->getData())) { - throw new \codename\core\exception(self::EXCEPTION_ENTRYDELETE_NOOBJECTLOADED, \codename\core\exception::$ERRORLEVEL_FATAL); - } - $this->delete($this->data->getData($this->getPrimarykey())); - return $this; + public function getCount(): int + { + // + // NOTE: this has to be implemented per DB technology + // + throw new LogicException('Not implemented'); // TODO } /** - * [getData description] - * @return array [description] + * [getNestedCollections description] + * @return collection[] [description] */ - public function getData() : array { - return $this->data->getData(); + public function getNestedCollections(): array + { + return $this->collectionPlugins; } /** - * [protected description] - * @var \codename\core\model\plugin\collection[] + * I will validate the currently loaded dataset of the current model and return the array of errors that might have occurred + * @return array + * @throws ReflectionException + * @throws exception */ - protected $collectionPlugins = []; + public function entryValidate(): array + { + return $this->validate($this->data->getData())->getErrors(); + } /** - * [addCollectionModel description] - * @param \codename\core\model $model [description] - * @param string|null $modelField [description] - * @return \codename\core\model + * Returns the errors of the errorstack in this instance + * @return array */ - public function addCollectionModel(\codename\core\model $model, string $modelField = null) : \codename\core\model { - if($this->config->exists('collection')) { - - $collectionConfig = null; + public function getErrors(): array + { + return $this->errorstack->getErrors(); + } + /** + * Validates the given data after normalizing it. + * @param array $data + * @return model + * @throws ReflectionException + * @throws exception + * @todo required seems to have some bugs + * @todo bring back to life the UNIQUE constraint checker + * @todo move the UNIQUE constraint checks to a separate method + */ + public function validate(array $data): model + { // - // try to determine modelfield by the best-matching collection + // CHANGED 2020-07-29 reset the current errorstack just right before validation // - if(!$modelField) { - if($this->config->exists('collection')) { - foreach($this->config->get('collection') as $collectionFieldName => $config) { - if($config['model'] === $model->getIdentifier()) { - $modelField = $collectionFieldName; - $collectionConfig = $config; - } - } - } - } + $this->errorstack->reset(); - // - // Still no modelfield - // - if(!$modelField) { - throw new exception('EXCEPTION_UNKNOWN_COLLECTION_MODEL', exception::$ERRORLEVEL_ERROR, [$this->getIdentifier(), $model->getIdentifier()]); - } + foreach ($this->config->get('field') as $field) { + if (in_array($field, [$this->getPrimaryKey(), $this->getIdentifier() . "_modified", $this->getIdentifier() . "_created"])) { + continue; + } + if (!array_key_exists($field, $data) || is_null($data[$field]) || (is_string($data[$field]) && strlen($data[$field]) == 0)) { + if (is_array($this->config->get('required')) && in_array($field, $this->config->get("required"))) { + $this->errorstack->addError($field, 'FIELD_IS_REQUIRED'); + } + continue; + } - // - // Case where we haven't retrieved the collection config yet - // - if(!$collectionConfig) { - $collectionConfig = $this->config->get('collection>'.$modelField); - } + if ($this->config->exists('children') && $this->config->exists('children>' . $field)) { + // validate child using a child/nested model + $childConfig = $this->config->get('children>' . $field); - // - // Still no collection config - // - if(!$collectionConfig) { - throw new exception('EXCEPTION_NO_COLLECTION_CONFIG', exception::$ERRORLEVEL_ERROR, $modelField); - } + if ($childConfig['type'] === 'foreign') { + // + // Normal Foreign-Key-based child (1:1) + // + $foreignConfig = $this->config->get('foreign>' . $childConfig['field']); - if($collectionConfig['model'] != $model->getIdentifier()) { - throw new exception('EXCEPTION_MODEL_ADDCOLLECTIONMODEL_INCOMPATIBLE', exception::$ERRORLEVEL_ERROR, [$collectionConfig['model'], $model->getIdentifier()]); - } + // get the join plugin valid for the child reference field + $res = $this->getNestedJoins($foreignConfig['model'], $childConfig['field']); - $modelFieldInstance = $this->getModelfieldInstance($modelField); + if (count($res) === 1) { + $join = $res[0]; // reset($res); + $join->model->validate($data[$field]); + if (count($errors = $join->model->getErrors()) > 0) { + $this->errorstack->addError($field, 'FIELD_INVALID', $errors); + } + } else { + continue; + } + } elseif ($childConfig['type'] === 'collection') { + // + // Collections in a virtual field + // + + // TODO: get the corresponding model + // we might introduce a new "addCollectionModel" method or so + + if (isset($this->collectionPlugins[$field])) { + if (is_array($data[$field])) { + foreach ($data[$field] as $collectionItem) { + $this->collectionPlugins[$field]->collectionModel->validate($collectionItem); + if (count($errors = $this->collectionPlugins[$field]->collectionModel->getErrors()) > 0) { + $this->errorstack->addError($field, 'FIELD_INVALID', $errors); + } + } + } + } else { + continue; + } + } + } - // Finally, add model - $this->collectionPlugins[$modelFieldInstance->get()] = new \codename\core\model\plugin\collection( - $modelFieldInstance, - $this, - $model - ); + if (count($errors = app::getValidator($this->getFieldtype($this->getModelfieldInstance($field)))->reset()->validate($data[$field])) > 0) { + $this->errorstack->addError($field, 'FIELD_INVALID', $errors); + } + } - } else { - throw new exception('EXCEPTION_NO_COLLECTION_KEY', exception::$ERRORLEVEL_ERROR, $this->getIdentifier()); - } + // model validator + if ($this->config->exists('validators')) { + $validators = $this->config->get('validators'); + foreach ($validators as $validator) { + // NOTE: reset validator needed, as app::getValidator() caches the validator instance, + // including the current errorstack + if (count($errors = app::getValidator($validator)->reset()->validate($data)) > 0) { + // + // NOTE/CHANGED 2020-02-18 + // split errors into field-related and others + // to improve validation handling + // + $dataErrors = []; + $fieldErrors = []; + foreach ($errors as $error) { + if (in_array($error['__IDENTIFIER'], $this->getFields())) { + $fieldErrors[] = $error; + } else { + $dataErrors[] = $error; + } + } + if (count($dataErrors) > 0) { + $this->errorstack->addError('DATA', 'INVALID', $dataErrors); + } + if (count($fieldErrors) > 0) { + $this->errorstack->addErrors($fieldErrors); + } + } + } + } - return $this; + return $this; } /** - * [addModel description] - * @param \codename\core\model $model [description] - * @param string $type [description] - * @param string|null $modelField [description] - * @param string|null $referenceField [description] - * @return \codename\core\model [description] + * resets all the parameters of the instance for another query + * @return void */ - public function addModel(\codename\core\model $model, string $type = plugin\join::TYPE_LEFT, string $modelField = null, string $referenceField = null) : \codename\core\model { - - $thisKey = null; - $joinKey = null; - - $conditions = []; - - // model field provided - // - // - if($modelField != null) { - // modelField is already provided - $thisKey = $modelField; - - // look for reference field in foreign key config - $fkeyConfig = $this->config->get('foreign>'.$modelField); - if($fkeyConfig != null) { - if($referenceField == null || $referenceField == $fkeyConfig['key']) { - $joinKey = $fkeyConfig['key']; - $conditions = $fkeyConfig['condition'] ?? []; - } else { - // reference field is not equal - // e.g. you're trying to join on unjoinable fields - // throw new exception('EXCEPTION_MODEL_SQL_ADDMODEL_INVALID_REFERENCEFIELD', exception::$ERRORLEVEL_ERROR, array($this->getIdentifer(), $referenceField)); - } - } else { - // we're missing the foreignkey config for the field provided - // throw new exception('EXCEPTION_MODEL_SQL_ADDMODEL_UNKNOWN_FOREIGNKEY_CONFIG', exception::$ERRORLEVEL_ERROR, array($this->getIdentifer(), $modelField)); - } - } else { - // search for modelfield, as it is null - if($this->config->exists('foreign')) { - foreach($this->config->get('foreign') as $fkeyName => $fkeyConfig) { - // if we found compatible models - if($fkeyConfig['model'] == $model->getIdentifier()) { - if(is_array($fkeyConfig['key'])) { - $thisKey = array_keys($fkeyConfig['key']); // current keys - $joinKey = array_values($fkeyConfig['key']); // keys of foreign model - } else { - $thisKey = $fkeyName; - if($referenceField == null || $referenceField == $fkeyConfig['key']) { - $joinKey = $fkeyConfig['key']; - } - } - $conditions = $fkeyConfig['condition'] ?? []; - break; - } - } - } + public function reset(): void + { + $this->cache = false; + // $this->fieldlist = []; + // $this->hiddenFields = []; + $this->filter = $this->defaultfilter; + $this->aggregateFilter = $this->defaultAggregateFilter; + $this->flagfilter = $this->defaultflagfilter; + $this->filterCollections = $this->defaultfilterCollections; + $this->limit = null; + $this->offset = null; + $this->filterDuplicates = false; + $this->order = []; + $this->errorstack->reset(); + foreach ($this->nestedModels as $nest) { + $nest->model->reset(); } + // TODO: reset collection models? + } - // Try Reverse Join - if(($thisKey == null) || ($joinKey == null)) { - if($model->config->exists('foreign')) { - foreach($model->config->get('foreign') as $fkeyName => $fkeyConfig) { - if($fkeyConfig['model'] == $this->getIdentifier()) { - if($referenceField == null || $referenceField == $fkeyName) { - if($thisKey == null || $thisKey == $fkeyConfig['key']) { - $joinKey = $fkeyName; - } - if($joinKey == null || $joinKey == $fkeyName) { - $thisKey = $fkeyConfig['key']; - } - $conditions = $fkeyConfig['condition'] ?? []; - // $thisKey = $fkeyConfig['key']; - // $joinKey = $fkeyName; - break; - } - } + /** + * Returns the primary key configured in the model's JSON config + * @return string + * @throws exception + */ + public function getPrimaryKey(): string + { + if ($this->primarykey === null) { + if (!$this->config->exists("primary")) { + throw new exception(self::EXCEPTION_GETPRIMARYKEY_NOPRIMARYKEYINCONFIG, exception::$ERRORLEVEL_FATAL, $this->config->get()); } - } + $this->primarykey = $this->config->get('primary')[0]; } + return $this->primarykey; + } - if(($thisKey == null) || ($joinKey == null)) { - throw new exception('EXCEPTION_MODEL_ADDMODEL_INVALID_OPERATION', exception::$ERRORLEVEL_ERROR, array($this->getIdentifier(), $model->getIdentifier(), $modelField, $referenceField)); - } + /** + * Gets the current model identifier (name) + * @return string + */ + abstract public function getIdentifier(): string; - // fallback to bare model joining - if($model instanceof \codename\core\model\schemeless\dynamic || $this instanceof \codename\core\model\schemeless\dynamic) { - $pluginDriver = 'dynamic'; + /** + * [getNestedJoins description] + * @param string|null $model name of a model to look for + * @param string|null $modelField name of a field the model is joined upon + * @return join[] [array of joins may be empty] + */ + public function getNestedJoins(string $model = null, string $modelField = null): array + { + if ($model || $modelField) { + return array_values( + array_filter($this->getNestedJoins(), function (join $join) use ($model, $modelField) { + return ($model === null || $join->model->getIdentifier() === $model) && ($modelField === null || $join->modelField === $modelField); + }) + ); } else { - $pluginDriver = $this->compatibleJoin($model) ? $this->getType() : 'bare'; - } - - // - // FEATURE/CHANGED 2020-07-21: - // Added feature 'force_virtual_join' get/setForceVirtualJoin - // to overcome join limits by some RDBMS like MySQL. - // - if($model->getForceVirtualJoin()) { - if($this->getType() == $model->getType()) { - $pluginDriver = 'dynamic'; - } else { - $pluginDriver = 'bare'; - } + return $this->nestedModels; } + } - // - // Detect (possible) virtual field configuration right here - // - $virtualField = null; - if(($children = $this->config->get('children')) != null) { - foreach($children as $field => $config) { - if($config['type'] === 'foreign') { - $foreign = $this->config->get('foreign>'.$config['field']); - if($this->config->get('datatype>'.$field) == 'virtual') { - if($thisKey === $config['field']) { - $virtualField = $field; - break; + /** + * Returns the datatype of the given field + * @param modelfield $field + * @return string|null + */ + public function getFieldtype(modelfield $field): ?string + { + $specifier = $field->get(); + if (!isset($this->cachedFieldtype[$specifier])) { + if (($fieldtype = $this->config->get("datatype>" . $specifier))) { + // field in this model + $this->cachedFieldtype[$specifier] = $fieldtype; + } else { + // check nested model configs + foreach ($this->nestedModels as $joinPlugin) { + $fieldtype = $joinPlugin->model->getFieldtype($field); + if ($fieldtype !== null) { + $this->cachedFieldtype[$specifier] = $fieldtype; + return $fieldtype; + } } - } + $this->cachedFieldtype[$specifier] = null; } - } } + return $this->cachedFieldtype[$specifier]; + } - $class = '\\codename\\core\\model\\plugin\\join\\' . $pluginDriver; - array_push($this->nestedModels, new $class($model, $type, $thisKey, $joinKey, $conditions, $virtualField)); - // check for already-added ? - - return $this; + /** + * [getModelfieldInstance description] + * @param string $field [description] + * @return modelfield [description] + * @throws ReflectionException + * @throws exception + */ + protected function getModelfieldInstance(string $field): modelfield + { + return modelfield::getInstance($field); } /** - * state of force_virtual_join feature - * @var bool + * Returns array of fields that exist in the model + * @return array */ - protected $forceVirtualJoin = false; + public function getFields(): array + { + return $this->config->get('field'); + } /** - * Sets the force_virtual_join feature state - * This enables the model to be joined virtually - * to avoid join limits of various RDBMS - * @param bool $state + * [getData description] + * @return array [description] */ - public function setForceVirtualJoin(bool $state) { - $this->forceVirtualJoin = $state; - return $this; + public function getData(): array + { + return $this->data->getData(); } /** - * Gets the current state of the force_virtual_join feature - * @return bool + * I will delete the previously loaded entry. + * @return model + * @throws exception */ - public function getForceVirtualJoin() : bool { - return $this->forceVirtualJoin; + public function entryDelete(): model + { + if ($this->data === null || empty($this->data->getData())) { + throw new exception(self::EXCEPTION_ENTRYDELETE_NOOBJECTLOADED, exception::$ERRORLEVEL_FATAL); + } + $this->delete($this->data->getData($this->getPrimaryKey())); + return $this; } /** - * adds a model using custom parameters - * and optionally using custom extra conditions - * - * this can be used to join models that have no explicit foreign key reference to each other - * - * @param \codename\core\model $model [description] - * @param string $type [description] - * @param string|null $modelField [description] - * @param string|null $referenceField [description] - * @param array $conditions - * @return \codename\core\model [description] + * [addCollectionModel description] + * @param model $model [description] + * @param string|null $modelField [description] + * @return model + * @throws ReflectionException + * @throws exception */ - public function addCustomJoin(\codename\core\model $model, string $type = plugin\join::TYPE_LEFT, ?string $modelField = null, ?string $referenceField = null, array $conditions = []) : \codename\core\model { - $thisKey = $modelField; - $joinKey = $referenceField; + public function addCollectionModel(model $model, string $modelField = null): model + { + if ($this->config->exists('collection')) { + $collectionConfig = null; + + // + // try to determine modelfield by the best-matching collection + // + if (!$modelField) { + if ($this->config->exists('collection')) { + foreach ($this->config->get('collection') as $collectionFieldName => $config) { + if ($config['model'] === $model->getIdentifier()) { + $modelField = $collectionFieldName; + $collectionConfig = $config; + } + } + } + } - // fallback to bare model joining - if($model instanceof \codename\core\model\schemeless\dynamic || $this instanceof \codename\core\model\schemeless\dynamic) { - $pluginDriver = 'dynamic'; - } else { - $pluginDriver = $this->compatibleJoin($model) ? $this->getType() : 'bare'; - } + // + // Still no modelfield + // + if (!$modelField) { + throw new exception('EXCEPTION_UNKNOWN_COLLECTION_MODEL', exception::$ERRORLEVEL_ERROR, [$this->getIdentifier(), $model->getIdentifier()]); + } - $class = '\\codename\\core\\model\\plugin\\join\\' . $pluginDriver; - array_push($this->nestedModels, new $class($model, $type, $thisKey, $joinKey, $conditions)); - return $this; - } + // + // Case where we haven't retrieved the collection config yet + // + if (!$collectionConfig) { + $collectionConfig = $this->config->get('collection>' . $modelField); + } - /** - * [addRecursiveModel description] - * @param \codename\core\model $model [model instance to recurse] - * @param string $selfReferenceField [field used for self-referencing] - * @param string $anchorField [field used as anchor point] - * @param array $anchorConditions [additional anchor conditions - e.g. the starting point] - * @param string $type [type of join] - * @param string|null $modelField [description] - * @param string|null $referenceField [description] - * @param array $conditions [description] - * @return \codename\core\model [description] - */ - public function addRecursiveModel(\codename\core\model $model, string $selfReferenceField, string $anchorField, array $anchorConditions, string $type = plugin\join::TYPE_LEFT, ?string $modelField = null, ?string $referenceField = null, array $conditions = []) : \codename\core\model { - $thisKey = $modelField; - $joinKey = $referenceField; - - // TODO: auto-determine modelField and referenceField / thisKey and joinKey - - if((!$model->config->get('foreign>'.$selfReferenceField.'>model') == $model->getIdentifier() - || !$model->config->get('foreign>'.$selfReferenceField.'>key') == $model->getPrimaryKey()) - && (!$model->config->get('foreign>'.$anchorField.'>model') == $model->getIdentifier() - || !$model->config->get('foreign>'.$anchorField.'>key') == $model->getPrimaryKey()) - ) { - throw new exception('INVALID_RECURSIVE_MODEL_JOIN', exception::$ERRORLEVEL_ERROR); - } - - // fallback to bare model joining - if($model instanceof \codename\core\model\schemeless\dynamic || $this instanceof \codename\core\model\schemeless\dynamic) { - $pluginDriver = 'dynamic'; - } else { - $pluginDriver = $this->compatibleJoin($model) ? $this->getType() : 'bare'; - } - - $class = '\\codename\\core\\model\\plugin\\join\\recursive\\' . $pluginDriver; - array_push($this->nestedModels, new $class($model, $selfReferenceField, $anchorField, $anchorConditions, $type, $thisKey, $joinKey, $conditions)); - return $this; - } + // + // Still no collection config + // + if (!$collectionConfig) { + throw new exception('EXCEPTION_NO_COLLECTION_CONFIG', exception::$ERRORLEVEL_ERROR, $modelField); + } - /** - * [setRecursive description] - * @param string $selfReferenceField [description] - * @param string $anchorField [description] - * @param array $anchorConditions [description] - * @return \codename\core\model [description] - */ - public function setRecursive(string $selfReferenceField, string $anchorField, array $anchorConditions): \codename\core\model { - - if($this->recursive) { - // kill, already active? - throw new exception('EXCEPTION_MODEL_SETRECURSIVE_ALREADY_ENABLED', exception::$ERRORLEVEL_ERROR); - } - - $this->recursive = true; - - if((!$this->config->get('foreign>'.$selfReferenceField.'>model') == $this->getIdentifier() - || !$this->config->get('foreign>'.$selfReferenceField.'>key') == $this->getPrimaryKey()) - && (!$this->config->get('foreign>'.$anchorField.'>model') == $this->getIdentifier() - || !$this->config->get('foreign>'.$anchorField.'>key') == $this->getPrimaryKey()) - ) { - throw new exception('INVALID_RECURSIVE_MODEL_CONFIG', exception::$ERRORLEVEL_ERROR); - } - - $this->recursiveSelfReferenceField = $this->getModelfieldInstance($selfReferenceField); - $this->recursiveAnchorField = $this->getModelfieldInstance($anchorField); - - foreach($anchorConditions as $cond) { - if($cond instanceof \codename\core\model\plugin\filter) { - $this->recursiveAnchorConditions[] = $cond; + if ($collectionConfig['model'] != $model->getIdentifier()) { + throw new exception('EXCEPTION_MODEL_ADDCOLLECTIONMODEL_INCOMPATIBLE', exception::$ERRORLEVEL_ERROR, [$collectionConfig['model'], $model->getIdentifier()]); + } + + $modelFieldInstance = $this->getModelfieldInstance($modelField); + + // Finally, add model + $this->collectionPlugins[$modelFieldInstance->get()] = new collection( + $modelFieldInstance, + $this, + $model + ); } else { - $this->recursiveAnchorConditions[] = $this->createFilterPluginInstance($cond); + throw new exception('EXCEPTION_NO_COLLECTION_KEY', exception::$ERRORLEVEL_ERROR, $this->getIdentifier()); } - } - return $this; + return $this; } /** - * [createFilterPluginInstance description] - * @param array $data [description] - * @return \codename\core\model\plugin\filter [description] + * adds a model using custom parameters + * and optionally using custom extra conditions + * + * this can be used to join models that have no explicit foreign key reference to each other + * + * @param model $model [description] + * @param string $type [description] + * @param string|null $modelField [description] + * @param string|null $referenceField [description] + * @param array $conditions + * @return model [description] */ - protected function createFilterPluginInstance(array $data): \codename\core\model\plugin\filter { - $field = $data['field']; - $value = $data['value']; - $operator = $data['operator']; - $conjunction = $data['conjunction'] ?? null; - $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); - if(\is_array($value)) { - if(\count($value) === 0) { - throw new exception('EXCEPTION_MODEL_CREATEFILTERPLUGININSTANCE_INVALID_VALUE', exception::$ERRORLEVEL_ERROR); + public function addCustomJoin(model $model, string $type = join::TYPE_LEFT, ?string $modelField = null, ?string $referenceField = null, array $conditions = []): model + { + $thisKey = $modelField; + $joinKey = $referenceField; + + // fallback to bare model joining + if ($model instanceof dynamic || $this instanceof dynamic) { + $pluginDriver = 'dynamic'; + } else { + $pluginDriver = $this->compatibleJoin($model) ? $this->getType() : 'bare'; } - return new $class($this->getModelfieldInstance($field), $value, $operator, $conjunction); - } else { - $modelfieldInstance = $this->getModelfieldInstance($field); - return new $class($modelfieldInstance, $this->delimitImproved($modelfieldInstance->get(), $value), $operator, $conjunction); - } + + $class = '\\codename\\core\\model\\plugin\\join\\' . $pluginDriver; + $this->nestedModels[] = new $class($model, $type, $thisKey, $joinKey, $conditions); + return $this; } /** - * [protected description] - * @var bool + * determines if the model is join able + * in the same run (e.g., DB compatibility and stuff) + * @param model $model [the model to check direct join compatibility with] + * @return bool */ - protected $recursive = false; + protected function compatibleJoin(model $model): bool + { + // + // NOTE/CHANGED 2020-07-21: Feature 'force_virtual_join' is checked right here + // to allow virtually joining a table via already available features of the framework. + // This overcomes the problem of join count limitations + // While preserving ORM capabilities + // + // If $model has the force_virtual_join feature enabled, + // this method will return false, no matter if its mysql==mysql or else. + // + return $this->getType() == $model->getType() && !$model->getForceVirtualJoin(); + } /** - * [protected description] - * @var \codename\core\value\text\modelfield|null + * Returns the driver that shall be used for the model + * @return string */ - protected $recursiveSelfReferenceField = null; + protected function getType(): string + { + return static::DB_TYPE; + } /** - * [protected description] - * @var \codename\core\value\text\modelfield|null + * Gets the current state of the force_virtual_join feature + * @return bool */ - protected $recursiveAnchorField = null; + public function getForceVirtualJoin(): bool + { + return $this->forceVirtualJoin; + } /** - * [protected description] - * @var \codename\core\model\plugin\filter[] + * Sets the force_virtual_join feature state + * This enables the model to be joined virtually + * to avoid join limits of various RDBMS + * @param bool $state + * @return model */ - protected $recursiveAnchorConditions = []; + public function setForceVirtualJoin(bool $state): static + { + $this->forceVirtualJoin = $state; + return $this; + } /** - * contains configured join plugin instances for nested models - * @var \codename\core\model\plugin\join[] - */ - protected $nestedModels = array(); + * [addRecursiveModel description] + * @param model $model [model instance to recurse] + * @param string $selfReferenceField [field used for self-referencing] + * @param string $anchorField [field used as anchor point] + * @param array $anchorConditions [additional anchor conditions - e.g., the starting point] + * @param string $type [type of join] + * @param string|null $modelField [description] + * @param string|null $referenceField [description] + * @param array $conditions [description] + * @return model [description] + * @throws exception + */ + public function addRecursiveModel(model $model, string $selfReferenceField, string $anchorField, array $anchorConditions, string $type = join::TYPE_LEFT, ?string $modelField = null, ?string $referenceField = null, array $conditions = []): model + { + $thisKey = $modelField; + $joinKey = $referenceField; - /** - * I load an entry of the given model identified by the $primarykey to the current instance. - * @param string $primaryKey - * @return \codename\core\model - */ - public function entryLoad(string $primaryKey) : \codename\core\model { - $entry = $this->loadByUnique($this->getPrimarykey(), $primaryKey); - if(empty($entry)) { - throw new \codename\core\exception(self::EXCEPTION_ENTRYLOAD_FAILED, \codename\core\exception::$ERRORLEVEL_FATAL); + // TODO: auto-determine modelField and referenceField / thisKey and joinKey + + if ( + ( + !$model->config->get('foreign>' . $selfReferenceField . '>model') == $model->getIdentifier() || + !$model->config->get('foreign>' . $selfReferenceField . '>key') == $model->getPrimaryKey() + ) && ( + !$model->config->get('foreign>' . $anchorField . '>model') == $model->getIdentifier() || + !$model->config->get('foreign>' . $anchorField . '>key') == $model->getPrimaryKey() + ) + ) { + throw new exception('INVALID_RECURSIVE_MODEL_JOIN', exception::$ERRORLEVEL_ERROR); } - $this->entryMake($entry); - return $this; - } - /** - * I save the currently loaded entry to the model storage - * @throws \codename\core\exception - * @return \codename\core\model - */ - public function entrySave() : \codename\core\model { - if($this->data === null || empty($this->data->getData())) { - throw new \codename\core\exception(self::EXCEPTION_ENTRYSAVE_NOOBJECTLOADED, \codename\core\exception::$ERRORLEVEL_FATAL); + // fallback to bare model joining + if ($model instanceof dynamic || $this instanceof dynamic) { + $pluginDriver = 'dynamic'; + } else { + $pluginDriver = $this->compatibleJoin($model) ? $this->getType() : 'bare'; } - $this->saveWithChildren($this->data->getData()); + + $class = '\\codename\\core\\model\\plugin\\join\\recursive\\' . $pluginDriver; + $this->nestedModels[] = new $class($model, $selfReferenceField, $anchorField, $anchorConditions, $type, $thisKey, $joinKey, $conditions); return $this; } /** - * I will overwrite the fields of my model using the $data array - * @param array $data - * @throws \codename\core\exception - * @return \codename\core\model - */ - public function entryUpdate(array $data) : \codename\core\model { - if(count($data) == 0) { - throw new \codename\core\exception(self::EXCEPTION_ENTRYUPDATE_UPDATEELEMENTEMPTY, \codename\core\exception::$ERRORLEVEL_FATAL, null); + * [setRecursive description] + * @param string $selfReferenceField [description] + * @param string $anchorField [description] + * @param array $anchorConditions [description] + * @return model [description] + * @throws ReflectionException + * @throws exception + */ + public function setRecursive(string $selfReferenceField, string $anchorField, array $anchorConditions): model + { + if ($this->recursive) { + // kill, already active? + throw new exception('EXCEPTION_MODEL_SETRECURSIVE_ALREADY_ENABLED', exception::$ERRORLEVEL_ERROR); } - if($this->data === null || empty($this->data->getData())) { - throw new \codename\core\exception(self::EXCEPTION_ENTRYUPDATE_NOOBJECTLOADED, \codename\core\exception::$ERRORLEVEL_FATAL, null); + + $this->recursive = true; + + if ( + ( + !$this->config->get('foreign>' . $selfReferenceField . '>model') == $this->getIdentifier() || + !$this->config->get('foreign>' . $selfReferenceField . '>key') == $this->getPrimaryKey() + ) && ( + !$this->config->get('foreign>' . $anchorField . '>model') == $this->getIdentifier() || + !$this->config->get('foreign>' . $anchorField . '>key') == $this->getPrimaryKey() + ) + ) { + throw new exception('INVALID_RECURSIVE_MODEL_CONFIG', exception::$ERRORLEVEL_ERROR); } - foreach($this->getFields() as $field) { - if(array_key_exists($field, $data)) { - $this->fieldSet($this->getModelfieldInstance($field), $data[$field]); + + $this->recursiveSelfReferenceField = $this->getModelfieldInstance($selfReferenceField); + $this->recursiveAnchorField = $this->getModelfieldInstance($anchorField); + + foreach ($anchorConditions as $cond) { + if ($cond instanceof filter) { + $this->recursiveAnchorConditions[] = $cond; + } else { + $this->recursiveAnchorConditions[] = $this->createFilterPluginInstance($cond); } } + return $this; } /** - * I set a flag (identified by the integer $flagval) to TRUE. - * @param int $flagval - * @throws \codename\core\exception - * @return \codename\core\model + * [createFilterPluginInstance description] + * @param array $data [description] + * @return filter [description] + * @throws ReflectionException + * @throws exception */ - public function entrySetflag(int $flagval) : \codename\core\model { - if($this->data === null || empty($this->data->getData())) { - throw new \codename\core\exception(self::EXCEPTION_ENTRYSETFLAG_NOOBJECTLOADED, \codename\core\exception::$ERRORLEVEL_FATAL, null); - } - if(!$this->config->exists('flag')) { - throw new \codename\core\exception(self::EXCEPTION_ENTRYSETFLAG_NOFLAGSINMODEL, \codename\core\exception::$ERRORLEVEL_FATAL, null); - } - if($flagval < 0) { - // Only allow >= 0 - throw new \codename\core\exception(self::EXCEPTION_INVALID_FLAG_VALUE, \codename\core\exception::$ERRORLEVEL_ERROR, $flagval); + protected function createFilterPluginInstance(array $data): filter + { + $field = $data['field']; + $value = $data['value']; + $operator = $data['operator']; + $conjunction = $data['conjunction'] ?? null; + $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); + if (is_array($value)) { + if (count($value) === 0) { + throw new exception('EXCEPTION_MODEL_CREATEFILTERPLUGININSTANCE_INVALID_VALUE', exception::$ERRORLEVEL_ERROR); + } + return new $class($this->getModelfieldInstance($field), $value, $operator, $conjunction); + } else { + $modelfieldInstance = $this->getModelfieldInstance($field); + return new $class($modelfieldInstance, $this->delimitImproved($modelfieldInstance->get(), $value), $operator, $conjunction); } - - $flag = $this->fieldGet($this->getModelfieldInstance($this->table . '_flag')); - $flag |= $flagval; - $this->fieldSet($this->getModelfieldInstance($this->table . '_flag'), $flag); - return $this; } /** - * I set a flag (identified by the integer $flagval) to FALSE. - * @param int $flagval - * @throws \codename\core\exception - * @return \codename\core\model + * [delimitImproved description] + * @param string $field [description] + * @param null $value + * @return mixed [type] [description] */ - public function entryUnsetflag(int $flagval) : \codename\core\model { - if($this->data === null || empty($this->data->getData())) { - throw new \codename\core\exception(self::EXCEPTION_ENTRYUNSETFLAG_NOOBJECTLOADED, \codename\core\exception::$ERRORLEVEL_FATAL, null); + protected function delimitImproved(string $field, $value = null): mixed + { + $fieldtype = $this->fieldTypeCache[$field] ?? $this->fieldTypeCache[$field] = $this->getFieldtypeImproved($field); + + // CHANGED 2020-12-30 removed \is_string($value) && \strlen($value) == 0 + // Which converted '' to NULL - which is simply wrong. + if ($value === null) { + return null; + } + + if ($fieldtype == 'number') { + if (is_numeric($value)) { + return $value; + } + if (strlen($value) == 0) { + return null; + } + return $value; + } + if ($fieldtype == 'number_natural') { + if (is_int($value)) { + return $value; + } + if (is_string($value) && strlen($value) == 0) { + return null; + } + return (int)$value; } - if(!$this->config->exists('flag')) { - throw new \codename\core\exception(self::EXCEPTION_ENTRYUNSETFLAG_NOFLAGSINMODEL, \codename\core\exception::$ERRORLEVEL_FATAL, null); + if ($fieldtype == 'boolean') { + if (is_string($value) && strlen($value) == 0) { + return null; + } + if ($value) { + return true; + } + return false; } - if($flagval < 0) { - // Only allow >= 0 - throw new \codename\core\exception(self::EXCEPTION_INVALID_FLAG_VALUE, \codename\core\exception::$ERRORLEVEL_ERROR, $flagval); + if (str_starts_with($fieldtype, 'text')) { + if (is_string($value) && strlen($value) == 0) { + return null; + } } - $flag = $this->fieldGet($this->getModelfieldInstance($this->table . '_flag')); - $flag &= ~$flagval; - $this->fieldSet($this->getModelfieldInstance($this->table . '_flag'), $flag); - return $this; + return $value; } - - /** - * outputs a singular and final flag field value - * based on a given starting point - which may also be 0 (no flag) - * as a combination of several flags given (with states) - * this DOES NOT change existing flags, unless they're explicitly specified in another state - */ /** - * outputs a singular and final flag field value - * based on a given starting point - which may also be 0 (no flag) - * as a combination of several flags given (with states) - * this DOES NOT change existing flags, unless they're explicitly specified in another state - * - * @param int $flag - * @param array $flagSettings - * @return int + * [getFieldtypeImproved description] + * @param string $specifier [description] + * @return string|null */ - public function flagfieldValue(int $flag, array $flagSettings) : int { - if(!$this->config->exists('flag')) { - throw new \codename\core\exception(self::EXCEPTION_MODEL_FUNCTION_FLAGFIELDVALUE_NOFLAGSINMODEL, \codename\core\exception::$ERRORLEVEL_FATAL, null); - } - $flags = $this->config->get('flag'); - $validFlagValues = array_values($flags); - foreach($flagSettings as $flagval => $state) { - if(in_array($flagval, $validFlagValues)) { - if($state === true) { - $flag |= $flagval; - } else if($state === false) { - $flag &= ~$flagval; + public function getFieldtypeImproved(string $specifier): ?string + { + if (!isset($this->cachedFieldtype[$specifier])) { + // fieldtype not in current model config + if (($fieldtype = $this->config->get("datatype>" . $specifier))) { + // field in this model + $this->cachedFieldtype[$specifier] = $fieldtype; } else { - // do nothing! + // check nested model configs + foreach ($this->nestedModels as $joinPlugin) { + $fieldtype = $joinPlugin->model->getFieldtypeImproved($specifier); + if ($fieldtype !== null) { + $this->cachedFieldtype[$specifier] = $fieldtype; + return $fieldtype; + } + } + + $this->cachedFieldtype[$specifier] = null; } - } } - return $flag; + return $this->cachedFieldtype[$specifier]; } /** - * Creates an instance - * @param array $modeldata + * I load an entry of the given model identified by the $primarykey to the current instance. + * @param string $primaryKey * @return model - * @todo refactor the constructor for no method args + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception */ - public function __CONSTRUCT(array $modeldata = array()) { - $this->errorstack = new \codename\core\errorstack('VALIDATION'); - $this->modeldata = new \codename\core\config($modeldata); + public function entryLoad(string $primaryKey): model + { + $entry = $this->loadByUnique($this->getPrimaryKey(), $primaryKey); + if (empty($entry)) { + throw new exception(self::EXCEPTION_ENTRYLOAD_FAILED, exception::$ERRORLEVEL_FATAL); + } + $this->entryMake($entry); return $this; } /** - * Initiates a servicing instance for this model - * @return void + * {@inheritDoc} + * @param string $field + * @param string $value + * @return array + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception */ - protected function initServicingInstance() { - // no implementation for base model + public function loadByUnique(string $field, string $value): array + { + $data = $this + ->addFilter($field, $value) + ->setLimit(1) + ->search()->getResult(); + if (count($data) == 0) { + return []; + } + return $data[0]; } /** - * [protected description] - * @var model\servicing + * {@inheritDoc} + * @return array + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception */ - protected $servicingInstance = null; + public function getResult(): array + { + $result = $this->result; - /** - * model data passed during initialization - * @var \codename\core\config - */ - protected $modeldata = null; + if ($result === null) { + $this->result = $this->internalGetResult(); + $result = $this->result; + } + + // execute any bare joins, if set + $result = $this->performBareJoin($result); + + $result = $this->normalizeResult($result); + $this->data = new datacontainer($result); + return $this->data->getData(); + } /** - * - * {@inheritDoc} - * @see \codename\core\model_interface::load($primaryKey) + * internal getResult + * @return array */ - public function load($primaryKey) : array { - return (is_null($primaryKey) ? array() : $this->loadByUnique($this->getPrimarykey(), $primaryKey)); - } + abstract protected function internalGetResult(): array; /** - * Loads the given entry as well as the depending objects - * @param string $primaryKey + * perform a shim / bare metal join + * @param array $result [the resultset] * @return array + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception */ - public function loadAll(string $primaryKey) : array { + protected function performBareJoin(array $result): array + { + if (count($this->getNestedJoins()) == 0) { + return $result; + } - if($this->config->exists("foreign")) { - foreach($this->config->get("foreign") as $reference) { - $model = app::getModel($reference['model']); - if(get_class($model) !== get_class($this)) { - $this->addModel(app::getModel($reference['model'])); + // + // Loop through Joins + // + foreach ($this->getNestedJoins() as $join) { + $nest = $join->model; + + $vKey = null; + if ($this instanceof virtualFieldResultInterface && $this->virtualFieldResult) { + $vKey = $join->virtualField; + } + + // virtual field? + if ($vKey && !$nest->getForceVirtualJoin()) { + // + // NOTE/CHANGED 2020-09-15 Forced virtual joins + // require us to skip performBareJoin at this point in general + // (for both vkey and non-vkey joins) + // + + // + // Skip recursive performBareJoin + // if we have none coming up next + // + if (count($nest->getNestedJoins()) == 0) { + continue; + } + + // make sure vKey is in current fieldlist... + // this is for situations + // where: + // - a virtual field result enabled + // - a vfield config present + // - respective model joined + // - another (bare-joined) model relying on a field + // - but field(s) hidden, e.g., by hideAllFields + $ifl = $this->getInternalIntersectFieldlist(); + + if (!array_key_exists($vKey, $ifl)) { + throw new exception('EXCEPTION_MODEL_PERFORMBAREJOIN_MISSING_VKEY', exception::$ERRORLEVEL_ERROR, [ + 'model' => $this->getIdentifier(), + 'vKey' => $vKey, + ]); + } + + // + // Unwind resultset + // [ item, item, item ] -> [ item[key], item[key], item[key] ] + // + $tResult = array_map(function ($r) use ($vKey) { + return $r[$vKey]; + }, $result); + + // + // Recursively check for bareJoin-able models + // with a subset of the current result + // + $tResult = $nest->performBareJoin($tResult); + + // + // Re-wind resultset + // [ item[key], item[key], item[key] ] -> merge into [ item, item, item ] + // + foreach ($result as $index => &$r) { + $r[$vKey] = array_merge($r[$vKey], $tResult[$index]); + } + } elseif (!$nest->getForceVirtualJoin()) { + // + // NOTE/CHANGED 2020-09-15 Forced virtual joins + // require us to skip performBareJoin at this point in general + // (for both vkey and non-vkey joins) + // + $result = $nest->performBareJoin($result); + } + + // + // check if model is joining compatible + // we explicitly join incompatible models using bare-data here! + // + if (!$this->compatibleJoin($nest) && ($join instanceof executableJoinInterface)) { + $subresult = $nest->search()->getResult(); + + if ($vKey) { + // + // Unwind resultset + // [ item, item, item ] -> [ item[key], item[key], item[key] ] + // + $tResult = array_map(function ($r) use ($vKey) { + return $r[$vKey]; + }, $result); + + // + // Recursively perform the + // with a subset of the current result + // + $tResult = $join->join($tResult, $subresult); + + // + // Re-wind resultset + // [ item[key], item[key], item[key] ] -> merge into [ item, item, item ] + // + foreach ($result as $index => &$r) { + $r[$vKey] = array_merge($tResult[$index]); + } + } else { + $result = $join->join($result, $subresult); } + } elseif (!$this->compatibleJoin($nest) && ($join instanceof dynamicJoinInterface)) { + // + // CHANGED 2020-07-22 vkey handling inside dynamic joins + // Special join handling + // using dynamic join method + // vKey is specified either way (but may be null) + // so the join module may handle the virtual field result + // + $result = $join->dynamicJoin($result, [ + 'vkey' => $vKey, + ]); } } - return $this->addFilter($this->getPrimarykey(), $primaryKey)->search()->getResult()[0]; + return $result; } /** - * [addUseindex description] - * @param array $fields [description] - * @return model [description] + * Normalizes a result. Nests normalizeRow when more than one single row is in the result. + * @param array $result + * @return array + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception */ - public function addUseIndex(array $fields) : model { - throw new \LogicException('Not implemented for this kind of model'); + protected function normalizeResult(array $result): array + { + if (count($result) == 0) { + return []; + } + + // Normalize single row + if (count($result) == 1) { + $result = reset($result); + return [$this->normalizeRow($result)]; + } + + // Normalize each row + foreach ($result as $key => $value) { + $result[$key] = $this->normalizeRow($value); + } + return $result; } /** - * - * {@inheritDoc} - * @see \codename\core\model_interface::addFilter($field, $value, $operator) + * Normalizes a single row of a dataset + * @param array $dataset + * @return array + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception */ - public function addFilter(string $field, $value = null, string $operator = '=', string $conjunction = null) : model { - $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); - if(\is_array($value)) { - if(\count($value) === 0) { - trigger_error('Empty array filter values have no effect on resultset', E_USER_NOTICE); - return $this; + protected function normalizeRow(array $dataset): array + { + if (count($dataset) == 1 && isset($dataset[0])) { + $dataset = $dataset[0]; + } + + foreach ($dataset as $field => $thisRow) { + // Performance optimization (and fix): + // Check for (key == null) first, as it is faster than is_string + // NOTE: checking for !is_string commented-out + // we need to check - at least for booleans (DB provides 0 and 1 instead of true/false) + // if($dataset[$field] === null || !is_string($dataset[$field])) {continue;} + if ($thisRow === null) { + continue; + } + + // special case: we need boolean normalization (0 / 1) + // but otherwise, just skip + if ( + (isset($this->normalizeModelFieldTypeCache[$field]) && ($this->normalizeModelFieldTypeCache[$field] !== 'boolean')) + && !is_string($thisRow) + ) { + continue; + } + + // determine virtuality status of the field + if (!isset($this->normalizeModelFieldTypeVirtualCache[$field])) { + $tVirtualModelField = $this->getModelfieldVirtualInstance($field); + $this->normalizeModelFieldTypeCache[$field] = $this->getFieldtype($tVirtualModelField); + $this->normalizeModelFieldTypeVirtualCache[$field] = $this->normalizeModelFieldTypeCache[$field] === 'virtual'; + } + + /// + /// Fixing a bad performance issue + /// using result-specific model field caching + /// as they're re-constructed EVERY call! + /// + if (!isset($this->normalizeModelFieldCache[$field])) { + if ($this->normalizeModelFieldTypeVirtualCache[$field]) { + $this->normalizeModelFieldCache[$field] = $this->getModelfieldVirtualInstance($field); + } else { + $this->normalizeModelFieldCache[$field] = $this->getModelfieldInstance($field); + } + } + + if (!isset($this->normalizeModelFieldTypeCache[$field])) { + $this->normalizeModelFieldTypeCache[$field] = $this->getFieldtype($this->normalizeModelFieldCache[$field]); + } + + // + // HACK: only normalize boolean fields + // + if ($this->normalizeModelFieldTypeCache[$field] === 'boolean') { + $dataset[$field] = $this->importField($this->normalizeModelFieldCache[$field], $thisRow); + continue; + } + + if (!isset($this->normalizeModelFieldTypeStructureCache[$field])) { + $this->normalizeModelFieldTypeStructureCache[$field] = str_contains($this->normalizeModelFieldTypeCache[$field], 'structu'); + } + + if ($this->normalizeModelFieldTypeStructureCache[$field] && !is_array($thisRow)) { + $dataset[$field] = $thisRow == null ? null : app::object2array(json_decode($thisRow, false)/*, 512, JSON_UNESCAPED_UNICODE)*/); } - \array_push($this->filter, new $class($this->getModelfieldInstance($field), $value, $operator, $conjunction)); - } else { - $modelfieldInstance = $this->getModelfieldInstance($field); - \array_push($this->filter, new $class($modelfieldInstance, $this->delimitImproved($modelfieldInstance->get(), $value), $operator, $conjunction)); } - return $this; + return $dataset; } /** - * add a custom filter plugin - * @param \codename\core\model\plugin\filter $filterPlugin [description] - * @return model [description] + * [getModelfieldVirtualInstance description] + * @param string $field [description] + * @return modelfield [description] + * @throws ReflectionException + * @throws exception */ - public function addFilterPlugin(\codename\core\model\plugin\filter $filterPlugin) : model { - array_push($this->filter, $filterPlugin); - return $this; + protected function getModelfieldVirtualInstance(string $field): modelfield + { + return virtual::getInstance($field); } - /** - * [addFilterPluginCollection description] - * @param \codename\core\model\plugin\filter\filterInterface[] $filterPlugins [array of filter plugin instances] - * @param string $groupOperator [operator to be used between all collection items] - * @param string $groupName [filter group name] - * @param string|null $conjunction [conjunction to be used inside a filter group] - * @return model + * Converts the given field, and it's value from a human-readable format into a storage format + * @param modelfield $field + * @param mixed $value + * @return mixed + * @throws DateMalformedStringException + * @throws exception */ - public function addFilterPluginCollection(array $filterPlugins, string $groupOperator = 'AND', string $groupName = 'default', string $conjunction = null) : model { - $filterCollection = array(); - foreach($filterPlugins as $filter) { - if($filter instanceof \codename\core\model\plugin\filter\filterInterface - || $filter instanceof \codename\core\model\plugin\managedFilterInterface) { - $filterCollection[] = $filter; - } else { - throw new exception('MODEL_INVALID_FILTER_PLUGIN', exception::$ERRORLEVEL_ERROR); + protected function importField(modelfield $field, mixed $value = null): mixed + { + $fieldType = $this->importFieldTypeCache[$field->get()] ?? $this->importFieldTypeCache[$field->get()] = $this->getFieldtype($field); + switch ($fieldType) { + case 'number_natural': + if (is_string($value) && strlen($value) === 0) { + return null; + } + break; + case 'boolean': + // allow null booleans + // may be necessary for conditional unique keys + if (is_null($value)) { + return $value; + } + // pure boolean + if (is_bool($value)) { + return $value; + } + // int: 0 or 1 + if (is_int($value)) { + if ($value !== 1 && $value !== 0) { + throw new exception('EXCEPTION_MODEL_IMPORTFIELD_BOOLEAN_INVALID', exception::$ERRORLEVEL_ERROR, [ + 'field' => $field->get(), + 'value' => $value, + ]); + } + return $value === 1; + } + // string boolean + if (is_string($value)) { + // fallback, empty string + if (strlen($value) === 0) { + return null; + } + if ($value === '1') { + return true; + } elseif ($value === '0') { + return false; + } elseif ($value === 'true') { + return true; + } elseif ($value === 'false') { + return false; + } + } + // fallback + return false; + case 'text_date': + if (is_null($value)) { + return $value; + } + // automatically convert input value + return (new DateTime($value))->format('Y-m-d'); } - } - if(count($filterCollection) > 0) { - $this->filterCollections[$groupName][] = array( - 'operator' => $groupOperator, - 'filters' => $filterCollection, - 'conjunction' => $conjunction - ); - } - return $this; + return $value; } /** * * {@inheritDoc} - * @see \codename\core\model_interface::addFilterList($field, $value, $operator) + * @see model_interface::setLimit */ - public function addFilterList(string $field, $value = null, string $operator = '=', string $conjunction = null) : model { - $class = '\\codename\\core\\model\\plugin\\filterlist\\' . $this->getType(); - // NOTE: the value becomes into model\schematic\sql checked - array_push($this->filter, new $class($this->getModelfieldInstance($field), $value, $operator, $conjunction)); + public function setLimit(int $limit): model + { + $class = '\\codename\\core\\model\\plugin\\limit\\' . $this->getType(); + $this->limit = new $class($limit); return $this; } /** - * [addAggregateFilter description] - * @param string $field [description] - * @param string|int|bool|null $value [description] - * @param string $operator [description] - * @param string|null $conjunction [description] - * @return model [description] + * + * {@inheritDoc} + * @param string $field + * @param mixed|null $value + * @param string $operator + * @param string|null $conjunction + * @return model + * @throws ReflectionException + * @throws exception + * @see model_interface::addFilter, $value, $operator) */ - public function addAggregateFilter(string $field, $value = null, string $operator = '=', string $conjunction = null) : model { - $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); - if(is_array($value)) { - if(count($value) == 0) { - trigger_error('Empty array filter values have no effect on resultset', E_USER_NOTICE); - return $this; - } - array_push($this->aggregateFilter, new $class($this->getModelfieldInstance($field), $value, $operator, $conjunction)); - } else { - $modelfieldInstance = $this->getModelfieldInstance($field); - array_push($this->aggregateFilter, new $class($modelfieldInstance, $this->delimitImproved($modelfieldInstance->get(), $value), $operator, $conjunction)); - } - return $this; + public function addFilter(string $field, mixed $value = null, string $operator = '=', string $conjunction = null): model + { + $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); + if (is_array($value)) { + if (count($value) === 0) { + trigger_error('Empty array filter values have no effect on resultset'); + return $this; + } + $this->filter[] = new $class($this->getModelfieldInstance($field), $value, $operator, $conjunction); + } else { + $modelfieldInstance = $this->getModelfieldInstance($field); + $this->filter[] = new $class($modelfieldInstance, $this->delimitImproved($modelfieldInstance->get(), $value), $operator, $conjunction); + } + return $this; } /** - * [addDefaultAggregateFilter description] - * @param string $field [description] - * @param string|int|bool|null $value [description] - * @param string $operator [description] - * @param string|null $conjunction [description] - * @return model [description] + * I am capable of creating a new entry for the current model by the given array $data. + * @param array $data + * @return model */ - public function addDefaultAggregateFilter(string $field, $value = null, string $operator = '=', string $conjunction = null) : model { - $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); - if(is_array($value)) { - if(count($value) == 0) { - trigger_error('Empty array filter values have no effect on resultset', E_USER_NOTICE); - return $this; - } - $instance = new $class($this->getModelfieldInstance($field), $value, $operator, $conjunction); - array_push($this->aggregateFilter, $instance); - array_push($this->defaultAggregateFilter, $instance); - } else { - $modelfieldInstance = $this->getModelfieldInstance($field); - $instance = new $class($modelfieldInstance, $this->delimitImproved($modelfieldInstance->get(), $value), $operator, $conjunction); - array_push($this->aggregateFilter, $instance); - array_push($this->defaultAggregateFilter, $instance); - } - return $this; + public function entryMake(array $data = []): model + { + $this->data = new datacontainer($data); + return $this; } /** - * [addAggregateFilterPlugin description] - * @param \codename\core\model\plugin\aggregatefilter $filterPlugin [description] - * @return model [description] + * I save the currently loaded entry to the model storage + * @return model + * @throws exception */ - public function addAggregateFilterPlugin(\codename\core\model\plugin\aggregatefilter $filterPlugin) : model { - array_push($this->aggregateFilter, $filterPlugin); - return $this; + public function entrySave(): model + { + if ($this->data === null || empty($this->data->getData())) { + throw new exception(self::EXCEPTION_ENTRYSAVE_NOOBJECTLOADED, exception::$ERRORLEVEL_FATAL); + } + $this->saveWithChildren($this->data->getData()); + return $this; } /** - * - * {@inheritDoc} - * @see \codename\core\model_interface::addFilter($field, $value, $operator) + * I will overwrite the fields of my model using the $data array + * @param array $data + * @return model + * @throws ReflectionException + * @throws exception */ - public function addFieldFilter(string $field, string $otherField, string $operator = '=', string $conjunction = null) : model { - $class = '\\codename\\core\\model\\plugin\\fieldfilter\\' . $this->getType(); - array_push($this->filter, new $class($this->getModelfieldInstance($field), $this->getModelfieldInstance($otherField), $operator, $conjunction)); + public function entryUpdate(array $data): model + { + if (count($data) == 0) { + throw new exception(self::EXCEPTION_ENTRYUPDATE_UPDATEELEMENTEMPTY, exception::$ERRORLEVEL_FATAL, null); + } + if ($this->data === null || empty($this->data->getData())) { + throw new exception(self::EXCEPTION_ENTRYUPDATE_NOOBJECTLOADED, exception::$ERRORLEVEL_FATAL, null); + } + foreach ($this->getFields() as $field) { + if (array_key_exists($field, $data)) { + $this->fieldSet($this->getModelfieldInstance($field), $data[$field]); + } + } return $this; } - /** - * Provides an additional collection of filter arrays - * to be used in queries. - * like [0] => { - * operator => 'AND' - * filters => { filter1, filter2 } - * } - * ... - * which are chained as groups with the default operator (AND - or other, if defined) - * and chained internally as defined via joinMethod - * @var array + * I will set the given $field's value to $value of the previously loaded dataset / entry. + * @param modelfield $field + * @param mixed $value + * @return model + * @throws exception */ - protected $filterCollections = array(); + public function fieldSet(modelfield $field, mixed $value): model + { + if (!$this->fieldExists($field)) { + throw new exception(self::EXCEPTION_FIELDSET_FIELDNOTFOUNDINMODEL, exception::$ERRORLEVEL_FATAL, $field); + } + if ($this->data === null || empty($this->data->getData())) { + throw new exception(self::EXCEPTION_FIELDSET_NOOBJECTLOADED, exception::$ERRORLEVEL_FATAL); + } + $this->data->setData($field->get(), $value); + return $this; + } /** - * Contains instances of the filters that will be used again after resetting the model - * @var array + * Returns true if the given $field exists in this model's configuration + * @param modelfield $field + * @return bool */ - protected $defaultfilterCollections = array(); - - /** - * Adds a grouped collection of filters to the underlying filter collection - * this is used for changing operators (AND/OR/...) and grouping several filters (where statements) - * @TODO: make this better, could also use valueobjects? - * @param array $filters [array of array( 'field' => ..., 'value' => ... )-elements] - * @param string $groupOperator [e.g. 'AND' or 'OR'] - */ - public function addFilterCollection(array $filters, string $groupOperator = 'AND', string $groupName = 'default', string $conjunction = null) : model { - $filterCollection = array(); - foreach($filters as $filter) { - $field = $filter['field']; - $value = $filter['value']; - $operator = $filter['operator']; - $filter_conjunction = $filter['conjunction'] ?? null; - $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); - if(is_array($value)) { - if(count($value) == 0) { - trigger_error('Empty array filter values have no effect on resultset', E_USER_NOTICE); - continue; + protected function fieldExists(modelfield $field): bool + { + if ($field->getTable() != null) { + if ($field->getTable() == ($this->table ?? null)) { + return in_array($field->get(), $this->getFields()); + } else { + foreach ($this->getNestedJoins() as $join) { + if ($join->model->fieldExists($field)) { + return true; + } + } } - array_push($filterCollection, new $class($this->getModelfieldInstance($field), $value, $operator, $filter_conjunction)); - } else { - $modelfieldInstance = $this->getModelfieldInstance($field); - array_push($filterCollection, new $class($modelfieldInstance, $this->delimitImproved($modelfieldInstance->get(), $value), $operator, $filter_conjunction)); } - } - if(count($filterCollection) > 0) { - $this->filterCollections[$groupName][] = array( - 'operator' => $groupOperator, - 'filters' => $filterCollection, - 'conjunction' => $conjunction - ); - } - return $this; + return in_array($field->get(), $this->getFields()); } /** - * [addDefaultFilterCollection description] - * @param array $filters [array of filters] - * @param string|null $groupOperator [operator inside the group items] - * @param string $groupName [name of group to usage across models] - * @param string|null $conjunction [conjunction of this group, inside the group of same-name filtercollections] - * @return model [description] - */ - public function addDefaultFilterCollection(array $filters, string $groupOperator = null, string $groupName = 'default', string $conjunction = null) : model { - $filterCollection = array(); - foreach($filters as $filter) { - $field = $filter['field']; - $value = $filter['value']; - $operator = $filter['operator']; - $filter_conjunction = $filter['conjunction'] ?? null; - $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); - if(is_array($value)) { - if(count($value) == 0) { - trigger_error('Empty array filter values have no effect on resultset', E_USER_NOTICE); - continue; - } - array_push($filterCollection, new $class($this->getModelfieldInstance($field), $value, $operator, $filter_conjunction)); - } else { - $modelfieldInstance = $this->getModelfieldInstance($field); - array_push($filterCollection, new $class($modelfieldInstance, $this->delimitImproved($modelfieldInstance->get(), $value), $operator, $filter_conjunction)); + * I set a flag (identified by the integer $flagval) to TRUE. + * @param int $flagval + * @return model + * @throws ReflectionException + * @throws exception + */ + public function entrySetFlag(int $flagval): model + { + if ($this->data === null || empty($this->data->getData())) { + throw new exception(self::EXCEPTION_ENTRYSETFLAG_NOOBJECTLOADED, exception::$ERRORLEVEL_FATAL, null); } - } - if(\count($filterCollection) > 0) { - $this->defaultfilterCollections[$groupName][] = array( - 'operator' => $groupOperator, - 'filters' => $filterCollection, - 'conjunction' => $conjunction - ); - $this->filterCollections[$groupName][] = array( - 'operator' => $groupOperator, - 'filters' => $filterCollection, - 'conjunction' => $conjunction - ); - } - return $this; + if (!$this->config->exists('flag')) { + throw new exception(self::EXCEPTION_ENTRYSETFLAG_NOFLAGSINMODEL, exception::$ERRORLEVEL_FATAL, null); + } + if ($flagval < 0) { + // Only allow >= 0 + throw new exception(self::EXCEPTION_INVALID_FLAG_VALUE, exception::$ERRORLEVEL_ERROR, $flagval); + } + + $flag = $this->fieldGet($this->getModelfieldInstance($this->table . '_flag')); + $flag |= $flagval; + $this->fieldSet($this->getModelfieldInstance($this->table . '_flag'), $flag); + return $this; } /** - * - * {@inheritDoc} - * @see \codename\core\model_interface::addDefaultfilter($field, $value, $operator) + * I will return the given $field's value of the previously loaded dataset. + * @param modelfield $field + * @return mixed + * @throws exception */ - public function addDefaultfilter(string $field, $value = null, string $operator = '=', string $conjunction = null) : model { - $field = $this->getModelfieldInstance($field); - // if(!$this->fieldExists($field)) { - // throw new \codename\core\exception(self::EXCEPTION_ADDDEFAULTFILTER_FIELDNOTFOUND, \codename\core\exception::$ERRORLEVEL_FATAL, $field); - // } - $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); + public function fieldGet(modelfield $field): mixed + { + if (!$this->fieldExists($field)) { + throw new exception(self::EXCEPTION_FIELDGET_FIELDNOTFOUNDINMODEL, exception::$ERRORLEVEL_FATAL, $field); + } + if ($this->data === null || empty($this->data->getData())) { + throw new exception(self::EXCEPTION_FIELDGET_NOOBJECTLOADED, exception::$ERRORLEVEL_FATAL); + } + return $this->data->getData($field->get()); + } - if(is_array($value)) { - if(count($value) == 0) { - trigger_error('Empty array filter values have no effect on resultset', E_USER_NOTICE); - return $this; - } - $instance = new $class($field, $value, $operator, $conjunction); - array_push($this->defaultfilter, $instance); - array_push($this->filter, $instance); - } else { - $instance = new $class($field, $this->delimit($field, $value), $operator, $conjunction); - array_push($this->defaultfilter, $instance); - array_push($this->filter, $instance); + /** + * I set a flag (identified by the integer $flagval) to FALSE. + * @param int $flagval + * @return model + * @throws ReflectionException + * @throws exception + */ + public function entryUnsetFlag(int $flagval): model + { + if ($this->data === null || empty($this->data->getData())) { + throw new exception(self::EXCEPTION_ENTRYUNSETFLAG_NOOBJECTLOADED, exception::$ERRORLEVEL_FATAL, null); + } + if (!$this->config->exists('flag')) { + throw new exception(self::EXCEPTION_ENTRYUNSETFLAG_NOFLAGSINMODEL, exception::$ERRORLEVEL_FATAL, null); + } + if ($flagval < 0) { + // Only allow >= 0 + throw new exception(self::EXCEPTION_INVALID_FLAG_VALUE, exception::$ERRORLEVEL_ERROR, $flagval); } + $flag = $this->fieldGet($this->getModelfieldInstance($this->table . '_flag')); + $flag &= ~$flagval; + $this->fieldSet($this->getModelfieldInstance($this->table . '_flag'), $flag); return $this; } /** + * outputs a singular and final flag field value. + * based on a given starting point - which may also be 0 (no flag). + * as a combination of several flags given (with states). + * this DOES NOT change existing flags unless they're explicitly specified in another state. * - * {@inheritDoc} - * @see \codename\core\model_interface::addDefaultfilter($field, $value, $operator) + * @param int $flag + * @param array $flagSettings + * @return int + * @throws exception */ - public function addDefaultfilterlist(string $field, $value = null, string $operator = '=', string $conjunction = null) : model { - $field = $this->getModelfieldInstance($field); - // if(!$this->fieldExists($field)) { - // throw new \codename\core\exception(self::EXCEPTION_ADDDEFAULTFILTER_FIELDNOTFOUND, \codename\core\exception::$ERRORLEVEL_FATAL, $field); - // } - $class = '\\codename\\core\\model\\plugin\\filterlist\\' . $this->getType(); - - if(is_array($value)) { - if(count($value) == 0) { - trigger_error('Empty array filter values have no effect on resultset', E_USER_NOTICE); - return $this; + public function flagfieldValue(int $flag, array $flagSettings): int + { + if (!$this->config->exists('flag')) { + throw new exception(self::EXCEPTION_MODEL_FUNCTION_FLAGFIELDVALUE_NOFLAGSINMODEL, exception::$ERRORLEVEL_FATAL, null); + } + $flags = $this->config->get('flag'); + $validFlagValues = array_values($flags); + foreach ($flagSettings as $flagval => $state) { + if (in_array($flagval, $validFlagValues)) { + if ($state === true) { + $flag |= $flagval; + } elseif ($state === false) { + $flag &= ~$flagval; + } else { + // do nothing! + } } - $instance = new $class($field, $value, $operator, $conjunction); - array_push($this->defaultfilter, $instance); - array_push($this->filter, $instance); - } else { - $instance = new $class($field, $this->delimit($field, $value), $operator, $conjunction); - array_push($this->defaultfilter, $instance); - array_push($this->filter, $instance); } - return $this; + return $flag; } /** * * {@inheritDoc} - * @see \codename\core\model_interface::addOrder($field, $order) + * @param mixed $primaryKey + * @return array + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + * @see model_interface::load */ - public function addOrder(string $field, string $order = 'ASC') : model { - $field = $this->getModelfieldInstanceRecursive($field) ?? $this->getModelfieldInstance($field); - if(!$this->fieldExists($field)) { - // check for existance of a calculated field! - $found = false; - foreach($this->fieldlist as $f) { - if($f->field == $field) { - $found = true; - break; - } - } - - if(!$found) { - throw new \codename\core\exception(self::EXCEPTION_ADDORDER_FIELDNOTFOUND, \codename\core\exception::$ERRORLEVEL_FATAL, $field); - } - } - $class = '\\codename\\core\\model\\plugin\\order\\' . $this->getType(); - array_push($this->order, new $class($field, $order)); - return $this; + public function load(mixed $primaryKey): array + { + return (is_null($primaryKey) ? [] : $this->loadByUnique($this->getPrimaryKey(), $primaryKey)); } /** - * [addOrderPlugin description] - * @param \codename\core\model\plugin\order $orderPlugin [description] - * @return model [description] + * Loads the given entry as well as the depending on objects + * @param string $primaryKey + * @return array + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception */ - public function addOrderPlugin(\codename\core\model\plugin\order $orderPlugin) : model { - array_push($this->order, $orderPlugin); - return $this; + public function loadAll(string $primaryKey): array + { + if ($this->config->exists("foreign")) { + foreach ($this->config->get("foreign") as $reference) { + $model = app::getModel($reference['model']); + if (get_class($model) !== get_class($this)) { + $this->addModel(app::getModel($reference['model'])); + } + } + } + return $this->addFilter($this->getPrimaryKey(), $primaryKey)->search()->getResult()[0]; } /** - * - * {@inheritDoc} - * @see \codename\core\model_interface::addField($field) + * [addModel description] + * @param model $model [description] + * @param string $type [description] + * @param string|null $modelField [description] + * @param string|null $referenceField [description] + * @return model [description] + * @throws exception */ - public function addField(string $field, string $alias = null) : model { - if(strpos($field, ',') !== false) { - if($alias) { - // This is impossible, multiple fields and a singular alias. - // We won't support (field1, field2), (alias1, alias2) in this method - throw new exception('EXCEPTION_ADDFIELD_ALIAS_ON_MULTIPLE_FIELDS', exception::$ERRORLEVEL_ERROR, [ 'field' => $field, 'alias' => $alias ]); + public function addModel(model $model, string $type = join::TYPE_LEFT, string $modelField = null, string $referenceField = null): model + { + $thisKey = null; + $joinKey = null; + + $conditions = []; + + // + // model field provided + // + if ($modelField != null) { + // modelField is already provided + $thisKey = $modelField; + + // look for reference field in foreign key config + $fkeyConfig = $this->config->get('foreign>' . $modelField); + if ($fkeyConfig != null) { + if ($referenceField == null || $referenceField == $fkeyConfig['key']) { + $joinKey = $fkeyConfig['key']; + $conditions = $fkeyConfig['condition'] ?? []; + } else { + // reference field is not equal + // e.g., you're trying to join on unjoinable fields + // throw new exception('EXCEPTION_MODEL_SQL_ADDMODEL_INVALID_REFERENCEFIELD', exception::$ERRORLEVEL_ERROR, array($this->getIdentifier(), $referenceField)); + } + } else { + // we're missing the foreignkey config for the field provided + // throw new exception('EXCEPTION_MODEL_SQL_ADDMODEL_UNKNOWN_FOREIGNKEY_CONFIG', exception::$ERRORLEVEL_ERROR, array($this->getIdentifier(), $modelField)); } - foreach(explode(',', $field) as $myField) { - $this->addField(trim($myField)); + } elseif ($this->config->exists('foreign')) { + foreach ($this->config->get('foreign') as $fkeyName => $fkeyConfig) { + // if we found compatible models + if ($fkeyConfig['model'] == $model->getIdentifier()) { + if (is_array($fkeyConfig['key'])) { + $thisKey = array_keys($fkeyConfig['key']); // current keys + $joinKey = array_values($fkeyConfig['key']); // keys of a foreign model + } else { + $thisKey = $fkeyName; + if ($referenceField == null || $referenceField == $fkeyConfig['key']) { + $joinKey = $fkeyConfig['key']; + } + } + $conditions = $fkeyConfig['condition'] ?? []; + break; + } } - return $this; } - $field = $this->getModelfieldInstance($field); - if(!$this->fieldExists($field)) { - throw new \codename\core\exception(self::EXCEPTION_ADDFIELD_FIELDNOTFOUND, \codename\core\exception::$ERRORLEVEL_FATAL, $field); + // Try Reverse Join + if (($thisKey == null) || ($joinKey == null)) { + if ($model->config->exists('foreign')) { + foreach ($model->config->get('foreign') as $fkeyName => $fkeyConfig) { + if ($fkeyConfig['model'] == $this->getIdentifier()) { + if ($referenceField == null || $referenceField == $fkeyName) { + if ($thisKey == null || $thisKey == $fkeyConfig['key']) { + $joinKey = $fkeyName; + } + if ($joinKey == null || $joinKey == $fkeyName) { + $thisKey = $fkeyConfig['key']; + } + $conditions = $fkeyConfig['condition'] ?? []; + break; + } + } + } + } } - $class = '\\codename\\core\\model\\plugin\\field\\' . $this->getType(); - $alias = $alias ? $this->getModelfieldInstance($alias) : null; - $this->fieldlist[] = new $class($field, $alias); - if (!$alias && in_array($field->getValue(), $this->hiddenFields)) { - $fieldKey = array_search($field->getValue(), $this->hiddenFields); - unset($this->hiddenFields[$fieldKey]); + if (($thisKey == null) || ($joinKey == null)) { + throw new exception('EXCEPTION_MODEL_ADDMODEL_INVALID_OPERATION', exception::$ERRORLEVEL_ERROR, [$this->getIdentifier(), $model->getIdentifier(), $modelField, $referenceField]); + } + + // fallback to bare model joining + if ($model instanceof dynamic || $this instanceof dynamic) { + $pluginDriver = 'dynamic'; + } else { + $pluginDriver = $this->compatibleJoin($model) ? $this->getType() : 'bare'; + } + + // + // FEATURE/CHANGED 2020-07-21: + // Added feature 'force_virtual_join' get/setForceVirtualJoin + // to overcome join limits by some RDBMS like MySQL. + // + if ($model->getForceVirtualJoin()) { + if ($this->getType() == $model->getType()) { + $pluginDriver = 'dynamic'; + } else { + $pluginDriver = 'bare'; + } + } + + // + // Detect (possible) virtual field configuration right here + // + $virtualField = null; + if (($children = $this->config->get('children')) != null) { + foreach ($children as $field => $config) { + if ($config['type'] === 'foreign') { + if ($this->config->get('datatype>' . $field) == 'virtual') { + if ($thisKey === $config['field']) { + $virtualField = $field; + break; + } + } + } + } } + + $class = '\\codename\\core\\model\\plugin\\join\\' . $pluginDriver; + $this->nestedModels[] = new $class($model, $type, $thisKey, $joinKey, $conditions, $virtualField); + // check for already-added? + return $this; } /** - * virtual field functions - * @var callable[] - */ - protected $virtualFields = []; - - /** - * [addVirtualField description] - * @param string $field [description] - * @param callable $fieldFunction [description] - * @return model [this instance] + * [addUseindex description] + * @param array $fields [description] + * @return model [description] */ - public function addVirtualField(string $field, callable $fieldFunction) : model { - $this->virtualFields[$field] = $fieldFunction; - return $this; + public function addUseIndex(array $fields): model + { + throw new LogicException('Not implemented for this kind of model'); } /** - * [handleVirtualFields description] - * @param array $dataset [description] - * @return array [description] + * add a custom filter plugin + * @param filter $filterPlugin [description] + * @return model [description] */ - public function handleVirtualFields(array $dataset) : array { - foreach($this->virtualFields as $field => $function) { - $dataset[$field] = $function($dataset); - } - return $dataset; + public function addFilterPlugin(filter $filterPlugin): model + { + $this->filter[] = $filterPlugin; + return $this; } /** - * - * {@inheritDoc} - * @see \codename\core\model_interface::hideField($field) + * [addFilterPluginCollection description] + * @param array $filterPlugins [array of filter plugin instances] + * @param string $groupOperator [operator to be used between all collection items] + * @param string $groupName [filter group name] + * @param string|null $conjunction [conjunction to be used inside a filter group] + * @return model + * @throws exception */ - public function hideField(string $field) : model { - if(strpos($field, ',') !== false) { - foreach(explode(',', $field) as $myField) { - $this->hideField(trim($myField)); + public function addFilterPluginCollection(array $filterPlugins, string $groupOperator = 'AND', string $groupName = 'default', string $conjunction = null): model + { + $filterCollection = []; + foreach ($filterPlugins as $filter) { + if ($filter instanceof filterInterface || $filter instanceof managedFilterInterface) { + $filterCollection[] = $filter; + } else { + throw new exception('MODEL_INVALID_FILTER_PLUGIN', exception::$ERRORLEVEL_ERROR); } - return $this; } - $this->hiddenFields[] = $field; + if (count($filterCollection) > 0) { + $this->filterCollections[$groupName][] = [ + 'operator' => $groupOperator, + 'filters' => $filterCollection, + 'conjunction' => $conjunction, + ]; + } return $this; } /** - * [hideAllFields description] - * @return model [description] + * @param string $field + * @param null $value + * @param string $operator + * @param string|null $conjunction + * @return model + * @throws ReflectionException + * @throws exception + * @see model_interface::addFilterList, $value, $operator) */ - public function hideAllFields() : model { - foreach($this->getFields() as $field) { - $this->hideField($field); - } - return $this; + public function addFilterList(string $field, $value = null, string $operator = '=', string $conjunction = null): model + { + $class = '\\codename\\core\\model\\plugin\\filterlist\\' . $this->getType(); + // NOTE: the value becomes into model\schematic\sql checked + $this->filter[] = new $class($this->getModelfieldInstance($field), $value, $operator, $conjunction); + return $this; } /** - * groupBy fields - * @var \codename\core\model\plugin\group[] + * [addAggregateFilter description] + * @param string $field [description] + * @param mixed $value [description] + * @param string $operator [description] + * @param string|null $conjunction [description] + * @return model [description] + * @throws ReflectionException + * @throws exception */ - protected $group = array(); + public function addAggregateFilter(string $field, mixed $value = null, string $operator = '=', string $conjunction = null): model + { + $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); + if (is_array($value)) { + if (count($value) == 0) { + trigger_error('Empty array filter values have no effect on resultset'); + return $this; + } + $this->aggregateFilter[] = new $class($this->getModelfieldInstance($field), $value, $operator, $conjunction); + } else { + $modelfieldInstance = $this->getModelfieldInstance($field); + $this->aggregateFilter[] = new $class($modelfieldInstance, $this->delimitImproved($modelfieldInstance->get(), $value), $operator, $conjunction); + } + return $this; + } /** - * @inheritDoc + * [addDefaultAggregateFilter description] + * @param string $field [description] + * @param mixed $value [description] + * @param string $operator [description] + * @param string|null $conjunction [description] + * @return model [description] + * @throws ReflectionException + * @throws exception */ - public function addGroup(string $field) : model { - $field = $this->getModelfieldInstance($field); - $aliased = false; - if(!$this->fieldExists($field)) { - $foundInFieldlist = false; - foreach($this->fieldlist as $checkField) { - if($checkField->field->get() == $field->get()) { - $foundInFieldlist = true; - - // At this point, check for 'virtuality' of a field - // e.g. aliased, calculated and aggregates - // (the latter ones are usually calculated fields) - // - if($checkField instanceof \codename\core\model\plugin\calculatedfield - || $checkField instanceof \codename\core\model\plugin\aggregate) { - $aliased = true; + public function addDefaultAggregateFilter(string $field, mixed $value = null, string $operator = '=', string $conjunction = null): model + { + $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); + if (is_array($value)) { + if (count($value) == 0) { + trigger_error('Empty array filter values have no effect on resultset'); + return $this; } - break; - } - } - if($foundInFieldlist === false) { - throw new \codename\core\exception(self::EXCEPTION_ADDGROUP_FIELDDOESNOTEXIST, \codename\core\exception::$ERRORLEVEL_FATAL, array($field, $this->fieldlist)); + $instance = new $class($this->getModelfieldInstance($field), $value, $operator, $conjunction); + } else { + $modelfieldInstance = $this->getModelfieldInstance($field); + $instance = new $class($modelfieldInstance, $this->delimitImproved($modelfieldInstance->get(), $value), $operator, $conjunction); } - } - $class = '\\codename\\core\\model\\plugin\\group\\' . $this->getType(); - $groupInstance = new $class($field); - $groupInstance->aliased = $aliased; - $this->group[] = $groupInstance; - return $this; + $this->aggregateFilter[] = $instance; + $this->defaultAggregateFilter[] = $instance; + return $this; } /** - * exception thrown when trying to add a nonexisting field to grouping parameters - * @var string + * [addAggregateFilterPlugin description] + * @param aggregatefilter $filterPlugin [description] + * @return model [description] */ - const EXCEPTION_ADDGROUP_FIELDDOESNOTEXIST = "EXCEPTION_ADDGROUP_FIELDDOESNOTEXIST"; + public function addAggregateFilterPlugin(aggregatefilter $filterPlugin): model + { + $this->aggregateFilter[] = $filterPlugin; + return $this; + } /** - * @inheritDoc + * @param string $field + * @param string $otherField + * @param string $operator + * @param string|null $conjunction + * @return model + * @throws ReflectionException + * @throws exception + * @see model_interface::addFilter, $value, $operator) */ - public function addCalculatedField(string $field, string $calculation) : model { - $field = $this->getModelfieldInstance($field); - // only check for EXISTANCE of the fieldname, cancel if so - we don't want duplicates! - if($this->fieldExists($field)) { - throw new \codename\core\exception(self::EXCEPTION_ADDCALCULATEDFIELD_FIELDALREADYEXISTS, \codename\core\exception::$ERRORLEVEL_FATAL, $field); - } - $class = '\\codename\\core\\model\\plugin\\calculatedfield\\' . $this->getType(); - $this->fieldlist[] = new $class($field, $calculation); - return $this; + public function addFieldFilter(string $field, string $otherField, string $operator = '=', string $conjunction = null): model + { + $class = '\\codename\\core\\model\\plugin\\fieldfilter\\' . $this->getType(); + $this->filter[] = new $class($this->getModelfieldInstance($field), $this->getModelfieldInstance($otherField), $operator, $conjunction); + return $this; } /** - * [removeCalculatedField description] - * @param string $field [description] - * @return model [description] + * Adds a grouped collection of filters to the underlying filter collection + * this is used for changing operators (AND/OR/...) and grouping several filters (where statements) + * @TODO: make this better, could also use value-objects? + * @param array $filters [array of array ('field' => ..., 'value' => ...)-elements] + * @param string $groupOperator [e.g., 'AND' or 'OR'] + * @param string $groupName + * @param string|null $conjunction + * @return model + * @throws ReflectionException + * @throws exception */ - public function removeCalculatedField(string $field) : model { - $field = $this->getModelfieldInstance($field); - $this->fieldlist = array_filter($this->fieldlist, function($item) use ($field) { - if($item instanceof \codename\core\model\plugin\calculatedfield) { - if($item->field->get() == $field->get()) { - return false; - } + public function addFilterCollection(array $filters, string $groupOperator = 'AND', string $groupName = 'default', string $conjunction = null): model + { + $filterCollection = []; + foreach ($filters as $filter) { + $field = $filter['field']; + $value = $filter['value']; + $operator = $filter['operator']; + $filter_conjunction = $filter['conjunction'] ?? null; + $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); + if (is_array($value)) { + if (count($value) == 0) { + trigger_error('Empty array filter values have no effect on resultset'); + continue; + } + $filterCollection[] = new $class($this->getModelfieldInstance($field), $value, $operator, $filter_conjunction); + } else { + $modelfieldInstance = $this->getModelfieldInstance($field); + $filterCollection[] = new $class($modelfieldInstance, $this->delimitImproved($modelfieldInstance->get(), $value), $operator, $filter_conjunction); + } } - return true; - }); - return $this; + if (count($filterCollection) > 0) { + $this->filterCollections[$groupName][] = [ + 'operator' => $groupOperator, + 'filters' => $filterCollection, + 'conjunction' => $conjunction, + ]; + } + return $this; } /** - * adds a field that uses aggregate functions to be calculated - * - * @param string $field [description] - * @param string $calculationType [description] - * @param string $fieldBase [description] - * @return model [description] + * [addDefaultFilterCollection description] + * @param array $filters [array of filters] + * @param string $groupOperator [operator inside the group items] + * @param string $groupName [name of a group to usage across models] + * @param string|null $conjunction [conjunction of this group, inside the group of same-name filtercollections] + * @return model [description] + * @throws ReflectionException + * @throws exception */ - public function addAggregateField(string $field, string $calculationType, string $fieldBase) : model { - $field = $this->getModelfieldInstance($field); - $fieldBase = $this->getModelfieldInstance($fieldBase); - // only check for EXISTANCE of the fieldname, cancel if so - we don't want duplicates! - if($this->fieldExists($field)) { - throw new \codename\core\exception(self::EXCEPTION_ADDAGGREGATEFIELD_FIELDALREADYEXISTS, \codename\core\exception::$ERRORLEVEL_FATAL, $field); - } - $class = '\\codename\\core\\model\\plugin\\aggregate\\' . $this->getType(); - $this->fieldlist[] = new $class($field, $calculationType, $fieldBase); - return $this; + public function addDefaultFilterCollection(array $filters, string $groupOperator = 'AND', string $groupName = 'default', string $conjunction = null): model + { + $filterCollection = []; + foreach ($filters as $filter) { + $field = $filter['field']; + $value = $filter['value']; + $operator = $filter['operator']; + $filter_conjunction = $filter['conjunction'] ?? null; + $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); + if (is_array($value)) { + if (count($value) == 0) { + trigger_error('Empty array filter values have no effect on resultset'); + continue; + } + $filterCollection[] = new $class($this->getModelfieldInstance($field), $value, $operator, $filter_conjunction); + } else { + $modelfieldInstance = $this->getModelfieldInstance($field); + $filterCollection[] = new $class($modelfieldInstance, $this->delimitImproved($modelfieldInstance->get(), $value), $operator, $filter_conjunction); + } + } + if (count($filterCollection) > 0) { + $this->defaultfilterCollections[$groupName][] = [ + 'operator' => $groupOperator, + 'filters' => $filterCollection, + 'conjunction' => $conjunction, + ]; + $this->filterCollections[$groupName][] = [ + 'operator' => $groupOperator, + 'filters' => $filterCollection, + 'conjunction' => $conjunction, + ]; + } + return $this; } /** - * [addFulltextField description] - * @param string $field [description] - * @param string $value [description] - * @param string $fields [description] - * @return model [description] + * + * {@inheritDoc} + * @param string $field + * @param mixed|null $value + * @param string $operator + * @param string|null $conjunction + * @return model + * @throws ReflectionException + * @throws exception + * @see model_interface::addDefaultFilter, $value, $operator) */ - public function addFulltextField(string $field, string $value, $fields) : model { - $field = $this->getModelfieldInstance($field); - if(!is_array($fields)) { - $fields = explode(',', $fields); - } - if (count($fields) === 0) { - throw new \codename\core\exception(self::EXCEPTION_ADDFULLTEXTFIELD_NO_FIELDS_FOUND, \codename\core\exception::$ERRORLEVEL_FATAL, $fields); - } - $thisFields = []; - foreach($fields as $resultField) { - $thisFields[] = $this->getModelfieldInstance(trim($resultField)); - } - $class = '\\codename\\core\\model\\plugin\\fulltext\\' . $this->getType(); - $this->fieldlist[] = new $class($field, $value, $thisFields); - return $this; - } + public function addDefaultFilter(string $field, mixed $value = null, string $operator = '=', string $conjunction = null): model + { + $field = $this->getModelfieldInstance($field); + $class = '\\codename\\core\\model\\plugin\\filter\\' . $this->getType(); - /** - * [EXCEPTION_ADDFULLTEXTFIELD_NO_FIELDS_FOUND description] - * @var string - */ - const EXCEPTION_ADDFULLTEXTFIELD_NO_FIELDS_FOUND = 'EXCEPTION_ADDFULLTEXTFIELD_NO_FIELDS_FOUND'; + if (is_array($value)) { + if (count($value) == 0) { + trigger_error('Empty array filter values have no effect on resultset'); + return $this; + } + $instance = new $class($field, $value, $operator, $conjunction); + } else { + $instance = new $class($field, $this->delimit($field, $value), $operator, $conjunction); + } + $this->defaultfilter[] = $instance; + $this->filter[] = $instance; + return $this; + } /** - * exception thrown on duplicate field existance (during addition of an aggregated field) - * @var string + * Returns the field's value as a string. + * It delimits the field using a colon if it is required by the field's datatype + * @param modelfield $field + * @param mixed|null $value + * @return mixed */ - const EXCEPTION_ADDAGGREGATEFIELD_FIELDALREADYEXISTS = 'EXCEPTION_ADDAGGREGATEFIELD_FIELDALREADYEXISTS'; + protected function delimit(modelfield $field, mixed $value = null): mixed + { + $fieldtype = $this->getFieldtype($field); - /** - * exception thrown if we try to add a calculated field which already exists (either as db field or another calculated one) - * @var string - */ - const EXCEPTION_ADDCALCULATEDFIELD_FIELDALREADYEXISTS = "EXCEPTION_ADDCALCULATEDFIELD_FIELDALREADYEXISTS"; + // CHANGED 2020-12-30 removed \is_string($value) && \strlen($value) == 0 + // Which converted '' to NULL - which is simply wrong. + if ($value === null) { + return null; + } - /** - * - * {@inheritDoc} - * @see \codename\core\model_interface::setLimit($limit) - */ - public function setLimit(int $limit) : model { - $class = '\\codename\\core\\model\\plugin\\limit\\' . $this->getType(); - $this->limit = new $class($limit); - return $this; + if ($fieldtype == 'number') { + if (is_numeric($value)) { + return $value; + } + if (strlen($value) == 0) { + return null; + } + return $value; + } + if ($fieldtype == 'number_natural') { + if (is_int($value)) { + return $value; + } + if (is_string($value) && strlen($value) == 0) { + return null; + } + return (int)$value; + } + if ($fieldtype == 'boolean') { + if (is_string($value) && strlen($value) == 0) { + return null; + } + if ($value) { + return true; + } + return false; + } + if (str_starts_with($fieldtype, 'text')) { + if (is_string($value) && strlen($value) == 0) { + return null; + } + } + return $value; } /** - * - * {@inheritDoc} - * @see \codename\core\model_interface::setOffset($offset) + * @param string $field + * @param null $value + * @param string $operator + * @param string|null $conjunction + * @return model + * @throws ReflectionException + * @throws exception + * @see model_interface::addDefaultFilter, $value, $operator) */ - public function setOffset(int $offset) : model { - $class = '\\codename\\core\\model\\plugin\\offset\\' . $this->getType(); - $this->offset = new $class($offset); - return $this; - } + public function addDefaultFilterList(string $field, $value = null, string $operator = '=', string $conjunction = null): model + { + $field = $this->getModelfieldInstance($field); + $class = '\\codename\\core\\model\\plugin\\filterlist\\' . $this->getType(); - /** - * {@inheritDoc} - */ - public function setFilterDuplicates(bool $state) : \codename\core\model { - $this->filterDuplicates = $state; + if (is_array($value)) { + if (count($value) == 0) { + trigger_error('Empty array filter values have no effect on resultset'); + return $this; + } + $instance = new $class($field, $value, $operator, $conjunction); + } else { + $instance = new $class($field, $this->delimit($field, $value), $operator, $conjunction); + } + $this->defaultfilter[] = $instance; + $this->filter[] = $instance; return $this; } /** * * {@inheritDoc} - * @see \codename\core\model_interface::setCache($cache) - */ - public function useCache() : model { - $this->cache = true; - return $this; - } - - /** - * @inheritDoc + * @param string $field + * @param string $order + * @return model + * @throws ReflectionException + * @throws exception + * @see model_interface::addOrder, $order) */ - public function loadByUnique(string $field, string $value) : array { - $data = $this->addFilter($field, $value, '=')->setLimit(1); - - // the primary key based cache should ONLY be active, if we're querying only this model - // without joins and only with a filter on the primary key - // - // if($field == $this->getPrimarykey() && count($this->filter) === 1 && count($this->filterCollections) === 0 && count($this->getNestedJoins()) === 0) { - // $cacheObj = app::getCache(); - // $cacheGroup = $this->getCachegroup(); - // $cacheKey = "PRIMARY_" . $value; - // - // $myData = $cacheObj->get($cacheGroup, $cacheKey); - // - // if(!is_array($myData) || count($myData) === 0) { - // $myData = $data->search()->getResult(); - // if(count($myData) == 1) { - // $cacheObj->set($cacheGroup, $cacheKey, $myData); - // } - // } else { - // // REVIEW: - // // We reset() the model here, as the filter created previously - // // may be passed to the next query... - // $data->reset(); - // } - // - // if(count($myData) === 1) { - // return $myData[0]; - // } else if(count($myData) > 1) { - // throw new \codename\core\exception('EXCEPTION_MODEL_LOADBYUNIQUE_MULTIPLE_RESULTS', exception::$ERRORLEVEL_FATAL, $myData); - // } - // return array(); - // } - // - // $data->useCache(); + public function addOrder(string $field, string $order = 'ASC'): model + { + $field = $this->getModelfieldInstanceRecursive($field) ?? $this->getModelfieldInstance($field); + if (!$this->fieldExists($field)) { + // check for the existence of a calculated field! + $found = false; + foreach ($this->fieldlist as $f) { + if ($f->field == $field) { + $found = true; + break; + } + } - $data = $data->search()->getResult(); - if(count($data) == 0) { - return array(); + if (!$found) { + throw new exception(self::EXCEPTION_ADDORDER_FIELDNOTFOUND, exception::$ERRORLEVEL_FATAL, $field); + } } - return $data[0]; + $class = '\\codename\\core\\model\\plugin\\order\\' . $this->getType(); + $this->order[] = new $class($field, $order); + return $this; } /** - * [getFieldtypeImproved description] - * @param string $specifier [description] - * @return string|null + * Returns a modelfield instance or null + * by traversing the current nested join tree + * and identifying the correct schema and table + * + * @param string $field [description] + * @return modelfield|null + * @throws ReflectionException + * @throws exception */ - public function getFieldtypeImproved(string $specifier) : ?string { - if(isset($this->cachedFieldtype[$specifier])) { - return $this->cachedFieldtype[$specifier]; - } else { - - // // DEBUG - // \codename\core\app::getResponse()->setData('getFieldtypeCounter', \codename\core\app::getResponse()->getData('getFieldtypeCounter') +1 ); - - // fieldtype not in current model config - if(($fieldtype = $this->config->get("datatype>" . $specifier))) { - - // field in this model - $this->cachedFieldtype[$specifier] = $fieldtype; - return $this->cachedFieldtype[$specifier]; + protected function getModelfieldInstanceRecursive(string $field): ?modelfield + { + $initialInstance = $this->getModelfieldInstance($field); - } else { + // Already defined (schema+table+field) + if ($initialInstance->getSchema()) { + return $initialInstance; + } - // check nested model configs - foreach($this->nestedModels as $joinPlugin) { - $fieldtype = $joinPlugin->model->getFieldtypeImproved($specifier); - if($fieldtype !== null) { - $this->cachedFieldtype[$specifier] = $fieldtype; - return $fieldtype; + if ($initialInstance->getTable()) { + // table is already defined, compare to a current model and perform checks + if ($initialInstance->getTable() == $this->table) { + if (in_array($initialInstance->get(), $this->getFields())) { + return $this->getModelfieldInstance($this->schema . '.' . $this->table . '.' . $initialInstance->get()); + } } - } - - // cache error value, too - $fieldtype = null; - - // // DEBUG - // \codename\core\app::getResponse()->setData('fieldtype_errors', array_merge(\codename\core\app::getResponse()->getData('fieldtype_errors') ?? [], [ $this->getIdentifier().':'.$specifier ]) ); - - $this->cachedFieldtype[$specifier] = $fieldtype; - return $this->cachedFieldtype[$specifier]; + } elseif (in_array($initialInstance->get(), $this->getFields())) { + return $this->getModelfieldInstance($this->schema . '.' . $this->table . '.' . $initialInstance->get()); } - } - } - - /** - * Returns the datatype of the given field - * @param \codename\core\value\text\modelfield $field - * @return string - */ - public function getFieldtype(\codename\core\value\text\modelfield $field) { - $specifier = $field->get(); - if(isset($this->cachedFieldtype[$specifier])) { - return $this->cachedFieldtype[$specifier]; - } else { - - // // DEBUG - // \codename\core\app::getResponse()->setData('getFieldtypeCounter', \codename\core\app::getResponse()->getData('getFieldtypeCounter') +1 ); - - // fieldtype not in current model config - if(($fieldtype = $this->config->get("datatype>" . $specifier))) { - - // field in this model - $this->cachedFieldtype[$specifier] = $fieldtype; - return $this->cachedFieldtype[$specifier]; - - } else { - - // check nested model configs - foreach($this->nestedModels as $joinPlugin) { - $fieldtype = $joinPlugin->model->getFieldtype($field); - if($fieldtype !== null) { - $this->cachedFieldtype[$specifier] = $fieldtype; - return $fieldtype; + // Traverse tree + foreach ($this->getNestedJoins() as $join) { + if ($instance = $join->model->getModelfieldInstanceRecursive($field)) { + return $instance; } - } - - // cache error value, too - $fieldtype = null; - - // // DEBUG - // \codename\core\app::getResponse()->setData('fieldtype_errors', array_merge(\codename\core\app::getResponse()->getData('fieldtype_errors') ?? [], [ $this->getIdentifier().':'.$specifier ]) ); - - $this->cachedFieldtype[$specifier] = $fieldtype; - return $this->cachedFieldtype[$specifier]; } - } - } - - /** - * internal in-mem caching of fieldtypes - * @var array - */ - protected $cachedFieldtype = array(); - - /** - * Returns array of fields that exist in the model - * @return array - */ - public function getFields() : array { - return $this->config->get('field'); + return null; } /** - * [getFieldlistArray description] - * @param \codename\core\model\plugin\field[] $fields [description] - * @return array [description] + * [addOrderPlugin description] + * @param order $orderPlugin [description] + * @return model [description] */ - protected function getFieldlistArray(array $fields) : array { - $returnFields = []; - if(count($fields) > 0) { - foreach($fields as $field) { - if($field->alias ?? false) { - $returnFields[] = $field->alias->get(); - } else { - $returnFields[] = $field->field->get(); - } - } - } - - // filter out hidden fields by difference calculation - $returnFields = array_diff($returnFields, $this->hiddenFields); - - return $returnFields; + public function addOrderPlugin(order $orderPlugin): model + { + $this->order[] = $orderPlugin; + return $this; } /** - * Returns a list of fields (as strings) - * that are part of the current model - * or were added dynamically (aliased, function-based or implicit) - * and handles hidden fields, too. * - * By the way, virtual fields are *not* returned by this function - * As the framework defines virtual fields as only to be existant - * if the corresponding join is also used. - * - * @return string[] + * {@inheritDoc} + * @param string $field + * @param string|null $alias + * @return model + * @throws ReflectionException + * @throws exception + * @see model_interface::addField */ - public function getCurrentAliasedFieldlist() : array { - $result = array(); - if(\count($this->fieldlist) == 0 && \count($this->hiddenFields) > 0) { - // - // Include all fields but specific ones - // - foreach($this->getFields() as $fieldName) { - if($this->config->get('datatype>'.$fieldName) !== 'virtual') { - if(!in_array($fieldName, $this->hiddenFields)) { - $result[] = $fieldName; - } - } - } - } else { - if(count($this->fieldlist) > 0) { - // - // Explicit field list - // - foreach($this->fieldlist as $field) { - if($field instanceof \codename\core\model\plugin\calculatedfield\calculatedfieldInterface) { - // - // Get the field's alias - // - $result[] = $field->field->get(); - } else if($field instanceof \codename\core\model\plugin\aggregate\aggregateInterface) { - // - // aggregate field's alias - // - $result[] = $field->field->get(); - } else if($field instanceof \codename\core\model\plugin\fulltext\fulltextInterface) { - - // - // fulltext field's alias - // - $result[] = $field->field->get(); - } else if($this->config->get('datatype>'.$field->field->get()) !== 'virtual' && (!in_array($field->field->get(), $this->hiddenFields) || $field->alias)) { - - // - // omit virtual fields - // they're not part of the DB. - // - $fieldAlias = $field->alias !== null ? $field->alias->get() : null; - if($fieldAlias) { - $result[] = $fieldAlias; - } else { - $result[] = $field->field->get(); - } - } - } - - // - // add the rest of the data-model-defined fields - // as long as they're not hidden. - // - foreach($this->getFields() as $fieldName) { - if($this->config->get('datatype>'.$fieldName) !== 'virtual') { - if(!in_array($fieldName, $this->hiddenFields)) { - $result[] = $fieldName; - } - } - } - - // - // NOTE: - // array_unique can be used on arrays that contain objects or sub-arrays - // you need to use SORT_REGULAR for this case (!) - // - $result = array_unique($result, SORT_REGULAR); - - } else { - // - // No explicit fieldlist - // No explicit hidden fields - // - if(count($this->hiddenFields) === 0) { - // - // The rest of the fields. Skip virtual fields - // - foreach($this->getFields() as $fieldName) { - if($this->config->get('datatype>'.$fieldName) !== 'virtual') { - $result[] = $fieldName; - } + public function addField(string $field, string $alias = null): model + { + if (str_contains($field, ',')) { + if ($alias) { + // This is impossible, multiple fields and a singular alias. + // We won't support (field1, field2), (alias1, alias2) in this method + throw new exception('EXCEPTION_ADDFIELD_ALIAS_ON_MULTIPLE_FIELDS', exception::$ERRORLEVEL_ERROR, ['field' => $field, 'alias' => $alias]); + } + foreach (explode(',', $field) as $myField) { + $this->addField(trim($myField)); } - } else { - // ugh? - } + return $this; } - } - - return array_values($result); - } + $field = $this->getModelfieldInstance($field); + if (!$this->fieldExists($field)) { + throw new exception(self::EXCEPTION_ADDFIELD_FIELDNOTFOUND, exception::$ERRORLEVEL_FATAL, $field); + } - /** - * Enables virtual field result functionality on this model instance - * @param bool $state [description] - * @return \codename\core\model [description] - */ - public function setVirtualFieldResult(bool $state) : \codename\core\model { - return $this; + $class = '\\codename\\core\\model\\plugin\\field\\' . $this->getType(); + $alias = $alias ? $this->getModelfieldInstance($alias) : null; + $this->fieldlist[] = new $class($field, $alias); + if (!$alias && in_array($field->getValue(), $this->hiddenFields)) { + $fieldKey = array_search($field->getValue(), $this->hiddenFields); + unset($this->hiddenFields[$fieldKey]); + } + return $this; } /** - * returns an array of virtual fields (names) currently configured - * @return array [description] + * [addVirtualField description] + * @param string $field [description] + * @param callable $fieldFunction [description] + * @return model [this instance] */ - public function getVirtualFields() : array { - return array_keys($this->virtualFields); + public function addVirtualField(string $field, callable $fieldFunction): model + { + $this->virtualFields[$field] = $fieldFunction; + return $this; } /** - * primarykey cache field - * @var string - */ - protected $primarykey = null; - - /** - * Returns the primary key that was configured in the model's JSON config - * @return string + * [handleVirtualFields description] + * @param array $dataset [description] + * @return array [description] */ - public function getPrimarykey() : string { - if($this->primarykey === null) { - if(!$this->config->exists("primary")) { - throw new \codename\core\exception(self::EXCEPTION_GETPRIMARYKEY_NOPRIMARYKEYINCONFIG, \codename\core\exception::$ERRORLEVEL_FATAL, $this->config->get()); - } - $this->primarykey = $this->config->get('primary')[0]; + public function handleVirtualFields(array $dataset): array + { + foreach ($this->virtualFields as $field => $function) { + $dataset[$field] = $function($dataset); } - return $this->primarykey; + return $dataset; } /** - * Validates the data array and returns true if no errors occured - * @param array $data - * @return bool + * [hideAllFields description] + * @return model [description] */ - public function isValid(array $data) : bool { - return (count($this->validate($data)->getErrors()) == 0); + public function hideAllFields(): model + { + foreach ($this->getFields() as $field) { + $this->hideField($field); + } + return $this; } /** - * Returns the errors of the errorstack in this instance - * @return array + * + * {@inheritDoc} + * @see model_interface::hideField */ - public function getErrors() : array { - return $this->errorstack->getErrors(); + public function hideField(string $field): model + { + if (str_contains($field, ',')) { + foreach (explode(',', $field) as $myField) { + $this->hideField(trim($myField)); + } + return $this; + } + $this->hiddenFields[] = $field; + return $this; } /** - * Validates the given data after normalizing it. - * @param array $data - * @return model - * @todo requred seems to have some bugs - * @todo bring back to life the UNIQUE constraint checker - * @todo move the UNIQUE constraint checks to a separaate method + * @param string $field + * @return $this + * @throws ReflectionException + * @throws exception */ - public function validate(array $data) : model { - - // - // CHANGED 2020-07-29 reset the current errorstack just right before validation - // - $this->errorstack->reset(); - - foreach($this->config->get('field') as $field) { - if(in_array($field, array($this->getPrimarykey(), $this->getIdentifier() . "_modified", $this->getIdentifier() . "_created"))) { - continue; - } - if (!array_key_exists($field, $data) || is_null($data[$field]) || (is_string($data[$field]) && strlen($data[$field]) == 0) ) { - if(is_array($this->config->get('required')) && in_array($field, $this->config->get("required"))) { - $this->errorstack->addError($field, 'FIELD_IS_REQUIRED', null); - } - continue; - } - - if($this->config->exists('children') && $this->config->exists('children>'.$field)) { - // validate child using child/nested model - $childConfig = $this->config->get('children>'.$field); - - if($childConfig['type'] === 'foreign') { - // - // Normal Foreign-Key based child (1:1) - // - $foreignConfig = $this->config->get('foreign>'.$childConfig['field']); - $foreignKeyField = $childConfig['field']; - - // get the join plugin valid for the child reference field - $res = $this->getNestedJoins($foreignConfig['model'], $childConfig['field']); - - if(count($res) === 1) { - $join = $res[0]; // reset($res); - $join->model->validate($data[$field]); - if(count($errors = $join->model->getErrors()) > 0) { - $this->errorstack->addError($field, 'FIELD_INVALID', $errors); - } - } else { - continue; - } - } else if($childConfig['type'] === 'collection') { - // - // Collections in a virtual field - // - $collectionConfig = $this->config->get('collection>'.$field); - - // TODO: get the corresponding model - // we might introduce a new "addCollectionModel" method or so - - if(isset($this->collectionPlugins[$field])) { - if(is_array($data[$field])) { - foreach($data[$field] as $collectionItem) { - $this->collectionPlugins[$field]->collectionModel->validate($collectionItem); - if(count($errors = $this->collectionPlugins[$field]->collectionModel->getErrors()) > 0) { - $this->errorstack->addError($field, 'FIELD_INVALID', $errors); - } + public function addGroup(string $field): model + { + $field = $this->getModelfieldInstance($field); + $aliased = false; + if (!$this->fieldExists($field)) { + $foundInFieldlist = false; + foreach ($this->fieldlist as $checkField) { + if ($checkField->field->get() == $field->get()) { + $foundInFieldlist = true; + + // At this point, check for 'virtuality' of a field + // e.g., aliased, calculated and aggregates + // (the latter ones are usually calculated fields) + // + if ($checkField instanceof calculatedfield || $checkField instanceof aggregate) { + $aliased = true; } - } - } else { - continue; + break; } - } } - - if (count($errors = app::getValidator($this->getFieldtype($this->getModelfieldInstance($field)))->reset()->validate($data[$field])) > 0) { - $this->errorstack->addError($field, 'FIELD_INVALID', $errors); + if ($foundInFieldlist === false) { + throw new exception(self::EXCEPTION_ADDGROUP_FIELDDOESNOTEXIST, exception::$ERRORLEVEL_FATAL, [$field, $this->fieldlist]); } } - - // model validator - if($this->config->exists('validators')) { - $validators = $this->config->get('validators'); - foreach($validators as $validator) { - // NOTE: reset validator needed, as app::getValidator() caches the validator instance, - // including the current errorstack - if(count($errors = app::getValidator($validator)->reset()->validate($data)) > 0) { - // - // NOTE/CHANGED 2020-02-18 - // split errors into field-related and others - // to improve validation handling - // - $dataErrors = []; - $fieldErrors = []; - foreach($errors as $error) { - if(in_array($error['__IDENTIFIER'], $this->getFields())) { - $fieldErrors[] = $error; - } else { - $dataErrors[] = $error; - } - } - if(count($dataErrors) > 0) { - $this->errorstack->addError('DATA', 'INVALID', $dataErrors); - } - if(count($fieldErrors) > 0) { - $this->errorstack->addErrors($fieldErrors); - } - } - } - } - - // $dataob = $this->data; - // if(is_array($this->config->get("unique"))) { - // foreach($this->config->get("unique") as $key => $fields) { - // if(!is_array($fields)) { - // continue; - // } - // $filtersApplied = 0; - // - // // exclude my own dataset if UPDATE is in progress - // if(array_key_exists($this->getPrimarykey(), $data) && strlen($data[$this->getPrimarykey()]) > 0) { - // $this->addFilter($this->getPrimarykey(), $data[$this->getPrimarykey()], '!='); - // } - // - // foreach($fields as $field) { - // // if(!array_key_exists($field, $data) || strlen($data[$field]) == 0) { - // // continue; - // // } - // if(is_array($field)) { - // // $this->addFilter($field, $data[$field] ?? null, '='); - // $uniqueFilters = []; - // foreach($field as $uniqueFieldComponent) { - // $uniqueFilters[] = [ 'field' => $uniqueFieldComponent, 'value' => $data[$uniqueFieldComponent], 'operator' => '=']; - // if($data[$uniqueFieldComponent] === null) { - // break; - // } - // } - // $this->addFilterCollection($uniqueFilters, 'AND'); - // } else { - // $this->addFilter($field, $data[$field] ?? null, '='); - // } - // $filtersApplied++; - // } - // - // if($filtersApplied === 0) { - // continue; - // } - // - // if(count($this->search()->getResult()) > 0) { - // $this->errorstack->addError($field, 'FIELD_DUPLICATE', $data[$field]); - // } - // } - // } - // $this->data = $dataob; - + $class = '\\codename\\core\\model\\plugin\\group\\' . $this->getType(); + $groupInstance = new $class($field); + $groupInstance->aliased = $aliased; + $this->group[] = $groupInstance; return $this; } - /** - * internal caching variable containing the list of fields in the model - * @var array + * @param string $field + * @param string $calculation + * @return $this + * @throws ReflectionException + * @throws exception */ - protected $normalizeDataFieldCache = null; + public function addCalculatedField(string $field, string $calculation): model + { + $field = $this->getModelfieldInstance($field); + // only check for EXISTENCE of the fieldname, cancel if so - we don't want duplicates! + if ($this->fieldExists($field)) { + throw new exception(self::EXCEPTION_ADDCALCULATEDFIELD_FIELDALREADYEXISTS, exception::$ERRORLEVEL_FATAL, $field); + } + $class = '\\codename\\core\\model\\plugin\\calculatedfield\\' . $this->getType(); + $this->fieldlist[] = new $class($field, $calculation); + return $this; + } /** - * normalizes data in the given array. - *
Tries to identify complex datastructures by the Hiden $FIELDNAME."_" fields and makes objects of them - * @param array $data - * @return array + * [removeCalculatedField description] + * @param string $field [description] + * @return model [description] + * @throws ReflectionException + * @throws exception */ - public function normalizeData(array $data) : array { - $myData = array(); - - $flagFieldName = $this->table . '_flag'; - - if(!$this->normalizeDataFieldCache) { - $this->normalizeDataFieldCache = $this->getFields(); - } - - foreach($this->normalizeDataFieldCache as $field) { - // if field has object identified - // - // OBSOLETE, possibly. From the old days. - // - // if(array_key_exists($field.'_', $data)) { - // $object = array(); - // foreach($data as $key => $value) { - // if(strpos($key, $field.'__') !== false) { - // $object[str_replace($field . '__', '', strtolower($key))] = $data[$key]; - // } - // } - // $myData[$field] = $object; - // } - - if($field == $flagFieldName) { - if(array_key_exists($flagFieldName, $data)) { - if(!is_array($data[$flagFieldName])) { - // - // CHANGED 2021-09-21: flag field values may be passed-through - // if not in array-format - // TODO: validate flags? - // - $myData[$field] = $data[$flagFieldName]; - continue; - } - - $flagval = 0; - foreach($data[$flagFieldName] as $flagname => $status) { - $currflag = $this->config->get("flag>$flagname"); - if(is_null($currflag) || !$status) { - continue; - } - $flagval |= $currflag; - } - $myData[$field] = $flagval; - } else { - unset($data[$field]); + public function removeCalculatedField(string $field): model + { + $field = $this->getModelfieldInstance($field); + $this->fieldlist = array_filter($this->fieldlist, function ($item) use ($field) { + if ($item instanceof calculatedfield) { + if ($item->field->get() == $field->get()) { + return false; } - continue; } - - // Otherwise the field exists in the data object - if(\array_key_exists($field, $data)) { - // $myData[$field] = $this->importField($this->getModelfieldInstance($field), $data[$field]); - $myData[$field] = $this->importFieldImproved($field, $data[$field]); - } - - } - return $myData; + return true; + }); + return $this; } /** - * Returns the given $flagname's flag integer value - * @param string $flagname - * @throws \codename\core\exception - * @return mixed|null - * @deprecated + * adds a field that uses aggregate functions to be calculated + * + * @param string $field [description] + * @param string $calculationType [description] + * @param string $fieldBase [description] + * @return model [description] + * @throws ReflectionException + * @throws exception */ - public function getFlag(string $flagname) { - if(!$this->config->exists("flag>$flagname")) { - throw new \codename\core\exception(self::EXCEPTION_GETFLAG_FLAGNOTFOUND, \codename\core\exception::$ERRORLEVEL_FATAL, $flagname); - return null; + public function addAggregateField(string $field, string $calculationType, string $fieldBase): model + { + $field = $this->getModelfieldInstance($field); + $fieldBase = $this->getModelfieldInstance($fieldBase); + // only check for EXISTENCE of the fieldname, cancel if so - we don't want duplicates! + if ($this->fieldExists($field)) { + throw new exception(self::EXCEPTION_ADDAGGREGATEFIELD_FIELDALREADYEXISTS, exception::$ERRORLEVEL_FATAL, $field); } - return $this->config->get("flag>$flagname"); + $class = '\\codename\\core\\model\\plugin\\aggregate\\' . $this->getType(); + $this->fieldlist[] = new $class($field, $calculationType, $fieldBase); + return $this; } /** - * Returns true if the given flag name is set to true in the data array. Returns false otherwise - * @param string $flagname - * @param array $data - * @return bool - * @todo Validate the flag in the model constructor (model configurator) - * @todo add \codename\core\exceptions + * [addFulltextField description] + * @param string $field [description] + * @param string $value [description] + * @param mixed $fields [description] + * @return model [description] + * @throws ReflectionException + * @throws exception */ - public function isFlag(int $flagvalue, array $data) : bool { - $flagField = $this->getIdentifier() . '_flag'; - if(!array_key_exists($flagField, $data)) { - throw new \codename\core\exception(self::EXCEPTION_ISFLAG_NOFLAGFIELD, \codename\core\exception::$ERRORLEVEL_FATAL, array('field' => $flagField, 'data' => $data)); + public function addFulltextField(string $field, string $value, mixed $fields): model + { + $field = $this->getModelfieldInstance($field); + if (!is_array($fields)) { + $fields = explode(',', $fields); } - return (($data[$flagField] & $flagvalue) == $flagvalue); - } - - /** - * Converts the storage format into a human readible format - * @param \codename\core\value\text\modelfield $field - * @param mixed|null $value - * @return mixed|null - */ - public function exportField(\codename\core\value\text\modelfield $field, $value = null) { - if(is_null($value)) { - return $value; + if (count($fields) === 0) { + throw new exception(self::EXCEPTION_ADDFULLTEXTFIELD_NO_FIELDS_FOUND, exception::$ERRORLEVEL_FATAL, $fields); } - - switch($this->getFieldtype($field)) { - case 'boolean' : - return $value === null ? null : ($value ? true : false); // ? 'true' : 'false'; - break; - case 'text_date': - return date('Y-m-d', strtotime($value)); - break; - case 'text' : - return $value; // str_replace('#__DELIMITER__#', $this->delimiter, $value); - break; + $thisFields = []; + foreach ($fields as $resultField) { + $thisFields[] = $this->getModelfieldInstance(trim($resultField)); } - - return $value; + $class = '\\codename\\core\\model\\plugin\\fulltext\\' . $this->getType(); + $this->fieldlist[] = new $class($field, $value, $thisFields); + return $this; } /** - * Returns true if the given $field exists in this model's configuration - * @param \codename\core\value\text\modelfield $field - * @return bool + * + * {@inheritDoc} + * @see model_interface::setOffset */ - protected function fieldExists(\codename\core\value\text\modelfield $field) : bool { - if($field->getTable() != null) { - if($field->getTable() == ($this->table ?? null)) { - return in_array($field->get(), $this->getFields()); - } else { - foreach($this->getNestedJoins() as $join) { - if($join->model->fieldExists($field)) { - return true; - } - } - } - } - return in_array($field->get(), $this->getFields()); + public function setOffset(int $offset): model + { + $class = '\\codename\\core\\model\\plugin\\offset\\' . $this->getType(); + $this->offset = new $class($offset); + return $this; } /** - * Returns the default cache client - * @return cache + * {@inheritDoc} */ - protected function getCache() : cache { - return app::getCache(); + public function setFilterDuplicates(bool $state): model + { + $this->filterDuplicates = $state; + return $this; } /** - * [getCurrentCacheIdentifierParameters description] - * @return array [description] + * {@inheritDoc} */ - protected function getCurrentCacheIdentifierParameters() : array { - $params = []; - $params['filter'] = $this->filter; - $params['filtercollections'] = $this->filterCollections; - foreach($this->getNestedJoins() as $join) { - // - // CHANGED 2021-09-24: nested model's join plugin parameters were not correctly incorporated into cache key - // - $params['nest'][] = [ - 'cacheIdentifier' => $join->model->getCurrentCacheIdentifierParameters(), - 'model' => $join->model->getIdentifier(), - 'cacheParamters' => $join->getCurrentCacheIdentifierParameters(), - ]; - } - return $params; + public function setForUpdate(bool $state): model + { + $this->forUpdate = $state; + return $this; } /** - * Perform the given query and save the result in the instance - * @param string $query - * @return void + * + * {@inheritDoc} + * @see model_interface::setCache */ - protected function doQuery(string $query, array $params = array()) { - // if cache, load it - if($this->cache) { - $cacheObj = app::getCache(); - $cacheGroup = $this->getCachegroup(); - $cacheKey = "manualcache" . md5(serialize( - array( - get_class($this), - $this->getIdentifier(), - $query, - $this->getCurrentCacheIdentifierParameters(), - $params - ) - )); - - // \codename\core\app::getResponse()->setData('cache_params', array( - // get_class($this), - // $query, - // $this->getCurrentCacheIdentifierParameters(), - // $params - // )); - - $this->result = $cacheObj->get($cacheGroup, $cacheKey); - - if (!is_null($this->result) && is_array($this->result)) { - $this->reset(); - return $this; - } - } - - $this->result = $this->internalQuery($query, $params); - - // save last query - if($this->saveLastQuery) { - $this->lastQuery = $query; - } - - // if cache, save it - if ($this->cache && count($this->getResult()) > 0) { - $result = $this->getResult(); - - $cacheObj->set($cacheGroup, $cacheKey, $this->getResult()); - } - $this->reset(); - return; + public function useCache(): model + { + $this->cache = true; + return $this; } /** - * @inheritDoc - */ - public function getResult() : array { - $result = $this->result; - - if ($result === null) { - $this->result = $this->internalGetResult(); - $result = $this->result; - } - - // execute any bare joins, if set - $result = $this->performBareJoin($result); - - $result = $this->normalizeResult($result); - $this->data = new \codename\core\datacontainer($result); - return $this->data->getData(); - } - - /** - * perform a shim / bare metal join - * @param array $result [the resultset] - * @return array + * Returns a list of fields (as strings) + * that are part of the current model + * or were added dynamically (aliased, function-based or implicit) + * and handles hidden fields, too. + * + * By the way, virtual fields are *not* returned by this function + * As the framework defines virtual fields as only to be existent + * if the corresponding join is also used. + * + * @return string[] */ - protected function performBareJoin(array $result) : array { - if(\count($this->getNestedJoins()) == 0) { - return $result; - } - - // - // Loop through Joins - // - foreach($this->getNestedJoins() as $join) { - $nest = $join->model; - - $vKey = null; - if($this instanceof \codename\core\model\virtualFieldResultInterface && $this->virtualFieldResult) { - // pick only parts of the arrays - // if(($children = $this->config->get('children')) !== null) { - // foreach($children as $vField => $config) { - // if($config['type'] === 'foreign' && $config['field'] === $join->modelField) { - // $vKey = $vField; - // } - // } - // } - $vKey = $join->virtualField; - } - - // virtual field? - if($vKey && !$nest->getForceVirtualJoin()) { - // - // NOTE/CHANGED 2020-09-15 Forced virtual joins - // require us to skip performBareJoin at this point in general - // (for both vkey and non-vkey joins) - // - - // - // Skip recursive performBareJoin - // if we have none coming up next - // - if(count($nest->getNestedJoins()) == 0) { - continue; - } - - // make sure vKey is in current fieldlist... - // this is for situations - // where - // - virtual field result enabled - // - vfield config present - // - respective model joined - // - another (bare-joined) model relying on a field - // - but field(s) hidden, e.g. by hideAllFields - $ifl = $this->getInternalIntersectFieldlist(); - - if(!array_key_exists($vKey, $ifl)) { - throw new exception('EXCEPTION_MODEL_PERFORMBAREJOIN_MISSING_VKEY', exception::$ERRORLEVEL_ERROR, [ - 'model' => $this->getIdentifier(), - 'vKey' => $vKey, - ]); - } - - // - // Unwind resultset - // [ item, item, item ] -> [ item[key], item[key], item[key] ] - // - $tResult = array_map(function($r) use ($vKey) { - return $r[$vKey]; - }, $result); - - // - // Recursively check for bareJoinable models - // with a subset of the current result - // - $tResult = $nest->performBareJoin($tResult); - - // - // Re-wind resultset - // [ item[key], item[key], item[key] ] -> merge into [ item, item, item ] - // - foreach($result as $index => &$r) { - $r[$vKey] = array_merge( $r[$vKey], $tResult[$index]); - } - } else if(!$nest->getForceVirtualJoin()) { - // - // NOTE/CHANGED 2020-09-15 Forced virtual joins - // require us to skip performBareJoin at this point in general - // (for both vkey and non-vkey joins) - // - $result = $nest->performBareJoin($result); - } - - // - // check if model is joining compatible - // we explicitly join incompatible models using a bare-data here! - // - if(!$this->compatibleJoin($nest) && ($join instanceof \codename\core\model\plugin\join\executableJoinInterface)) { - - $subresult = $nest->search()->getResult(); - - if($vKey) { + public function getCurrentAliasedFieldlist(): array + { + $result = []; + if (count($this->fieldlist) == 0 && count($this->hiddenFields) > 0) { // - // Unwind resultset - // [ item, item, item ] -> [ item[key], item[key], item[key] ] + // Include all fields but specific ones // - $tResult = array_map(function($r) use ($vKey) { - return $r[$vKey]; - }, $result); - + foreach ($this->getFields() as $fieldName) { + if ($this->config->get('datatype>' . $fieldName) !== 'virtual') { + if (!in_array($fieldName, $this->hiddenFields)) { + $result[] = $fieldName; + } + } + } + } elseif (count($this->fieldlist) > 0) { // - // Recursively perform the - // with a subset of the current result + // Explicit field list // - $tResult = $join->join($tResult, $subresult); + foreach ($this->fieldlist as $field) { + if ($field instanceof calculatedfieldInterface) { + // + // Get the field's alias + // + $result[] = $field->field->get(); + } elseif ($field instanceof aggregateInterface) { + // + // aggregate field's alias + // + $result[] = $field->field->get(); + } elseif ($field instanceof fulltextInterface) { + // + // fulltext field's alias + // + $result[] = $field->field->get(); + } elseif ($this->config->get('datatype>' . $field->field->get()) !== 'virtual' && (!in_array($field->field->get(), $this->hiddenFields) || $field->alias)) { + // + // omit virtual fields + // they're not part of the DB. + // + $fieldAlias = $field->alias?->get(); + if ($fieldAlias) { + $result[] = $fieldAlias; + } else { + $result[] = $field->field->get(); + } + } + } // - // Re-wind resultset - // [ item[key], item[key], item[key] ] -> merge into [ item, item, item ] + // add the rest of the data-model-defined fields + // as long as they're not hidden. // - foreach($result as $index => &$r) { - $r[$vKey] = array_merge( $tResult[$index] ); + foreach ($this->getFields() as $fieldName) { + if ($this->config->get('datatype>' . $fieldName) !== 'virtual') { + if (!in_array($fieldName, $this->hiddenFields)) { + $result[] = $fieldName; + } + } } - } else { - $result = $join->join($result, $subresult); - } - } else if(!$this->compatibleJoin($nest) && ($join instanceof \codename\core\model\plugin\join\dynamicJoinInterface)) { - // - // CHANGED 2020-07-22 vkey handling inside dynamic joins - // Special join handling - // using dynamic join method - // vKey is specified either way (but may be null) - // so the join module may handle the virtual field result - // - $result = $join->dynamicJoin($result, [ - 'vkey' => $vKey, - ]); - } - } - return $result; - } - - /** - * internal query - */ - protected abstract function internalQuery(string $query, array $params = array()); - - /** - * internal getResult - * @return array - */ - protected abstract function internalGetResult() : array; - - /** - * determines query storing state - * @var bool - */ - public $saveLastQuery = false; - - /** - * contains the last query performed with this model instance - */ - protected $lastQuery = ''; - - /** - * returns the last query performed and stored. - * @return string - */ - public function getLastQuery() : string { - return $this->lastQuery; - } - - /** - * Returns the lastInsertId returned from db driver - * May contain foreign ids. - */ - public function lastInsertId() { - return $this->db->lastInsertId(); - } - - /** - * Normalizes a result. Nests normalizeRow when more than one single row is in the result. - * @param array $result - * @return array - */ - protected function normalizeResult(array $result) : array { - if(count($result) == 0) { - return array(); - } - - // - // CHANGED 2020-05-13 - major change - // we're no longer resetting normalizeModelFieldCache & normalizeModelFieldTypeCache - // as it was reset every time we called normalizeResult. - // - // $this->normalizeModelFieldCache = array(); - // $this->normalizeModelFieldTypeCache = array(); - - // Normalize single row - if(count($result) == 1) { - $result = reset($result); - return array($this->normalizeRow($result)); - } - - // Normalize each row - foreach($result as $key => $value) { - $result[$key] = $this->normalizeRow($value); - } - return $result; - } - - /** - * Temporary model field cache during normalizeResult / normalizeRow - * This is being reset each time normalizeResult is going to call normalizeRow - * @author Kevin Dargel - * @var \codename\core\value\text\modelfield[] - */ - protected $normalizeModelFieldCache = array(); - - /** - * Temporary model field type cache during normalizeResult / normalizeRow - * This is being reset each time normalizeResult is going to call normalizeRow - * @author Kevin Dargel - * @var \codename\core\value\text\modelfield[] - */ - protected $normalizeModelFieldTypeCache = array(); - - /** - * [protected description] - * @var bool[] - */ - protected $normalizeModelFieldTypeStructureCache = array(); - - /** - * [protected description] - * @var bool[] - */ - protected $normalizeModelFieldTypeVirtualCache = array(); + // + // NOTE: + // array_unique can be used on arrays that contain objects or sub-arrays + // you need to use SORT_REGULAR for this case (!) + // + $result = array_unique($result, SORT_REGULAR); + } elseif (count($this->hiddenFields) === 0) { + // + // The rest of the fields. Skip virtual fields + // + foreach ($this->getFields() as $fieldName) { + if ($this->config->get('datatype>' . $fieldName) !== 'virtual') { + $result[] = $fieldName; + } + } + } + + return array_values($result); + } /** - * [getModelfieldInstance description] - * @param string $field [description] - * @return \codename\core\value\text\modelfield [description] + * Enables virtual field result functionality on this model instance + * @param bool $state [description] + * @return model [description] */ - protected function getModelfieldInstance(string $field): \codename\core\value\text\modelfield { - return \codename\core\value\text\modelfield::getInstance($field); + public function setVirtualFieldResult(bool $state): model + { + return $this; } /** - * Returns a modelfield instance or null - * by traversing the current nested join tree - * and identifying the correct schema and table - * - * @param string $field [description] - * @return \codename\core\value\text\modelfield|null - */ - protected function getModelfieldInstanceRecursive(string $field): ?\codename\core\value\text\modelfield { - $initialInstance = $this->getModelfieldInstance($field); - - // Already defined (schema+table+field) - if($initialInstance->getSchema()) { - return $initialInstance; - } - - if(!$initialInstance->getSchema() || !$initialInstance->getTable()) { - // Schema or even table not defined, search for it. - if($initialInstance->getTable()) { - // table is already defined, compare to current model and perform checks - if($initialInstance->getTable() == $this->table) { - if(in_array($initialInstance->get(), $this->getFields())) { - return $this->getModelfieldInstance($this->schema.'.'.$this->table.'.'.$initialInstance->get()); - } - } - } else { - // search by field only - if(in_array($initialInstance->get(), $this->getFields())) { - return $this->getModelfieldInstance($this->schema.'.'.$this->table.'.'.$initialInstance->get()); - } - } - } - - // Traverse tree - foreach($this->getNestedJoins() as $join) { - if($instance = $join->model->getModelfieldInstanceRecursive($field)) { - return $instance; - } - } - - return null; + * returns an array of virtual fields (names) currently configured + * @return array [description] + */ + public function getVirtualFields(): array + { + return array_keys($this->virtualFields); } /** - * [getModelfieldVirtualInstance description] - * @param string $field [description] - * @return \codename\core\value\text\modelfield [description] + * Validates the data array and returns true if no errors occurred + * @param array $data + * @return bool + * @throws ReflectionException + * @throws exception */ - protected function getModelfieldVirtualInstance(string $field): \codename\core\value\text\modelfield { - return \codename\core\value\text\modelfield\virtual::getInstance($field); + public function isValid(array $data): bool + { + return (count($this->validate($data)->getErrors()) == 0); } /** - * Normalizes a single row of a dataset - * @param array $dataset + * Normalizes data in the given array. + * Tries to identify complex datastructures by the Hidden $FIELDNAME."_" fields and make objects of them + * @param array $data + * @return array + * @throws DateMalformedStringException + * @throws exception */ - protected function normalizeRow(array $dataset) : array { - if(\count($dataset) == 1 && isset($dataset[0])) { - $dataset = $dataset[0]; - } - - foreach($dataset as $field=>$thisRow) { - - // Performance optimization (and fix): - // Check for (key == null) first, as it is faster than is_string - // NOTE: checking for !is_string commented-out - // we need to check - at least for booleans (DB provides 0 and 1 instead of true/false) - // if($dataset[$field] === null || !is_string($dataset[$field])) {continue;} - if($dataset[$field] === null) { continue; } - - // special case: we need boolean normalization (0 / 1) - // but otherwise, just skip - if( - ( isset($this->normalizeModelFieldTypeCache[$field]) && ($this->normalizeModelFieldTypeCache[$field] !== 'boolean')) - && !\is_string($dataset[$field]) - ) { continue; } - - // determine virtuality status of the field - if(!isset($this->normalizeModelFieldTypeVirtualCache[$field])) { - $tVirtualModelField = $this->getModelfieldVirtualInstance($field); - $this->normalizeModelFieldTypeCache[$field] = $this->getFieldtype($tVirtualModelField); - $this->normalizeModelFieldTypeVirtualCache[$field] = $this->normalizeModelFieldTypeCache[$field] === 'virtual'; - } + public function normalizeData(array $data): array + { + $myData = []; - /// - /// Fixing a bad performance issue - /// using result-specific model field caching - /// as they're re-constructed EVERY call! - /// - if(!isset($this->normalizeModelFieldCache[$field])) { - if($this->normalizeModelFieldTypeVirtualCache[$field]) { - $this->normalizeModelFieldCache[$field] = $this->getModelfieldVirtualInstance($field); - } else { - $this->normalizeModelFieldCache[$field] = $this->getModelfieldInstance($field); - } - } + $flagFieldName = $this->table . '_flag'; - if(!isset($this->normalizeModelFieldTypeCache[$field])) { - $this->normalizeModelFieldTypeCache[$field] = $this->getFieldtype($this->normalizeModelFieldCache[$field]); - } + if (!$this->normalizeDataFieldCache) { + $this->normalizeDataFieldCache = $this->getFields(); + } - // - // HACK: only normalize boolean fields - // - if($this->normalizeModelFieldTypeCache[$field] === 'boolean') { - $dataset[$field] = $this->importField($this->normalizeModelFieldCache[$field], $dataset[$field]); - continue; - } + foreach ($this->normalizeDataFieldCache as $field) { + if ($field == $flagFieldName) { + if (array_key_exists($flagFieldName, $data)) { + if (!is_array($data[$flagFieldName])) { + // + // CHANGED 2021-09-21: flag field values may be passed-through + // if not in array-format + // TODO: validate flags? + // + $myData[$field] = $data[$flagFieldName]; + continue; + } - if(!isset($this->normalizeModelFieldTypeStructureCache[$field])) { - $this->normalizeModelFieldTypeStructureCache[$field] = strpos($this->normalizeModelFieldTypeCache[$field], 'structu') !== false; + $flagval = 0; + foreach ($data[$flagFieldName] as $flagname => $status) { + $currflag = $this->config->get("flag>$flagname"); + if (is_null($currflag) || !$status) { + continue; + } + $flagval |= $currflag; + } + $myData[$field] = $flagval; + } else { + unset($data[$field]); + } + continue; } - if($this->normalizeModelFieldTypeStructureCache[$field] && !is_array($dataset[$field])) { - $dataset[$field] = $dataset[$field] == null ? null : app::object2array(json_decode($dataset[$field], false)/*, 512, JSON_UNESCAPED_UNICODE)*/); + // Otherwise, the field exists in the data object + if (array_key_exists($field, $data)) { + $myData[$field] = $this->importFieldImproved($field, $data[$field]); } - - } - return $dataset; - } - - /** - * function is required to remove the default filter from the number generator - * @return [type] [description] - */ - public function removeDefaultFilter() { - $this->defaultfilter = []; - $this->defaultAggregateFilter = []; - $this->defaultflagfilter = []; - $this->defaultfilterCollections = []; - return $this; - } - - /** - * resets all the parameters of the instance for another query - * @return void - */ - public function reset() { - $this->cache = false; - // $this->fieldlist = array(); - // $this->hiddenFields = array(); - $this->filter = $this->defaultfilter; - $this->aggregateFilter = $this->defaultAggregateFilter; - $this->flagfilter = $this->defaultflagfilter; - $this->filterCollections = $this->defaultfilterCollections; - $this->limit = null; - $this->offset = null; - $this->filterDuplicates = false; - $this->order = array(); - $this->errorstack->reset(); - foreach($this->nestedModels as $nest) { - $nest->model->reset(); } - // TODO: reset collection models? - return; + return $myData; } /** - * internal variable containing field types for a given field - * to improve performance of ::importField - * @var [type] - */ - protected $importFieldTypeCache = []; - - protected $fieldTypeCache = []; - - protected function importFieldImproved(string $field, $value = null) { - $fieldType = $this->fieldTypeCache[$field] ?? $this->fieldTypeCache[$field] = $this->getFieldtypeImproved($field); - switch($fieldType) { - case 'number_natural': - if(\is_string($value) && \strlen($value) === 0) { - return null; - } - break; - case 'boolean' : - // allow null booleans - // may be needed for conditional unique keys - if(\is_null($value)) { - return $value; - } - // pure boolean - if(\is_bool($value)) { - return $value; - } - // int: 0 or 1 - if(\is_int($value)) { - if($value !== 1 && $value !== 0) { - throw new exception('EXCEPTION_MODEL_IMPORTFIELD_BOOLEAN_INVALID', exception::$ERRORLEVEL_ERROR, [ - 'field' => $field, - 'value' => $value - ]); - } - return $value === 1 ? true : false; - } - // string boolean - if(\is_string($value)) { - // fallback, empty string - if(\strlen($value) === 0) { - return null; - } - if($value === '1') { - return true; - } else if($value === '0') { - return false; - } else if($value === 'true') { - return true; - } elseif ($value === 'false') { - return false; - } - } - // fallback - return false; - break; - case 'text_date': - if(\is_null($value)) { - return $value; - } - // automatically convert input value - // ctor returns FALSE on creation error, see http://php.net/manual/de/datetime.construct.php - $date = new \DateTime($value); - if($date !== false) { - return $date->format('Y-m-d'); - } - return null; - break; - } - return $value; - } - - /** - * Converts the given field and it's value from a human readible format into a storage format - * @param \codename\core\value\text\modelfield $field - * @param mixed|null $value - * @return mixed|null + * @param string $field + * @param mixed $value + * @return mixed + * @throws DateMalformedStringException + * @throws exception */ - protected function importField(\codename\core\value\text\modelfield $field, $value = null) { - $fieldType = $this->importFieldTypeCache[$field->get()] ?? $this->importFieldTypeCache[$field->get()] = $this->getFieldtype($field); - switch($fieldType) { + protected function importFieldImproved(string $field, mixed $value = null): mixed + { + $fieldType = $this->fieldTypeCache[$field] ?? $this->fieldTypeCache[$field] = $this->getFieldtypeImproved($field); + switch ($fieldType) { case 'number_natural': - if(\is_string($value) && \strlen($value) === 0) { - return null; - } - break; - case 'boolean' : + if (is_string($value) && strlen($value) === 0) { + return null; + } + break; + case 'boolean': // allow null booleans - // may be needed for conditional unique keys - if(\is_null($value)) { + // may be necessary for conditional unique keys + if (is_null($value)) { return $value; } // pure boolean - if(\is_bool($value)) { + if (is_bool($value)) { return $value; } // int: 0 or 1 - if(\is_int($value)) { - if($value !== 1 && $value !== 0) { - throw new exception('EXCEPTION_MODEL_IMPORTFIELD_BOOLEAN_INVALID', exception::$ERRORLEVEL_ERROR, [ - 'field' => $field->get(), - 'value' => $value - ]); + if (is_int($value)) { + if ($value !== 1 && $value !== 0) { + throw new exception('EXCEPTION_MODEL_IMPORTFIELD_BOOLEAN_INVALID', exception::$ERRORLEVEL_ERROR, [ + 'field' => $field, + 'value' => $value, + ]); } - return $value === 1 ? true : false; + return $value === 1; } // string boolean - if(\is_string($value)) { - // fallback, empty string - if(\strlen($value) === 0) { - return null; - } - if($value === '1') { - return true; - } else if($value === '0') { - return false; - } else if($value === 'true') { - return true; - } elseif ($value === 'false') { - return false; - } + if (is_string($value)) { + // fallback, empty string + if (strlen($value) === 0) { + return null; + } + if ($value === '1') { + return true; + } elseif ($value === '0') { + return false; + } elseif ($value === 'true') { + return true; + } elseif ($value === 'false') { + return false; + } } // fallback return false; - break; case 'text_date': - if(\is_null($value)) { + if (is_null($value)) { return $value; } // automatically convert input value - // ctor returns FALSE on creation error, see http://php.net/manual/de/datetime.construct.php - $date = new \DateTime($value); - if($date !== false) { - return $date->format('Y-m-d'); - } - return null; - break; - /* case 'text' : - if(is_null($value)) { - return $value; - } - return str_replace($this->delimiter, '#__DELIMITER__#', $value); - break; */ + return (new DateTime($value))->format('Y-m-d'); } return $value; } /** - * Returns the driver that shall be used for the model + * Returns the given $flagname's flag integer value + * @param string $flagname + * @return mixed + * @throws exception + */ + public function getFlag(string $flagname): mixed + { + if (!$this->config->exists("flag>$flagname")) { + throw new exception(self::EXCEPTION_GETFLAG_FLAGNOTFOUND, exception::$ERRORLEVEL_FATAL, $flagname); + } + return $this->config->get("flag>$flagname"); + } + + /** + * Returns true if the given flag name is set to true in the data array. Returns false otherwise + * @param int $flagvalue + * @param array $data + * @return bool + * @throws exception + * @todo Validate the flag in the model constructor (model configurator) + * @todo add \codename\core\exceptions + */ + public function isFlag(int $flagvalue, array $data): bool + { + $flagField = $this->getIdentifier() . '_flag'; + if (!array_key_exists($flagField, $data)) { + throw new exception(self::EXCEPTION_ISFLAG_NOFLAGFIELD, exception::$ERRORLEVEL_FATAL, ['field' => $flagField, 'data' => $data]); + } + return (($data[$flagField] & $flagvalue) == $flagvalue); + } + + /** + * Converts the storage format into a human-readable format + * @param modelfield $field + * @param mixed|null $value + * @return mixed + */ + public function exportField(modelfield $field, mixed $value = null): mixed + { + if (is_null($value)) { + return $value; + } + + return match ($this->getFieldtype($field)) { + 'boolean' => (bool)$value, + 'text_date' => date('Y-m-d', strtotime($value)), + default => $value, + }; + } + + /** + * returns the last query performed and stored. * @return string */ - protected function getType() : string { - return static::DB_TYPE; + public function getLastQuery(): string + { + return $this->lastQuery; } /** - * [delimitImproved description] - * @param string $field [description] - * @param [type] $value [description] - * @return [type] [description] + * Returns the lastInsertId returned from db driver + * May contain foreign ids. + * @return int|string|bool|null */ - protected function delimitImproved(string $field, $value = null) { - $fieldtype = $this->fieldTypeCache[$field] ?? $this->fieldTypeCache[$field] = $this->getFieldtypeImproved($field); + public function lastInsertId(): int|string|bool|null + { + return $this->db->lastInsertId(); + } - // CHANGED 2020-12-30 removed \is_string($value) && \strlen($value) == 0 - // Which converted '' to NULL - which is simply wrong. - if($value === null) { - return null; - } + /** + * function is required to remove the default filter from the number generator + * @return model [type] [description] + */ + public function removeDefaultFilter(): static + { + $this->defaultfilter = []; + $this->defaultAggregateFilter = []; + $this->defaultflagfilter = []; + $this->defaultfilterCollections = []; + return $this; + } - // if(strpos($fieldtype, 'text') !== false || strpos($fieldtype, 'ject_') !== false || strpos($fieldtype, 'structure') !== false) { - // return "" . $value . ""; - // } - if($fieldtype == 'number') { - if(\is_numeric($value)) { - return $value; - } - if(\strlen($value) == 0) { - return null; - } - return $value; - } - if($fieldtype == 'number_natural') { - if(\is_int($value)) { - return $value; - } - if(\is_string($value) && \strlen($value) == 0) { - return null; - } - return (int) $value; - } - if($fieldtype == 'boolean') { - if(\is_string($value) && \strlen($value) == 0) { - return null; - } - if($value) { - return true; - } + /** + * loads a new config file (uncached) + * implement me! + * @return config + */ + abstract protected function loadConfig(): config; + + /** + * Whether a model is discrete/self-contained + * and/or performs its work as a subquery + * @return bool [description] + */ + protected function isDiscreteModel(): bool + { return false; - } - if(strpos($fieldtype, 'text') === 0) { - if(\is_string($value) && \strlen($value) == 0) { - return null; - } - } - return $value; } /** - * Returns the field's value as a string. - *
It delimits the field using a colon if it is required by the field's datatype - * @param \codename\core\value\text\modelfield $field - * @param string $value - * @return string + * Initiates a servicing instance for this model + * @return void */ - protected function delimit(\codename\core\value\text\modelfield $field, $value = null) { - $fieldtype = $this->getFieldtype($field); + protected function initServicingInstance(): void + { + // no implementation for base model + } - // CHANGED 2020-12-30 removed \is_string($value) && \strlen($value) == 0 - // Which converted '' to NULL - which is simply wrong. - if($value === null) { - return null; + /** + * [getFieldlistArray description] + * @param field[] $fields [description] + * @return array [description] + */ + protected function getFieldlistArray(array $fields): array + { + $returnFields = []; + if (count($fields) > 0) { + foreach ($fields as $field) { + if ($field->alias ?? false) { + $returnFields[] = $field->alias->get(); + } else { + $returnFields[] = $field->field->get(); + } + } } - // if(strpos($fieldtype, 'text') !== false || strpos($fieldtype, 'ject_') !== false || strpos($fieldtype, 'structure') !== false) { - // return "" . $value . ""; - // } - if($fieldtype == 'number') { - if(is_numeric($value)) { - return $value; - } - if(strlen($value) == 0) { - return null; + // filter out hidden fields by difference calculation + return array_diff($returnFields, $this->hiddenFields); + } + + /** + * Perform the given query and save the result in the instance + * @param string $query + * @param array $params + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function doQuery(string $query, array $params = []): void + { + $cacheObj = null; + $cacheGroup = null; + $cacheKey = null; + // if cached, load it + if ($this->cache) { + $cacheObj = app::getCache(); + $cacheGroup = $this->getCacheGroup(); + $cacheKey = "manualcache" . md5( + serialize( + [ + get_class($this), + $this->getIdentifier(), + $query, + $this->getCurrentCacheIdentifierParameters(), + $params, + ] + ) + ); + + $this->result = $cacheObj->get($cacheGroup, $cacheKey); + + if (is_array($this->result)) { + $this->reset(); + return; } - return $value; } - if($fieldtype == 'number_natural') { - if(is_int($value)) { - return $value; - } - if(is_string($value) && strlen($value) == 0) { - return null; - } - return (int) $value; + + $this->result = $this->internalQuery($query, $params); + + // save last query + if ($this->saveLastQuery) { + $this->lastQuery = $query; } - if($fieldtype == 'boolean') { - if(\is_string($value) && \strlen($value) == 0) { - return null; - } - if($value) { - return true; + + // if cached, save it + if ($this->cache) { + $result = $this->getResult(); + if (count($result) > 0) { + $cacheObj->set($cacheGroup, $cacheKey, $result); } - return false; - } - if(strpos($fieldtype, 'text') === 0) { - if(\is_string($value) && \strlen($value) == 0) { - return null; - } } - return $value; + $this->reset(); + } + + /** + * Returns the default cache client + * @return cache + * @throws ReflectionException + * @throws exception + */ + protected function getCache(): cache + { + return app::getCache(); } /** @@ -3041,22 +3043,44 @@ protected function delimit(\codename\core\value\text\modelfield $field, $value = * @return string * @todo prevent collision by using the PSR-4 namespace from ReflectionClass:: */ - protected function getCacheGroup() : string { + protected function getCacheGroup(): string + { return get_class($this); } /** - * Deletes dependencies of elements in this model - * @return void + * [getCurrentCacheIdentifierParameters description] + * @return array [description] */ - protected function deleteChildren(string $primaryKey) { - return; + protected function getCurrentCacheIdentifierParameters(): array + { + $params = []; + $params['filter'] = $this->filter; + $params['filtercollections'] = $this->filterCollections; + foreach ($this->getNestedJoins() as $join) { + // + // CHANGED 2021-09-24: nested model's join plugin parameters were not correctly incorporated into a cache key + // + $params['nest'][] = [ + 'cacheIdentifier' => $join->model->getCurrentCacheIdentifierParameters(), + 'model' => $join->model->getIdentifier(), + 'cacheParameters' => $join->getCurrentCacheIdentifierParameters(), + ]; + } + return $params; } /** - * Gets the current model identifier (name) - * @return string + * internal query */ - public abstract function getIdentifier() : string; + abstract protected function internalQuery(string $query, array $params = []); + /** + * Deletes dependencies of elements in this model + * @param string $primaryKey + * @return void + */ + protected function deleteChildren(string $primaryKey): void + { + } } diff --git a/backend/class/model/abstractDynamicValueModel.php b/backend/class/model/abstractDynamicValueModel.php index cd28cb9..d22dd82 100644 --- a/backend/class/model/abstractDynamicValueModel.php +++ b/backend/class/model/abstractDynamicValueModel.php @@ -1,827 +1,851 @@ errorstack = new \codename\core\errorstack('MODEL_ABSTRACT_DYNAMIC_VALUE'); - $this->initializeDataModel(); - } - - /** - * [normalizeRecursivelyByFieldlist description] - * @param array $result [description] - * @return array [description] - */ - public function normalizeRecursivelyByFieldlist(array $result) : array { - $fResult = []; - // - // normalize - // TODO: Sibling Joins? - // - foreach($this->getNestedJoins() as $join) { - // normalize using nested model - BUT: only if it's NOT already actively used as a child virtual field - $found = false; - if(($children = $this->config->get('children')) != null) { - foreach($children as $field => $config) { - if($config['type'] === 'foreign') { - $foreign = $this->config->get('foreign>'.$config['field']); - if($foreign['model'] === $join->model->getIdentifier()) { - if($this->config->get('datatype>'.$field) == 'virtual') { - $found = true; - break; - } +abstract class abstractDynamicValueModel extends model +{ + /** + * [DB_TYPE description] + * @var string + */ + public const string DB_TYPE = 'bare'; + + /** + * dummy "table" name + * @var null|string + */ + public ?string $table = null; + + /** + * dummy "schema" name + * @var null|string + */ + public ?string $schema = null; + + /** + * data model internally used for storing stuff + * @var model + */ + protected model $dataModel; + /** + * contains a pkey filter value, if active + * @var mixed + */ + protected mixed $primaryKeyFilterValue = null; + /** + * contains a pkey default filter value, if active + * @var mixed + */ + protected mixed $primaryKeyDefaultFilterValue = null; + /** + * array of to-be-used filters + * @var array + */ + protected array $filterValues = []; + /** + * default filter values + * @var array + */ + protected array $defaultFilterValues = []; + /** + * the configuration for the dynamic model parts + * @var null|config + */ + protected ?config $dynamicConfig = null; + /** + * if data model uses partitioning in some way, + * we can specify a reference field + * + * @var string|null + */ + protected ?string $dataModelReferenceField = null; + /** + * override some field config overrides + * @var array|null + */ + protected ?array $dataModelReferenceFieldConfigOverride = null; + /** + * additional reference fields to be used + * @var array + */ + protected array $dataModelAdditionalReferenceFields = []; + /** + * additional reference fields to be used + * @var array + */ + protected array $dataModelAdditionalReferenceFieldConfigOverride = []; + /** + * field handlers per field + * @var array|handler[] + */ + protected array $fieldHandlerInstances = []; + /** + * field of the data model used for identifying a value + * @var string + */ + protected string $dataModelIdentifierField; + /** + * field of the data model providing the datatype + * @var string + */ + protected string $dataModelDatatypeField; + /** + * field of the data model containing the value + * @var string + */ + protected string $dataModelValueField; + /** + * define overrides that apply automatically to filter(s) + * and values saved + * @var array|null + */ + protected ?array $dataModelDatasetOverrides = null; + /** + * contains in-memory stored entries used by ":: getValue" internally + * @var array[] + */ + protected array $datasetCache = []; + + /** + * [__construct description] + * @param array $modeldata [description] + */ + public function __construct(array $modeldata) + { + parent::__construct($modeldata); + $this->errorstack = new errorstack('MODEL_ABSTRACT_DYNAMIC_VALUE'); + $this->initializeDataModel(); + } + + /** + * initialized the datamodel. to be overridden + * @return void + */ + abstract protected function initializeDataModel(): void; + + /** + * [normalizeRecursivelyByFieldlist description] + * @param array $result [description] + * @return array [description] + */ + public function normalizeRecursivelyByFieldlist(array $result): array + { + $fResult = []; + // + // normalize + // TODO: Sibling Joins? + // + foreach ($this->getNestedJoins() as $join) { + // normalize using nested model - BUT: only if it's NOT already actively used as a child virtual field + $found = false; + if (($children = $this->config->get('children')) != null) { + foreach ($children as $field => $config) { + if ($config['type'] === 'foreign') { + $foreign = $this->config->get('foreign>' . $config['field']); + if ($foreign['model'] === $join->model->getIdentifier()) { + if ($this->config->get('datatype>' . $field) == 'virtual') { + $found = true; + break; + } + } + } + } + } + if ($found) { + continue; + } + + /** + * FIXME @Kevin: Weil wegen Baum und sehr sehr russisch + * @var [type] + */ + if ($join->model instanceof schemeless\json) { + continue; + } + + $normalized = $join->model->normalizeRecursivelyByFieldlist($result); + + // // METHOD 1: merge manually, row by row + foreach ($normalized as $index => $r) { + // normalize using this model + $fResult[$index] = array_merge(($fResult[$index] ?? []), $r); } - } } - } - if($found) { - continue; - } - - /** - * FIXME @Kevin: Weil wegen Baum und sehr sehr russisch - * @var [type] - */ - if($join->model instanceof \codename\core\model\schemeless\json) { - continue; - } - - $normalized = $join->model->normalizeRecursivelyByFieldlist($result); - - // // METHOD 1: merge manually, row by row - foreach($normalized as $index => $r) { - // normalize using this model - $fResult[$index] = array_merge(($fResult[$index] ?? []), $r); - } - } - // - // Normalize using this model's fields - // - foreach($result as $index => $r) { - // normalize using this model - $fResult[$index] = array_merge(($fResult[$index] ?? []), $this->normalizeByFieldlist($r)); - } + // + // Normalize using this model's fields + // + foreach ($result as $index => $r) { + // normalize using this model + $fResult[$index] = array_merge(($fResult[$index] ?? []), $this->normalizeByFieldlist($r)); + } - return $fResult; - } - - /** - * [normalizeByFieldlist description] - * @param array $dataset [description] - * @return array [description] - */ - public function normalizeByFieldlist(array $dataset) : array { - if(count($this->hiddenFields) > 0) { - // explicitly keep out hidden fields - $dataset = array_diff_key($dataset, array_flip( $this->hiddenFields )); - } - if(count($this->fieldlist) > 0) { - // return $dataset; - return array_intersect_key( $dataset, array_flip( array_merge( $this->getFieldlistArray($this->fieldlist), $this->getFields(), array_keys($this->virtualFields) ) ) ); - } else { - // return $dataset; - return array_intersect_key( $dataset, array_flip( array_merge( $this->getFields(), array_keys($this->virtualFields)) ) ); - } - } - - - /** - * initialized the datamodel. to be overridden - * @return void - */ - protected abstract function initializeDataModel(); - - /** - * pseudo-function for setting schema & table - * - * @param string|null $connection [no-op] - * @param string $schema [description] - * @param string $table [description] - * @return model [description] - */ - public function setConfig(string $connection = null, string $schema, string $table) : model { - $this->schema = $schema; - $this->table = $table; - return $this; - } - - /** - * [setConfig description] - * @param string|null $file [description] - * @param config|null $configInstance [description] - * @return model [description] - */ - protected function setDynamicConfig(?string $file = null, ?config $configInstance = null) : model { - if(!$file && !$configInstance) { - throw new exception('ABSTRACT_DYNAMIC_VALUE_MODEL_INVALID_CONFIGURATION', exception::$ERRORLEVEL_FATAL); + return $fResult; } - if($configInstance) { - $this->dynamicConfig = $configInstance; - } else { - $this->dynamicConfig = new \codename\core\config\json($file, true, true); + + /** + * [normalizeByFieldlist description] + * @param array $dataset [description] + * @return array [description] + */ + public function normalizeByFieldlist(array $dataset): array + { + if (count($this->hiddenFields) > 0) { + // explicitly keep out hidden fields + $dataset = array_diff_key($dataset, array_flip($this->hiddenFields)); + } + if (count($this->fieldlist) > 0) { + // return $dataset; + return array_intersect_key($dataset, array_flip(array_merge($this->getFieldlistArray($this->fieldlist), $this->getFields(), array_keys($this->virtualFields)))); + } else { + // return $dataset; + return array_intersect_key($dataset, array_flip(array_merge($this->getFields(), array_keys($this->virtualFields)))); + } } - return $this; - } - - /** - * contains a pkey filter value, if active - * @var [type] - */ - protected $primaryKeyFilterValue = null; - - /** - * contains a pkey default filter value, if active - * @var [type] - */ - protected $primaryKeyDefaultFilterValue = null; - - /** - * array of to-be-used filters - * @var [type] - */ - protected $filterValues = []; - - /** - * default filter values - * @var [type] - */ - protected $defaultFilterValues = []; - - /** - * @inheritDoc - */ - public function addFilter(string $field, $value = null, string $operator = '=', string $conjunction = null) : model { - if($field === $this->primarykey && $value && $operator === '=' && $conjunction === null) { - $this->primaryKeyFilterValue = $value; - } else { - if(in_array($field, $this->getFields()) && $value && $operator === '=' && $conjunction === null) { - $this->filterValues[$field] = $value; - } else { - throw new exception('MODEL_ABSTRACT_DYNAMIC_VALUE_MODEL_UNSUPPORTED_OPERATION_ADDFILTER', exception::$ERRORLEVEL_ERROR, [ - 'field' => $field, - 'value' => $value, - 'operator' => $operator, - 'conjunction' => $conjunction - ]); - } + + /** + * pseudo-function for setting schema and table + * + * @param string|null $connection [no-op] + * @param string $schema [description] + * @param string $table [description] + * @return model [description] + */ + public function setConfig(?string $connection, string $schema, string $table): model + { + $this->schema = $schema; + $this->table = $table; + return $this; } - return $this; - } - - /** - * @inheritDoc - */ - public function addDefaultFilter(string $field, $value = null, string $operator = '=', string $conjunction = null) : model { - // echo("add Default filter ". $field .", ". $value.", ". $operator.", ". $conjunction. " pkey: ".$this->primarykey); - if($field === $this->primarykey && $value && $operator === '=' && $conjunction === null) { - $this->primaryKeyDefaultFilterValue = $value; - $this->primaryKeyFilterValue = $this->primaryKeyDefaultFilterValue; - } else { - if(in_array($field, $this->getFields()) && $value && $operator === '=' && $conjunction === null) { - $this->defaultFilterValues[$field] = $value; - $this->filterValues[$field] = $this->defaultFilterValues[$field]; - } else { - throw new exception('MODEL_ABSTRACT_DYNAMIC_VALUE_MODEL_UNSUPPORTED_OPERATION_ADDDEFAULTFILTER', exception::$ERRORLEVEL_ERROR, [ - 'field' => $field, - 'value' => $value, - 'operator' => $operator, - 'conjunction' => $conjunction - ]); - } + + /** + * {@inheritDoc} + */ + public function setLimit(int $limit): model + { + return $this; } - return $this; - } - - /** - * @inheritDoc - */ - public function setLimit(int $limit) : model - { - return $this; - } - - /** - * the configuration for the dynamic model parts - * @var config - */ - protected $dynamicConfig = null; - - /** - * enables setting the primary key - * which may be a special reference field in the data model - * @param string $name [description] - * @param string|null $referenceField [if defined, uses the field as reference during save] - * @param array|null $configOverride [allows overriding the configuration for the PKEY field] - */ - protected function setPrimaryKey(string $name, ?string $referenceField = null, ?array $configOverride = null) { - $this->primarykey = $name; - if($referenceField) { - $this->dataModelReferenceField = $referenceField; + + /** + * [getFieldHandlers description] + * @param [type] $field [description] + * @return handler|null + * @throws ReflectionException + * @throws exception + */ + public function getFieldHandlers($field): ?array + { + if ($fieldHandlers = $this->config->get('field_handler>' . $field)) { + foreach ($fieldHandlers as $handlerName => $handlerConfig) { + if (!isset($this->fieldHandlerInstances[$field][$handlerName])) { + $class = app::getInheritedClass('handler_' . $handlerName); + $handlerInstance = new $class($handlerConfig); + $this->fieldHandlerInstances[$field][$handlerName] = $handlerInstance; + } + } + } + return $this->fieldHandlerInstances[$field] ?? null; } - if($configOverride) { - $this->dataModelReferenceFieldConfigOverride = $configOverride; + + /** + * {@inheritDoc} + */ + public function reset(): void + { + parent::reset(); + // reset the special filter provided above + $this->primaryKeyFilterValue = $this->primaryKeyDefaultFilterValue; + $this->filterValues = $this->defaultFilterValues; } - } - - /** - * if data model uses partitioning in some way - * we can specify a reference field - * - * @var string|null - */ - protected $dataModelReferenceField = null; - - /** - * override some field config overrides - * @var array|null - */ - protected $dataModelReferenceFieldConfigOverride = null; - - /** - * additional reference fields to be used - * @var array - */ - protected $dataModelAdditionalReferenceFields = []; - - /** - * additional reference fields to be used - * @var array - */ - protected $dataModelAdditionalReferenceFieldConfigOverride = []; - - - /** - * enables setting an additional reference field - * which may be a special reference field in the data model - * @param string $name [description] - * @param string|null $referenceField [if defined, uses the field as reference during save] - * @param array|null $configOverride [allows overriding the configuration for the PKEY field] - */ - protected function addAdditionalReferenceField(string $name, ?string $referenceField = null, ?array $configOverride = null) { - $this->dataModelAdditionalReferenceFields[$name] = $referenceField; - if($configOverride) { - $this->dataModelAdditionalReferenceFieldConfigOverride[$name] = $configOverride; + + /** + * {@inheritDoc} + */ + public function delete(mixed $primaryKey = null): model + { + throw new LogicException('Not implemented'); // TODO } - } - - /** - * @inheritDoc - */ - protected function loadConfig() : config - { - $components = $this->dynamicConfig->get(); - - $config = [ - 'field' => [ $this->primarykey ], - 'primary' => [ $this->primarykey ], - 'required' => [], - 'foreign' => [], - 'formConfigProvider' => [], - 'datatype' => [], - ]; - - // add a foreign key - if($this->dataModelReferenceField) { - - $fieldConfig = [ - 'datatype' => $this->dataModel->getConfig()->get('datatype>'.$this->dataModelReferenceField), - // do not set FKEY. crud intervenes badly ATM. - // 'foreign' => [ - // 'model' => $this->dataModel->getIdentifier(), - // 'schema' => $this->dataModel->schema, - // 'table' => $this->dataModel->table, - // 'key' => $this->dataModelReferenceField, - // 'display' => '{$element["'.$this->dataModelReferenceField.'"]}' - // ] - ]; - if($this->dataModelReferenceFieldConfigOverride) { - $fieldConfig = array_replace_recursive($fieldConfig, $this->dataModelReferenceFieldConfigOverride); - } - - $components = array_merge( - [ $this->primarykey => $fieldConfig ], - $components - ); - - // primary key can only be supplied here - $config['primary'] = $this->primarykey; + + /** + * [saveWithChildren description] + * @param array $data [description] + * @return model [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function saveWithChildren(array $data): model + { + return $this->save($data); } - // Additional reference fields - if($this->dataModelAdditionalReferenceFields) { - foreach($this->dataModelAdditionalReferenceFields as $field => $referenceField) { - $fieldConfig = [ - 'datatype' => $this->dataModel->getConfig()->get('datatype>'.$referenceField), - // do not set FKEY. crud intervenes badly ATM. - // 'foreign' => [ - // 'model' => $this->dataModel->getIdentifier(), - // 'schema' => $this->dataModel->schema, - // 'table' => $this->dataModel->table, - // 'key' => $this->dataModelReferenceField, - // 'display' => '{$element["'.$this->dataModelReferenceField.'"]}' - // ] - ]; - if($configOverride = $this->dataModelAdditionalReferenceFieldConfigOverride[$field]) { - $fieldConfig = array_replace_recursive($fieldConfig, $configOverride); + /** + * {@inheritDoc} + * @param array $data + * @return model + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function save(array $data): model + { + $dataModel = $this->dataModel; + + // todo: dynamic transaction name based on this->getIdentifier? + $transaction = new transaction($this->getIdentifier() . '_data_save', [$dataModel]); + $transaction->start(); + + $invalidateFieldCaches = []; + + $useDataModelReferenceFieldValue = $this->dataModelReferenceField ? $data[$this->primarykey] : false; + + $useDataModelAdditionalReferenceFieldValues = count($this->dataModelAdditionalReferenceFields) > 0 ? array_intersect_key($data, array_flip($this->dataModelAdditionalReferenceFields)) : false; + + // DEBUG + // $saveCache = []; + + // open transaction? + foreach ($this->getFields() as $field) { + // + // NOTE/CHANGED 2019-11-07: filter for PKEY and additional reference fields (the latter one has been added) + // + if (array_key_exists($field, $data) && ($field !== $this->getPrimaryKey()) && !(array_key_exists($field, $this->dataModelAdditionalReferenceFields))) { + // TODO! + $saveData = [ + $this->dataModelIdentifierField => $field, + $this->dataModelValueField => $data[$field], + $this->dataModelDatatypeField => $this->getConfig()->get('datatype>' . $field), + ]; + + // overriding the pkey value + if ($useDataModelReferenceFieldValue !== false) { + $saveData[$this->dataModelReferenceField] = $useDataModelReferenceFieldValue; + } + // overriding additional reference field values + // here lies the DDD-magic (Domain Driven Design) + if ($useDataModelAdditionalReferenceFieldValues !== false) { + $saveData = array_replace($saveData, $useDataModelAdditionalReferenceFieldValues); + } + + $saveData[$this->dataModelValueField] = $this->applyFieldHandler($field, $saveData[$this->dataModelValueField], $data); + + // override/add some data provided via dataset overrides + // e.g., fixed types + if ($this->dataModelDatasetOverrides) { + $saveData = array_replace($saveData, $this->dataModelDatasetOverrides); + } + + // try to get pkey (of dataModel - e.g., variable_id or portalsetting_id) and set, if defined (to allow creating OR update) + $dataModel->addFilter($this->dataModelIdentifierField, $field); + + // we have to filter it... + if ($useDataModelReferenceFieldValue !== false) { + $dataModel->addFilter($this->dataModelReferenceField, $useDataModelReferenceFieldValue); + } + // filtering additional reference fields + // here lies the DDD-magic (Domain Driven Design) + if ($useDataModelAdditionalReferenceFieldValues !== false) { + foreach ($useDataModelAdditionalReferenceFieldValues as $key => $value) { + $dataModel->addFilter($key, $value); + } + } + + $dataset = $dataModel->search()->getResult()[0] ?? null; + + if ($dataset) { + $saveData[$dataModel->getPrimaryKey()] = $dataset[$dataModel->getPrimaryKey()]; + } + + // TEST: Skip saving null values + if ($dataset === null && $data[$field] === null) { + continue; + } + + // \codename\core\app::getResponse()->setData('DEBUG_SAVE_'.$field, $saveData); + // continue; + // return $this; + + // extend with some basic data? + $dataModel->save($saveData); + + // DEBUG + // $saveCache[] = $saveData; + + $invalidateFieldCaches[] = $field; + } } - $components = array_merge( - $components, - [ $field => $fieldConfig ] - ); + // DEBUG! + // \codename\core\app::getResponse()->setData('model_'.$this->getIdentifier().'_config', [ + // '$this->dataModelReferenceField' => $this->dataModelReferenceField, + // '$data[$this->primarykey]' => $data[$this->primarykey], + // ]); + // \codename\core\app::getResponse()->setData('model_'.$this->getIdentifier().'_save_cache', $saveCache); + + $transaction->end(); + + // reset cached values? + $this->invalidateFieldCaches($invalidateFieldCaches); - // primary key can only be supplied here - // $config['primary'] = $this->primarykey; - } + return $this; } - foreach($components as $key => $var) { - // Add key as "field" - $config['field'][] = $key; - - // Supply datatype - if($var['datatype']) { - $config['datatype'][$key] = $var['datatype']; - } else { - // error? - } - - // required state - if($var['required'] ?? false) { - $config['required'][] = $key; - } - - // optional - if($var['foreign'] ?? false) { - $config['foreign'][$key] = $var['foreign']; - } - - // field handler - if($var['field_handler'] ?? false) { - $config['field_handler'][$key] = $var['field_handler']; - } - - // formConfigProvider for a field - // e.g - // - "field": "..." (field reference used - FKEY needed) - // - "inheritedClass" : "..." explicit value (class) - // - if($var['formConfigProvider'] ?? false) { - $config['formConfigProvider'][$key] = $var['formConfigProvider']; - } - - // form field config override(s) - if($var['fieldconfig'] ?? false) { - $config['fieldconfig'][$key] = $var['fieldconfig']; - } - // categorize - $config['category'][$key] = $var['category'] ?? 'default'; // fallback! - - // TODO: editing rights? + /** + * apply field handlers (if configured) + * @param string $field [description] + * @param mixed $value [description] + * @param array $dataset + * @return mixed [description] + * @throws ReflectionException + * @throws exception + */ + protected function applyFieldHandler(string $field, mixed $value, array $dataset): mixed + { + if ($fieldHandlers = $this->config->get('field_handler>' . $field)) { + foreach ($fieldHandlers as $handlerName => $handlerConfig) { + if (!isset($this->fieldHandlerInstances[$field][$handlerName])) { + $class = app::getInheritedClass('handler_' . $handlerName); + $handlerInstance = new $class($handlerConfig); + $this->fieldHandlerInstances[$field][$handlerName] = $handlerInstance; + } + $value = $this->fieldHandlerInstances[$field][$handlerName]->handleValue($value, $dataset); + } + } + return $value; + } + + /** + * {@inheritDoc} + */ + public function addFilter(string $field, mixed $value = null, string $operator = '=', string $conjunction = null): model + { + if ($field === $this->primarykey && $value && $operator === '=' && $conjunction === null) { + $this->primaryKeyFilterValue = $value; + } elseif (in_array($field, $this->getFields()) && $value && $operator === '=' && $conjunction === null) { + $this->filterValues[$field] = $value; + } else { + throw new exception('MODEL_ABSTRACT_DYNAMIC_VALUE_MODEL_UNSUPPORTED_OPERATION_ADDFILTER', exception::$ERRORLEVEL_ERROR, [ + 'field' => $field, + 'value' => $value, + 'operator' => $operator, + 'conjunction' => $conjunction, + ]); + } + return $this; } - $this->config = new \codename\core\config($config); - return $this->config; - } - - /** - * field handlers per field - * @var array|\codename\core\handler[] - */ - protected $fieldHandlerInstances = []; - - /** - * apply field handlers (if configured) - * @param string $field [description] - * @param mixed $value [description] - * @param array $dataset - * @return mixed [description] - */ - protected function applyFieldHandler($field, $value, array $dataset) { - if($fieldHandlers = $this->config->get('field_handler>'.$field)) { - foreach($fieldHandlers as $handlerName => $handlerConfig) { - if(!isset($this->fieldHandlerInstances[$field][$handlerName])) { - $class = app::getInheritedClass('handler_'.$handlerName); - $handlerInstance = new $class($handlerConfig); - $this->fieldHandlerInstances[$field][$handlerName] = $handlerInstance; + /** + * {@inheritDoc} + * @return model + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function search(): model + { + $dataset = []; + foreach ($this->getFields() as $field) { + if (in_array($field, $this->hiddenFields)) { + continue; + } + if ($field !== $this->getPrimaryKey()) { + $val = $this->getValue($field, $this->primaryKeyFilterValue); + if (array_key_exists($field, $this->filterValues)) { + if ($this->filterValues[$field] != $val) { + $this->result = []; + return $this; + } + } + $dataset[$field] = $val; + } else { + $dataset[$this->getPrimaryKey()] = $this->primaryKeyFilterValue; + } + } + + foreach ($this->getFields() as $field) { + if (in_array($field, $this->hiddenFields)) { + continue; + } + $dataset[$field] = $this->getFieldHandlerOutput($field, $dataset[$field], $dataset); } - $value = $this->fieldHandlerInstances[$field][$handlerName]->handleValue($value, $dataset); - } + + $this->result = [$dataset]; + return $this; } - return $value; - } - - /** - * get getOutput() values of field handlers - * - * @param [type] $field [description] - * @param [type] $value [description] - * @param array $dataset [description] - * @return mixed [description] - */ - protected function getFieldHandlerOutput($field, $value, array $dataset) { - if($fieldHandlers = $this->config->get('field_handler>'.$field)) { - foreach($fieldHandlers as $handlerName => $handlerConfig) { - if(!isset($this->fieldHandlerInstances[$field][$handlerName])) { - $class = app::getInheritedClass('handler_'.$handlerName); - $handlerInstance = new $class($handlerConfig); - $this->fieldHandlerInstances[$field][$handlerName] = $handlerInstance; + + /** + * returns the value for the given identifier - or null + * + * @param string $identifier [description] + * @param mixed $referenceFieldValue [description] + * @return mixed + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception + */ + protected function getValue(string $identifier, mixed $referenceFieldValue = null): mixed + { + $cacheKey = $referenceFieldValue ?? 0; + + if (!($this->datasetCache[$cacheKey] ?? false)) { + if ($this->dataModelReferenceField) { + $this->dataModel->addFilter($this->dataModelReferenceField, $referenceFieldValue); + } + + // TODO: additional reference fields + + // TODO: check for duplicates? + + // + // retrieve all available datasets (using given reference field) + // to avoid consecutive calls to this function + // querying DB/data again and again + // + $res = $this->dataModel + ->search()->getResult(); + + // map identifier to a key of to-be-cached resultset items + $result = []; + foreach ($res as $r) { + $result[$r[$this->dataModelIdentifierField]] = $r; + } + $this->datasetCache[$cacheKey] = $result; } - $value = $this->fieldHandlerInstances[$field][$handlerName]->getOutput($value, $dataset); - } + + return $this->datasetCache[$cacheKey][$identifier][$this->dataModelValueField] ?? null; } - return $value; - } - - /** - * [getFieldHandlers description] - * @param [type] $field [description] - * @return \codename\core\handler[]|null - */ - public function getFieldHandlers($field) : ?array { - if($fieldHandlers = $this->config->get('field_handler>'.$field)) { - foreach($fieldHandlers as $handlerName => $handlerConfig) { - if(!isset($this->fieldHandlerInstances[$field][$handlerName])) { - $class = app::getInheritedClass('handler_'.$handlerName); - $handlerInstance = new $class($handlerConfig); - $this->fieldHandlerInstances[$field][$handlerName] = $handlerInstance; + + /** + * get getOutput() values of field handlers + * + * @param $field + * @param $value + * @param array $dataset [description] + * @return mixed [description] + * @throws ReflectionException + * @throws exception + */ + protected function getFieldHandlerOutput($field, $value, array $dataset): mixed + { + if ($fieldHandlers = $this->config->get('field_handler>' . $field)) { + foreach ($fieldHandlers as $handlerName => $handlerConfig) { + if (!isset($this->fieldHandlerInstances[$field][$handlerName])) { + $class = app::getInheritedClass('handler_' . $handlerName); + $handlerInstance = new $class($handlerConfig); + $this->fieldHandlerInstances[$field][$handlerName] = $handlerInstance; + } + $value = $this->fieldHandlerInstances[$field][$handlerName]->getOutput($value, $dataset); + } } - } + return $value; } - return $this->fieldHandlerInstances[$field] ?? null; - } - - /** - * @inheritDoc - */ - protected function internalQuery(string $query, array $params = array()) - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - protected function internalGetResult() : array - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * field of the data model used for identifying a value - * @var string - */ - protected $dataModelIdentifierField; - - /** - * field of the data model providing the datatype - * @var string - */ - protected $dataModelDatatypeField; - - /** - * field of the data model containing the value - * @var string - */ - protected $dataModelValueField; - - /** - * define overrides that apply automatically to filter(s) - * and values saved - * @var array|null - */ - protected $dataModelDatasetOverrides = null; - - /** - * step needed for setting basic parameters - * for handling the data model - * - * @param string $identifierField [description] - * @param string $datatypeField [description] - * @param string $valueField [description] - */ - protected function setDataModelConfig(string $identifierField, string $datatypeField, string $valueField) { - $this->dataModelIdentifierField = $identifierField; - $this->dataModelDatatypeField = $datatypeField; - $this->dataModelValueField = $valueField; - } - - /** - * [setDataModelDatasetOverrides description] - * @param array $dataset [description] - */ - protected function setDataModelDatasetOverrides(array $dataset) { - if($this->dataModelDatasetOverrides) { - throw new exception('DATAMODEL_DATASET_OVERRIDES_CAN_ONLY_BE_SET_ONCE', exception::$ERRORLEVEL_ERROR); + + /** + * optional method cleaning separate caches, on demand. + * @param array $fields + */ + protected function invalidateFieldCaches(array $fields): void + { } - $this->dataModelDatasetOverrides = $dataset; - if($this->dataModelDatasetOverrides) { - foreach($this->dataModelDatasetOverrides as $field => $value) { - $this->dataModel->addDefaultfilter($field, $value); - } + /** + * {@inheritDoc} + */ + public function copy(mixed $primaryKey): model + { + throw new LogicException('Not implemented'); // TODO } - } - - /** - * returns the value for the given identifier - or null - * - * @param string $identifier [description] - * @param mixed|null $referenceFieldValue [description] - * @return mixed|null - */ - protected function getValue(string $identifier, $referenceFieldValue = null) { - $cacheKey = $referenceFieldValue ?? 0; - $dataset = null; - - if(!($this->datasetCache[$cacheKey] ?? false)) { - if($this->dataModelReferenceField) { - $this->dataModel->addFilter($this->dataModelReferenceField, $referenceFieldValue); - } - - // TODO: additional reference fields - - // TODO: check for duplicates? - - // - // retrieve all available datasets (using given reference field) - // to avoid consecutive calls to this function - // querying DB/data again and again - // - $res = $this->dataModel - ->search()->getResult(); - - // map identifier to key of to-be-cached resultset items - $result = []; - foreach($res as $r) { - $result[$r[$this->dataModelIdentifierField]] = $r; - } - $this->datasetCache[$cacheKey] = $result; + + /** + * {@inheritDoc} + */ + public function withFlag(int $flagval): model + { + throw new LogicException('Not implemented'); // TODO } - return $this->datasetCache[$cacheKey][$identifier][$this->dataModelValueField] ?? null; - } - - /** - * contains in-memory stored entries used by ::getValue internally - * @var array[] - */ - protected $datasetCache = []; - - /** - * @inheritDoc - */ - public function search() : model - { - $dataset = []; - foreach($this->getFields() as $field) { - if(in_array($field, $this->hiddenFields)) { - continue; - } - if($field !== $this->getPrimarykey()) { - $val = $this->getValue($field, $this->primaryKeyFilterValue); - if(array_key_exists($field, $this->filterValues)) { - if($this->filterValues[$field] != $val) { - $this->result = []; - return $this; - } - } - $dataset[$field] = $val; - } else { - $dataset[$this->getPrimarykey()] = $this->primaryKeyFilterValue; - } + /** + * {@inheritDoc} + */ + public function withoutFlag(int $flagval): model + { + throw new LogicException('Not implemented'); // TODO } - foreach($this->getFields() as $field) { - if(in_array($field, $this->hiddenFields)) { - continue; - } - $dataset[$field] = $this->getFieldHandlerOutput($field, $dataset[$field], $dataset); + /** + * {@inheritDoc} + */ + public function withDefaultFlag(int $flagval): model + { + throw new LogicException('Not implemented'); // TODO } - $this->result = [$dataset]; - return $this; - } - - /** - * @inheritDoc - */ - public function reset() - { - parent::reset(); - // reset the special filter provided above - $this->primaryKeyFilterValue = $this->primaryKeyDefaultFilterValue; - $this->filterValues = $this->defaultFilterValues; - } - - /** - * @inheritDoc - */ - public function delete($primaryKey = null) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function save(array $data) : model - { - $dataModel = $this->dataModel; - - // todo: dynamic transaction name based on this->getIdentifier? - $transaction = new \codename\core\transaction($this->getIdentifier().'_data_save', [$dataModel]); - $transaction->start(); - - $invalidateFieldCaches = []; - - $useDataModelReferenceFieldValue = $this->dataModelReferenceField ? $data[$this->primarykey] : false; - - $useDataModelAdditionalReferenceFieldValues = count($this->dataModelAdditionalReferenceFields) > 0 ? array_intersect_key($data, array_flip($this->dataModelAdditionalReferenceFields)): false; - - // DEBUG - // $saveCache = []; - - // open transaction? - foreach($this->getFields() as $field) { - // - // NOTE/CHANGED 2019-11-07: filter for PKEY and additional reference fields (the latter one has been added) - // - if(array_key_exists($field, $data) && ($field !== $this->getPrimarykey()) && !(array_key_exists($field, $this->dataModelAdditionalReferenceFields))) { - - // TODO! - $saveData = [ - $this->dataModelIdentifierField => $field, - $this->dataModelValueField => $data[$field], - $this->dataModelDatatypeField => $this->getConfig()->get('datatype>'.$field) - ]; + /** + * {@inheritDoc} + */ + public function withoutDefaultFlag(int $flagval): model + { + throw new LogicException('Not implemented'); // TODO + } - // overriding the pkey value - if($useDataModelReferenceFieldValue !== false) { - $saveData[$this->dataModelReferenceField] = $useDataModelReferenceFieldValue; + /** + * [setConfig description] + * @param string|null $file [description] + * @param config|null $configInstance [description] + * @return model [description] + * @throws ReflectionException + * @throws exception + */ + protected function setDynamicConfig(?string $file = null, ?config $configInstance = null): model + { + if (!$file && !$configInstance) { + throw new exception('ABSTRACT_DYNAMIC_VALUE_MODEL_INVALID_CONFIGURATION', exception::$ERRORLEVEL_FATAL); } - // overriding additional reference field values - // here lies the DDD-magic (Domain Driven Design) - if($useDataModelAdditionalReferenceFieldValues !== false) { - $saveData = array_replace($saveData, $useDataModelAdditionalReferenceFieldValues); + if ($configInstance) { + $this->dynamicConfig = $configInstance; + } else { + $this->dynamicConfig = new json($file, true, true); } + return $this; + } - $saveData[$this->dataModelValueField] = $this->applyFieldHandler($field, $saveData[$this->dataModelValueField], $data); + /** + * enables setting the primary key, + * which may be a special reference field in the data model + * @param string $name [description] + * @param string|null $referenceField [if defined, uses the field as reference during save] + * @param array|null $configOverride [allows overriding the configuration for the PKEY field] + */ + protected function setPrimaryKey(string $name, ?string $referenceField = null, ?array $configOverride = null): void + { + $this->primarykey = $name; + if ($referenceField) { + $this->dataModelReferenceField = $referenceField; + } + if ($configOverride) { + $this->dataModelReferenceFieldConfigOverride = $configOverride; + } + } - // override/add some data provided via dataset overrides - // e.g. fixed types - if($this->dataModelDatasetOverrides) { - $saveData = array_replace($saveData, $this->dataModelDatasetOverrides); + /** + * enables setting an additional reference field, + * which may be a special reference field in the data model + * @param string $name [description] + * @param string|null $referenceField [if defined, uses the field as reference during save] + * @param array|null $configOverride [allows overriding the configuration for the PKEY field] + */ + protected function addAdditionalReferenceField(string $name, ?string $referenceField = null, ?array $configOverride = null): void + { + $this->dataModelAdditionalReferenceFields[$name] = $referenceField; + if ($configOverride) { + $this->dataModelAdditionalReferenceFieldConfigOverride[$name] = $configOverride; } + } - // try to get pkey (of dataModel - e.g. variable_id or portalsetting_id) and set, if defined (to allow create OR update) - $dataModel->addFilter($this->dataModelIdentifierField, $field); + /** + * {@inheritDoc} + */ + protected function loadConfig(): config + { + $components = $this->dynamicConfig->get(); + + $config = [ + 'field' => [$this->primarykey], + 'primary' => [$this->primarykey], + 'required' => [], + 'foreign' => [], + 'formConfigProvider' => [], + 'datatype' => [], + ]; + + // add a foreign key + if ($this->dataModelReferenceField) { + $fieldConfig = [ + 'datatype' => $this->dataModel->getConfig()->get('datatype>' . $this->dataModelReferenceField), + // do not set FKEY. crud intervenes badly ATM. + // 'foreign' => [ + // 'model' => $this->dataModel->getIdentifier(), + // 'schema' => $this->dataModel->schema, + // 'table' => $this->dataModel->table, + // 'key' => $this->dataModelReferenceField, + // 'display' => '{$element["'.$this->dataModelReferenceField.'"]}' + // ] + ]; + if ($this->dataModelReferenceFieldConfigOverride) { + $fieldConfig = array_replace_recursive($fieldConfig, $this->dataModelReferenceFieldConfigOverride); + } - // we have to filter it... - if($useDataModelReferenceFieldValue !== false) { - $dataModel->addFilter($this->dataModelReferenceField, $useDataModelReferenceFieldValue); + $components = array_merge( + [$this->primarykey => $fieldConfig], + $components + ); + + // primary key can only be supplied here + $config['primary'] = $this->primarykey; } - // filtering additional reference fields - // here lies the DDD-magic (Domain Driven Design) - if($useDataModelAdditionalReferenceFieldValues !== false) { - foreach($useDataModelAdditionalReferenceFieldValues as $key => $value) { - $dataModel->addFilter($key, $value); - } + + // Additional reference fields + if ($this->dataModelAdditionalReferenceFields) { + foreach ($this->dataModelAdditionalReferenceFields as $field => $referenceField) { + $fieldConfig = [ + 'datatype' => $this->dataModel->getConfig()->get('datatype>' . $referenceField), + // do not set FKEY. crud intervenes badly ATM. + // 'foreign' => [ + // 'model' => $this->dataModel->getIdentifier(), + // 'schema' => $this->dataModel->schema, + // 'table' => $this->dataModel->table, + // 'key' => $this->dataModelReferenceField, + // 'display' => '{$element["'.$this->dataModelReferenceField.'"]}' + // ] + ]; + if ($configOverride = $this->dataModelAdditionalReferenceFieldConfigOverride[$field]) { + $fieldConfig = array_replace_recursive($fieldConfig, $configOverride); + } + + $components = array_merge( + $components, + [$field => $fieldConfig] + ); + + // primary key can only be supplied here + // $config['primary'] = $this->primarykey; + } } - $dataset = $dataModel->search()->getResult()[0] ?? null; + foreach ($components as $key => $var) { + // Add key as "field" + $config['field'][] = $key; - if($dataset) { - $saveData[$dataModel->getPrimarykey()] = $dataset[$dataModel->getPrimarykey()]; - } + // Supply datatype + if ($var['datatype']) { + $config['datatype'][$key] = $var['datatype']; + } else { + // error? + } + + // required state + if ($var['required'] ?? false) { + $config['required'][] = $key; + } + + // optional + if ($var['foreign'] ?? false) { + $config['foreign'][$key] = $var['foreign']; + } + + // field handler + if ($var['field_handler'] ?? false) { + $config['field_handler'][$key] = $var['field_handler']; + } + + // formConfigProvider for a field + // e.g. + // - "field": "..." (field reference used - FKEY needed) + // - "inheritedClass" : "..." explicit value (class) + // + if ($var['formConfigProvider'] ?? false) { + $config['formConfigProvider'][$key] = $var['formConfigProvider']; + } + + // form field config override(s) + if ($var['fieldconfig'] ?? false) { + $config['fieldconfig'][$key] = $var['fieldconfig']; + } + // categorize + $config['category'][$key] = $var['category'] ?? 'default'; // fallback! - // TEST: Skip saving null values - if($dataset === null && $data[$field] === null) { - continue; + // TODO: editing rights? } - // \codename\core\app::getResponse()->setData('DEBUG_SAVE_'.$field, $saveData); - // continue; - // return $this; + $this->config = new config($config); + return $this->config; + } - // extend with some basic data? - $dataModel->save($saveData); + /** + * {@inheritDoc} + */ + protected function internalQuery(string $query, array $params = []) + { + throw new LogicException('Not implemented'); // TODO + } - // DEBUG - // $saveCache[] = $saveData; + /** + * {@inheritDoc} + */ + protected function internalGetResult(): array + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * step needed for setting basic parameters + * for handling the data model + * + * @param string $identifierField [description] + * @param string $datatypeField [description] + * @param string $valueField [description] + */ + protected function setDataModelConfig(string $identifierField, string $datatypeField, string $valueField): void + { + $this->dataModelIdentifierField = $identifierField; + $this->dataModelDatatypeField = $datatypeField; + $this->dataModelValueField = $valueField; + } - $invalidateFieldCaches[] = $field; - } + /** + * [setDataModelDatasetOverrides description] + * @param array $dataset [description] + * @throws ReflectionException + * @throws exception + */ + protected function setDataModelDatasetOverrides(array $dataset): void + { + if ($this->dataModelDatasetOverrides) { + throw new exception('DATAMODEL_DATASET_OVERRIDES_CAN_ONLY_BE_SET_ONCE', exception::$ERRORLEVEL_ERROR); + } + $this->dataModelDatasetOverrides = $dataset; + + if ($this->dataModelDatasetOverrides) { + foreach ($this->dataModelDatasetOverrides as $field => $value) { + $this->dataModel->addDefaultFilter($field, $value); + } + } } - // DEBUG! - // \codename\core\app::getResponse()->setData('model_'.$this->getIdentifier().'_config', [ - // '$this->dataModelReferenceField' => $this->dataModelReferenceField, - // '$data[$this->primarykey]' => $data[$this->primarykey], - // ]); - // \codename\core\app::getResponse()->setData('model_'.$this->getIdentifier().'_save_cache', $saveCache); - - $transaction->end(); - - // reset cached values? - $this->invalidateFieldCaches($invalidateFieldCaches); - - return $this; - } - - /** - * optional method cleaning separate caches, on demand. - * @param string[] $fields - */ - protected function invalidateFieldCaches($fields): void { - return; - } - - /** - * [saveWithChildren description] - * @param array $data [description] - * @return model [description] - */ - public function saveWithChildren(array $data) : model { - return $this->save($data); - } - - /** - * @inheritDoc - */ - public function copy($primaryKey) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function withFlag(int $flagval) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function withoutFlag(int $flagval) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function withDefaultFlag(int $flagval) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function withoutDefaultFlag(int $flagval) : model - { - throw new \LogicException('Not implemented'); // TODO - } + /** + * {@inheritDoc} + */ + public function addDefaultFilter(string $field, mixed $value = null, string $operator = '=', string $conjunction = null): model + { + if ($field === $this->primarykey && $value && $operator === '=' && $conjunction === null) { + $this->primaryKeyDefaultFilterValue = $value; + $this->primaryKeyFilterValue = $this->primaryKeyDefaultFilterValue; + } elseif (in_array($field, $this->getFields()) && $value && $operator === '=' && $conjunction === null) { + $this->defaultFilterValues[$field] = $value; + $this->filterValues[$field] = $this->defaultFilterValues[$field]; + } else { + throw new exception('MODEL_ABSTRACT_DYNAMIC_VALUE_MODEL_UNSUPPORTED_OPERATION_ADDDEFAULTFILTER', exception::$ERRORLEVEL_ERROR, [ + 'field' => $field, + 'value' => $value, + 'operator' => $operator, + 'conjunction' => $conjunction, + ]); + } + return $this; + } } diff --git a/backend/class/model/discreteModelSchematicSqlInterface.php b/backend/class/model/discreteModelSchematicSqlInterface.php index 122dcf0..793dfc6 100644 --- a/backend/class/model/discreteModelSchematicSqlInterface.php +++ b/backend/class/model/discreteModelSchematicSqlInterface.php @@ -1,4 +1,5 @@ field = $field; $this->fieldBase = $fieldBase; $this->calculationType = $calculationType; return $this; } - } diff --git a/backend/class/model/plugin/aggregate/aggregateInterface.php b/backend/class/model/plugin/aggregate/aggregateInterface.php index 47318f1..69bf37b 100644 --- a/backend/class/model/plugin/aggregate/aggregateInterface.php +++ b/backend/class/model/plugin/aggregate/aggregateInterface.php @@ -1,30 +1,31 @@ calculationType) { - case 'count': - $sql = 'COUNT('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'count_distinct': - $sql = 'COUNT(DISTINCT '.$tableAlias.$this->fieldBase->get().')'; - break; - case 'sum': - $sql = 'SUM('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'avg': - $sql = 'AVG('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'max': - $sql = 'MAX('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'min': - $sql = 'MIN('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'year': - $sql = 'YEAR('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'quarter': - $sql = 'QUARTER('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'month': - $sql = 'MONTH('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'day': - $sql = 'DAY('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'timestampdiff-year': - $sql = 'TIMESTAMPDIFF(YEAR, '.$tableAlias.$this->fieldBase->get().', CURDATE())'; - break; - default: - throw new exception('EXCEPTION_MODEL_PLUGIN_CALCULATION_MYSQL_UNKKNOWN_CALCULATION_TYPE', exception::$ERRORLEVEL_ERROR, $this->calculationType); - break; +class mysql extends aggregate implements aggregateInterface +{ + /** + * {@inheritDoc} + * @param string|null $tableAlias + * @return string + * @throws exception + */ + public function get(string $tableAlias = null): string + { + $tableAlias = $tableAlias ? $tableAlias . '.' : ''; + $sql = match ($this->calculationType) { + 'count' => 'COUNT(' . $tableAlias . $this->fieldBase->get() . ')', + 'count_distinct' => 'COUNT(DISTINCT ' . $tableAlias . $this->fieldBase->get() . ')', + 'sum' => 'SUM(' . $tableAlias . $this->fieldBase->get() . ')', + 'avg' => 'AVG(' . $tableAlias . $this->fieldBase->get() . ')', + 'max' => 'MAX(' . $tableAlias . $this->fieldBase->get() . ')', + 'min' => 'MIN(' . $tableAlias . $this->fieldBase->get() . ')', + 'year' => 'YEAR(' . $tableAlias . $this->fieldBase->get() . ')', + 'quarter' => 'QUARTER(' . $tableAlias . $this->fieldBase->get() . ')', + 'month' => 'MONTH(' . $tableAlias . $this->fieldBase->get() . ')', + 'day' => 'DAY(' . $tableAlias . $this->fieldBase->get() . ')', + 'timestampdiff-year' => 'TIMESTAMPDIFF(YEAR, ' . $tableAlias . $this->fieldBase->get() . ', CURDATE())', + default => throw new exception('EXCEPTION_MODEL_PLUGIN_CALCULATION_MYSQL_UNKNOWN_CALCULATION_TYPE', exception::$ERRORLEVEL_ERROR, $this->calculationType), + }; + return $sql . ' AS ' . $this->field->get(); } - return $sql . ' AS ' . $this->field->get(); - } - } diff --git a/backend/class/model/plugin/aggregate/postgresql.php b/backend/class/model/plugin/aggregate/postgresql.php new file mode 100644 index 0000000..3657e36 --- /dev/null +++ b/backend/class/model/plugin/aggregate/postgresql.php @@ -0,0 +1,40 @@ +fieldBase->get()) . '"'; + $sql = match ($this->calculationType) { + 'count' => 'COUNT(' . $field . ')', + 'count_distinct' => 'COUNT(DISTINCT ' . $field . ')', + 'sum' => 'SUM(' . $field . ')', + 'avg' => 'AVG(' . $field . ')', + 'max' => 'MAX(' . $field . ')', + 'min' => 'MIN(' . $field . ')', + 'year' => 'date_part(\'year\', ' . $field . ')', + 'quarter' => 'date_part(\'quarter\', ' . $field . ')', + 'month' => 'date_part(\'month\', ' . $field . ')', + 'day' => 'date_part(\'day\', ' . $field . ')', + default => throw new exception('EXCEPTION_MODEL_PLUGIN_CALCULATION_POSTGRESQL_UNKNOWN_CALCULATION_TYPE', exception::$ERRORLEVEL_ERROR, $this->calculationType), + }; + return $sql . ' AS "' . $this->field->get() . '"'; + } +} diff --git a/backend/class/model/plugin/aggregate/sqlite.php b/backend/class/model/plugin/aggregate/sqlite.php index cd24dea..3a53302 100644 --- a/backend/class/model/plugin/aggregate/sqlite.php +++ b/backend/class/model/plugin/aggregate/sqlite.php @@ -1,62 +1,39 @@ calculationType) { - case 'count': - $sql = 'COUNT('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'count_distinct': - $sql = 'COUNT(DISTINCT '.$tableAlias.$this->fieldBase->get().')'; - break; - case 'sum': - $sql = 'SUM('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'avg': - $sql = 'AVG('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'max': - $sql = 'MAX('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'min': - $sql = 'MIN('.$tableAlias.$this->fieldBase->get().')'; - break; - case 'year': - $sql = 'CAST(strftime(\'%Y\','.$tableAlias.$this->fieldBase->get().') as integer)'; - break; - case 'quarter': - $sql = '(CAST(strftime(\'%m\','.$tableAlias.$this->fieldBase->get().') as integer) + 2) / 3'; - break; - case 'month': - $sql = 'CAST(strftime(\'%m\','.$tableAlias.$this->fieldBase->get().') as integer)'; - break; - case 'day': - $sql = 'CAST(strftime(\'%d\','.$tableAlias.$this->fieldBase->get().') as integer)'; - break; - // case 'timestampdiff-year': - // $sql = 'TIMESTAMPDIFF(YEAR, '.$tableAlias.$this->fieldBase->get().', CURDATE())'; - // break; - default: - throw new exception('EXCEPTION_MODEL_PLUGIN_CALCULATION_SQLITE_UNKKNOWN_CALCULATION_TYPE', exception::$ERRORLEVEL_ERROR, $this->calculationType); - break; +class sqlite extends aggregate implements aggregateInterface +{ + /** + * {@inheritDoc} + * @param string|null $tableAlias + * @return string + * @throws exception + */ + public function get(string $tableAlias = null): string + { + $tableAlias = $tableAlias ? $tableAlias . '.' : ''; + $sql = match ($this->calculationType) { + 'count' => 'COUNT(' . $tableAlias . $this->fieldBase->get() . ')', + 'count_distinct' => 'COUNT(DISTINCT ' . $tableAlias . $this->fieldBase->get() . ')', + 'sum' => 'SUM(' . $tableAlias . $this->fieldBase->get() . ')', + 'avg' => 'AVG(' . $tableAlias . $this->fieldBase->get() . ')', + 'max' => 'MAX(' . $tableAlias . $this->fieldBase->get() . ')', + 'min' => 'MIN(' . $tableAlias . $this->fieldBase->get() . ')', + 'year' => 'CAST(strftime(\'%Y\',' . $tableAlias . $this->fieldBase->get() . ') as integer)', + 'quarter' => '(CAST(strftime(\'%m\',' . $tableAlias . $this->fieldBase->get() . ') as integer) + 2) / 3', + 'month' => 'CAST(strftime(\'%m\',' . $tableAlias . $this->fieldBase->get() . ') as integer)', + 'day' => 'CAST(strftime(\'%d\',' . $tableAlias . $this->fieldBase->get() . ') as integer)', + default => throw new exception('EXCEPTION_MODEL_PLUGIN_CALCULATION_SQLITE_UNKNOWN_CALCULATION_TYPE', exception::$ERRORLEVEL_ERROR, $this->calculationType), + }; + return $sql . ' AS ' . $this->field->get(); } - return $sql . ' AS ' . $this->field->get(); - } - } diff --git a/backend/class/model/plugin/aggregatefilter.php b/backend/class/model/plugin/aggregatefilter.php index 539ca94..9edcc51 100644 --- a/backend/class/model/plugin/aggregatefilter.php +++ b/backend/class/model/plugin/aggregatefilter.php @@ -1,44 +1,50 @@ field = $field; $this->value = $value; $this->operator = $operator; @@ -47,11 +53,10 @@ public function __CONSTRUCT(\codename\core\value\text\modelfield $field, $value } /** - * @inheritDoc + * {@inheritDoc} */ - public function getFieldValue(string $tableAlias = null) : string + public function getFieldValue(?string $tableAlias = null): string { - return $this->field->getValue(); + return $this->field->getValue(); } - } diff --git a/backend/class/model/plugin/aggregatefilter/mysql.php b/backend/class/model/plugin/aggregatefilter/mysql.php index 904f4e0..58f52df 100644 --- a/backend/class/model/plugin/aggregatefilter/mysql.php +++ b/backend/class/model/plugin/aggregatefilter/mysql.php @@ -1,21 +1,24 @@ operator == 'ILIKE') { - $this->operator = 'LIKE'; +class mysql extends aggregatefilter implements filterInterface +{ + /** + * {@inheritDoc} + */ + public function __construct(modelfield $field, mixed $value, string $operator) + { + parent::__construct($field, $value, $operator); + return $this; } - return $this; - } } diff --git a/backend/class/model/plugin/aggregatefilter/sqlite.php b/backend/class/model/plugin/aggregatefilter/sqlite.php index 5831e45..7d4845e 100644 --- a/backend/class/model/plugin/aggregatefilter/sqlite.php +++ b/backend/class/model/plugin/aggregatefilter/sqlite.php @@ -1,21 +1,24 @@ operator == 'ILIKE') { - $this->operator = 'LIKE'; +class sqlite extends aggregatefilter implements filterInterface +{ + /** + * {@inheritDoc} + */ + public function __construct(modelfield $field, mixed $value, string $operator) + { + parent::__construct($field, $value, $operator); + return $this; } - return $this; - } } diff --git a/backend/class/model/plugin/calculatedfield.php b/backend/class/model/plugin/calculatedfield.php index 17174da..d4cbe89 100644 --- a/backend/class/model/plugin/calculatedfield.php +++ b/backend/class/model/plugin/calculatedfield.php @@ -1,35 +1,39 @@ field = $field; $this->calculation = $calculation; return $this; } - } diff --git a/backend/class/model/plugin/calculatedfield/calculatedfieldInterface.php b/backend/class/model/plugin/calculatedfield/calculatedfieldInterface.php index 3994d97..6f7469b 100644 --- a/backend/class/model/plugin/calculatedfield/calculatedfieldInterface.php +++ b/backend/class/model/plugin/calculatedfield/calculatedfieldInterface.php @@ -1,26 +1,28 @@ Use it to add fields to a model request - * @param \codename\core\value\text\modelfield $field + * Use it to add fields to a model request + * @param modelfield $field + * @param string $calculation */ - public function __CONSTRUCT(\codename\core\value\text\modelfield $field, string $calculation); + public function __construct(modelfield $field, string $calculation); /** * returns the appropriate query string for the respective database type * based on the settings provided in this object * @return string */ - public function get() : string; + public function get(): string; } diff --git a/backend/class/model/plugin/calculatedfield/mysql.php b/backend/class/model/plugin/calculatedfield/mysql.php index 6140210..b057ddb 100644 --- a/backend/class/model/plugin/calculatedfield/mysql.php +++ b/backend/class/model/plugin/calculatedfield/mysql.php @@ -1,20 +1,21 @@ -calculation . ' AS ' . $this->field->get(); - } - +class mysql extends calculatedfield implements calculatedfieldInterface +{ + /** + * {@inheritDoc} + */ + public function get(): string + { + return $this->calculation . ' AS ' . $this->field->get(); + } } diff --git a/backend/class/model/plugin/calculatedfield/postgresql.php b/backend/class/model/plugin/calculatedfield/postgresql.php index 38d8e21..b21517c 100644 --- a/backend/class/model/plugin/calculatedfield/postgresql.php +++ b/backend/class/model/plugin/calculatedfield/postgresql.php @@ -1,20 +1,21 @@ -calculation . ' AS ' . $this->field->get(); - } - +class postgresql extends calculatedfield implements calculatedfieldInterface +{ + /** + * {@inheritDoc} + */ + public function get(): string + { + return $this->calculation . ' AS "' . $this->field->get() . '"'; + } } diff --git a/backend/class/model/plugin/calculatedfield/sqlite.php b/backend/class/model/plugin/calculatedfield/sqlite.php index c4f3c94..cb8e630 100644 --- a/backend/class/model/plugin/calculatedfield/sqlite.php +++ b/backend/class/model/plugin/calculatedfield/sqlite.php @@ -1,20 +1,21 @@ calculation . ' AS ' . $this->field->get(); - } - +class sqlite extends calculatedfield implements calculatedfieldInterface +{ + /** + * {@inheritDoc} + */ + public function get(): string + { + return $this->calculation . ' AS ' . $this->field->get(); + } } diff --git a/backend/class/model/plugin/collection.php b/backend/class/model/plugin/collection.php index 079782f..7b80404 100644 --- a/backend/class/model/plugin/collection.php +++ b/backend/class/model/plugin/collection.php @@ -1,103 +1,110 @@ field = $field; + $this->baseModel = $baseModel; + $this->collectionModel = $collectionModel; - /** - * Undocumented function - * - * @param \codename\core\value\text\modelfield $field - * @param \codename\core\model $baseModel - * @param \codename\core\model $collectionModel - */ - public function __construct(\codename\core\value\text\modelfield $field, \codename\core\model $baseModel, \codename\core\model $collectionModel) { - $this->field = $field; - $this->baseModel = $baseModel; - $this->collectionModel = $collectionModel; + // prepare some data + foreach ($this->collectionModel->config->get('foreign') as $fkey => $fcfg) { + if ($fcfg['model'] == $this->baseModel->getIdentifier()) { + $this->baseField = $fcfg['key']; + $this->collectionModelBaseRefField = $fkey; - // prepare some data - foreach($this->collectionModel->config->get('foreign') as $fkey => $fcfg) { - if($fcfg['model'] == $this->baseModel->getIdentifier()) { - $this->baseField = $fcfg['key']; - $this->collectionModelBaseRefField = $fkey; + // + // FKEY field needs to be set + // Workaround: simply don't allow NULL values here. + // + $this->collectionModel->addDefaultFilter($fkey, null, '!='); + break; + } + } - // - // FKEY field needs to be set - // Workaround: simply don't allow NULL values here. - // - $this->collectionModel->addDefaultfilter($fkey, null, '!='); - break; - } + if (!$this->baseField) { + throw new exception('EXCEPTION_MODEL_PLUGIN_COLLECTION_MISSING_BASEFIELD', exception::$ERRORLEVEL_ERROR); + } + if (!$this->collectionModelBaseRefField) { + throw new exception('EXCEPTION_MODEL_PLUGIN_COLLECTION_MISSING_COLLECTIONMODEL_BASEREF_FIELD', exception::$ERRORLEVEL_ERROR); + } } - if(!$this->baseField) { - throw new exception('EXCEPTION_MODEL_PLUGIN_COLLECTION_MISSING_BASEFIELD', exception::$ERRORLEVEL_ERROR); - } - if(!$this->collectionModelBaseRefField) { - throw new exception('EXCEPTION_MODEL_PLUGIN_COLLECTION_MISSING_COLLECTIONMODEL_BASEREF_FIELD', exception::$ERRORLEVEL_ERROR); + /** + * returns the field name in the base model + * we're referencing + * + * @return string + */ + public function getBaseField(): string + { + return $this->baseField; } - } - - /** - * field of the base model - * that is used as the join counterpart - * mostly, this should be the PKEY of the base model - * @var string - */ - protected $baseField = null; - - /** - * the field of the collection model - * that references the base model - * @var string - */ - protected $collectionModelBaseRefField = null; - - /** - * returns the field name in the base model - * we're referencing - * - * @return string - */ - public function getBaseField() : string { - return $this->baseField; - } - - /** - * returns the field name in the auxiliary model - * that stores the reference to the base model - * (-> getBaseField) - * - * @return string - */ - public function getCollectionModelBaseRefField() : string { - return $this->collectionModelBaseRefField; - } + /** + * returns the field name in the auxiliary model + * that stores the reference to the base model + * (-> getBaseField) + * + * @return string + */ + public function getCollectionModelBaseRefField(): string + { + return $this->collectionModelBaseRefField; + } } diff --git a/backend/class/model/plugin/entry.php b/backend/class/model/plugin/entry.php deleted file mode 100755 index 3fd50fb..0000000 --- a/backend/class/model/plugin/entry.php +++ /dev/null @@ -1,126 +0,0 @@ -The field passed to the method is not a component of this model. So it cannot be set. - * @var string - */ - CONST EXCEPTION_FIELDSET_FIELDNOTFOUNDINMODEL = 'EXCEPTION_FIELDSET_FIELDNOTFOUNDINMODEL'; - - /** - * You are trying to get the contents of a field inside the current modelEntry instance. - *
The field passed to the method is not a component of this model. So it cannot be returned. - * @var string - */ - CONST EXCEPTION_FIELDGET_FIELDNOTFOUNDINMODEL = 'EXCEPTION_FIELDGET_FIELDNOTFOUNDINMODEL'; - - /** - * Contaions the datacontainer object - * @var \codename\core\datacontainer - */ - protected $data = null; - - /** - * Returns true if the given $field exists in the current model - * @param string $field - * @return bool - */ - public function fieldExists(string $field) : bool { - return in_array($field, $this->config->get('field')); - } - - /** - * Sets the given $field's to $value - * @param string $field - * @param mixed|null $value - * @return \codename\core\model - */ - public function fieldSet(string $field, $value) : \codename\core\model { - if(!$this->fieldExists($field)) { - throw new \codename\core\exception(self::EXCEPTION_FIELDSET_FIELDNOTFOUNDINMODEL, \codename\core\exception::$ERRORLEVEL_FATAL, $field); - } - $this->data->setData($field, $value); - return $this; - } - - /** - * Gets the value from the given $field - * @param string $field - * @throws \codename\core\exception - * @return mixed|null - */ - public function fieldGet(string $field) { - if(!$this->fieldExists($field)) { - throw new \codename\core\exception(self::EXCEPTION_FIELDGET_FIELDNOTFOUNDINMODEL, \codename\core\exception::$ERRORLEVEL_FATAL, $field); - } - return $this->data->getData($field); - } - - /** - * @todo DOCUMENTATION - */ - public function entryMake(array $data = array()) : \codename\core\model { - $this->data = new \codename\core\datacontainer($data); - return $this; - } - - /** - * @todo DOCUMENTATION - */ - public function entryValidate() : array { - return $this->validate($this->data->getData()); - } - - /** - * @todo DOCUMENTATION - */ - public function entryUpdate(array $data) : \codename\core\model { - foreach($this->getFields() as $field) { - - } - } - - /** - * @todo DOCUMENTATION - */ - public function entryDelete() : \codename\core\model { - if(is_null($this->data)) { - return $this; - } - $this->delete($this->data->getData($this->getPrimarykey())); - return $this; - } - - /** - * @todo DOCUMENTATION - */ - public function entryLoad(string $primaryKey) : \codename\core\model { - $entry = $this->loadByUnique($this->getPrimarykey(), $primaryKey); - if(count($entry) == 0) { - return $this; - } - $this->entryMake($entry); - return $this; - } - - /** - * @todo DOCUMENTATION - */ - public function entrySave() : \codename\core\model { - if(is_null($this->data)) { - return $this; - } - - $this->save($this->data->getData()); - return $this; - } - -} diff --git a/backend/class/model/plugin/field.php b/backend/class/model/plugin/field.php index 49d5741..4bb4dfe 100755 --- a/backend/class/model/plugin/field.php +++ b/backend/class/model/plugin/field.php @@ -1,34 +1,39 @@ field = $field; $this->alias = $alias; return $this; } - } diff --git a/backend/class/model/plugin/field/bare.php b/backend/class/model/plugin/field/bare.php index d5a2cf5..622f0c9 100644 --- a/backend/class/model/plugin/field/bare.php +++ b/backend/class/model/plugin/field/bare.php @@ -1,12 +1,14 @@ ', + '>=', + '<', + '<=', + ]; /** * $field that is used to filter data from the model - * @var \codename\core\value\text\modelfield + * @var null|modelfield */ - public $field = null; - + public ?modelfield $field = null; /** * Contains the field to compare to - * @var \codename\core\value\text\modelfield + * @var null|modelfield */ - public $value = null; - + public ?modelfield $value = null; /** * Contains the $operator for the $field * @var string $operator */ - public $operator = "="; - + public string $operator = "="; /** * the conjunction to be used (AND, OR, XOR, ...) * may be null - * @var string $conjunction + * @var null|string $conjunction */ - public $conjunction = null; + public ?string $conjunction = null; /** - * [allowedOperators description] - * @var array + * @param modelfield $field + * @param $value + * @param string $operator + * @param string|null $conjunction + * @throws exception + * @see \codename\core\model_plugin_filter::__construct(string $field, string $value, string $operator) */ - const allowedOperators = [ - '=', - '!=', - '>', - '>=', - '<', - '<=', - ]; - - /** - * - * {@inheritDoc} - * @see \codename\core\model_plugin_filter::__CONSTRUCT(string $field, string $value, string $operator) - */ - public function __CONSTRUCT(\codename\core\value\text\modelfield $field, $value, string $operator, string $conjunction = null) { + public function __construct(modelfield $field, $value, string $operator, string $conjunction = null) + { $this->field = $field; // TODO: Check for type of value ! must be \codename\core\value\text\modelfield $this->value = $value; $this->operator = $operator; - if(!\in_array($this->operator, static::allowedOperators)) { - throw new exception('EXCEPTION_INVALID_OPERATOR', exception::$ERRORLEVEL_ERROR, $this->operator); + if (!in_array($this->operator, static::allowedOperators)) { + throw new exception('EXCEPTION_INVALID_OPERATOR', exception::$ERRORLEVEL_ERROR, $this->operator); } $this->conjunction = $conjunction; return $this; @@ -72,10 +77,10 @@ public function __CONSTRUCT(\codename\core\value\text\modelfield $field, $value, */ public function getLeftFieldValue(string $tableAlias = null): string { - // if tableAlias is set, return the field name prefixed with the alias - // otherwise, just return the full modelfield value - // TODO: check for cross-model filters... - return $tableAlias ? ($tableAlias . '.' . $this->field->get()) : $this->field->getValue(); + // if tableAlias is set, return the field name prefixed with the alias + // otherwise, just return the full modelfield value + // TODO: check for cross-model filters... + return $tableAlias ? ($tableAlias . '.' . $this->field->get()) : $this->field->getValue(); } /** @@ -85,10 +90,9 @@ public function getLeftFieldValue(string $tableAlias = null): string */ public function getRightFieldValue(string $tableAlias = null): string { - // if tableAlias is set, return the field name prefixed with the alias - // otherwise, just return the full modelfield value - // TODO: check for cross-model filters... - return $tableAlias ? ($tableAlias . '.' . $this->value->get()) : $this->value->getValue(); + // if tableAlias is set, return the field name prefixed with the alias + // otherwise, just return the full modelfield value + // TODO: check for cross-model filters... + return $tableAlias ? ($tableAlias . '.' . $this->value->get()) : $this->value->getValue(); } - } diff --git a/backend/class/model/plugin/fieldfilter/mysql.php b/backend/class/model/plugin/fieldfilter/mysql.php index 798f50c..85803bc 100644 --- a/backend/class/model/plugin/fieldfilter/mysql.php +++ b/backend/class/model/plugin/fieldfilter/mysql.php @@ -1,12 +1,14 @@ field->get() . '"') : '"' . str_replace('.', '"."', $this->field->getValue()) . '"'; + } + + /** + * returns the right field value/name + * @param string|null $tableAlias [the current table alias, if any] + * @return string + */ + public function getRightFieldValue(string $tableAlias = null): string + { + // if tableAlias is set, return the field name prefixed with the alias + // otherwise, just return the full modelfield value + // TODO: check for cross-model filters... + return $tableAlias ? ($tableAlias . '.' . '"' . $this->field->get() . '"') : '"' . str_replace('.', '"."', $this->field->getValue()) . '"'; + } + +} diff --git a/backend/class/model/plugin/fieldfilter/sqlite.php b/backend/class/model/plugin/fieldfilter/sqlite.php index 150c572..dd56bd0 100644 --- a/backend/class/model/plugin/fieldfilter/sqlite.php +++ b/backend/class/model/plugin/fieldfilter/sqlite.php @@ -1,11 +1,13 @@ field = $field; $this->value = $value; $this->operator = $operator; @@ -47,14 +53,13 @@ public function __CONSTRUCT(\codename\core\value\text\modelfield $field, $value } /** - * @inheritDoc + * {@inheritDoc} */ - public function getFieldValue(string $tableAlias = null): string + public function getFieldValue(?string $tableAlias = null): string { - // if tableAlias is set, return the field name prefixed with the alias - // otherwise, just return the full modelfield value - // TODO: check for cross-model filters... - return $tableAlias ? ($tableAlias . '.' . $this->field->get()) : $this->field->getValue(); + // if tableAlias is set, return the field name prefixed with the alias + // otherwise, just return the full modelfield value + // TODO: check for cross-model filters... + return $tableAlias ? ($tableAlias . '.' . $this->field->get()) : $this->field->getValue(); } - } diff --git a/backend/class/model/plugin/filter/custom.php b/backend/class/model/plugin/filter/custom.php index fbb8f5f..915d352 100644 --- a/backend/class/model/plugin/filter/custom.php +++ b/backend/class/model/plugin/filter/custom.php @@ -1,22 +1,24 @@ field->getValue(); - } +class custom extends filter implements filterInterface +{ + /** + * {@inheritDoc} + */ + public function getFieldValue(?string $tableAlias = null): string + { + // if tableAlias is set, return the field name prefixed with the alias + // otherwise, just return the full modelfield value + // TODO: check for cross-model filters... + return $this->field->getValue(); + } } diff --git a/backend/class/model/plugin/filter/dynamic.php b/backend/class/model/plugin/filter/dynamic.php index 0207385..70f1152 100644 --- a/backend/class/model/plugin/filter/dynamic.php +++ b/backend/class/model/plugin/filter/dynamic.php @@ -1,12 +1,14 @@ call the method and pass the $field and the $value to it. - *
you can also add the $operator - * @param \codename\core\value\text\modelfield $field - * @param string $value + * call the method and pass the $field and the $value to it. + * You can also add the $operator + * @param modelfield $field + * @param mixed $value * @param string $operator */ - public function __CONSTRUCT(\codename\core\value\text\modelfield $field, $value = null, string $operator); + public function __construct(modelfield $field, mixed $value, string $operator); /** * returns the field specifier, optionally using a given table alias - * @param string|null $tableAlias [description] + * @param string|null $tableAlias [description] * @return string [description] */ - public function getFieldValue(string $tableAlias = null) : string; + public function getFieldValue(?string $tableAlias = null): string; } diff --git a/backend/class/model/plugin/filter/json.php b/backend/class/model/plugin/filter/json.php index fdd2e06..38c10fa 100644 --- a/backend/class/model/plugin/filter/json.php +++ b/backend/class/model/plugin/filter/json.php @@ -1,133 +1,125 @@ field->get(); - if(($this->operator == '=') || ($this->operator == '!=')) { - // check for (un)equality - if(is_array($this->value)) { - // - // SQL Simile: IN (...) / NOT IN (...) - // - if(!array_key_exists($fieldName, $data) || !in_array($data[$fieldName], $this->value)) { - return ($this->operator == '!='); - } else { - return ($this->operator == '='); - } - } else { - // - // SQL Simile: = / != / <> - // - if(!array_key_exists($fieldName, $data) || $data[$fieldName] !== $this->value) { - return ($this->operator == '!='); - } else { - return ($this->operator == '='); - } - } - } else if(($this->operator == '>=') - || ($this->operator == '<=') - || ($this->operator == '>') - || ($this->operator == '<')) { - - // - if(array_key_exists($fieldName, $data)) { - - $dataValue = $data[$fieldName]; - if(is_numeric($this->value)) { - // integer comparison - if(is_int($this->value)) { - return ($this->operator == '>=' && $dataValue >= $this->value) || - ($this->operator == '<=' && $dataValue <= $this->value) || - ($this->operator == '>' && $dataValue > $this->value) || - ($this->operator == '<' && $dataValue < $this->value); - } else if(is_float($this->value)) { - // float/double comparison - return ($this->operator == '>=' && bccomp($dataValue, $this->value) >= 0) || - ($this->operator == '<=' && bccomp($dataValue, $this->value) <= 0) || - ($this->operator == '>' && bccomp($dataValue, $this->value) === 1) || - ($this->operator == '<' && bccomp($dataValue, $this->value) === -1); - } else { - var_dump($this->value); - } - } else { - die("non-numeric"); - } - - die("error"); - } - } else if(($this->operator == 'LIKE') - || ($this->operator == 'ILIKE') - ) { - $dataValue = $data[$fieldName]; - - // case-(in)-sensitive string matching - if(strlen($dataValue) === 0 || strlen($this->value) === 0) { - return strlen($dataValue) == strlen($this->value); // pretty stupid. if we like to have it 'equal' we can simply rely on this. +class json extends filter implements filterInterface, executableFilterInterface +{ + /** + * {@inheritDoc} + */ + public function matches(array $data): bool + { + $fieldName = $this->field->get(); + if (($this->operator == '=') || ($this->operator == '!=')) { + // check for (un)equality + if (is_array($this->value)) { + // + // SQL Simile: IN (...) / NOT IN (...) + // + if (!array_key_exists($fieldName, $data) || !in_array($data[$fieldName], $this->value)) { + return ($this->operator == '!='); + } else { + return ($this->operator == '='); + } + } elseif (!array_key_exists($fieldName, $data) || $data[$fieldName] !== $this->value) { + return ($this->operator == '!='); + } else { + return ($this->operator == '='); + } + } elseif (($this->operator == '>=') + || ($this->operator == '<=') + || ($this->operator == '>') + || ($this->operator == '<')) { + // + if (array_key_exists($fieldName, $data)) { + $dataValue = $data[$fieldName]; + if (is_numeric($this->value)) { + // integer comparison + if (is_int($this->value)) { + return ($this->operator == '>=' && $dataValue >= $this->value) || + ($this->operator == '<=' && $dataValue <= $this->value) || + ($this->operator == '>' && $dataValue > $this->value) || + ($this->operator == '<' && $dataValue < $this->value); + } elseif (is_float($this->value)) { + // float/double comparison + return ($this->operator == '>=' && bccomp($dataValue, $this->value) >= 0) || + ($this->operator == '<=' && bccomp($dataValue, $this->value) <= 0) || + ($this->operator == '>' && bccomp($dataValue, $this->value) === 1) || + ($this->operator == '<' && bccomp($dataValue, $this->value) === -1); + } else { + var_dump($this->value); + } + } else { + die("non-numeric"); + } + + die("error"); + } else { + return false; + } + } elseif ($this->operator == 'LIKE') { + $dataValue = $data[$fieldName]; + + // case-(in)-sensitive string matching + if (strlen($dataValue) === 0 || strlen($this->value) === 0) { + return strlen($dataValue) == strlen($this->value); // pretty foolish. if we like to have it 'equal', we can simply rely on this. + } else { + $operator = $this->operator; + $strposFunc = function (string $haystack, string $needle, int $offset = 0) use ($operator) { + return $operator == 'LIKE' ? stripos($haystack, $needle, $offset) : strpos($haystack, $needle, $offset); + }; + + // catch case: single or double wildcard only, e.g. '%' or '%%' + + // wildcard at the beginning + $wildcardStart = $this->value[0] == '%'; + $wildcardEnd = $this->value[strlen($this->value) - 1] == '%'; + + $needle = substr($this->value, ($wildcardStart ? 1 : 0), strlen($this->value) - ($wildcardStart ? 1 : 0) - ($wildcardEnd ? 1 : 0)); + $needlePos = $strposFunc($dataValue, $needle); + + // echo("
Needle: '{$needle}', Value: '{$dataValue}' "); + + // no match at all + if ($needlePos === false) { + // echo(" -- not found"); + return false; + } + + if (!$wildcardStart && $needlePos > 0) { + // no wildcard, string MUST start with needle + // echo(" -- !wildcardStart && needlePos > 0"); + return false; + } + // we may fix: + if (!$wildcardEnd && $needlePos != (strlen($this->value) - strlen($needle))) { + // echo(" -- !wildcardEnd && needlePos != " . (strlen($this->value)-strlen($needle))); + return false; + } + + // otherwise, everything's OK! + return true; + } } else { - - $operator = $this->operator; - $strposFunc = function(string $haystack, string $needle, int $offset = 0) use ($operator) { - return $operator == 'ILIKE' ? stripos($haystack, $needle, $offset) : strpos($haystack, $needle, $offset); - }; - - // catch case: single or double wildcard only, e.g '%' or '%%' - - // wildcard at beginning - $wildcardStart = $this->value[0] == '%'; - $wildcardEnd = $this->value[strlen($this->value)-1] == '%'; - - $needle = substr($this->value, ($wildcardStart ? 1 : 0), strlen($this->value) - ($wildcardStart ? 1 : 0) - ($wildcardEnd ? 1 : 0)); - $needlePos = $strposFunc($dataValue, $needle); - - // echo("
Needle: '{$needle}', Value: '{$dataValue}' "); - - // no match at all - if($needlePos === false) { - // echo(" -- not found"); - return false; - } - - if(!$wildcardStart && $needlePos > 0) { - // no wildcard, string MUST start with needle - // echo(" -- !wildcardStart && needlePos > 0"); - return false; - } - // we may fix: - if(!$wildcardEnd && $needlePos != (strlen($this->value)-strlen($needle))) { - // echo(" -- !wildcardEnd && needlePos != " . (strlen($this->value)-strlen($needle))); return false; - } - - // otherwise, everything's ok! - return true; - } - } else { - return false; } - } - - /** - * @inheritDoc - */ - public function getFieldValue(string $tableAlias = null): string - { - // - // no table alias - // - return $this->field->getValue(); - } + /** + * {@inheritDoc} + */ + public function getFieldValue(?string $tableAlias = null): string + { + // + // no table alias + // + return $this->field->getValue(); + } } diff --git a/backend/class/model/plugin/filter/mysql.php b/backend/class/model/plugin/filter/mysql.php index da4f782..23a06f6 100644 --- a/backend/class/model/plugin/filter/mysql.php +++ b/backend/class/model/plugin/filter/mysql.php @@ -1,53 +1,60 @@ ', - '>=', - '<', - '<=', - 'LIKE', - 'NOT LIKE', - ]; +class mysql extends filter implements filterInterface +{ + /** + * [allowedOperators description] + * @var array + */ + public const array allowedOperators = [ + '=', + '!=', + '>', + '>=', + '<', + '<=', + 'LIKE', + 'NOT LIKE', + ]; - /** - * @inheritDoc - */ - public function __CONSTRUCT(\codename\core\value\text\modelfield $field, $value = null, string $operator, string $conjunction = null) { - parent::__CONSTRUCT($field, $value, $operator, $conjunction); - if($this->operator == 'ILIKE') { - $this->operator = 'LIKE'; + /** + * {@inheritDoc} + * @param modelfield $field + * @param mixed $value + * @param string $operator + * @param string|null $conjunction + * @throws exception + */ + public function __construct(modelfield $field, mixed $value, string $operator, string $conjunction = null) + { + parent::__construct($field, $value, $operator, $conjunction); + if (!in_array($this->operator, self::allowedOperators)) { + throw new exception('EXCEPTION_INVALID_OPERATOR', exception::$ERRORLEVEL_ERROR, $this->operator); + } + return $this; } - if(!\in_array($this->operator, self::allowedOperators)) { - throw new exception('EXCEPTION_INVALID_OPERATOR', exception::$ERRORLEVEL_ERROR, $this->operator); - } - return $this; - } - /** - * @inheritDoc - */ - public function getFieldValue(string $tableAlias = null): string - { - // if tableAlias is set, return the field name prefixed with the alias - // otherwise, just return the full modelfield value - // TODO: check for cross-model filters... - return $tableAlias ? ($tableAlias . '.' . $this->field->get()) : $this->field->getValue(); - } + /** + * {@inheritDoc} + */ + public function getFieldValue(?string $tableAlias = null): string + { + // if tableAlias is set, return the field name prefixed with the alias + // otherwise, just return the full modelfield value + // TODO: check for cross-model filters... + return $tableAlias ? ($tableAlias . '.' . $this->field->get()) : $this->field->getValue(); + } } diff --git a/backend/class/model/plugin/filter/postgresql.php b/backend/class/model/plugin/filter/postgresql.php index 431a853..555847a 100755 --- a/backend/class/model/plugin/filter/postgresql.php +++ b/backend/class/model/plugin/filter/postgresql.php @@ -1,18 +1,63 @@ ', + '>=', + '<', + '<=', + 'LIKE', + 'NOT LIKE', + ]; + + /** + * {@inheritDoc} + * @param modelfield $field + * @param mixed $value + * @param string $operator + * @param string|null $conjunction + * @throws exception + */ + public function __construct(modelfield $field, mixed $value, string $operator, string $conjunction = null) + { + parent::__construct($field, $value, $operator, $conjunction); + if (!in_array($this->operator, self::allowedOperators)) { + throw new exception('EXCEPTION_INVALID_OPERATOR', exception::$ERRORLEVEL_ERROR, $this->operator); + } + $this->operator = match ($this->operator) { + 'LIKE' => 'ILIKE', + 'NOT LIKE' => 'NOT ILIKE', + default => $this->operator + }; + return $this; + } + /** - * @inheritDoc + * {@inheritDoc} */ - public function getFieldValue(string $tableAlias = null): string + public function getFieldValue(?string $tableAlias = null): string { - // Case sensivity wrappers for PGSQL - return $tableAlias ? ($tableAlias . '.' . '"'.$this->field->get().'"') : '"'.$this->field->getValue().'"'; + // Case sensitivity wrappers for PGSQL + return $tableAlias ? ($tableAlias . '.' . '"' . $this->field->get() . '"') : '"' . str_replace('.', '"."', $this->field->getValue()) . '"'; } } diff --git a/backend/class/model/plugin/filter/sqlite.php b/backend/class/model/plugin/filter/sqlite.php index 0f7d1b7..c75ec8f 100644 --- a/backend/class/model/plugin/filter/sqlite.php +++ b/backend/class/model/plugin/filter/sqlite.php @@ -1,53 +1,60 @@ ', - '>=', - '<', - '<=', - 'LIKE', - 'NOT LIKE', - ]; +class sqlite extends filter implements filterInterface +{ + /** + * [allowedOperators description] + * @var array + */ + public const array allowedOperators = [ + '=', + '!=', + '>', + '>=', + '<', + '<=', + 'LIKE', + 'NOT LIKE', + ]; - /** - * @inheritDoc - */ - public function __CONSTRUCT(\codename\core\value\text\modelfield $field, $value = null, string $operator, string $conjunction = null) { - parent::__CONSTRUCT($field, $value, $operator, $conjunction); - if($this->operator == 'ILIKE') { - $this->operator = 'LIKE'; + /** + * {@inheritDoc} + * @param modelfield $field + * @param mixed $value + * @param string $operator + * @param string|null $conjunction + * @throws exception + */ + public function __construct(modelfield $field, mixed $value, string $operator, string $conjunction = null) + { + parent::__construct($field, $value, $operator, $conjunction); + if (!in_array($this->operator, self::allowedOperators)) { + throw new exception('EXCEPTION_INVALID_OPERATOR', exception::$ERRORLEVEL_ERROR, $this->operator); + } + return $this; } - if(!\in_array($this->operator, self::allowedOperators)) { - throw new exception('EXCEPTION_INVALID_OPERATOR', exception::$ERRORLEVEL_ERROR, $this->operator); - } - return $this; - } - /** - * @inheritDoc - */ - public function getFieldValue(string $tableAlias = null): string - { - // if tableAlias is set, return the field name prefixed with the alias - // otherwise, just return the full modelfield value - // TODO: check for cross-model filters... - return $tableAlias ? ($tableAlias . '.' . $this->field->get()) : $this->field->getValue(); - } + /** + * {@inheritDoc} + */ + public function getFieldValue(?string $tableAlias = null): string + { + // if tableAlias is set, return the field name prefixed with the alias + // otherwise, just return the full modelfield value + // TODO: check for cross-model filters... + return $tableAlias ? ($tableAlias . '.' . $this->field->get()) : $this->field->getValue(); + } } diff --git a/backend/class/model/plugin/filter/xml.php b/backend/class/model/plugin/filter/xml.php index 3bb08f8..9d86956 100644 --- a/backend/class/model/plugin/filter/xml.php +++ b/backend/class/model/plugin/filter/xml.php @@ -1,11 +1,14 @@ field = $field; $this->value = $value; $this->operator = $operator; @@ -47,14 +53,13 @@ public function __CONSTRUCT(\codename\core\value\text\modelfield $field, $value } /** - * @inheritDoc + * {@inheritDoc} */ - public function getFieldValue(string $tableAlias = null): string + public function getFieldValue(?string $tableAlias = null): string { - // if tableAlias is set, return the field name prefixed with the alias - // otherwise, just return the full modelfield value - // TODO: check for cross-model filters... - return $tableAlias ? ($tableAlias . '.' . $this->field->get()) : $this->field->getValue(); + // if tableAlias is set, return the field name prefixed with the alias + // otherwise, just return the full modelfield value + // TODO: check for cross-model filters... + return $tableAlias ? ($tableAlias . '.' . $this->field->get()) : $this->field->getValue(); } - } diff --git a/backend/class/model/plugin/filterlist/filterlistInterface.php b/backend/class/model/plugin/filterlist/filterlistInterface.php index 0c4f470..5055030 100644 --- a/backend/class/model/plugin/filterlist/filterlistInterface.php +++ b/backend/class/model/plugin/filterlist/filterlistInterface.php @@ -1,27 +1,30 @@ call the method and pass the $field and the $value to it. - *
you can also add the $operator - * @param \codename\core\value\text\modelfield $field - * @param string $value + * call the method and pass the $field and the $value to it. + * You can also add the $operator + * @param modelfield $field + * @param mixed $value * @param string $operator */ - public function __CONSTRUCT(\codename\core\value\text\modelfield $field, $value = null, string $operator); + public function __construct(modelfield $field, mixed $value, string $operator); /** * returns the field specifier, optionally using a given table alias - * @param string|null $tableAlias [description] + * @param string|null $tableAlias [description] * @return string [description] */ - public function getFieldValue(string $tableAlias = null) : string; + public function getFieldValue(?string $tableAlias = null): string; } diff --git a/backend/class/model/plugin/filterlist/mysql.php b/backend/class/model/plugin/filterlist/mysql.php index 2c4240f..e4d9c26 100644 --- a/backend/class/model/plugin/filterlist/mysql.php +++ b/backend/class/model/plugin/filterlist/mysql.php @@ -1,32 +1,34 @@ operator == 'ILIKE') { - $this->operator = 'LIKE'; +class mysql extends filterlist implements filterlistInterface +{ + /** + * {@inheritDoc} + */ + public function __construct(modelfield $field, mixed $value, string $operator, string $conjunction = null) + { + parent::__construct($field, $value, $operator, $conjunction); + return $this; } - return $this; - } - /** - * @inheritDoc - */ - public function getFieldValue(string $tableAlias = null): string - { - // if tableAlias is set, return the field name prefixed with the alias - // otherwise, just return the full modelfield value - // TODO: check for cross-model filters... - return $tableAlias ? ($tableAlias . '.' . $this->field->get()) : $this->field->getValue(); - } + /** + * {@inheritDoc} + */ + public function getFieldValue(?string $tableAlias = null): string + { + // if tableAlias is set, return the field name prefixed with the alias + // otherwise, just return the full modelfield value + // TODO: check for cross-model filters... + return $tableAlias ? ($tableAlias . '.' . $this->field->get()) : $this->field->getValue(); + } } diff --git a/backend/class/model/plugin/filterlist/postgresql.php b/backend/class/model/plugin/filterlist/postgresql.php new file mode 100644 index 0000000..bde2423 --- /dev/null +++ b/backend/class/model/plugin/filterlist/postgresql.php @@ -0,0 +1,22 @@ +field->get() . '"') : '"' . str_replace('.', '"."', $this->field->getValue()) . '"'; + } +} diff --git a/backend/class/model/plugin/fulltext.php b/backend/class/model/plugin/fulltext.php index 9b4d0c9..f8b47ca 100644 --- a/backend/class/model/plugin/fulltext.php +++ b/backend/class/model/plugin/fulltext.php @@ -1,49 +1,55 @@ field = $field; + $this->fields = $fields; + $this->value = $value; + return $this; } - $this->field = $field; - $this->fields = $fields; - $this->value = $value; - return $this; - } - } diff --git a/backend/class/model/plugin/fulltext/filter/mysql.php b/backend/class/model/plugin/fulltext/filter/mysql.php index 9d8563b..5c042cf 100644 --- a/backend/class/model/plugin/fulltext/filter/mysql.php +++ b/backend/class/model/plugin/fulltext/filter/mysql.php @@ -1,78 +1,84 @@ $this->value, - ]; - } + /** + * [__construct description] + * @param modelfield|string[] $fields [description] + * @param null $value + * @param string|null $conjunction [description] + * @throws ReflectionException + * @throws exception + */ + public function __construct(array $fields, $value = null, string $conjunction = null) + { + foreach ($fields as &$thisfield) { + if (!$thisfield instanceof modelfield) { + $thisfield = modelfield::getInstance($thisfield); + } + } + $this->fields = $fields; + $this->value = $value; + $this->operator = '>'; // by default, this value, and nothing else. + $this->conjunction = $conjunction; + return $this; + } - /** - * [__construct description] - * @param \codename\core\value\text\modelfield[]|string[] $fields [description] - * @param [type] $value [description] - * @param string|null $conjunction [description] - */ - public function __construct(array $fields, $value = null, string $conjunction = null) { - foreach($fields as &$thisfield) { - if (!$thisfield instanceof \codename\core\value\text\modelfield) { - $thisfield = \codename\core\value\text\modelfield::getInstance($thisfield); - } + /** + * {@inheritDoc} + */ + public function getFilterQueryParameters(): array + { + return [ + 'match_against' => $this->value, + ]; } - $this->fields = $fields; - $this->value = $value; - $this->operator = '>'; // by default, this value, and nothing else. - $this->conjunction = $conjunction; - return $this; - } - /** - * @inheritDoc - */ - public function getFilterQuery(array $variableNameMap, $tableAlias = null): string - { - $tableAlias = $tableAlias ? $tableAlias.'.' : ''; - $fields = []; - foreach($this->fields as $field) { - $fields[] = $tableAlias.$field->get(); + /** + * {@inheritDoc} + */ + public function getFilterQuery(array $variableNameMap, $tableAlias = null): string + { + $tableAlias = $tableAlias ? $tableAlias . '.' : ''; + $fields = []; + foreach ($this->fields as $field) { + $fields[] = $tableAlias . $field->get(); + } + return 'MATCH (' . implode(', ', $fields) . ') AGAINST (:' . $variableNameMap['match_against'] . ' IN BOOLEAN MODE) ' . $this->operator . ' 0'; } - $sql = 'MATCH ('.implode(', ', $fields).') AGAINST (:'.$variableNameMap['match_against'].' IN BOOLEAN MODE) '. $this->operator.' 0'; - return $sql; - } } diff --git a/backend/class/model/plugin/fulltext/fulltextInterface.php b/backend/class/model/plugin/fulltext/fulltextInterface.php index 03c28ec..fd321f6 100644 --- a/backend/class/model/plugin/fulltext/fulltextInterface.php +++ b/backend/class/model/plugin/fulltext/fulltextInterface.php @@ -1,47 +1,47 @@ fields as $field) { - $fields[] = $tableAlias.$field->get(); - } - $sql = 'MATCH ('.implode(', ', $fields).') AGAINST (:'.$variableName.' IN BOOLEAN MODE)'; - $alias = $this->field->get(); - if ($alias ?? false) { - $sql .= ' AS '.$alias; +class mysql extends fulltext implements fulltextInterface +{ + /** + * {@inheritDoc} + */ + public function getValue(): string + { + return $this->value; } - return $sql; - } - /** - * @inheritDoc - */ - public function getValue(): string - { - return $this->value; - } - - /** - * @inheritDoc - */ - public function getField(): string - { - return $this->field->get(); - } + /** + * {@inheritDoc} + */ + public function getField(): string + { + return $this->field->get(); + } + /** + * {@inheritDoc} + */ + public function get(string $value, string $tableAlias = null): string + { + $tableAlias = $tableAlias ? $tableAlias . '.' : ''; + $fields = []; + foreach ($this->fields as $field) { + $fields[] = $tableAlias . $field->get(); + } + $sql = 'MATCH (' . implode(', ', $fields) . ') AGAINST (:' . $value . ' IN BOOLEAN MODE)'; + $alias = $this->field->get(); + if ($alias ?? false) { + $sql .= ' AS ' . $alias; + } + return $sql; + } } diff --git a/backend/class/model/plugin/group.php b/backend/class/model/plugin/group.php index a2e8d17..f3e1852 100644 --- a/backend/class/model/plugin/group.php +++ b/backend/class/model/plugin/group.php @@ -1,36 +1,39 @@ field = $field; return $this; } - } diff --git a/backend/class/model/plugin/group/groupInterface.php b/backend/class/model/plugin/group/groupInterface.php index 3decabe..3644817 100644 --- a/backend/class/model/plugin/group/groupInterface.php +++ b/backend/class/model/plugin/group/groupInterface.php @@ -1,20 +1,20 @@ -Use it to add fields to a model request - * @param \codename\core\value\text\modelfield $field + * Use it to add fields to a model request + * @param modelfield $field */ - public function __CONSTRUCT(\codename\core\value\text\modelfield $field); - + public function __construct(modelfield $field); } diff --git a/backend/class/model/plugin/group/mysql.php b/backend/class/model/plugin/group/mysql.php index 437b53a..c6a5792 100644 --- a/backend/class/model/plugin/group/mysql.php +++ b/backend/class/model/plugin/group/mysql.php @@ -1,12 +1,14 @@ -model = $model; - $this->type = $type; - $this->referenceField = $referenceField; - $this->modelField = $modelField; - $this->conditions = $conditions; - $this->virtualField = $virtualField; - // TODO: perform null check? - return $this; - } - - /** - * return the database-driver-specific keyword for this join - * @return string - */ - public abstract function getJoinMethod() : string; - - /** - * provides information about this join plugin's parameters - * to be used for model caching features - * @return array - */ - public function getCurrentCacheIdentifierParameters(): array { - return [ - 'method' => $this->getJoinMethod(), - 'modelField' => $this->modelField, - 'referenceField' => $this->referenceField, - 'conditions' => $this->conditions, - 'vfield' => $this->virtualField, - ]; - } - +abstract class join extends plugin +{ + /** + * use current model default + * @var string + */ + public const string TYPE_DEFAULT = 'DEFAULT'; + + /** + * perform a left join + * @var string + */ + public const string TYPE_LEFT = 'LEFT'; + + /** + * perform a right join + * @var string + */ + public const string TYPE_RIGHT = 'RIGHT'; + + /** + * perform a full join + * @var string + */ + public const string TYPE_FULL = 'FULL'; + + /** + * perform an inner join + * @var string + */ + public const string TYPE_INNER = 'INNER'; + + /** + * $model used for joining + * @var model + */ + public $model = null; + + /** + * Contains the join type + * @var null|string $type + */ + public ?string $type = null; + + /** + * Contains the field to be joined upon (reference - the OTHER model) + * @var null|string|array + */ + public null|string|array $referenceField = null; + + /** + * Contains the field to be joined upon (this model) + * @var null|string|array + */ + public null|string|array $modelField = null; + + /** + * [public description] + * @var array + */ + public array $conditions = []; + + /** + * the current alias that is used + * @var string|null + */ + public ?string $currentAlias = null; + + /** + * [public description] + * @var string|null + */ + public ?string $virtualField = null; + + /** + * @see \codename\core\model_plugin_filter::__construct(string $field, string $value, string $operator) + */ + public function __construct(model $model, string $type, null|string|array $modelField, null|string|array $referenceField, array $conditions = [], ?string $virtualField = null) + { + $this->model = $model; + $this->type = $type; + $this->referenceField = $referenceField; + $this->modelField = $modelField; + $this->conditions = $conditions; + $this->virtualField = $virtualField; + // TODO: perform null check? + return $this; + } + + /** + * provides information about this join plugin's parameters + * to be used for model caching features + * @return array + */ + public function getCurrentCacheIdentifierParameters(): array + { + return [ + 'method' => $this->getJoinMethod(), + 'modelField' => $this->modelField, + 'referenceField' => $this->referenceField, + 'conditions' => $this->conditions, + 'vfield' => $this->virtualField, + ]; + } + + /** + * return the database-driver-specific keyword for this join + * @return string + */ + abstract public function getJoinMethod(): string; } diff --git a/backend/class/model/plugin/join/bare.php b/backend/class/model/plugin/join/bare.php index a8a5ba4..b711c02 100644 --- a/backend/class/model/plugin/join/bare.php +++ b/backend/class/model/plugin/join/bare.php @@ -1,5 +1,7 @@ type) { - case self::TYPE_LEFT: - return $this->type; - /*case self::TYPE_RIGHT: - return $this->type;*/ - /*case self::TYPE_FULL: - // not supported on MySQL - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_MYSQL_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - case self::TYPE_INNER: - return 'INNER JOIN';*/ - case self::TYPE_DEFAULT: - return self::TYPE_LEFT; // default fallback - case self::TYPE_INNER: - return $this->type; +class bare extends join implements executableJoinInterface +{ + /** + * {@inheritDoc} + * @param array $left + * @param array $right + * @return array + * @throws exception + */ + public function join(array $left, array $right): array + { + if ($this->getJoinMethod() == self::TYPE_LEFT) { + return $this->internalJoin($left, $right, $this->modelField, $this->referenceField); + } elseif ($this->getJoinMethod() == self::TYPE_RIGHT) { + return $this->internalJoin($right, $left, $this->referenceField, $this->modelField); + } elseif ($this->getJoinMethod() == self::TYPE_INNER) { + return $this->internalJoin($left, $right, $this->modelField, $this->referenceField); + } + return $left; } - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - } - /** - * [internalJoin description] - * @param array $left [left side rows] - * @param array $right [right side rows] - * @return array [result] - */ - protected function internalJoin(array $left, array $right, string $leftField, string $rightField) : array { - $success = array_walk ($left, function(array &$leftValue, $key, array $userDict) { - $right = $userDict[0]; - $leftField = $userDict[1]; - $rightField = $userDict[2]; - - $found = false; - if(isset($leftValue[$leftField])) { - foreach($right as $rightValue) { - if(($leftValue[$leftField] ?? null) == ($rightValue[$rightField] ?? null)) { - $leftValue = array_merge($leftValue, $rightValue); - $found = true; - break; - } - } - } - if(!$found) { - if($this->getJoinMethod() == self::TYPE_INNER) { - $leftValue = null; - } else if($this->getJoinMethod() == self::TYPE_LEFT) { - $emptyFields = []; - foreach($this->model->config->get('field') as $field) { - $emptyFields[$field] = null; - } - $leftValue = array_merge($leftValue, $emptyFields); + /** + * {@inheritDoc} + * @return string + * @throws exception + */ + public function getJoinMethod(): string + { + switch ($this->type) { + case self::TYPE_LEFT: + case self::TYPE_INNER: + return $this->type; + case self::TYPE_DEFAULT: + return self::TYPE_LEFT; // default fallback } - } - }, [$right, $leftField, $rightField]); - - if(!$success) { - // error? + throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); } - // kick out empty array elements previously set NULL - if($this->getJoinMethod() == self::TYPE_INNER) { - $left = array_filter($left, function($v, $k) { - return $v != null; - }, ARRAY_FILTER_USE_BOTH); - } + /** + * [internalJoin description] + * @param array $left [left side rows] + * @param array $right [right side rows] + * @param string $leftField + * @param string $rightField + * @return array [result] + * @throws exception + */ + protected function internalJoin(array $left, array $right, string $leftField, string $rightField): array + { + $success = array_walk($left, function (array &$leftValue, mixed $key, array $userDict) { + $right = $userDict[0]; + $leftField = $userDict[1]; + $rightField = $userDict[2]; - return $left; - } + $found = false; + if (isset($leftValue[$leftField])) { + foreach ($right as $rightValue) { + if ($leftValue[$leftField] == ($rightValue[$rightField] ?? null)) { + $leftValue = array_merge($leftValue, $rightValue); + $found = true; + break; + } + } + } + if (!$found) { + if ($this->getJoinMethod() == self::TYPE_INNER) { + $leftValue = null; + } elseif ($this->getJoinMethod() == self::TYPE_LEFT) { + $emptyFields = []; + foreach ($this->model->config->get('field') as $field) { + $emptyFields[$field] = null; + } + $leftValue = array_merge($leftValue, $emptyFields); + } + } + }, [$right, $leftField, $rightField]); - /** - * @inheritDoc - */ - public function join(array $left, array $right) : array { - if($this->getJoinMethod() == self::TYPE_LEFT) { - return $this->internalJoin($left, $right, $this->modelField, $this->referenceField); - } else if($this->getJoinMethod() == self::TYPE_RIGHT) { - return $this->internalJoin($right, $left, $this->referenceField, $this->modelField); - } else if($this->getJoinMethod() == self::TYPE_INNER) { - return $this->internalJoin($left, $right, $this->modelField, $this->referenceField); - } - return $left; - } + if (!$success) { + // error? + } + // kick out empty array elements previously set NULL + if ($this->getJoinMethod() == self::TYPE_INNER) { + $left = array_filter($left, function ($v) { + return $v != null; + }); + } + + return $left; + } } diff --git a/backend/class/model/plugin/join/dynamic.php b/backend/class/model/plugin/join/dynamic.php index 6c3126e..2f91b0b 100644 --- a/backend/class/model/plugin/join/dynamic.php +++ b/backend/class/model/plugin/join/dynamic.php @@ -1,127 +1,124 @@ type) { - case self::TYPE_LEFT: - return $this->type; - /*case self::TYPE_RIGHT: - return $this->type;*/ - /*case self::TYPE_FULL: - // not supported on MySQL - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_MYSQL_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - case self::TYPE_INNER: - return 'INNER JOIN';*/ - case self::TYPE_DEFAULT: - return self::TYPE_LEFT; // default fallback - case self::TYPE_INNER: - return $this->type; +use codename\core\model\plugin\join; +use DateMalformedStringException; +use ReflectionException; + +class dynamic extends join implements dynamicJoinInterface +{ + /** + * [protected description] + * @var array|null + */ + protected ?array $__emptyDataset = null; + + /** + * {@inheritDoc} + * @return string + * @throws exception + */ + public function getJoinMethod(): string + { + switch ($this->type) { + case self::TYPE_LEFT: + case self::TYPE_INNER: + return $this->type; + case self::TYPE_DEFAULT: + return self::TYPE_LEFT; // default fallback + } + throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); } - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - } - /** - * @inheritDoc - */ - public function dynamicJoin(array $result, ?array $params = null): array - { - $newResult = []; - - // - // CHANGED 2020-07-22 vkey handling inside dynamic joins - // - $vKey = $params['vkey']; - - foreach($result as $baseResultRow) { - // - // If we have a FKEY value provided, query for the dataset - // using the given model (and all of its descendants!) - // - if($leftValue = $baseResultRow[$this->modelField]) { + /** + * {@inheritDoc} + * @param array $result + * @param array|null $params + * @return array + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception + */ + public function dynamicJoin(array $result, ?array $params = null): array + { + $newResult = []; // - // TODO: we might backup the filters/filtercollections first - // and re-apply them afterwards - // NOTE: this might get risky, if you only apply regular filters before - // and not defaultfilters. It should not break the logic! + // CHANGED 2020-07-22 vkey handling inside dynamic joins // - $res = $this->model->addFilter($this->referenceField, $leftValue)->search()->getResult(); - - foreach($res as $partialResultRow) { - // - // CHANGED 2020-07-22 vkey handling inside dynamic joins - // - if($vKey) { - $newResult[] = array_merge( - $baseResultRow, - [ - $vKey => $partialResultRow - ] - ); - } else { - $newResult[] = array_merge($baseResultRow, $partialResultRow); - } - } - } else { - // In case of empty FKEY value - // simply output the base result without added data - // at least, if it's a LEFT join - if($this->type === static::TYPE_INNER) { - // NONE ! - } else if($this->type == static::TYPE_LEFT) { - // - // Add a pseudo dataset (empty values) - // - $newResult[] = array_merge($baseResultRow, $this->getEmptyDataset()); - } else { - // TODO: other join types? - $newResult[] = $baseResultRow; + $vKey = $params['vkey']; + + foreach ($result as $baseResultRow) { + // + // If we have a FKEY value provided, query for the dataset + // using the given model (and all of its descendants!) + // + if ($leftValue = $baseResultRow[$this->modelField]) { + // + // TODO: we might backup the filters/filtercollections first + // and re-apply them afterwards + // NOTE: this might get risky, if you only apply regular filters before + // and not default filters. It should not break the logic! + // + $res = $this->model->addFilter($this->referenceField, $leftValue)->search()->getResult(); + + foreach ($res as $partialResultRow) { + // + // CHANGED 2020-07-22 vkey handling inside dynamic joins + // + if ($vKey) { + $newResult[] = array_merge( + $baseResultRow, + [ + $vKey => $partialResultRow, + ] + ); + } else { + $newResult[] = array_merge($baseResultRow, $partialResultRow); + } + } + } elseif ($this->type === static::TYPE_INNER) { + // NONE ! + } elseif ($this->type == static::TYPE_LEFT) { + // + // Add a pseudo dataset (empty values) + // + $newResult[] = array_merge($baseResultRow, $this->getEmptyDataset()); + } else { + // TODO: other join types? + $newResult[] = $baseResultRow; + } } - } - } - - // Resetting the cache for producing an empty dataset - $this->resetEmptyDataset(); - - return $newResult; - } - - /** - * [protected description] - * @var array|null - */ - protected $__emptyDataset = null; + // Resetting the cache for producing an empty dataset + $this->resetEmptyDataset(); - /** - * Returns an empty dataset using the current model configuration - * @return array [description] - */ - protected function getEmptyDataset(): array { - if(!$this->__emptyDataset) { - $this->__emptyDataset = []; - foreach($this->model->getCurrentAliasedFieldlist() as $field) { - $this->__emptyDataset[$field] = null; - } + return $newResult; } - return $this->__emptyDataset; - } - - /** - * [resetEmptyDataset description] - * @return void - */ - protected function resetEmptyDataset(): void { - $this->__emptyDataset = null; - } + /** + * Returns an empty dataset using the current model configuration + * @return array [description] + */ + protected function getEmptyDataset(): array + { + if (!$this->__emptyDataset) { + $this->__emptyDataset = []; + foreach ($this->model->getCurrentAliasedFieldlist() as $field) { + $this->__emptyDataset[$field] = null; + } + } + return $this->__emptyDataset; + } + /** + * [resetEmptyDataset description] + * @return void + */ + protected function resetEmptyDataset(): void + { + $this->__emptyDataset = null; + } } diff --git a/backend/class/model/plugin/join/dynamicJoinInterface.php b/backend/class/model/plugin/join/dynamicJoinInterface.php index 14a5dc7..413074c 100644 --- a/backend/class/model/plugin/join/dynamicJoinInterface.php +++ b/backend/class/model/plugin/join/dynamicJoinInterface.php @@ -1,4 +1,5 @@ joinByArrayKey = $this->model->getPrimarykey() == $referenceField; - } - - - /** - * @inheritDoc - */ - public function getJoinMethod(): string - { - switch($this->type) { - case self::TYPE_LEFT: - return $this->type; - /*case self::TYPE_RIGHT: - return $this->type;*/ - /*case self::TYPE_FULL: - // not supported on MySQL - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_MYSQL_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - case self::TYPE_INNER: - return 'INNER JOIN';*/ - case self::TYPE_DEFAULT: - return self::TYPE_LEFT; // default fallback - case self::TYPE_INNER: - return $this->type; +class json extends join implements executableJoinInterface +{ + /** + * defines that we're joining via an array key, not via it's contents + * @var bool + */ + protected bool $joinByArrayKey = true; + + + /** + * {@inheritDoc} + * @param model $model + * @param string $type + * @param $modelField + * @param $referenceField + * @param array $conditions + * @param string|null $virtualField + * @throws exception + */ + public function __construct( + model $model, + string $type, + $modelField, + $referenceField, + array $conditions = [], + ?string $virtualField = null + ) { + parent::__construct($model, $type, $modelField, $referenceField, $conditions, $virtualField); + $this->joinByArrayKey = $this->model->getPrimaryKey() == $referenceField; } - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - } - - /** - * [internalJoin description] - * @param array $left [left side dataset] - * @param array $right [right side dataset] - * @param string $leftField [left field to join upon] - * @param string $rightField [right field to join upon] - * @return array [merged/reduced/expanded structures/datasets] - */ - protected function internalJoin(array $left, array $right, string $leftField, string $rightField) : array { - $success = array_walk ($left, function(array &$leftValue, $key, array $userDict) { - $right = $userDict[0]; - $leftField = $userDict[1]; - $rightField = $userDict[2]; - $found = false; - - if($this->joinByArrayKey) { - if(isset($right[$leftValue[$leftField]])) { - $leftValue = array_merge($leftValue, $right[$leftValue[$leftField]]); - $found = true; + /** + * {@inheritDoc} + * @param array $left + * @param array $right + * @return array + * @throws exception + */ + public function join(array $left, array $right): array + { + if ($this->getJoinMethod() == self::TYPE_LEFT) { + return $this->internalJoin($left, $right, $this->modelField, $this->referenceField); + } elseif ($this->getJoinMethod() == self::TYPE_RIGHT) { + return $this->internalJoin($right, $left, $this->referenceField, $this->modelField); + } elseif ($this->getJoinMethod() == self::TYPE_INNER) { + return $this->internalJoin($left, $right, $this->modelField, $this->referenceField); } + return $left; + } - } else { - foreach($right as $rightValue) { - if($leftValue[$leftField] == $rightValue[$rightField]) { - $leftValue = array_merge($leftValue, $rightValue); - $found = true; - break; - - } + /** + * {@inheritDoc} + * @return string + * @throws exception + */ + public function getJoinMethod(): string + { + switch ($this->type) { + case self::TYPE_LEFT: + case self::TYPE_INNER: + return $this->type; + case self::TYPE_DEFAULT: + return self::TYPE_LEFT; // default fallback } - } - if(!$found && $this->getJoinMethod() == self::TYPE_INNER) { - $leftValue = null; - } - }, [$right, $leftField, $rightField]); - - if(!$success) { - // error? + throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); } - // kick out empty array elements previously set NULL - if($this->getJoinMethod() == self::TYPE_INNER) { - $left = array_filter($left, function($v, $k) { - return $v != null; - }, ARRAY_FILTER_USE_BOTH); - } + /** + * [internalJoin description] + * @param array $left [left side dataset] + * @param array $right [right side dataset] + * @param string $leftField [left field to join upon] + * @param string $rightField [right field to join upon] + * @return array [merged/reduced/expanded structures/datasets] + * @throws exception + */ + protected function internalJoin(array $left, array $right, string $leftField, string $rightField): array + { + $success = array_walk($left, function (array &$leftValue, $key, array $userDict) { + $right = $userDict[0]; + $leftField = $userDict[1]; + $rightField = $userDict[2]; + + $found = false; + + if ($this->joinByArrayKey) { + if (isset($leftValue[$leftField]) && isset($right[$leftValue[$leftField]])) { + $leftValue = array_merge($leftValue, $right[$leftValue[$leftField]]); + $found = true; + } + } else { + foreach ($right as $rightValue) { + if ($leftValue[$leftField] == $rightValue[$rightField]) { + $leftValue = array_merge($leftValue, $rightValue); + $found = true; + break; + } + } + } + if (!$found && $this->getJoinMethod() == self::TYPE_INNER) { + $leftValue = null; + } + }, [$right, $leftField, $rightField]); + + if (!$success) { + // error? + } - return $left; - } + // kick out empty array elements previously set NULL + if ($this->getJoinMethod() == self::TYPE_INNER) { + $left = array_filter($left, function ($v, $k) { + return $v != null; + }, ARRAY_FILTER_USE_BOTH); + } - /** - * @inheritDoc - */ - public function join(array $left, array $right) : array { - if($this->getJoinMethod() == self::TYPE_LEFT) { - return $this->internalJoin($left, $right, $this->modelField, $this->referenceField); - } else if($this->getJoinMethod() == self::TYPE_RIGHT) { - return $this->internalJoin($right, $left, $this->referenceField, $this->modelField); - } else if($this->getJoinMethod() == self::TYPE_INNER) { - return $this->internalJoin($left, $right, $this->modelField, $this->referenceField); + return $left; } - return $left; - } - } diff --git a/backend/class/model/plugin/join/mysql.php b/backend/class/model/plugin/join/mysql.php index a822468..f04299c 100644 --- a/backend/class/model/plugin/join/mysql.php +++ b/backend/class/model/plugin/join/mysql.php @@ -1,5 +1,7 @@ type) { - case self::TYPE_LEFT: - return 'LEFT JOIN'; - case self::TYPE_RIGHT: - return 'RIGHT JOIN'; - case self::TYPE_FULL: - // not supported on MySQL - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_MYSQL_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - case self::TYPE_INNER: - return 'INNER JOIN'; - case self::TYPE_DEFAULT: - return null; +class mysql extends join +{ + /** + * {@inheritDoc} + * @return string + * @throws exception + */ + public function getJoinMethod(): string + { + switch ($this->type) { + case self::TYPE_DEFAULT: + case self::TYPE_LEFT: + return 'LEFT JOIN'; + case self::TYPE_RIGHT: + return 'RIGHT JOIN'; + case self::TYPE_FULL: + // not supported on MySQL + throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_MYSQL_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); + case self::TYPE_INNER: + return 'INNER JOIN'; + } + throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); } - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - } - -} \ No newline at end of file +} diff --git a/backend/class/model/plugin/join/postgresql.php b/backend/class/model/plugin/join/postgresql.php index 28c64d4..4a956be 100644 --- a/backend/class/model/plugin/join/postgresql.php +++ b/backend/class/model/plugin/join/postgresql.php @@ -1,6 +1,9 @@ type) { - case self::TYPE_LEFT: - return 'LEFT JOIN'; - case self::TYPE_RIGHT: - return 'RIGHT JOIN'; - case self::TYPE_FULL: - // not implemented right now? - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_POSTGRESQL_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - case self::TYPE_INNER: - return 'INNER JOIN'; - case self::TYPE_DEFAULT: - return null; +class postgresql extends join +{ + /** + * {@inheritDoc} + */ + public function __construct( + model $model, + string $type, + $modelField, + $referenceField, + array $conditions = [], + ?string $virtualField = null + ) { + parent::__construct($model, $type, '"' . $modelField . '"', '"' . $referenceField . '"', $conditions, $virtualField); } - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - } + /** + * {@inheritDoc} + * @return string + * @throws exception + */ + public function getJoinMethod(): string + { + switch ($this->type) { + case self::TYPE_DEFAULT: + case self::TYPE_LEFT: + return 'LEFT JOIN'; + case self::TYPE_RIGHT: + return 'RIGHT JOIN'; + case self::TYPE_FULL: + // not implemented right now? + throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_POSTGRESQL_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); + case self::TYPE_INNER: + return 'INNER JOIN'; + } + throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); + } } diff --git a/backend/class/model/plugin/join/recursive.php b/backend/class/model/plugin/join/recursive.php index a272d0f..427c313 100644 --- a/backend/class/model/plugin/join/recursive.php +++ b/backend/class/model/plugin/join/recursive.php @@ -1,63 +1,66 @@ anchorField - * @var string - */ - protected $selfReferenceField = null; + /** + * Field that is used for self-reference + * selfReferenceField => anchorField + * @var null|string + */ + protected ?string $selfReferenceField = null; - /** - * Field that is used as anchor point - * @var string - */ - protected $anchorField = null; + /** + * Field that is used as anchor point + * @var null|string + */ + protected ?string $anchorField = null; - /** - * [protected description] - * @var \codename\core\model\plugin\filter - */ - protected $anchorConditions = []; + /** + * [protected description] + * @var filter[] + */ + protected array $anchorConditions = []; - /** - * @inheritDoc - */ - public function __CONSTRUCT( - \codename\core\model $model, - string $selfReferenceField, - string $anchorField, - array $anchorConditions, - string $type, - $modelField, - $referenceField, - array $conditions = [], - ?string $virtualField = null - ) { - parent::__CONSTRUCT($model, $type, $modelField, $referenceField, $conditions, $virtualField); - $this->selfReferenceField = $selfReferenceField; - $this->anchorField = $anchorField; - if(count($anchorConditions) > 0) { - foreach($anchorConditions as $cond) { - if($cond instanceof \codename\core\model\plugin\filter) { - $this->anchorConditions[] = $cond; + /** + * {@inheritDoc} + */ + public function __construct( + model $model, + string $selfReferenceField, + string $anchorField, + array $anchorConditions, + string $type, + $modelField, + $referenceField, + array $conditions = [], + ?string $virtualField = null + ) { + parent::__construct($model, $type, $modelField, $referenceField, $conditions, $virtualField); + $this->selfReferenceField = $selfReferenceField; + $this->anchorField = $anchorField; + if (count($anchorConditions) > 0) { + foreach ($anchorConditions as $cond) { + if ($cond instanceof filter) { + $this->anchorConditions[] = $cond; + } else { + $this->anchorConditions[] = $this->createFilterPluginInstance($cond); + } + } } else { - $this->anchorConditions[] = $this->createFilterPluginInstance($cond); + // throw new exception('PLUGIN_JOIN_RECURSIVE_ANCHOR_CONDITIONS_REQUIRED', exception::$ERRORLEVEL_ERROR); } - } - } else { - // throw new exception('PLUGIN_JOIN_RECURSIVE_ANCHOR_CONDITIONS_REQUIRED', exception::$ERRORLEVEL_ERROR); } - } - /** - * [createFilterPluginInstance description] - * @param array $data [description] - * @return \codename\core\model\plugin\filter [description] - */ - protected abstract function createFilterPluginInstance(array $data): \codename\core\model\plugin\filter; + /** + * [createFilterPluginInstance description] + * @param array $data [description] + * @return filter [description] + */ + abstract protected function createFilterPluginInstance(array $data): filter; } diff --git a/backend/class/model/plugin/join/recursive/mysql.php b/backend/class/model/plugin/join/recursive/mysql.php index 804e621..ffc010b 100644 --- a/backend/class/model/plugin/join/recursive/mysql.php +++ b/backend/class/model/plugin/join/recursive/mysql.php @@ -1,42 +1,51 @@ type) { - case self::TYPE_LEFT: - return 'LEFT JOIN'; - case self::TYPE_RIGHT: - return 'RIGHT JOIN'; - case self::TYPE_FULL: - // not supported on MySQL + /** + * {@inheritDoc} + * @return string + * @throws exception + */ + public function getJoinMethod(): string + { + switch ($this->type) { + case self::TYPE_DEFAULT: + case self::TYPE_LEFT: + return 'LEFT JOIN'; + case self::TYPE_RIGHT: + return 'RIGHT JOIN'; + case self::TYPE_FULL: + // not supported on MySQL + throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_RECURSIVE_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); + case self::TYPE_INNER: + return 'INNER JOIN'; + } throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_RECURSIVE_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - case self::TYPE_INNER: - return 'INNER JOIN'; - case self::TYPE_DEFAULT: - return null; } - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_RECURSIVE_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - } - /** - * @inheritDoc - */ - protected function createFilterPluginInstance( - array $data - ): \codename\core\model\plugin\filter { - return new \codename\core\model\plugin\filter\mysql( - \codename\core\value\text\modelfield::getInstance($data['field']), - $data['value'], - $data['operator'], - $data['conjunction'] ?? null, // ?? - ); - } + /** + * {@inheritDoc} + * @param array $data + * @return filter + * @throws ReflectionException + * @throws exception + */ + protected function createFilterPluginInstance( + array $data + ): filter { + return new filter\mysql( + modelfield::getInstance($data['field']), + $data['value'], + $data['operator'], + $data['conjunction'] ?? null, // ?? + ); + } } diff --git a/backend/class/model/plugin/join/recursive/sql.php b/backend/class/model/plugin/join/recursive/sql.php index 5aa779d..83f70b0 100644 --- a/backend/class/model/plugin/join/recursive/sql.php +++ b/backend/class/model/plugin/join/recursive/sql.php @@ -1,112 +1,159 @@ internalReferenceField = $referenceField; - $referenceField = $this->anchorFieldName; // default name - parent::__CONSTRUCT($model, $selfReferenceField, $anchorField, $anchorConditions, $type, $modelField, $referenceField, $conditions, $virtualField); - } - - /** - * [protected description] - * @var [type] - */ - protected $internalReferenceField = null; - - /** - * [anchorFieldName description] - * @var string - */ - protected $anchorFieldName = '__anchor'; - - /** - * @inheritDoc - */ - public function getSqlCteStatement(string $cteName, array &$params, string $refAlias = null): string - { - // return "WITH RECURSIVE - // AS ( - // SELECT as ___anchor, .* - // FROM - // WHERE - // UNION ALL - // SELECT .___anchor, .* - // FROM , - // WHERE . = . - // )"; - - $anchorConditionQuery = ''; - if(count($this->anchorConditions) > 0) { - $anchorConditionQuery = 'WHERE '.\codename\core\model\schematic\sql::convertFilterQueryArray( - $this->model->getFilters($this->anchorConditions, [], [], $params, $refAlias) // ?? - ); + /** + * [protected description] + * @var model\schematic\sql + */ + public $model = null; + /** + * [protected description] + * @var string|null [type] + */ + protected ?string $internalReferenceField = null; + /** + * [anchorFieldName description] + * @var string + */ + protected string $anchorFieldName = '__anchor'; + + /** + * {@inheritDoc} + * @param model $model + * @param string $selfReferenceField + * @param string $anchorField + * @param array $anchorConditions + * @param string $type + * @param $modelField + * @param $referenceField + * @param array $conditions + * @param string|null $virtualField + * @throws exception + */ + public function __construct( + model $model, + string $selfReferenceField, + string $anchorField, + array $anchorConditions, + string $type, + $modelField, + $referenceField, + array $conditions = [], + ?string $virtualField = null + ) { + // We exchange referenceField with the fixed anchor field name + $this->internalReferenceField = $referenceField; + $referenceField = $model->getPrimaryKey(); +// $referenceField = $this->anchorFieldName; // default name + parent::__construct($model, $selfReferenceField, $anchorField, $anchorConditions, $type, $modelField, $referenceField, $conditions, $virtualField); } - // if table is already a CTE, inherit it. - $refName = $refAlias ?? $this->model->getTableIdentifier(); - - // - // CTE Prefix / "WITH [RECURSIVE]" is implicitly added by the model class - // - return "{$cteName} " - . " AS ( " - . " SELECT " - // We default to the PKEY as (visible) anchor field: - // Default anchor field name (__anchor) - // Not to be confused with recursiveAnchorField - . " {$this->model->getPrimarykey()} as {$this->anchorFieldName}" - // . " , 0 as __level " // TODO: internal level tracking for keeping order? - - // Endless loop / circular reference protection for array-supporting RDBMS: - // . " , array[{$this->model->getPrimarykey()}] as __traversed " - - . " , {$refName}.* " - . " FROM {$refName} " - . " {$anchorConditionQuery} " - - // NOTE: UNION instead of UNION ALL prevents duplicates - // and is an implicit termination condition for the recursion - // as the some query might return rows already selected - // leading to 'zero added rows' - and finishing our query - . " UNION " - - . " SELECT " - . " {$cteName}.{$this->anchorFieldName} " - // . " , __level+1 " // TODO: internal level tracking for keeping order? - - // Endless loop / circular reference protection for array-supporting RDBMS: - // . " , {$cteName}.__traversed || {$refName}.{$this->getPrimarykey()} " - - . " , {$refName}.* " - . " FROM {$refName}, {$cteName} " - . " WHERE {$cteName}.{$this->selfReferenceField} = {$refName}.{$this->anchorField} " - // . " ORDER BY {$cteName}.{$this->anchorFieldName}, __level" // TODO: internal level tracking for keeping order? - - // Endless loop / circular reference protection for array-supporting RDBMS: - // . " AND {$this->model->getPrimarykey()} <> ALL ({$cteName}.__traversed) " - . ")"; - } + /** + * {@inheritDoc} + * @param string $cteName + * @param array $params + * @param string|null $refAlias + * @return string + * @throws exception + */ + public function getSqlCteStatement(string $cteName, array &$params, string $refAlias = null): string + { + $anchorConditionQuery = ''; + if (count($this->anchorConditions) > 0) { + $anchorConditionQuery = 'WHERE ' . model\schematic\sql::convertFilterQueryArray( + $this->model->getFilters($this->anchorConditions, [], [], $params, $refAlias) // ?? + ); + } + + // if a table is already a CTE, inherit it. + $refName = $refAlias ?? $this->model->getTableIdentifier(); + + // + // CTE Prefix / "WITH [RECURSIVE]" is implicitly added by the model class + // + + return "$cteName " + . " AS ( " + . " SELECT " + // We default to the PKEY as (visible) anchor field: + // Default anchor field name (__anchor) + // Not to be confused with recursiveAnchorField +// . " {$this->model->getPrimaryKey()} as $this->anchorFieldName" + // . " , 0 as __level " // TODO: internal level tracking for keeping order? + + // Endless loop / circular reference protection for array-supporting RDBMS: + // . " , array[{$this->model->getPrimaryKey()}] as __traversed " + + . " $refName.* " + . " FROM $refName " + . " $anchorConditionQuery " + + // NOTE: UNION instead of UNION ALL prevents duplicates + // and is an implicit termination condition for the recursion + // as some query might return rows already selected + // leading to 'zero added rows' - and finishing our query + . " UNION " + + . " SELECT " +// . " $cteName.$this->anchorFieldName " + // . " , __level+1 " // TODO: internal level tracking for keeping order? + + // Endless loop / circular reference protection for array-supporting RDBMS: + // . " , {$cteName}.__traversed || {$refName}.{$this->getPrimaryKey()} " + + . " $refName.* " + . " FROM $refName JOIN $cteName " + . " ON $refName.$this->selfReferenceField = $cteName.$this->anchorField " + // . " ORDER BY {$cteName}.{$this->anchorFieldName}, __level" // TODO: internal level tracking for keeping order? + + // Endless loop / circular reference protection for array-supporting RDBMS: + // . " AND {$this->model->getPrimaryKey()} <> ALL ({$cteName}.__traversed) " + . ")"; + +// return "$cteName " +// . " AS ( " +// . " SELECT " +// // We default to the PKEY as (visible) anchor field: +// // Default anchor field name (__anchor) +// // Not to be confused with recursiveAnchorField +// . " {$this->model->getPrimaryKey()} as $this->anchorFieldName" +// // . " , 0 as __level " // TODO: internal level tracking for keeping order? +// +// // Endless loop / circular reference protection for array-supporting RDBMS: +// // . " , array[{$this->model->getPrimaryKey()}] as __traversed " +// +// . " , $refName.* " +// . " FROM $refName " +// . " $anchorConditionQuery " +// +// // NOTE: UNION instead of UNION ALL prevents duplicates +// // and is an implicit termination condition for the recursion +// // as some query might return rows already selected +// // leading to 'zero added rows' - and finishing our query +// . " UNION " +// +// . " SELECT " +// . " $cteName.$this->anchorFieldName " +// // . " , __level+1 " // TODO: internal level tracking for keeping order? +// +// // Endless loop / circular reference protection for array-supporting RDBMS: +// // . " , {$cteName}.__traversed || {$refName}.{$this->getPrimaryKey()} " +// +// . " , $refName.* " +// . " FROM $refName, $cteName " +// . " WHERE $cteName.$this->selfReferenceField = $refName.$this->anchorField " +// // . " ORDER BY {$cteName}.{$this->anchorFieldName}, __level" // TODO: internal level tracking for keeping order? +// +// // Endless loop / circular reference protection for array-supporting RDBMS: +// // . " AND {$this->model->getPrimaryKey()} <> ALL ({$cteName}.__traversed) " +// . ")"; + } } diff --git a/backend/class/model/plugin/join/recursive/sqlite.php b/backend/class/model/plugin/join/recursive/sqlite.php index 87b7432..04e51ef 100644 --- a/backend/class/model/plugin/join/recursive/sqlite.php +++ b/backend/class/model/plugin/join/recursive/sqlite.php @@ -1,42 +1,51 @@ type) { - case self::TYPE_LEFT: - return 'LEFT JOIN'; - case self::TYPE_RIGHT: - // return 'RIGHT JOIN'; - case self::TYPE_FULL: - // not supported on MySQL + /** + * {@inheritDoc} + * @return string + * @throws exception + */ + public function getJoinMethod(): string + { + switch ($this->type) { + case self::TYPE_DEFAULT: + case self::TYPE_LEFT: + return 'LEFT JOIN'; + case self::TYPE_RIGHT: + // return 'RIGHT JOIN'; + case self::TYPE_FULL: + // not supported on MySQL + throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_RECURSIVE_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); + case self::TYPE_INNER: + return 'INNER JOIN'; + } throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_RECURSIVE_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - case self::TYPE_INNER: - return 'INNER JOIN'; - case self::TYPE_DEFAULT: - return null; } - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_RECURSIVE_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - } - /** - * @inheritDoc - */ - protected function createFilterPluginInstance( - array $data - ): \codename\core\model\plugin\filter { - return new \codename\core\model\plugin\filter\sqlite( - \codename\core\value\text\modelfield::getInstance($data['field']), - $data['value'], - $data['operator'], - $data['conjunction'] ?? null, // ?? - ); - } + /** + * {@inheritDoc} + * @param array $data + * @return filter + * @throws ReflectionException + * @throws exception + */ + protected function createFilterPluginInstance( + array $data + ): filter { + return new filter\sqlite( + modelfield::getInstance($data['field']), + $data['value'], + $data['operator'], + $data['conjunction'] ?? null, // ?? + ); + } } diff --git a/backend/class/model/plugin/join/sqlite.php b/backend/class/model/plugin/join/sqlite.php index e822396..8ce1960 100644 --- a/backend/class/model/plugin/join/sqlite.php +++ b/backend/class/model/plugin/join/sqlite.php @@ -1,5 +1,7 @@ type) { - case self::TYPE_LEFT: - return 'LEFT JOIN'; - case self::TYPE_RIGHT: - case self::TYPE_FULL: - // not supported on SQLite - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_SQLITE_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - case self::TYPE_INNER: - return 'INNER JOIN'; - case self::TYPE_DEFAULT: - return null; +class sqlite extends join +{ + /** + * {@inheritDoc} + * @return string + * @throws exception + */ + public function getJoinMethod(): string + { + switch ($this->type) { + case self::TYPE_DEFAULT: + case self::TYPE_LEFT: + return 'LEFT JOIN'; + case self::TYPE_RIGHT: + case self::TYPE_FULL: + // not supported on SQLite + throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_SQLITE_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); + case self::TYPE_INNER: + return 'INNER JOIN'; + } + throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); } - throw new exception('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE', exception::$ERRORLEVEL_ERROR, $this->type); - } - } diff --git a/backend/class/model/plugin/limit.php b/backend/class/model/plugin/limit.php index 1942e56..9043f19 100755 --- a/backend/class/model/plugin/limit.php +++ b/backend/class/model/plugin/limit.php @@ -1,27 +1,31 @@ limit = $limit; return $this; } - } diff --git a/backend/class/model/plugin/limit/json.php b/backend/class/model/plugin/limit/json.php index 3de1b71..2bb5471 100644 --- a/backend/class/model/plugin/limit/json.php +++ b/backend/class/model/plugin/limit/json.php @@ -1,12 +1,14 @@ offset = $offset; return $this; } - } diff --git a/backend/class/model/plugin/offset/mysql.php b/backend/class/model/plugin/offset/mysql.php index 8441fb8..7f8972d 100644 --- a/backend/class/model/plugin/offset/mysql.php +++ b/backend/class/model/plugin/offset/mysql.php @@ -1,12 +1,14 @@ -field = $field; $this->direction = $direction; return $this; } - } diff --git a/backend/class/model/plugin/order/bare.php b/backend/class/model/plugin/order/bare.php index 2cdca54..1e3c8cf 100644 --- a/backend/class/model/plugin/order/bare.php +++ b/backend/class/model/plugin/order/bare.php @@ -1,53 +1,54 @@ field->get(); - self::stable_usort($data, function(array $left, array $right) use ($key) { - if($left[$key] == $right[$key]) { - return 0; - } - $prepSort = array($left[$key], $right[$key]); - sort($prepSort); - if($prepSort[0] === $left[$key]) { - return $this->direction == 'ASC' ? -1 : 1; - } else { - return $this->direction == 'ASC' ? 1 : -1; - } - }); - return $data; - } +class bare extends order implements orderInterface, executableOrderInterface +{ + /** + * {@inheritDoc} + */ + public function order(array $data): array + { + $key = $this->field->get(); + self::stable_usort($data, function (array $left, array $right) use ($key) { + if ($left[$key] == $right[$key]) { + return 0; + } + $prepSort = [$left[$key], $right[$key]]; + sort($prepSort); + if ($prepSort[0] === $left[$key]) { + return $this->direction == 'ASC' ? -1 : 1; + } else { + return $this->direction == 'ASC' ? 1 : -1; + } + }); + return $data; + } - /** - * stable usort function - * @var [type] - */ - protected static function stable_usort(array &$array, $value_compare_func) - { - $index = 0; - foreach ($array as &$item) { - $item = array($index++, $item); - } - $result = usort($array, function($a, $b) use($value_compare_func) { - $result = call_user_func($value_compare_func, $a[1], $b[1]); - return $result == 0 ? $a[0] - $b[0] : $result; - }); - foreach ($array as &$item) { - $item = $item[1]; - } - return $result; - } + /** + * stable usort function + * @var [type] + */ + protected static function stable_usort(array &$array, $value_compare_func): bool + { + $index = 0; + foreach ($array as &$item) { + $item = [$index++, $item]; + } + $result = usort($array, function ($a, $b) use ($value_compare_func) { + $result = call_user_func($value_compare_func, $a[1], $b[1]); + return $result == 0 ? $a[0] - $b[0] : $result; + }); + foreach ($array as &$item) { + $item = $item[1]; + } + return $result; + } } diff --git a/backend/class/model/plugin/order/executableOrderInterface.php b/backend/class/model/plugin/order/executableOrderInterface.php index 190476c..5404205 100644 --- a/backend/class/model/plugin/order/executableOrderInterface.php +++ b/backend/class/model/plugin/order/executableOrderInterface.php @@ -1,17 +1,17 @@ baseModel = $model; - - // override $modeldata - $modeldata = []; - parent::__CONSTRUCT($modeldata); - $this->setConfig($model->getConfig()->get('connection'), null, $name); - } - - /** - * Returns the underyling base model - * @return model - */ - public function getBaseModel(): model { - return $this->baseModel; - } - - /** - * @inheritDoc - */ - public function getTableIdentifier( - ?string $schema = null, - ?string $model = null - ): string { - if($schema || $model) { - return parent::getTableIdentifier($schema, $model); - } else { - return $this->table; + /** + * the model the discrete query is relying on. + * @var sql + */ + protected sql $baseModel; + + /** + * {@inheritDoc} + * @param string $name + * @param sql $model + * @throws ReflectionException + * @throws exception + */ + public function __construct(string $name, sql $model) + { + $this->baseModel = $model; + + // override $modeldata + $modeldata = []; + parent::__construct($modeldata); + $this->setConfig($model->getConfig()->get('connection'), null, $name); } - } - - /** - * @inheritDoc - */ - public function setConfig(string $connection = null, ?string $schema, string $table) : \codename\core\model { - - $this->schema = $schema; - $this->table = $table; - if($connection != null) { - $this->db = app::getDb($connection, $this->storeConnection); + /** + * {@inheritDoc} + */ + public function setConfig(?string $connection, ?string $schema, string $table): model + { + $this->schema = $schema; + $this->table = $table; + + if ($connection != null) { + $this->db = app::getDb($connection, $this->storeConnection); + } + + $config = null; + // $config = app::getCache()->get('MODELCONFIG_', get_class($this)); + if (is_array($config)) { + $this->config = new config($config); + + // Connection now defined in model.json + if ($this->config->exists("connection")) { + $connection = $this->config->get("connection"); + } + $this->db = app::getDb($connection, $this->storeConnection); + + return $this; + } + + if (!$this->config) { + $this->config = $this->loadConfig(); + } + + // Connection now defined in model.json + if ($this->config->exists("connection")) { + $connection = $this->config->get("connection"); + } else { + $connection = 'default'; + } + + if (!$this->db) { + $this->db = app::getDb($connection, $this->storeConnection); + } + + // app::getCache()->set('MODELCONFIG_', get_class($this), $this->config->get()); + return $this; } - $config = null; - // $config = app::getCache()->get('MODELCONFIG_', get_class($this)); - if(is_array($config)) { - $this->config = new \codename\core\config($config); - - // Connection now defined in model .json - if($this->config->exists("connection")) { - $connection = $this->config->get("connection"); - } - $this->db = app::getDb($connection, $this->storeConnection); - - return $this; + /** + * {@inheritDoc} + */ + protected function loadConfig(): config + { + // + // Inherit the config from the base model + // TODO: check what's happening with nested models, though. + // + return new config($this->baseModel->getConfig()->get()); } - if(!$this->config) { - $this->config = $this->loadConfig(); + /** + * Returns the underlying base model + * @return model + */ + public function getBaseModel(): model + { + return $this->baseModel; } - // Connection now defined in model .json - if($this->config->exists("connection")) { - $connection = $this->config->get("connection"); - } else { - $connection = 'default'; + /** + * {@inheritDoc} + */ + public function getFields(): array + { + return static::getAliasedFieldlistRecursive($this->baseModel); } - if(!$this->db) { - $this->db = app::getDb($connection, $this->storeConnection); + /** + * Function that returns all fields available, recursively. + * Calls ":: getCurrentAliasedFieldlist()" on every item. + * @param model $model [description] + * @return array [description] + */ + protected static function getAliasedFieldlistRecursive(model $model): array + { + $fields = $model->getCurrentAliasedFieldlist(); + foreach ($model->getNestedJoins() as $join) { + $fields = array_merge($fields, static::getAliasedFieldlistRecursive($join->model)); + } + return $fields; } - // app::getCache()->set('MODELCONFIG_', get_class($this), $this->config->get()); - return $this; - } - - /** - * @inheritDoc - */ - protected function loadConfig(): \codename\core\config - { - // - // Inherit the config from the base model - // TODO: check what's happening with nested models, though. - // - return new \codename\core\config($this->baseModel->getConfig()->get()); - } - - /** - * @inheritDoc - */ - public function getFields(): array - { - return static::getAliasedFieldlistRecursive($this->baseModel); - } - - /** - * Function that returns all fields available, - * recursively. Calls ::getCurrentAliasedFieldlist() on every item. - * @param model $model [description] - * @return array [description] - */ - protected static function getAliasedFieldlistRecursive(model $model): array { - $fields = $model->getCurrentAliasedFieldlist(); - foreach($model->getNestedJoins() as $join) { - $fields = array_merge($fields, static::getAliasedFieldlistRecursive($join->model)); - } - return $fields; - } - - /** - * @inheritDoc - */ - protected function isDiscreteModel(): bool - { - return true; - } - - /** - * @inheritDoc - */ - public function getDiscreteModelQuery(array &$params): string - { - $parentAlias = null; - $cteName = null; // null for this subquery stuff... - $cte = []; - - $tableUsage = [ "{$this->baseModel->schema}.{$this->baseModel->table}" => 1]; - $aliasCounter = 0; - $deepjoin = $this->baseModel->deepJoin($this->baseModel, $tableUsage, $aliasCounter, $parentAlias, $params, $cte); - - $fieldlist = $this->baseModel->getCurrentFieldlist($cteName, $params); - $fromQueryString = $this->baseModel->getTableIdentifier(); - - $fieldQueryString = ''; - - if(count($fieldlist) == 0) { - $fieldQueryString = ' * '; - } else { - $fields = array(); - foreach($fieldlist as $f) { - // schema and table specifier separator (.)(.) - // schema.table.field (and field may be a '*') - $fields[] = implode('.', $f); - } - // chain the fields - $fieldQueryString = implode(',', $fields); + /** + * {@inheritDoc} + * @param array $params + * @return string + * @throws exception + */ + public function getDiscreteModelQuery(array &$params): string + { + $parentAlias = null; + $cteName = null; // null for this subquery stuff... + $cte = []; + + $tableUsage = ["{$this->baseModel->schema}.{$this->baseModel->table}" => 1]; + $aliasCounter = 0; + $deepjoin = $this->baseModel->deepJoin($this->baseModel, $tableUsage, $aliasCounter, $parentAlias, $params, $cte); + + $fieldlist = $this->baseModel->getCurrentFieldlist($cteName, $params); + $fromQueryString = $this->baseModel->getTableIdentifier(); + + if (count($fieldlist) == 0) { + $fieldQueryString = ' * '; + } else { + $fields = []; + foreach ($fieldlist as $f) { + // schema and table specifier separator (.)(.) + // schema.table.field (and field may be a '*') + $fields[] = implode('.', $f); + } + // chain the fields + $fieldQueryString = implode(',', $fields); + } + + $mainAlias = null; + if ($tableUsage["{$this->baseModel->schema}.{$this->baseModel->table}"] > 1) { + $mainAlias = $this->baseModel->getTableIdentifier(); + } + + // clean start from filters + $query = $this->baseModel->getFilterQuery($params, $mainAlias); + + $groups = $this->baseModel->getGroups($mainAlias); + if (count($groups) > 0) { + $query .= ' GROUP BY ' . implode(', ', $groups); + } + + // + // HAVING clause + // + $aggregate = $this->baseModel->getAggregateQueryComponents($params); + if (count($aggregate) > 0) { + $query .= ' HAVING ' . static::convertFilterQueryArray($aggregate); + } + + if ($this->baseModel->order) { + // + // WARNING: real ordering via ORDER BY in sub queries is usually ignored by typical RDBMS + // in the final output - but it is applicable for ORDER BY + LIMIT/OFFSET queries! + // + $query .= $this->baseModel->getOrders($this->baseModel->order); + } + + if ($this->baseModel->limit) { + $query .= $this->baseModel->getLimit($this->baseModel->limit); + } + + if ($this->baseModel->offset) { + $query .= $this->baseModel->getOffset($this->baseModel->offset); + } + + return "(SELECT $fieldQueryString FROM $fromQueryString $deepjoin $query)"; } - $mainAlias = null; - if($tableUsage["{$this->baseModel->schema}.{$this->baseModel->table}"] > 1) { - $mainAlias = $this->baseModel->getTableIdentifier(); + /** + * {@inheritDoc} + */ + public function getTableIdentifier( + ?string $schema = null, + ?string $model = null + ): string { + if ($schema || $model) { + return parent::getTableIdentifier($schema, $model); + } else { + return $this->table; + } } - // clean start from filters - $query = ''; - - $query .= $this->baseModel->getFilterQuery($params, $mainAlias); - - $groups = $this->baseModel->getGroups($mainAlias); - if(count($groups) > 0) { - $query .= ' GROUP BY '. implode(', ', $groups); + /** + * {@inheritDoc} + */ + public function reset(): void + { + $this->baseModel->reset(); + parent::reset(); } - // - // HAVING clause - // - $aggregate = $this->baseModel->getAggregateQueryComponents($params); - if(count($aggregate) > 0) { - $query .= ' HAVING '. static::convertFilterQueryArray($aggregate); + /** + * {@inheritDoc} + */ + public function save(array $data): model + { + throw new LogicException('Not implemented.'); } - $orderQueryString = ''; - if($this->baseModel->order) { - // - // WARNING: real ordering via ORDER BY in subqueries is usually ignored by typical RDBMS - // in the final output - but it is applicable for ORDER BY + LIMIT/OFFSET queries! - // - $query .= $this->baseModel->getOrders($this->baseModel->order); + /** + * {@inheritDoc} + */ + public function replace(array $data): model + { + throw new LogicException('Not implemented.'); } - if($this->baseModel->limit) { - $query .= $this->baseModel->getLimit($this->baseModel->limit); + /** + * {@inheritDoc} + */ + public function update(array $data): model + { + throw new LogicException('Not implemented.'); } - if($this->baseModel->offset) { - $query .= $this->baseModel->getOffset($this->baseModel->offset); + /** + * {@inheritDoc} + */ + public function delete(mixed $primaryKey = null): model + { + throw new LogicException('Not implemented.'); } - return "(SELECT {$fieldQueryString} FROM {$fromQueryString} {$deepjoin} {$query})"; - } - - /** - * @inheritDoc - */ - public function reset() - { - $this->baseModel->reset(); - parent::reset(); - } - - /** - * @inheritDoc - */ - public function save(array $data): model { - throw new \LogicException('Not implemented.'); - } - - /** - * @inheritDoc - */ - public function replace(array $data): model { - throw new \LogicException('Not implemented.'); - } - - /** - * @inheritDoc - */ - public function update(array $data): model { - throw new \LogicException('Not implemented.'); - } - - /** - * @inheritDoc - */ - public function delete($primaryKey = null): model { - throw new \LogicException('Not implemented.'); - } + /** + * {@inheritDoc} + */ + protected function isDiscreteModel(): bool + { + return true; + } } diff --git a/backend/class/model/schematic/mysql.php b/backend/class/model/schematic/mysql.php index 934d6b5..0f7fbaf 100644 --- a/backend/class/model/schematic/mysql.php +++ b/backend/class/model/schematic/mysql.php @@ -1,18 +1,18 @@ schema}.{$this->table}"; - return parent::getFilterQuery($appliedFilters, $mainAlias); + $mainAlias = $mainAlias ?? "$this->schema.$this->table"; + return parent::getFilterQuery($appliedFilters, $mainAlias); } - } diff --git a/backend/class/model/schematic/sql.php b/backend/class/model/schematic/sql.php index 4dbccce..2812495 100644 --- a/backend/class/model/schematic/sql.php +++ b/backend/class/model/schematic/sql.php @@ -1,95 +1,214 @@ schema && $this->table) { - return get_class($this).'-'.$this->schema.'_'.$this->table; - } else { - throw new exception('EXCEPTION_MODELCONFIG_CACHE_KEY_MISSING_DATA', exception::$ERRORLEVEL_FATAL); - } - } + protected bool $virtualFieldResult = false; + /** + * [protected description] + * @var bool|null + */ + protected ?bool $useTimemachineState = null; + /** + * Whether to set *_modified field automatically + * during update + * @var bool + */ + protected bool $saveUpdateSetModifiedTimestamp = true; /** - * Creates and configures the instance of the model. Fallback connection is 'default' database - * @param string|null $connection [Name of the connection in the app configuration file] - * @param string $schema [Schema to use the model for] - * @param string $table [Table to use the model on] - * @return \codename\core\model + * [protected description] + * @var modelfield[] + */ + protected array $modelfieldInstance = []; + /** + * [protected description] + * @var null|array + */ + protected ?array $lastFilterQueryComponents = null; + /** + * [protected description] + * @var bool + */ + protected bool $saveLastFilterQueryComponents = false; + /** + * [protected description] + * @var int|string|null|bool + */ + protected int|string|bool|null $cachedLastInsertId = null; + /** + * [private description] + * @var bool */ - public function setConfig(string $connection = null, string $schema, string $table) : \codename\core\model { + private bool $countingModeOverride = false; + /** + * Creates and configures the instance of the model. Fallback connection is 'default' database + * @param string|null $connection [Name of the connection in the app configuration file] + * @param string $schema [Schema to use the model for] + * @param string $table [Table to use the model on] + * @return model + * @throws ReflectionException + * @throws exception + */ + public function setConfig(?string $connection, string $schema, string $table): model + { $this->schema = $schema; $this->table = $table; - if($connection != null) { - $this->db = app::getDb($connection, $this->storeConnection); + if ($connection != null) { + $this->db = app::getDb($connection, $this->storeConnection); } $config = app::getCache()->get('MODELCONFIG_', $this->getModelconfigCacheKey()); - if(is_array($config)) { - $this->config = new \codename\core\config($config); + if (is_array($config)) { + $this->config = new config($config); - // Connection now defined in model .json - if($this->config->exists("connection")) { - $connection = $this->config->get("connection"); + // Connection now defined in model.json + if ($this->config->exists("connection")) { + $connection = $this->config->get("connection"); } $this->db = app::getDb($connection, $this->storeConnection); return $this; } - if(!$this->config) { - $this->config = $this->loadConfig(); + if (!$this->config) { + $this->config = $this->loadConfig(); } - // Connection now defined in model .json - if($this->config->exists("connection")) { - $connection = $this->config->get("connection"); + // Connection now defined in model.json + if ($this->config->exists("connection")) { + $connection = $this->config->get("connection"); } else { - $connection = 'default'; + $connection = 'default'; } - if(!$this->db) { - $this->db = app::getDb($connection, $this->storeConnection); + if (!$this->db) { + $this->db = app::getDb($connection, $this->storeConnection); } - if(!in_array("{$this->table}_created", $this->config->get("field"))) { - throw new exception('EXCEPTION_MODEL_CONFIG_MISSING_FIELD', exception::$ERRORLEVEL_FATAL, "{$this->table}_created"); + if (!in_array("{$this->table}_created", $this->config->get("field"))) { + throw new exception('EXCEPTION_MODEL_CONFIG_MISSING_FIELD', exception::$ERRORLEVEL_FATAL, "{$this->table}_created"); } - if(!in_array("{$this->table}_modified", $this->config->get("field"))) { - throw new exception('EXCEPTION_MODEL_CONFIG_MISSING_FIELD', exception::$ERRORLEVEL_FATAL, "{$this->table}_modified"); + if (!in_array("{$this->table}_modified", $this->config->get("field"))) { + throw new exception('EXCEPTION_MODEL_CONFIG_MISSING_FIELD', exception::$ERRORLEVEL_FATAL, "{$this->table}_modified"); } app::getCache()->set('MODELCONFIG_', $this->getModelconfigCacheKey(), $this->config->get()); @@ -97,92 +216,28 @@ public function setConfig(string $connection = null, string $schema, string $tab } /** - * @inheritDoc - */ - protected function getType(): string - { - return $this->db->driver; - } - - /** - * @inheritDoc + * returns the cache key to be used for the config + * @return string + * @throws exception */ - protected function initServicingInstance() + protected function getModelconfigCacheKey(): string { - $testModules = [ - 'sql_'.$this->getType(), - 'sql', - ]; - - foreach($testModules as $module) { - try { - $class = \codename\core\app::getInheritedClass('model_servicing_sql_'.$this->getType()); - $this->servicingInstance = new $class(); - return; - } catch (\Exception $e) { + if ($this->schema && $this->table) { + return get_class($this) . '-' . $this->schema . '_' . $this->table; + } else { + throw new exception('EXCEPTION_MODELCONFIG_CACHE_KEY_MISSING_DATA', exception::$ERRORLEVEL_FATAL); } - } - - if($this->servicingInstance === null) { - throw new exception('EXCEPTON_MODEL_FAILED_INIT_SERVICING_INSTANCE', exception::$ERRORLEVEL_FATAL); - } - } - - /** - * [getServicingSqlInstance description] - * @return \codename\core\model\servicing\sql [description] - */ - protected function getServicingSqlInstance(): \codename\core\model\servicing\sql { - if($this->servicingInstance === null) { - $this->initServicingInstance(); - } - return $this->servicingInstance; } - /** - * Exception thrown when a model is missing a field that is required by the framework - * (e.g. _created and/or _modified) - * @var string - */ - const EXCEPTION_MODEL_CONFIG_MISSING_FIELD = 'EXCEPTION_MODEL_CONFIG_MISSING_FIELD'; - /** * loads a new config file (uncached) - * @return \codename\core\config - */ - protected function loadConfig() : \codename\core\config { - return new \codename\core\config\json('config/model/' . $this->schema . '_' . $this->table . '.json', true, true); - } - - /** - * - * {@inheritDoc} - * @see \codename\core\model_interface::getResult() - */ - /*public function getResult() : array { - $result = $this->result; - - if (is_null($result)) { - $this->result = $this->internalGetResult(); - $result = $this->result; - } - - $result = $this->normalizeResult($result); - $this->data = new \codename\core\datacontainer($result); - return $this->data->getData(); - }*/ - - /** - * @inheritDoc + * @return config + * @throws ReflectionException + * @throws exception */ - protected function getCurrentCacheIdentifierParameters(): array + protected function loadConfig(): config { - $params = parent::getCurrentCacheIdentifierParameters(); - // - // extend cache params by the virtual field result setting - // - $params['virtualfieldresult'] = $this->virtualFieldResult; - return $params; + return new config\json('config/model/' . $this->schema . '_' . $this->table . '.json', true, true); } /** @@ -191,1329 +246,587 @@ protected function getCurrentCacheIdentifierParameters(): array * ... or not? * This saves the dataset and children * - present in the configuration - * - present in the current dataset as a sub-array (named field) + * - present in the current dataset as a subarray (named field) * * * @param array $data - * @return \codename\core\model + * @return model + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception */ - public function saveWithChildren(array $data) : \codename\core\model { + public function saveWithChildren(array $data): model + { + // Open a virtual transaction + // as we might do some multimodel saving + $this->db->beginVirtualTransaction(); - // Open a virtual transaction - // as we might do some multi-model saving - $this->db->beginVirtualTransaction(); + $data2 = $data; - $data2 = $data; + // + // delay collection saving + // we might need the pkey, if the base model entry is not yet created + // + $childCollectionSaves = []; + + // save children + if ($this->config->exists('children')) { + foreach ($this->config->get('children') as $child => $childConfig) { + if ($childConfig['type'] === 'foreign') { + // + // Foreign Key-based child saving + // + + // get the nested models / join plugin instances + $foreignConfig = $this->config->get('foreign>' . $childConfig['field']); + $field = $childConfig['field']; + + // get the join plugin valid for the child reference field + $res = array_filter($this->getNestedJoins(), function (join $join) use ($field) { + return $join->modelField == $field; + }); + + if (count($res) === 1) { + // NOTE: array_filter preserves keys. use array_values to simply use index 0 + // TODO: check for required fields... + if (isset($data[$child])) { + $model = array_values($res)[0]->model; + $model->saveWithChildren($data[$child]); + // if we just inserted a NEW entry, get its primary key and save into the root model + if (empty($data[$child][$model->getPrimaryKey()])) { + $data2[$childConfig['field']] = $model->lastInsertId(); + } else { + $data2[$childConfig['field']] = $data[$child][$model->getPrimaryKey()]; + } + } + } elseif (count($res) > 1) { + throw new exception('EXCEPTION_MODEL_SCHEMATIC_SQL_CHILDREN_AMBIGUOUS_JOINS', exception::$ERRORLEVEL_ERROR, [ + 'child' => $child, + 'childConfig' => $childConfig, + 'foreign' => $field, + 'foreignConfig' => $foreignConfig, + ]); + } + // TODO: make sure we should do it like that. + unset($data2[$child]); + } elseif ($childConfig['type'] === 'collection') { + // + // Collection Saving of children + // + + // the collection saving done below + if (isset($this->collectionPlugins[$child]) && array_key_exists($child, $data)) { + $childCollectionSaves[$child] = $data[$child]; + } - // - // delay collection saving - // we might need the pkey, if the base model entry is not yet created - // - $childCollectionSaves = []; + // unset the child collection field + // as it cannot be handled by SQL + unset($data2[$child]); + } + } + } + // end save children - // save children - if($this->config->exists('children')) { - foreach($this->config->get('children') as $child => $childConfig) { + // + // Save the main dataset + // + $this->save($data2); - if($childConfig['type'] === 'foreign') { - // - // Foreign Key based child saving - // + // + // Determine, if we're updating OR inserting (depending on PKEY value existence) + // + $update = (array_key_exists($this->getPrimaryKey(), $data) && strlen($data[$this->getPrimaryKey()]) > 0); + if (!$update) { + $data[$this->getPrimaryKey()] = $this->lastInsertId(); + } - // get the nested models / join plugin instances - $foreignConfig = $this->config->get('foreign>'.$childConfig['field']); - $field = $childConfig['field']; - - // get the join plugin valid for the child reference field - $res = array_filter($this->getNestedJoins(), function(\codename\core\model\plugin\join $join) use ($field) { - return $join->modelField == $field; - }); - - if(count($res) === 1) { - // NOTE: array_filter preserves keys. use array_values to simply use index 0 - // TODO: check for required fields... - if(isset($data[$child])) { - $model = array_values($res)[0]->model; - $model->saveWithChildren($data[$child]); - // if we just inserted a NEW entry, get its primary key and save into the root model - if(empty($data[$child][$model->getPrimaryKey()])) { - $data2[$childConfig['field']] = $model->lastInsertId(); - } else { - $data2[$childConfig['field']] = $data[$child][$model->getPrimaryKey()]; + // + // Save child collections + // + if (count($childCollectionSaves) > 0) { + foreach ($childCollectionSaves as $child => $childData) { + if ($childData === null) { + continue; } - } - } else { - // error? - // Throw an exception if there is no single, but multiple joins that match our condition - if(count($res) > 1) { - throw new exception('EXCEPTION_MODEL_SCHEMATIC_SQL_CHILDREN_AMBIGUOUS_JOINS', exception::$ERRORLEVEL_ERROR, [ - 'child' => $child, - 'childConfig' => $childConfig, - 'foreign' => $field, - 'foreignConfig' => $foreignConfig - ]); - } - - // TODO: make sure we should do it like that. - // - } - unset($data2[$child]); - } else if($childConfig['type'] === 'collection') { - // - // Collection Saving of childs - // + $collection = $this->collectionPlugins[$child]; + $model = $collection->collectionModel; - // collection saving done below - if(isset($this->collectionPlugins[$child]) && array_key_exists($child, $data)) { - $childCollectionSaves[$child] = $data[$child]; - } + // TODO: get all existing references/entries + // that must be deleted/obsoleted + $model->addFilter($collection->getCollectionModelBaseRefField(), $data[$collection->getBaseField()]); + $existingCollectionItems = $model->search()->getResult(); - // unset the child collection field - // as it cannot be handled by SQL - unset($data2[$child]); - } - - } - } - // end save children - - // - // Save the main dataset - // - $this->save($data2); - - // - // Determine, if we're updating OR inserting (depending on PKEY value existance) - // - $update = (array_key_exists($this->getPrimarykey(), $data) && strlen($data[$this->getPrimarykey()]) > 0); - if(!$update) { - $data[$this->getPrimarykey()] = $this->lastInsertId(); - } - - // - // Save child collections - // - if(count($childCollectionSaves) > 0) { - foreach($childCollectionSaves as $child => $childData) { - - if($childData === null) { - continue; - } - - $collection = $this->collectionPlugins[$child]; - $model = $collection->collectionModel; - - // TODO: get all existing references/entries - // that must be deleted/obsoleted - $model->addFilter($collection->getCollectionModelBaseRefField(), $data[$collection->getBaseField()]); - $existingCollectionItems = $model->search()->getResult(); - - // determine must-have-pkeys - $targetStateIds = array_reduce($childData, function($carry, $item) use ($model) { - if($id = ($item[$model->getPrimaryKey()] ?? null)) { - $carry[] = $id; - } - return $carry; - }, []); + // determine must-have-pkeys + $targetStateIds = array_reduce($childData, function ($carry, $item) use ($model) { + if ($id = ($item[$model->getPrimaryKey()] ?? null)) { + $carry[] = $id; + } + return $carry; + }, []); - // determine must-have-pkeys - $existingIds = array_reduce($existingCollectionItems, function($carry, $item) use ($model) { - if($id = ($item[$model->getPrimaryKey()] ?? null)) { - $carry[] = $id; - } - return $carry; - }, []); - - // the difference - to-be-deleted IDs - $deleteIds = array_diff($existingIds, $targetStateIds); - - // delete them - foreach($deleteIds as $id) { - $model->delete($id); - } - - foreach($childData as $childValue) { - // TODO?: check for references! - // For now, we're just overwriting the reference to THIS model / current dataset - if(!isset($childValue[$collection->getCollectionModelBaseRefField()]) || ($childValue[$collection->getCollectionModelBaseRefField()] != $data[$collection->getBaseField()])) { - $childValue[$collection->getCollectionModelBaseRefField()] = $data[$collection->getBaseField()]; + // determine must-have-pkeys + $existingIds = array_reduce($existingCollectionItems, function ($carry, $item) use ($model) { + if ($id = ($item[$model->getPrimaryKey()] ?? null)) { + $carry[] = $id; + } + return $carry; + }, []); + + // the difference - to-be-deleted IDs + $deleteIds = array_diff($existingIds, $targetStateIds); + + // delete them + foreach ($deleteIds as $id) { + $model->delete($id); + } + + foreach ($childData as $childValue) { + // TODO?: check for references! + // For now, we're just overwriting the reference to THIS model / current dataset + if (!isset($childValue[$collection->getCollectionModelBaseRefField()]) || ($childValue[$collection->getCollectionModelBaseRefField()] != $data[$collection->getBaseField()])) { + $childValue[$collection->getCollectionModelBaseRefField()] = $data[$collection->getBaseField()]; + } + $model->saveWithChildren($childValue); + } } - $model->saveWithChildren($childValue); - } } - } - // end the virtual transaction - // if this is the last (outer) model to call save()/saveWithChildren() - // this closes the pending transaction on db/connection-level - $this->db->endVirtualTransaction(); + // end the virtual transaction + // if this is the last (outer) model to call save()/saveWithChildren() + // this closes the pending transaction on db/connection-level + $this->db->endVirtualTransaction(); - return $this; + return $this; } /** - * @inheritDoc + * returns the last inserted ID, if available + * @return int|string|bool|null [description] */ - protected function internalQuery(string $query, array $params = array()) { - // perform internal query - return $this->db->query($query, $params); + public function lastInsertId(): int|string|bool|null + { + return $this->cachedLastInsertId; } /** - * @inheritDoc + * + * {@inheritDoc} + * @param array $data [description] + * @return model [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + * @see \codename\core\modelInterface::save($data) + * + * [save description] */ - protected function internalGetResult(): array + public function save(array $data): model { - $result = $this->db->getResult(); - if($this->virtualFieldResult) { - - // echo("
" . print_r($result, true) . "
"); + $params = []; + if (array_key_exists($this->getPrimaryKey(), $data) && strlen($data[$this->getPrimaryKey()]) > 0) { + $query = $this->saveUpdate($data, $params); + $this->doQuery($query, $params); + if ($this->db->affectedRows() !== 1) { + throw new exception('MODEL_SAVE_UPDATE_FAILED', exception::$ERRORLEVEL_ERROR); + } + } else { + $query = $this->saveCreate($data, $params); + $this->cachedLastInsertId = null; + $this->doQuery($query, $params); + $this->cachedLastInsertId = $this->db->lastInsertId(); - $tResult = $this->getVirtualFieldResult($result); + // + // affected rows might be != 1 (e.g., 2 on MySQL) + // of doing a saveCreate with replacement = true + // (in overridden classes) + // This WILL fail at this point. + // + if ($this->db->affectedRows() !== 1) { + throw new exception('MODEL_SAVE_CREATE_FAILED', exception::$ERRORLEVEL_ERROR); + } + } + return $this; + } - // $fResult = []; - // - // // - // // normalize - // // - // foreach($this->getNestedJoins() as $join) { - // // normalize using nested model - BUT: only if it's NOT already actively used as a child virtual field - // $found = false; - // if(($children = $this->config->get('children')) != null) { - // foreach($children as $field => $config) { - // if($config['type'] === 'foreign') { - // $foreign = $this->config->get('foreign>'.$config['field']); - // if($foreign['model'] === $join->model->getIdentifier()) { - // if($this->config->get('datatype>'.$field) == 'virtual') { - // $found = true; - // break; - // } - // } - // } - // } - // } - // if($found) { - // continue; - // } - // - // foreach($tResult as $index => $r) { - // $fResult[$index] = array_merge(($fResult[$index] ?? []), $join->model->normalizeByFieldlist($r)); - // } - // } + /** + * returns a query that performs a save using UPDATE + * (e.g., we have an existing entry that needs to be updated) + * @param array $data [data] + * @param array &$param [reference array that keeps track of PDO variable names] + * @return string [query] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function saveUpdate(array $data, array &$param = []): string + { // - // foreach($tResult as $index => $r) { - // // normalize using this model - // $fResult[$index] = array_merge(($fResult[$index] ?? []), $this->normalizeByFieldlist($r)); - // } + // Disable cache reset if the model is not enabled for it. + // At the moment, we don't even use the PRIMARY cache // - // $result = $fResult; + if ($this->cache) { + $cacheGroup = $this->getCacheGroup(); + $cacheKey = "PRIMARY_" . $data[$this->getPrimaryKey()]; + $this->clearCache($cacheGroup, $cacheKey); + } + $raw = []; + // raw data for usage with the timemachine + if ($this->useTimemachine()) { + $raw = $data; + } - $result = $this->normalizeRecursivelyByFieldlist($tResult); + $query = 'UPDATE ' . $this->getTableIdentifier() . ' SET '; + $parts = []; - // - // Root element virtual fields - // - if(count($this->virtualFields) > 0) { - foreach($result as &$d) { - // NOTE: at the moment, we already handle virtual fields - // (e.g. a field added through ->addVirtualField) - // in ->getVirtualFieldResult(...) - // at the end, when we reached the original root structure again. + foreach ($this->config->get('field') as $field) { + if (in_array($field, [$this->getPrimaryKey(), $this->table . "_modified", $this->table . "_created"])) { + continue; + } - // - // NOTE/CHANGED 2019-09-10: we now handle virtual fields for the root model right here - // as we wouldn't get normalized structure fields the way we did it before - // - // Before, we were handling virtual fields inside ::getVirtualFieldResult() - // Which DOES NOT normalize those fields - so, inside a virtualField callback, you'd get JSON strings - // instead of "real" object/array data - // - $d = $this->handleVirtualFields($d); - } + // If it exists, set the field + if (array_key_exists($field, $data)) { + if (is_object($data[$field]) || is_array($data[$field])) { + $data[$field] = $this->jsonEncode($data[$field]); + } + + $var = $this->getStatementVariable(array_keys($param), $field); + + // performance hack: store modelfield instance! + if (!isset($this->modelfieldInstance[$field])) { + $this->modelfieldInstance[$field] = modelfield::getInstance($field); + } + $fieldInstance = $this->modelfieldInstance[$field]; + + $param[$var] = $this->getParametrizedValue($this->delimit($fieldInstance, $data[$field]), $this->getFieldtype($fieldInstance)); + $parts[] = $field . ' = ' . ':' . $var; + } } - } - return $result; - } + if ($this->saveUpdateSetModifiedTimestamp) { + $parts[] = $this->table . "_modified = " . $this->getServicingSqlInstance()->getSaveUpdateSetModifiedTimestampStatement($this); + } + $query .= implode(',', $parts); - /** - * [normalizeRecursivelyByFieldlist description] - * @param array $result [description] - * @return array [description] - */ - public function normalizeRecursivelyByFieldlist(array $result) : array { - - $fResult = []; - - // - // normalize - // - foreach($this->getNestedJoins() as $join) { - // normalize using nested model - BUT: only if it's NOT already actively used as a child virtual field - // $found = false; - // if(($children = $this->config->get('children')) != null) { - // foreach($children as $field => $config) { - // if($config['type'] === 'foreign') { - // $foreign = $this->config->get('foreign>'.$config['field']); - // if($foreign['model'] === $join->model->getIdentifier()) { - // if($this->config->get('datatype>'.$field) == 'virtual') { - // $found = true; - // break; - // } - // } - // } - // } - // } - // if($found) { - // continue; - // } - if($join->virtualField) { - continue; - } - - /** - * FIXME @Kevin: Weil wegen Baum und sehr sehr russisch - * @var [type] - */ - if($join->model instanceof \codename\core\model\schemeless\json) { - continue; - } - - $normalized = $join->model->normalizeRecursivelyByFieldlist($result); - - // // DEBUG - // echo("
Pre-Merge".chr(10));
-        // print_r($fResult);
-        // echo("
"); - - // echo("
".print_r($normalized, true)."
"); - - // // METHOD 1: merge manually, row by row - foreach($normalized as $index => $r) { - // normalize using this model - $fResult[$index] = array_merge(($fResult[$index] ?? []), $r); - } - - // METHOD 2: recursive merge - // NOTE: Actually, this doesn't work right. - // It may split a model's result apart into two array elements in some cases. - // $fResult = array_merge_recursive($fResult, $join->model->normalizeRecursivelyByFieldlist($result)); - - // TESTING, OLD - // foreach($fResult as $index => $r) { - // // $fResult[$index] = array_merge(($fResult[$index] ?? []), $join->model->normalizeByFieldlist($r)); - // // $fResult[$index] = array_merge(($fResult[$index] ?? []), $join->model->normalizeRec($r)); - // } - - // // DEBUG - // echo("
Post-merge".chr(10));
-        // print_r($fResult);
-        // echo("
"); - } - - // CHANGED 2021-03-13: build static fieldlist for normalization - // reduces calls to various array functions - // AND: fixes hidden field handling for certain use cases - $currentFieldlist = $this->getInternalIntersectFieldlist(); - - // - // Normalize using this model's fields - // - foreach($result as $index => $r) { - // normalize using this model - // CHANGED 2019-05-24: additionally call $this->normalizeRow around normalizeByFieldlist, - // otherwise we might run into issues, e.g. - // - "structure"-type fields are not json_decode'd, if present on the root model - // - ... other things? - // NOTE: as of 2019-09-10 the normalization of structure fields has changed - $fResult[$index] = array_merge(($fResult[$index] ?? []), $this->normalizeRow($this->normalizeByFieldlist($r, $currentFieldlist))); - } - - // \codename\core\app::getResponse()->setData('model_normalize_debug', array_merge(\codename\core\app::getResponse()->getData('model_normalize_debug') ?? [], $fResult)); - - // // DEBUG - // echo("
fResult:".chr(10));
-      // print_r($fResult);
-      // echo("
"); - - return $fResult; + $var = $this->getStatementVariable(array_keys($param), $this->getPrimaryKey()); + // use timemachine, if capable and enabled + // this stores delta values in a separate model + + if ($this->useTimemachine()) { + $tm = timemachine::getInstance($this->getIdentifier()); + $tm->saveState($data[$this->getPrimaryKey()], $raw); // we have to use raw data, as we can't use jsonfield arrays. + } + + $param[$var] = $this->getParametrizedValue($data[$this->getPrimaryKey()], 'number_natural'); // ? hardcoded type? + + $query .= " WHERE " . $this->getPrimaryKey() . " = " . ':' . $var; + return $query; } /** - * [normalizeByFieldlist description] - * @param array $dataset [description] - * @param array|null $fieldlist [optional, new: static fieldlist] - * @return array [description] + * [clearCache description] + * @param string $cacheGroup [description] + * @param string $cacheKey [description] + * @return void + * @throws ReflectionException + * @throws exception */ - public function normalizeByFieldlist(array $dataset, ?array $fieldlist = null) : array { - if($fieldlist) { - // CHANGED 2021-04-13: use provided fieldlist, see above - return array_intersect_key($dataset, $fieldlist); - } else if(count($this->fieldlist) > 0) { - // return $dataset; - return array_intersect_key( $dataset, array_flip( array_merge( $this->getFieldlistArray($this->fieldlist), $this->getFields(), array_keys($this->virtualFields) ) ) ); - } else { - // return $dataset; - return array_intersect_key( $dataset, array_flip( array_merge( $this->getFields(), array_keys($this->virtualFields)) ) ); - } + protected function clearCache(string $cacheGroup, string $cacheKey): void + { + $cacheObj = app::getCache(); + $cacheObj->clearKey($cacheGroup, $cacheKey); } /** - * returns the internal list of fields - * to be expected in the output and used via array intersection - * NOTE: the returned result array is flipped! - * @return array [description] + * Whether this model is timemachine-capable and enabled + * @return bool */ - protected function getInternalIntersectFieldlist(): array { - $fields = $this->getFields(); - if(count($this->hiddenFields) > 0) { - // remove hidden fields - $diff = array_diff($fields, $this->hiddenFields); - $fields = array_intersect($fields, $diff); - } - // VFR keys - $vfrKeys = []; - if($this->virtualFieldResult) { - foreach($this->getNestedJoins() as $join) { - if($join->virtualField) { - $vfrKeys[] = $join->virtualField; - } + protected function useTimemachine(): bool + { + if ($this->useTimemachineState === null) { + $this->useTimemachineState = ($this instanceof timemachineInterface) && $this->isTimemachineEnabled(); } - } - - if(count($this->fieldlist) > 0) { - return array_flip( array_merge( $this->getFieldlistArray($this->fieldlist), $fields, $vfrKeys,array_keys($this->virtualFields) ) ); - } else { - return array_flip( array_merge( $fields, array_keys($this->virtualFields), $vfrKeys ) ); - } + return $this->useTimemachineState; } /** - * @inheritDoc + * Returns a db-specific identifier (e.g., schema.table for the current model) + * or, if schema and model are specified, for a different schema+table + * @param string|null $schema [name of schema] + * @param string|null $model [name of schema] + * @return string [description] + * @throws exception */ - public function setVirtualFieldResult(bool $state) : \codename\core\model + public function getTableIdentifier(?string $schema = null, ?string $model = null): string { - $this->virtualFieldResult = $state; - return $this; + if ($schema || $model) { + return $this->getServicingSqlInstance()->getTableIdentifierParametrized($schema, $model); + } else { + return $this->getServicingSqlInstance()->getTableIdentifier($this); + } } /** - * State of the virtual field handling - * Decides whether to construct virtual fields (e.g. children results) - * and put them into the result - * - * Needs PDO to fetch via FETCH_NAMED - * to get distinct values for joined models - * - * @var bool + * [getServicingSqlInstance description] + * @return model\servicing\sql [description] + * @throws exception */ - protected $virtualFieldResult = false; + protected function getServicingSqlInstance(): model\servicing\sql + { + if ($this->servicingInstance === null) { + $this->initServicingInstance(); + } + return $this->servicingInstance; + } /** - * [populateTrackFieldsRecursive description] - * @param array &$trackFields [description] - * @return [type] [description] + * {@inheritDoc} + * @throws exception */ - protected function populateTrackFieldsRecursive(array &$trackFields) { - - // Track this model - if(count($trackFields) === 0) { - $vModelFieldlist = $this->getCurrentAliasedFieldlist(); - foreach($vModelFieldlist as $field) { - $trackFields[$field][] = $this; + protected function initServicingInstance(): void + { + try { + $class = app::getInheritedClass('model_servicing_sql_' . $this->getType()); + $this->servicingInstance = new $class(); + return; + } catch (\Exception) { } - } - - foreach($this->getNestedJoins() as $join) { - // for field tracking - // we have to make sure to only track - // 'compatible' models: - // - same DB/data technology - // - same DB/data connection* - // - not a forced virtual join - // - ... etc - // - // * = TODO: to be fully implemented. Not sure if we're doing it right, atm. - if($this->compatibleJoin($join->model) && $join->model instanceof \codename\core\model\schematic\sql) { - - $vModelFieldlist = $join->model->getCurrentAliasedFieldlist(); - foreach($vModelFieldlist as $field) { - - // - // exclude virtual fields? - // - if($join->model->getFieldtype(\codename\core\value\text\modelfield::getInstance($field)) === 'virtual') { - continue; - } - - $trackFields[$field][] = $join->model; - } - - // NOTE: compatibility already checked above - $join->model->populateTrackFieldsRecursive($trackFields); + if ($this->servicingInstance === null) { + throw new exception('EXCEPTION_MODEL_FAILED_INIT_SERVICING_INSTANCE', exception::$ERRORLEVEL_FATAL); } - } } /** - * [getVirtualFieldResult description] - * @param array $result [the original resultset] - * @param array &$track [array keeping track of model index/instances] - * @param array $structure [the current root object path] - * @param array &$trackFields [array to keep track of field indexes due to PDO FETCH_NAMED] - * @return [type] [description] + * {@inheritDoc} */ - public function getVirtualFieldResult(array $result, &$track = [], array $structure = [], &$trackFields = []) { - - // Construct field tracking array - // by diving into the whole structure beforehand - // one single time - if(count($trackFields) === 0) { - $this->populateTrackFieldsRecursive($trackFields); - } - - foreach($this->getNestedJoins() as $join) { - - // - // NOTE/CHANGED 2020-09-15: exclude "incompatible" models from tracking - // this includes forced virtual joins, as they HAVE to be excluded - // to avoid an 'obiwan' or similar error - index-based re-association - // for virtual resultsets. We treat a forced virtual join as an 'incompatible' model / 'blackbox' - // - if($this->compatibleJoin($join->model)) { - $track[$join->model->getIdentifier()][] = $join->model; - } - - if($join->model instanceof \codename\core\model\virtualFieldResultInterface) { - - $structureDive = []; - - // if virtualFieldResult enabled on this model - // use vField config from join plugin - if($this->virtualFieldResult) { - if($join->virtualField) { - $structureDive = [ $join->virtualField ]; - } - } - - // - // NOTE/CHANGED 2020-09-15: handle (in-)compatible joins separately - // As stated above, this is for forced virtual joins - we have to treat them as 'incompatible' models - // to avoid index confusion. At this point, we reset the tracking/structure dive, - // as we're 'virtually' diving into a different model resultset - // - if($this->compatibleJoin($join->model)) { - $result = $join->model->getVirtualFieldResult($result, $track, array_merge($structure, $structureDive), $trackFields); - } else { - // - // CHANGED 2021-04-13: kicked out, as it does not apply - // NOTE: we should keep an eye on this. - // At this point, we're not calling getVirtualFieldResult, as we either have - // - a completely different model technology - // - a forced virtual join - // - sth. else? - // - // >>> Those models handle their results for themselves. - // - // $result = $join->model->getVirtualFieldResult($result); - } - } - } - - // - // Re-normalizing joined data - // This is a completely different approach - // instead of iterating over all vField/children-supporting models - // We iterate over all models - as we have to handle mixed cases, too. - // - // CHANGED 2021-04-13, we include $this (current model) - // to propage/include renormalization for root model - // (e.g. if joining the same model recursively) - // - $subjects = array_merge([$this], $this->getNestedJoins()); - - foreach($subjects as $join) { - - $vModel = null; - $virtualField = null; - // Re-name/alias the current join model instance - if($join === $this) { - $vModel = $join; // this (model), root model renormalization - } else { - $vModel = $join->model; - if($this->virtualFieldResult) { - // handle $join->virtualField - $virtualField = $join->virtualField; - } - } - - $index = null; - if(count($indexes = array_keys($track[$vModel->getIdentifier()] ?? [], $vModel, true)) === 1) { - $index = $indexes[0]; - } else { - // What happens, if we join the same model instance twice or more? - } - - if($index === null) { - // index is still null -> model not found in currently nested models - // TODO: we might check for virtual field result or so? - // continue; - } - - $fields = []; - - - $vModelFieldlist = $vModel->getCurrentAliasedFieldlist(); - $fields = $vModelFieldlist; - - // determine per-field indexes - // as we might join the same model - // with differing fieldlists - $fieldValueIndexes = []; - - foreach($fields as $modelField) { - $index = null; - - if($trackFields[$modelField] ?? false) { - if(count($trackFields[$modelField]) === 1) { - // There's only a single occurrence of this modelfield - // Index is being unset. - $index = null; - } else if($vModel->getFieldtype(\codename\core\value\text\modelfield::getInstance($modelField)) === 'virtual') { - // Avoid virtual fields - // as we're handling them differently - // And they're not SQL-native. - $index = false; // null; // QUESTION: or false? - } else if(count($indexes = array_keys($trackFields[$modelField], $vModel, true)) === 1) { // NOTE/CHANGED: $vModel was $join->model before - which is an iteration variable from above! - // this is the expected field index - // when re-normaling from a FETCH_NAMED PDO result - $index = $indexes[0]; - } - } else { - $index = null; - } - - $fieldValueIndexes[$modelField] = $index; - } - - // - // Iterate over each dataset of the result - // And apply index renormalization (reversing FETCH_NAMED-based array-style results) - // - foreach($result as &$dataset) { - $vData = []; - - foreach($fields as $modelField) { - if(($vIndex = $fieldValueIndexes[$modelField]) !== null) { - - // DEBUG Just a test for vIndex === false when working on virtual field - // Doesnt work? - // NOTE: this might have an effect to unsetting virtual fields based on joins - // to NOT display if the respective models are not joined. Hopefully. - // Doesn't apply to the root model, AFAICS. - if($vIndex === false) { - continue; - } - - // Use index reference determined above - $vData[$modelField] = $dataset[$modelField][$vIndex] ?? null; - } else { - // Simply use the field value - $vData[$modelField] = $dataset[$modelField] ?? null; - } - } - - // Normalize the data against the respective vModel - $vData = $vModel->normalizeRow($vData); - - // Deep dive to set data in a sub-object path - // $structure might be [], which is simply the root level - $dive = &$dataset; - foreach($structure as $key) { - $dive[$key] = $dive[$key] ?? []; - $dive = &$dive[$key]; - } - - if($virtualField !== null) { - - // NOTE: Forward merging is bad for this case - // as array_merge overwrites existing keys with the latter one - // in this case, $dive[$virtualField] constains partial data - // which we HAVE to overwrite, in regard to $vData - - $dive[$virtualField] = array_merge($vData, $dive[$virtualField] ?? []); - } else { - // NOTE: Forward merge - // as $vData contains new information to be overwritten in $dive, - // as far as applicable. See note above. - $dive = array_merge($dive ?? [], $vData ); - } - - // handle custom virtual fields - // CHANGED 2019-06-05: we have to trigger virtual field handling - // AFTER diving, because we might be missing all the important fields... - // CHANGED 2020-11-13: we additionally have to check for vModel being 'compatible' - // e.g. JSON datamodel's virtual fields won't be handled here - causes bugs. - if($this->compatibleJoin($vModel) && count($vModel->getVirtualFields()) > 0) { - if($virtualField !== null) { - $dive[$virtualField] = $vModel->handleVirtualFields($dive[$virtualField]); - } else { - $dive = $vModel->handleVirtualFields($dive); - } - } - } - } - - if(($children = $this->config->get('children')) != null) { - foreach($children as $field => $config) { - - if($config['type'] === 'collection') { - - // check for active collectionmodel / plugin - if(isset($this->collectionPlugins[$field])) { - $collection = $this->collectionPlugins[$field]; - $vModel = $collection->collectionModel; - - // determine to-be-used index for THIS model, as it is the base for the collection? - // $index = - $index = null; - - if((!isset($track[$this->getIdentifier()])) || count($track[$this->getIdentifier()]) === 0) { - $index = null; - } else { - // foreach($this->getNestedJoins() as $join) { - // if($join->modelField === $config['field']) { - if(count($indexes = array_keys($track[$this->getIdentifier()], $this, true)) === 1) { - $index = $indexes[0]; - } - // } - // } - } - // if($index === null) { - // // err? - // } - - foreach($result as &$dataset) { - - - $filterValue = ($index !== null && is_array($dataset[$collection->getBaseField()])) ? $dataset[$collection->getBaseField()][$index] : $dataset[$collection->getBaseField()]; - - - $vModel->addFilter($collection->getCollectionModelBaseRefField(), $filterValue); - $vResult = $vModel->search()->getResult(); - - // // DEBUG - // \codename\core\app::getResponse()->setData( - // 'model_collection_debug', - // array_merge( - // \codename\core\app::getResponse()->getData('model_collection_debug') ?? [], - // [[ - // 'currentModelProcess' => $this->getIdentifier(), - // 'filter with' => [ - // $collection->getCollectionModelBaseRefField(), - // $dataset[$collection->getBaseField()], - // ], - // 'filterValue' => $filterValue, - // 'determination' => [ - // 'isset_' => isset($track[$this->getIdentifier()]), - // 'count' => isset($track[$this->getIdentifier()]) ? count($track[$this->getIdentifier()]) : 'NOT SET', - // 'indexes' => isset($track[$this->getIdentifier()]) ? array_keys($track[$this->getIdentifier()], $this, true) : 'NOT SET' - // ], - // 'params' => [ - // 'track' => $track, - // 'structure' => $structure, - // 'collection' => [ - // 'getBaseField' => $collection->getBaseField(), - // 'getCollectionModelBaseRefField' => $collection->getCollectionModelBaseRefField() - // ] - // ], - // 'index' => $index, - // 'testing', - // $dataset[$collection->getBaseField()], - // 'vResult' => $vResult - // ]] - // ) - // ); - - // new method: deep dive to set data - $dive = &$dataset; - foreach($structure as $key) { - $dive[$key] = $dive[$key] ?? []; - $dive = &$dive[$key]; - } - $dive[$field] = $vResult; - - // OLD METHOD - // $dataset[$field] = $vResult; - } - } - } - - // TODO: Handle collections? - } - } - - // \codename\core\app::getResponse()->setData('track', $track); - // \codename\core\app::getResponse()->setData('trackFields', $trackFields); - - // - // NOTE/CHANGED 2019-09-10: we now handle virtual field handling AFTER normalization of structure fields (JSON-decoding!) - // as we wouldn't get normalized structure fields the way we did it before - // - // Before, we were handling virtual fields inside ::getVirtualFieldResult() - // Which DOES NOT normalize those fields - so, inside a virtualField callback, you'd get JSON strings - // instead of "real" object/array data - // - // see: ::internalGetResult() in this class - // - // handle custom virtual fields - // if(count($structure) === 0) { - // if(count($this->virtualFields) > 0) { - // foreach($result as &$d) { - // $d = $this->handleVirtualFields($d); - // } - // } - // } - - // \codename\core\app::getResponse()->setData('trackFields', $trackFields); - - return $result; - } + protected function getType(): string + { + return $this->db->driver; + } /** - * the current database connection instance - * @return \codename\core\database [description] + * json_encode wrapper + * for customizing the output sent to the database + * Reason: pgsql is handling the encoding for itself, + * but MySQL is doing strict encoding handling + * @see http://stackoverflow.com/questions/4782319/php-json-encode-utf8-char-problem-mysql + * and esp. @see http://stackoverflow.com/questions/4782319/php-json-encode-utf8-char-problem-mysql/37353316#37353316 + * + * @param object|array $data [or even an object?] + * @return string [json-encoded string] + * @throws exception */ - public function getConnection(): \codename\core\database + protected function jsonEncode(object|array $data): string { - return $this->db; + return $this->getServicingSqlInstance()->jsonEncode($data); } /** - * enables overriding/setting the connection - * @param \codename\core\database $db [description] + * returns a PDO variable name + * kept safe from duplicates + * using recursive calls to this function + * + * @param array $existingKeys [array of already existing variable names] + * @param string $field [the field base name] + * @param string $add [what is added to the base name] + * @param int $c [some extra factor (counter)] + * @return string [variable name] */ - public function setConnectionOverride(\codename\core\database $db) { - $this->db = $db; + protected function getStatementVariable(array $existingKeys, string $field, string $add = '', int $c = 0): string + { + if ($c === 0) { + $baseName = str_replace('.', '_dot_', $field . (($add != '') ? ('_' . $add) : '')); + $baseName = preg_replace('/[^\w]+/', '_', $baseName); + } else { + $baseName = $field; + } + $name = $baseName . (($c > 0) ? ('_' . $c) : ''); + if (in_array($name, $existingKeys)) { + return $this->getStatementVariable($existingKeys, $baseName, $add, ++$c); + } + return $name; } /** - * Use right joining for this model - * which allows empty joined fields to appear - * @var bool + * get a parametrized value (array) + * for use with PDO + * @param mixed $value [description] + * @param string $fieldtype [description] + * @return array [description] */ - public $rightJoin = false; + protected function getParametrizedValue(mixed $value, string $fieldtype): array + { + if ($value === null) { + $param = PDO::PARAM_NULL; // Explicit NULL + } elseif ($fieldtype == 'number') { + $value = (float)$value; + $param = PDO::PARAM_STR; // explicitly use this one... + } elseif (($fieldtype === 'number_natural') || is_int($value)) { + // NOTE: if integer value supplied, explicitly use this as param type + $param = PDO::PARAM_INT; + } elseif ($fieldtype == 'boolean') { + // + // Temporary workaround for MySQL being so odd. + // bool == tinyint(1) in MySQL-world. + // So, we prey-evaluate + // the value to 0 or 1 (NULL being handled above) + // + $value = $value ? 1 : 0; + $param = PDO::PARAM_INT; + // $param = \PDO::PARAM_BOOL; + } else { + $param = PDO::PARAM_STR; // Fallback + } + return [ + $value, + $param, + ]; + } /** - * @inheritDoc + * gets the current identifier of this model + * in this case (sql), this is the table name + * NOTE: schema is omitted here + * @return string [table name] */ - protected function compatibleJoin(\codename\core\model $model): bool + public function getIdentifier(): string { - return parent::compatibleJoin($model) && ($this->db == $model->db); + return $this->table; } /** - * [deepJoin description] - * @param \codename\core\model $model [model currently worked-on] - * @param array &$tableUsage [table usage as reference] - * @param int &$aliasCounter [alias counter as reference] - * @param array &$params - * @param array &$cte [common table expressions, if any] - * @return string [query part] + * returns a query that performs a save using INSERT + * @param array $data [data] + * @param array &$param [reference array that keeps track of PDO variable names] + * @param bool $replace [use replace on duplicate unique/pkey] + * @return string [query] + * @throws ReflectionException + * @throws exception */ - public function deepJoin(\codename\core\model $model, array &$tableUsage = array(), int &$aliasCounter = 0, string $parentAlias = null, array &$params = [], array &$cte = []) { - if(\count($model->getNestedJoins()) == 0) { - return ''; - } - $ret = ''; - - // Loop through nested (children/parents) - foreach($model->getNestedJoins() as $join) { - $nest = $join->model; - - // check model joining compatible - if(!$model->compatibleJoin($nest)) { - continue; - } - - $cteName = null; + protected function saveCreate(array $data, array &$param = [], bool $replace = false): string + { + // TEMPORARY: SAVE LOG DISABLED + // $this->saveLog('CREATE', $data); - // preliminary CTE, model itself is recursive - // $cteName = null; - if($nest->recursive) { - $cteName = '__cte_recursive_'.(count($cte)+1); - $cte[] = $nest->getRecursiveSqlCteStatement($cteName, $params); - $join->referenceField = '__anchor'; - $tableUsage[$cteName] = 1; - // Also increase this counter, though this is a CTE - // to correctly keep track of ambiguous fields - $tableUsage["{$nest->schema}.{$nest->table}"]++; - $alias = $cteName; - $aliasAs = ''; // "AS ".$alias; - // $parentAlias = $cteName; + $query = 'INSERT INTO ' . $this->getTableIdentifier() . ' '; + $query .= ' ('; + $index = 0; + foreach ($this->config->get('field') as $field) { + if ($field == $this->getPrimaryKey() || in_array($field, [$this->table . "_modified", $this->table . "_created"])) { + continue; } - - - if($nest->recursive || $join instanceof \codename\core\model\plugin\join\recursive) { - // - // 'WITH ... RECURSIVE' CTE support - // - if($join instanceof \codename\core\model\plugin\sqlCteStatementInterface) { - $cteAlias = $cteName; // if table is already a CTE, passthrough - $cteName = '__cte_recursive_'.(count($cte)+1); - if(array_key_exists($cteName, $tableUsage)) { - // name collision - throw new exception('MODEL_SCHEMATIC_SQL_DEEP_JOIN_CTE_NAME_COLLISION', exception::$ERRORLEVEL_ERROR, $cteName); - } else { - $tableUsage[$cteName] = 1; - // Also increase this counter, though this is a CTE - // to correctly keep track of ambiguous fields - $tableUsage["{$nest->schema}.{$nest->table}"]++; - } - $cte[] = $join->getSqlCteStatement($cteName, $params, $cteAlias); - $alias = $cteName; - $aliasAs = "AS ".$alias; - } else { - // - // NOTE: only fire exception, if this really is a recursive join plugin - // as we also handle root-model recursion here. - // - if($join instanceof \codename\core\model\plugin\join\recursive) { - throw new exception('MODEL_SCHEMATIC_SQL_DEEP_JOIN_UNSUPPORTED_JOIN_RECURSIVE_PLUGIN', exception::$ERRORLEVEL_ERROR, get_class($join)); - } - } - } else { - if(array_key_exists("{$nest->schema}.{$nest->table}", $tableUsage)) { - $aliasCounter++; - $tableUsage["{$nest->schema}.{$nest->table}"]++; - $alias = "a".$aliasCounter; - $aliasAs = "AS ".$alias; - } else { - $tableUsage["{$nest->schema}.{$nest->table}"] = 1; - $aliasAs = ''; - - if($nest->isDiscreteModel()) { - // - // CHANGED/ADDED 2020-06-10 - // derived table, explicitly specify alias - // for usage with discrete model feature - // This is needed in the case of ONE/the first join of this derived table - // - $aliasAs = $nest->table; - $alias = $nest->getTableIdentifier(); // implode('.', array_filter([ $nest->schema, $nest->table ])); - } else { - $alias = $nest->getTableIdentifier(); // "{$nest->schema}.{$nest->table}"; + if (array_key_exists($field, $data)) { + if ($index > 0) { + $query .= ', '; } - } - } - - - // get join method from plugin - $joinMethod = $join->getJoinMethod(); - - // if $joinMethod == null == DEFAULT -> use current config. - // this should be deprecated or removed... - if($joinMethod == null) { - $joinMethod = "LEFT JOIN"; - if($this->rightJoin) { - $joinMethod = "RIGHT JOIN"; - } + $index++; + $query .= $field; } - - // find the correct KEY/field in the current model (do not simply join PKEY on PKEY (names)) - /* $thisKey = null; - $joinKey = null; - foreach($this->config->get('foreign') as $fkeyName => $fkeyConfig) { - if($fkeyConfig['table'] == $nest->table) { - $thisKey = $fkeyName; - $joinKey = $fkeyConfig['key']; - break; - } + } + $query .= ') VALUES ('; + $index = 0; + foreach ($this->config->get('field') as $field) { + if ($field == $this->getPrimaryKey() || in_array($field, [$this->table . "_modified", $this->table . "_created"])) { + continue; } - - // Reverse Join - if(($thisKey == null) || ($joinKey == null)) { - foreach($nest->config->get('foreign') as $fkeyName => $fkeyConfig) { - if($fkeyConfig['table'] == $this->table) { - $thisKey = $fkeyConfig['key']; - $joinKey = $fkeyName; - break; + if (array_key_exists($field, $data)) { + if ($index > 0) { + $query .= ', '; } - } - }*/ - - $thisKey = $join->modelField; - $joinKey = $join->referenceField; - if(($thisKey == null) || ($joinKey == null)) { - // - // CHANGED/ADDED 2020-06-10 - // We allow thisKey & joinKey to be null (models not directly in relation) - // In this case, additional conditions have to be defined - // See else - // - if(!$this->isDiscreteModel() && !$join->model->isDiscreteModel()) { - throw new \codename\core\exception(self::EXCEPTION_SQL_DEEPJOIN_INVALID_FOREIGNKEY_CONFIG, \codename\core\exception::$ERRORLEVEL_FATAL, array($this->table, $nest->table)); - } else { - // - // Check for additional conditions - // As we HAVE to have some references defined, somehow. - // - if(!$join->conditions || count($join->conditions) === 0) { - throw new \codename\core\exception(self::EXCEPTION_SQL_DEEPJOIN_INVALID_FOREIGNKEY_CONFIG, \codename\core\exception::$ERRORLEVEL_FATAL, array($this->table, $nest->table)); + if (is_object($data[$field]) || is_array($data[$field])) { + $data[$field] = $this->jsonEncode($data[$field]); } - } - } - - $joinComponents = []; + $index++; - $useAlias = $parentAlias ?? $this->getTableIdentifier(); // $this->table; + $var = $this->getStatementVariable(array_keys($param), $field); - if($thisKey === null && $joinKey === null) { - // only rely on conditions - $cAlias = $alias ?? $useAlias; // TODO: dunno if this is correct. test also reverse and forward joins - } else { - if(is_array($thisKey) && is_array($joinKey)) { - // TODO: check for equal array item counts! otherwise: exception - // perform a multi-component join - foreach($thisKey as $index => $thisKeyValue) { - $joinComponents[] = "{$alias}.{$joinKey[$index]} = {$useAlias}.{$thisKeyValue}"; - } - } else if(is_array($thisKey) && !is_array($joinKey)) { - foreach($thisKey as $index => $thisKeyValue) { - $joinComponents[] = "{$alias}.{$index} = {$useAlias}.{$thisKeyValue}"; + // performance hack: store modelfield instance! + if (!isset($this->modelfieldInstance[$field])) { + $this->modelfieldInstance[$field] = modelfield::getInstance($field); } - } else if(!is_array($thisKey) && is_array($joinKey)) { - throw new \LogicException('Not implemented multi-component foreign key join'); - } else { - $joinComponents[] = "{$alias}.{$joinKey} = {$useAlias}.{$thisKey}"; - } - - } - + $fieldInstance = $this->modelfieldInstance[$field]; - // DEBUG - // print_r( [ - // 'nest' => $nest->getIdentifier(), - // 'fkey.key' => $nest->getConfig()->get('foreign>'.$joinKey.'>key'), - // 'joinKey' => $joinKey, - // 'thisKey' => $thisKey, - // 'alias' => $alias, - // 'useAlias' => $useAlias, - // ] ); + $param[$var] = $this->getParametrizedValue($this->delimit($fieldInstance, $data[$field]), $this->getFieldtype($fieldInstance)); - // Determine the specific alias - // if we're doing a reverse join, current alias is simply wrong - // at least when using explicit values as condition parts - // NOTE/CHANGED 2020-09-15: for custom joins, this is wrong - // as the 'opposite site' also doesn't have an fkey reference. - $cAlias = null; - if(!is_array($joinKey) && ($nest->getConfig()->get('foreign>'.$joinKey.'>key') == $thisKey)) { - // - // Back-reference, validated by checking the existance - // of an FKEY config in the nested ref back to THIS model - // - $cAlias = $alias; - } else if(!is_array($thisKey) && ($this->getConfig()->get('foreign>'.$thisKey.'>key') == $joinKey)) { - // - // Forward reference, validated by checking the existance - // of an FKEY config in THIS model to the nested one - // - $cAlias = $useAlias; - } else { - // neither this, nor nested model has an fkey ref - this is a custom join! - $cAlias = $alias; + $query .= ':' . $var; } - - - // add conditions! - foreach($join->conditions as $filter) { - $operator = $filter['value'] == null ? ($filter['operator'] == '!=' ? 'IS NOT' : 'IS') : $filter['operator']; - - // - // NOTE/IMPORTANT: - // At the moment, we explicitly DO NOT support PDO Params in conditions - // as we also specify conditions referring to fields instead of values - // - $value = $filter['value'] == null ? 'NULL' : $filter['value']; - - // // - // // NOTE/CHANGED/ADDED 2020-09-15 added support for PDO Params - // // in conditioned joins - // // - // $value = null; - // - // if($filter['value'] !== null) { - // $var = $this->getStatementVariable(\array_keys($params), '_c_'.$filter['field']); - // $value = ':'.$var; - // // - // // TODO: implicit field type determination - // // TODO: array support (IN-QUERIES) - // // - // $params[$var] = $this->getParametrizedValue($filter['value'], 'text'); - // } else { - // $value = 'NULL'; - // } - - $tAlias = $cAlias; - - // - // ADDED 2020-09-15 Allow explicit model name for conditions - // To allow filters on both sides - // - if($filter['model_name'] ?? false) { - // explicit model override in filter dataset - if($filter['model_name'] == $this->getIdentifier()) { - $tAlias = $useAlias; - } else if($filter['model_name'] == $nest->getIdentifier()) { - $tAlias = $alias; - } else { - throw new exception('INVALID_JOIN_CONDITION_MODEL_NAME', exception::$ERRORLEVEL_ERROR); + } + $query .= " )"; + if ($replace) { + $query .= ' ON DUPLICATE KEY UPDATE '; + $parts = []; + foreach ($this->config->get('field') as $field) { + if ($field == $this->getPrimaryKey() || in_array($field, [$this->table . "_modified", $this->table . "_created"])) { + continue; + } + if (array_key_exists($field, $data)) { + $parts[] = "$field = VALUES($field)"; } - } - - $joinComponents[] = ($tAlias ? $tAlias.'.' : '') . "{$filter['field']} {$operator} {$value}"; - - // DEBUG Debugging join conditions for discrete models - // if($nest instanceof \codename\core\model\discreteModelSchematicSqlInterface) { - // \codename\core\app::getResponse()->setData('dbg_'.$filter['field'], [ - // '$cAlias' => $cAlias, - // '$alias' => $alias, - // '$useAlias' => $useAlias, - // '$join->currentAlias' => $join->currentAlias, - // ]); - // } - } - - $joinComponentsString = implode(' AND ', $joinComponents); - - // SQL USE INDEX implementation, limited to one index per table at a time - $useIndex = ''; - if($nest->useIndex ?? false && count($nest->useIndex) > 0) { - $useIndex = ' USE INDEX('.$nest->useIndex[0].') '; - } - - // - // CHANGED/ADDED 2020-06-10 Discrete models (empowering subqueries) - // NOTE: we're checking for discrete models here - // as they don't represent a table on its own, but merely an entire subquery - // - if($cteName !== null) { - $ret .= " {$joinMethod} {$cteName} {$aliasAs}{$useIndex} ON $joinComponentsString"; - } else if($nest->isDiscreteModel() && $nest instanceof \codename\core\model\discreteModelSchematicSqlInterface) { - $ret .= " {$joinMethod} {$nest->getDiscreteModelQuery($params)} {$aliasAs}{$useIndex} ON $joinComponentsString"; - } else { - $ret .= " {$joinMethod} {$nest->getTableIdentifier()} {$aliasAs}{$useIndex} ON $joinComponentsString"; } - - // CHANGED 2020-11-26: set alias or fallback to table name, by default - // To ensure correct duplicate field name handling across multiple tables - // CHANGED again: we have to leave this null, if no alias. - // This crashes filter methods, as it overrides the alias in any aspect. - // NOTE: we might have to include schema name, too. - $join->currentAlias = $alias; // ?? $nest->table; - - // DEBUG Deepjoin debugging, especially for discrete models - // \codename\core\app::getResponse()->setData('dbg_deepjoin_'.$this->getIdentifier(), [ - // '$cAlias' => $cAlias, - // '$alias' => $alias, - // '$useAlias' => $useAlias, - // '$join->currentAlias' => $join->currentAlias, - // ]); - - $ret .= $nest->deepJoin($nest, $tableUsage, $aliasCounter, $join->currentAlias, $params, $cte); + $query .= implode(',', $parts); } - - return $ret; - } - - /** - * @inheritDoc - */ - public function getCount(): int - { - // - // Russian Caviar Begin - // HACK/WORKAROUND for shrinking count-only-queries. - // - $this->countingModeOverride = true; - - $this->search(); - $count = $this->db->getResult()[0]['___count']; - - // - // Russian Caviar End - // - $this->countingModeOverride = false; - return $count; - } - - /** - * [private description] - * @var bool - */ - private $countingModeOverride = false; - - /** - * @inheritDoc - */ - public function reset() - { - if(!$this->countingModeOverride) { - parent::reset(); - } else { - // do not reset everything, if we're in special counting mode. - // just reset errorstack. - $this->errorstack->reset(); - } - } - - /** - * @inheritDoc - */ - public function addUseIndex(array $fields): \codename\core\model - { - $fieldString = (count($fields) === 1 ? $fields[0] : implode(',', $fields)); - $this->useIndex = [ 'index_'.md5($fieldString) ]; - // $this->useIndex = array_values(array_unique($this->useIndex)); - return $this; - } - - /** - * Returns a db-specific identifier (e.g. schema.table for the current model) - * or, if schema and model are specified, for a different schema+table - * @param string|null $schema [name of schema] - * @param string|null $model [name of schema] - * @return string [description] - */ - public function getTableIdentifier(?string $schema = null, ?string $model = null): string { - if($schema || $model) { - return $this->getServicingSqlInstance()->getTableIdentifierParametrized($schema, $model); - } else { - return $this->getServicingSqlInstance()->getTableIdentifier($this); - } - } - - /** - * [getRecursiveSqlCteStatement description] - * @param string $cteName [description] - * @param array &$params [description] - * @return string - */ - protected function getRecursiveSqlCteStatement(string $cteName, array &$params): string { - $anchorConditionQuery = ''; - if(count($this->recursiveAnchorConditions) > 0) { - $anchorConditionQuery = 'WHERE '.\codename\core\model\schematic\sql::convertFilterQueryArray( - $this->getFilters($this->recursiveAnchorConditions, [], [], $params) // ?? - ); - } - - // Default anchor field name (__anchor) - // Not to be confused with recursiveAnchorField - // In contrast to recursive joins, this is more or less static here. - $anchorFieldName = '__anchor'; - - // - // CTE Prefix / "WITH [RECURSIVE]" is implicitly added by the model class - // - $sql = "{$cteName} " - . " AS ( " - . " SELECT " - // We default to the PKEY as (visible) anchor field: - . " {$this->getPrimarykey()} as {$anchorFieldName} " - // . " , 0 as __level " // TODO: internal level tracking for keeping order? - - // Endless loop / circular reference protection for array-supporting RDBMS: - // . " , array[{$this->getPrimarykey()}] as __traversed " - - . " , {$this->getTableIdentifier()}.* " - . " FROM {$this->getTableIdentifier()} " - . " {$anchorConditionQuery} " - - // NOTE: UNION instead of UNION ALL prevents duplicates - // and is an implicit termination condition for the recursion - // as the some query might return rows already selected - // leading to 'zero added rows' - and finishing our query - . " UNION " - - . " SELECT " - . " {$cteName}.{$anchorFieldName} " - // . " , __level+1 " // TODO: internal level tracking for keeping order? - - // Endless loop / circular reference protection for array-supporting RDBMS: - // . " , {$cteName}.__traversed || {$this->getTableIdentifier()}.{$this->getPrimarykey()} " - - . " , {$this->getTableIdentifier()}.* " - - . " FROM {$this->getTableIdentifier()}, {$cteName} " - . " WHERE {$cteName}.{$this->recursiveSelfReferenceField->get()} = {$this->getTableIdentifier()}.{$this->recursiveAnchorField->get()} " - // . " ORDER BY {$cteName}.{$anchorFieldName}, __level" // TODO internal level tracking for keeping order? - - // Endless loop / circular reference protection for array-supporting RDBMS: - // . " AND {$this->getPrimarykey()} <> ALL ({$cteName}.__traversed) " - - . " )"; - - // print_r($sql); - return $sql; + $query .= ";"; + return $query; } /** * * {@inheritDoc} + * @return model + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception * @see \codename\core\model_interface::search() */ - public function search() : \codename\core\model { - - if($this->filterDuplicates) { - $query = "SELECT DISTINCT "; + public function search(): model + { + if ($this->filterDuplicates) { + $query = "SELECT DISTINCT "; } else { - $query = "SELECT "; + $query = "SELECT "; } - // first: deepJoin to get correct alias names + // First: deepJoin to get correct alias names // // Contains Fix for JIRA [CODENAME-493] // see below. We include the main table here, from the start. // As it simply IS part of the used tables. // - $tableUsage = [ "{$this->schema}.{$this->table}" => 1]; + $tableUsage = ["$this->schema.$this->table" => 1]; // prepare an array for values to submit as PDO statement parameters // done by-ref, so the values are arriving right here after // running getFilterQuery() $params = []; - $parentAlias = null; // ADDED 2021-05-05: CTEs $cte = []; $cteName = null; - if($this->recursive) { - $cteName = '__cte_recursive_'.(count($cte)+1); - $cte[] = $this->getRecursiveSqlCteStatement($cteName, $params); - $tableUsage[$cteName] = 1; - $parentAlias = $cteName; + if ($this->recursive) { + $cteName = '__cte_recursive_1'; + $cte[] = $this->getRecursiveSqlCteStatement($cteName, $params); + $tableUsage[$cteName] = 1; } $explicitDiscrete = false; // Root model is a discrete model // Use getTableIdentifier for setting a main alias - if($this->isDiscreteModel()) { - $cteName = $this->getTableIdentifier(); - $tableUsage[$cteName] = 1; - $parentAlias = $cteName; - $explicitDiscrete = true; + if ($this->isDiscreteModel()) { + $cteName = $this->getTableIdentifier(); + $tableUsage[$cteName] = 1; + $explicitDiscrete = true; } // // NOTE/CHANGED 2020-09-15: allow params in deepJoin() (conditions!) // $aliasCounter = 0; + $parentAlias = null; $deepjoin = $this->deepJoin($this, $tableUsage, $aliasCounter, $parentAlias, $params, $cte); // Prepend CTEs, if there are any @@ -1521,50 +834,50 @@ public function search() : \codename\core\model { // This leads to the fact // - we do not have to take care of the order of the CTEs // - we simply enable RECURSIVE by default, no matter we really use it - if(count($cte) > 0) { - $query = 'WITH RECURSIVE ' . implode(", \n", $cte) . "\n" . $query; + if (count($cte) > 0) { + $query = 'WITH RECURSIVE ' . implode(", \n", $cte) . "\n" . $query; } // // Russian Caviar // HACK/WORKAROUND for shrinking count-only-queries. // - if($this->countingModeOverride) { - $query .= 'COUNT('.$this->getTableIdentifier() . '.' . $this->wrapIdentifier($this->getPrimarykey()).') as ___count'; + if ($this->countingModeOverride) { + $query .= 'COUNT(' . $this->getTableIdentifier() . '.' . $this->wrapIdentifier($this->getPrimaryKey()) . ') as ___count'; } else { - // retrieve a list of all model field lists, recursively - // respecting hidden fields and duplicate field names in other models/tables - $fieldlist = $this->getCurrentFieldlist($cteName, $params); - - if(count($fieldlist) == 0) { - $query .= ' * '; - } else { - $fields = array(); - foreach($fieldlist as $f) { - // schema and table specifier separator (.)(.) - // schema.table.field (and field may be a '*') - $fields[] = implode('.', $f); - } - // chain the fields - $query .= implode(',', $fields); - } + // retrieve a list of all model field lists, recursively + // respecting hidden fields and duplicate field names in other models/tables + $fieldlist = $this->getCurrentFieldlist($cteName, $params); + + if (count($fieldlist) == 0) { + $query .= ' * '; + } else { + $fields = []; + foreach ($fieldlist as $f) { + // schema and table specifier separator (.)(.) + // schema.table.field (and field may be a '*') + $fields[] = implode('.', $f); + } + // chain the fields + $query .= implode(',', $fields); + } } // - // CHANGED/ADDED 2020-06-10 Discrete models (empowering subqueries) + // CHANGED/ADDED 2020-06-10 Discrete models (empowering sub queries) // NOTE: we're checking for discrete models here // as they don't represent a table on its own, but merely an entire subquery // - if($cteName !== null && !$explicitDiscrete) { - $query .= ' FROM ' . $cteName . ' '; - } else if($this->isDiscreteModel() && $this instanceof \codename\core\model\discreteModelSchematicSqlInterface) { - $query .= ' FROM ' . $this->getDiscreteModelQuery($params) . ' AS '. $this->table . ' '; // directly apply table alias + if ($cteName !== null && !$explicitDiscrete) { + $query .= ' FROM ' . $cteName . ' '; + } elseif ($this->isDiscreteModel() && $this instanceof discreteModelSchematicSqlInterface) { + $query .= ' FROM ' . $this->getDiscreteModelQuery($params) . ' AS ' . $this->table . ' '; // directly apply table alias } else { - $query .= ' FROM ' . $this->getTableIdentifier() . ' '; + $query .= ' FROM ' . $this->getTableIdentifier() . ' '; } - if($this->useIndex ?? false && count($this->useIndex) > 0) { - $query .= 'USE INDEX('.$this->useIndex[0].') '; + if (($this->useIndex ?? false) && count($this->useIndex) > 0) { + $query .= 'USE INDEX(' . $this->useIndex[0] . ') '; } // append the previously constructed deepjoin string @@ -1582,15 +895,15 @@ public function search() : \codename\core\model { // CHANGED again: we HAVE to omit setting the mainAlias by default // As this crashes queries using pre-set schema names $mainAlias = null; - if($tableUsage["{$this->schema}.{$this->table}"] > 1) { - $mainAlias = $this->getTableIdentifier(); + if ($tableUsage["$this->schema.$this->table"] > 1) { + $mainAlias = $this->getTableIdentifier(); } $query .= $this->getFilterQuery($params, $mainAlias); $groups = $this->getGroups($mainAlias); - if(count($groups) > 0) { - $query .= ' GROUP BY '. implode(', ', $groups); + if (count($groups) > 0) { + $query .= ' GROUP BY ' . implode(', ', $groups); } // @@ -1598,34 +911,38 @@ public function search() : \codename\core\model { // // $appliedAggregateFilters = []; $aggregate = $this->getAggregateQueryComponents($params); - if(count($aggregate) > 0) { - $query .= ' HAVING '. self::convertFilterQueryArray($aggregate); + if (count($aggregate) > 0) { + $query .= ' HAVING ' . self::convertFilterQueryArray($aggregate); } - if(count($this->order) > 0) { - $query .= $this->getOrders($this->order); + if (count($this->order) > 0) { + $query .= $this->getOrders($this->order); } // // Russian Caviar // HACK/WORKAROUND for shrinking count-only-queries. // - if(!$this->countingModeOverride) { - if(!is_null($this->limit)) { - $query .= $this->getLimit($this->limit); - } + if (!$this->countingModeOverride) { + if (!is_null($this->limit)) { + $query .= $this->getLimit($this->limit); + } - if(!is_null($this->offset) > 0) { - $query .= $this->getOffset($this->offset); - } + if (!is_null($this->offset) > 0) { + $query .= $this->getOffset($this->offset); + } + } + + if ($this->forUpdate ?? false) { + $query .= ' FOR UPDATE'; } // // Russian Caviar // HACK/WORKAROUND for shrinking count-only-queries. // - if($this->countingModeOverride && count($groups) > 0) { - $query = 'SELECT COUNT(___count) AS ___count FROM('.$query.') AS DerivedTableAlias'; + if ($this->countingModeOverride && count($groups) >= 1) { + $query = 'SELECT COUNT(___count) AS ___count FROM(' . $query . ') AS DerivedTableAlias'; } $this->doQuery($query, $params); @@ -1634,458 +951,984 @@ public function search() : \codename\core\model { } /** - * [protected description] - * @var bool|null + * [getRecursiveSqlCteStatement description] + * @param string $cteName [description] + * @param array &$params [description] + * @return string + * @throws exception */ - protected $useTimemachineState = null; + protected function getRecursiveSqlCteStatement(string $cteName, array &$params): string + { + $anchorConditionQuery = ''; + if (count($this->recursiveAnchorConditions) > 0) { + $anchorConditionQuery = 'WHERE ' . sql::convertFilterQueryArray( + $this->getFilters($this->recursiveAnchorConditions, [], [], $params) + ); + } + + // Default anchor field name (__anchor) + // Not to be confused with recursiveAnchorField + // In contrast to recursive join, this is more or less static here. +// $anchorFieldName = '__anchor'; + + // + // CTE Prefix / "WITH [RECURSIVE]" is implicitly added by the model class + // + + return "$cteName " + . " AS ( " + . " SELECT " + . " {$this->getTableIdentifier()}.* " + + . " FROM {$this->getTableIdentifier()} " + . " $anchorConditionQuery " + + // NOTE: UNION instead of UNION ALL prevents duplicates + // and is an implicit termination condition for the recursion + // as some query might return rows already selected + // leading to 'zero added rows' - and finishing our query + . " UNION " + + . " SELECT " + . " {$this->getTableIdentifier()}.* " + + . " FROM {$this->getTableIdentifier()} JOIN $cteName " + . " ON {$this->getTableIdentifier()}.{$this->recursiveSelfReferenceField->get()} = $cteName.{$this->recursiveAnchorField->get()} " + + . " )"; + + +// return "$cteName " +// . " AS ( " +// . " SELECT " +// // We default to the PKEY as (visible) anchor field: +// . " {$this->getPrimaryKey()} as $anchorFieldName " +// +// . " , {$this->getTableIdentifier()}.* " +// . " FROM {$this->getTableIdentifier()} " +// . " $anchorConditionQuery " +// +// // NOTE: UNION instead of UNION ALL prevents duplicates +// // and is an implicit termination condition for the recursion +// // as some query might return rows already selected +// // leading to 'zero added rows' - and finishing our query +// . " UNION " +// +// . " SELECT " +// . " $cteName.$anchorFieldName " +// +// +// . " , {$this->getTableIdentifier()}.* " +// +// . " FROM {$this->getTableIdentifier()}, $cteName " +// . " WHERE $cteName.{$this->recursiveSelfReferenceField->get()} = {$this->getTableIdentifier()}.{$this->recursiveAnchorField->get()} " +// +// . " )"; + } /** - * Whether this model is timemachine-capable and enabled - * @return bool + * [convertFilterQueryArray description] + * @param array $filterQueryArray [description] + * @return string [description] */ - protected function useTimemachine() : bool { - if($this->useTimemachineState === null) { - $this->useTimemachineState = ($this instanceof \codename\core\model\timemachineInterface) && $this->isTimemachineEnabled(); - } - return $this->useTimemachineState; + public static function convertFilterQueryArray(array $filterQueryArray): string + { + $queryPart = ''; + foreach ($filterQueryArray as $index => $filterQuery) { + if ($index > 0) { + $queryPart .= ' ' . $filterQuery['conjunction'] . ' '; + } + if (is_array($filterQuery['query'])) { + $queryPart .= self::convertFilterQueryArray($filterQuery['query']); + } else { + $queryPart .= $filterQuery['query']; + } + } + return '(' . $queryPart . ')'; } /** - * returns a query that performs a save using UPDATE - * (e.g. we have an existing entry that needs to be updated) - * @param array $data [data] - * @param array &$param [reference array that keeps track of PDO variable names] - * @return string [query] + * Converts the given array of model_plugin_filter instances to the WHERE... query string. + * Is capable of using $flagfilters for binary operations + * Handles named filtercollection groups + * the respective filtercollection(s) (and their filters) + * + * Returns a recursive array structure that can be converted to a query string + * + * @param array $filters [array of filters] + * @param array $flagfilters [array of flagfilters] + * @param array $filterCollections [array of filter collections] + * @param array &$appliedFilters [cross-model-instance array of currently applied filters, to keep track of PDO variables] + * @param string|null $currentAlias [current table alias provided during query time] + * @return array + * @throws exception */ - protected function saveUpdate(array $data, array &$param = array()) { + public function getFilters(array $filters = [], array $flagfilters = [], array $filterCollections = [], array &$appliedFilters = [], ?string $currentAlias = null): array + { + $where = []; - // TEMPORARY: SAVE LOG DISABLED - // $this->saveLog('UPDATE', $data); + // Loop through each filter + foreach ($filters as $filter) { + // collect data for a single filter + $filterQuery = [ + 'conjunction' => $filter->conjunction ?? $this->filterOperator, + 'query' => null, + ]; - // - // disable cache reset, if model is not enabled for it. - // At the moment, we don't even use the PRIMARY cache - // - if($this->cache) { - $cacheGroup = $this->getCachegroup(); - $cacheKey = "PRIMARY_" . $data[$this->getPrimarykey()]; - $this->clearCache($cacheGroup, $cacheKey); - } + if ($filter instanceof filterInterface) { + // handle regular filters - // raw data for usage with the timemachine - if($this->useTimemachine()) { - $raw = $data; + if ($filter instanceof filter) { + if (($schema = $filter->field->getSchema()) && ($table = $filter->field->getTable())) { + // explicit, fully qualified schema and table + $fullQualifier = $this->getTableIdentifier($schema, $table); + $filterFieldIdentifier = $filter->getFieldValue($fullQualifier); + } else { + $filterFieldIdentifier = $filter->getFieldValue($currentAlias); + } + } else { + $filterFieldIdentifier = $filter->getFieldValue($currentAlias); + } + + if (is_array($filter->value)) { + // filter value is an array (e.g., IN() match) + $values = []; + $i = 0; + foreach ($filter->value as $thisval) { + $var = $this->getStatementVariable(array_keys($appliedFilters), $filterFieldIdentifier, $i++); + $values[] = ':' . $var; // var = PDO Param + $appliedFilters[$var] = $this->getParametrizedValue($this->delimit($filter->field, $thisval), $this->getFieldtype($filter->field) ?? $this->getFallbackDatatype($thisval)); // values separated from query + } + $string = implode(', ', $values); + $operator = $filter->operator == '=' ? 'IN' : 'NOT IN'; + $filterQuery['query'] = $filterFieldIdentifier . ' ' . $operator . ' ( ' . $string . ') '; + } elseif ($filter->value === null) { + // filter value is a singular value + // NOTE: $filter->value == 'null' (equality operator, compared to string) may evaluate to TRUE if you're passing in a positive boolean (!) + // instead, we're now using the identity operator === to explicitly check for a string 'null' + // NOTE: $filter->value == null (equality operator, compared to NULL) may evaluate to TRUE if you're passing in a negative boolean (!) + // instead, we're now using the identity operator === to explicitly check for a real NULL + // @see http://www.php.net/manual/en/types.comparisons.php + + // CHANGED 2020-12-30 removed \is_string($filter->value) && \strlen($filter->value) == 0 || $filter->value === 'null' + // Which converted '' or 'null' to NULL - which is simply wrong or legacy code. + + $filterQuery['query'] = $filterFieldIdentifier . ' ' . ($filter->operator == '!=' ? 'IS NOT' : 'IS') . ' NULL'; // no param! + } else { + $var = $this->getStatementVariable(array_keys($appliedFilters), $filterFieldIdentifier); + $filterQuery['query'] = $filterFieldIdentifier . ' ' . $filter->operator . ' ' . ':' . $var . ' '; // var = PDO Param + $appliedFilters[$var] = $this->getParametrizedValue($filter->value, $this->getFieldtype($filter->field) ?? 'text'); // values separated from query + } + } elseif ($filter instanceof filterlistInterface) { + $string = is_array($filter->value) ? implode(',', $filter->value) : $filter->value; + + if (strlen($string) !== 0) { + if (!preg_match('/^([0-9,]+)$/i', $string)) { + throw new exception(self::EXCEPTION_SQL_GETFILTERS_INVALID_QUERY_VALUE, exception::$ERRORLEVEL_ERROR, $filter); + } + $operator = $filter->operator == '=' ? 'IN' : 'NOT IN'; + $filterQuery['query'] = $filter->getFieldValue($currentAlias) . ' ' . $operator . ' (' . $string . ') '; + } else { + $filterQuery['query'] = 'false'; + } + } elseif ($filter instanceof fieldfilter) { + // handle field-based filters + // this is not something PDO needs separately transmitted variables for + // value IS indeed a field name + // TODO: provide getFieldValue($tableAlias) also for fieldfilters + $filterQuery['query'] = $filter->getLeftFieldValue($currentAlias) . ' ' . $filter->operator . ' ' . $filter->getRightFieldValue($currentAlias); + } elseif ($filter instanceof managedFilterInterface) { + $variableNames = $filter->getFilterQueryParameters(); + $variableNameMap = []; + foreach ($variableNames as $vName => $vValue) { + $variableNameMap[$vName] = $this->getStatementVariable(array_keys($appliedFilters), $vName); + $appliedFilters[$variableNameMap[$vName]] = $this->getParametrizedValue($vValue, ''); + } + $filterQuery['query'] = $filter->getFilterQuery($variableNameMap, $currentAlias); + } + + // only handle if a query set + if ($filterQuery['query'] != null) { + $where[] = $filterQuery; + } else { + throw new exception(self::EXCEPTION_SQL_GETFILTERS_INVALID_QUERY, exception::$ERRORLEVEL_ERROR, $filter); + } } - $query = 'UPDATE ' . $this->getTableIdentifier() .' SET '; - $parts = []; + // handle flag filters (bit-oriented) + foreach ($flagfilters as $flagfilter) { + // collect data for a single filter + $filterQuery = [ + 'conjunction' => $flagfilter->conjunction ?? $this->filterOperator, + ]; - foreach ($this->config->get('field') as $field) { - if(in_array($field, array($this->getPrimarykey(), $this->table . "_modified", $this->table . "_created"))) { - continue; + $flagVar1 = $this->getStatementVariable(array_keys($appliedFilters), $this->table . '_flag'); + $appliedFilters[$flagVar1] = null; // temporary dummy value + $flagVar2 = $this->getStatementVariable(array_keys($appliedFilters), $this->table . '_flag'); + $appliedFilters[$flagVar2] = null; // temporary dummy value + + if ($flagfilter < 0) { + $filterQuery['query'] = $this->table . '_flag & ' . ':' . $flagVar1 . ' <> ' . ':' . $flagVar2 . ' '; // var = PDO Param + $appliedFilters[$flagVar1] = $this->getParametrizedValue($flagfilter * -1, 'number_natural'); // values separated from query + $appliedFilters[$flagVar2] = $this->getParametrizedValue($flagfilter * -1, 'number_natural'); // values separated from query + } else { + $filterQuery['query'] = $this->table . '_flag & ' . ':' . $flagVar1 . ' = ' . ':' . $flagVar2 . ' '; // var = PDO Param + $appliedFilters[$flagVar1] = $this->getParametrizedValue($flagfilter, 'number_natural'); // values separated from query + $appliedFilters[$flagVar2] = $this->getParametrizedValue($flagfilter, 'number_natural'); // values separated from query } - // If it exists, set the field - if(array_key_exists($field, $data)) { + // we don't have to check for existence of 'query', as it is definitely handled + // by the previous if-else clause + $where[] = $filterQuery; + } - if (is_object($data[$field]) || is_array($data[$field])) { - $data[$field] = $this->jsonEncode($data[$field]); - } + // collect groups of filter(collections) + $t_filtergroups = []; - $var = $this->getStatementVariable(array_keys($param), $field); + // Loop through each named group + foreach ($filterCollections as $groupName => $groupFilterCollection) { + // handle grouping of filtercollections + // by default; there's only a single group (e.g., 'default' ) + $t_groups = []; + + // Loop through each group member (which is a filtercollection) in a named group + foreach ($groupFilterCollection as $filterCollection) { + // collect filters in a filtercollection + $t_filters = []; + + // Loop through each filter in a filtercollection in a named group + foreach ($filterCollection['filters'] as $filter) { + // collect data for a single filter + $t_filter = [ + 'conjunction' => $filterCollection['operator'], + 'query' => null, + ]; + + if ($filter instanceof filterInterface) { + if ($filter instanceof filter) { + if (($schema = $filter->field->getSchema()) && ($table = $filter->field->getTable())) { + // explicit, fully qualified schema and table + $fullQualifier = $this->getTableIdentifier($schema, $table); + $filterFieldIdentifier = $filter->getFieldValue($fullQualifier); + } else { + $filterFieldIdentifier = $filter->getFieldValue($currentAlias); + } + } else { + $filterFieldIdentifier = $filter->getFieldValue($currentAlias); + } + + if (is_array($filter->value)) { + // value is an array + $values = []; + $i = 0; + foreach ($filter->value as $thisval) { + $var = $this->getStatementVariable(array_keys($appliedFilters), $filterFieldIdentifier, $i++); + $values[] = ':' . $var; // var = PDO Param + $appliedFilters[$var] = $this->getParametrizedValue($this->delimit($filter->field, $thisval), $this->getFieldtype($filter->field) ?? $this->getFallbackDatatype($thisval)); + } + $string = implode(', ', $values); + $operator = $filter->operator == '=' ? 'IN' : 'NOT IN'; + $t_filter['query'] = $filterFieldIdentifier . ' ' . $operator . ' ( ' . $string . ') '; + } elseif ($filter->value === null) { + // $var = $this->getStatementVariable(array_keys($appliedFilters), $filter->field->getValue()); + $t_filter['query'] = $filterFieldIdentifier . ' ' . ($filter->operator == '!=' ? 'IS NOT' : 'IS') . ' NULL'; // var = PDO Param + // $appliedFilters[$var] = $this->getParametrizedValue(null, $this->getFieldtype($filter->field)); + } else { + $var = $this->getStatementVariable(array_keys($appliedFilters), $filterFieldIdentifier); + $t_filter['query'] = $filterFieldIdentifier . ' ' . $filter->operator . ' ' . ':' . $var . ' '; + $appliedFilters[$var] = $this->getParametrizedValue($filter->value, $this->getFieldtype($filter->field) ?? 'text'); + } + } elseif ($filter instanceof managedFilterInterface) { + $variableNames = $filter->getFilterQueryParameters(); + $variableNameMap = []; + foreach ($variableNames as $vName => $vValue) { + $variableNameMap[$vName] = $this->getStatementVariable(array_keys($appliedFilters), $vName); + $appliedFilters[$variableNameMap[$vName]] = $this->getParametrizedValue($vValue, ''); + } + $t_filter['query'] = $filter->getFilterQuery($variableNameMap, $currentAlias); + } - // performance hack: store modelfield instance! - if(!isset($this->modelfieldInstance[$field])) { - $this->modelfieldInstance[$field] = \codename\core\value\text\modelfield::getInstance($field); + if ($t_filter['query'] != null) { + $t_filters[] = $t_filter; + } else { + throw new exception(self::EXCEPTION_SQL_GETFILTERS_INVALID_QUERY, exception::$ERRORLEVEL_ERROR, $filter); + } } - $fieldInstance = $this->modelfieldInstance[$field]; - $param[$var] = $this->getParametrizedValue($this->delimit($fieldInstance, $data[$field]), $this->getFieldtype($fieldInstance)); - $parts[] = $field . ' = ' . ':'.$var; + if (count($t_filters) > 0) { + // put all collected filters + // into a recursive array structure + $t_groups[] = [ + 'conjunction' => $filterCollection['conjunction'] ?? $this->filterOperator, + 'query' => $t_filters, + ]; + } } + + // put all collected filtercollections in the named group + // into a recursive array structure + $t_filtergroups[] = [ + 'group_name' => $groupName, + 'conjunction' => $this->filterOperator, + 'query' => $t_groups, + ]; } - if($this->saveUpdateSetModifiedTimestamp) { - $parts[] = $this->table . "_modified = ".$this->getServicingSqlInstance()->getSaveUpdateSetModifiedTimestampStatement($this); + if (count($t_filtergroups) > 0) { + // put all collected named groups + // into a recursive array structure + $where[] = [ + 'conjunction' => $this->filterOperator, + 'query' => $t_filtergroups, + ]; } - $query .= implode(',', $parts); - $var = $this->getStatementVariable(array_keys($param), $this->getPrimarykey()); - // use timemachine, if capable and enabled - // this stores delta values in a separate model + // return a recursive array structure + // that contains all collected + // - filters (filters, flagfilters, fieldfilters) + // - named groups, containing + // --- filtercollections, and their + // ----- filters + // everything with their conjunction parameter (AND/OR) + // which is constructed on need in ":: convertFilterQueryArray()" + return $where; + } - // if ( ((new \ReflectionClass($this))->implementsInterface('\\codename\\core\\model\\timemachineInterface') - if($this->useTimemachine()) { - $tm = \codename\core\timemachine::getInstance($this->getIdentifier()); - $tm->saveState($data[$this->getPrimarykey()], $raw); // we have to use raw data, as we can't use jsonified arrays. + /** + * returns an estimated core-framework datatype for a given value + * in case there's no definitive datatype specified + * @param mixed $value + * @return string|null + * @throws exception + */ + protected function getFallbackDatatype(mixed $value): ?string + { + if ($value === null) { + return null; // unspecified + } elseif (is_int($value)) { + return 'number_natural'; + } elseif (is_float($value)) { + return 'number'; + } elseif (is_bool($value)) { + return 'boolean'; + } elseif (is_string($value)) { + return 'text'; } + throw new exception('INVALID_FALLBACK_PARAMETER_TYPE', exception::$ERRORLEVEL_ERROR); + } - $param[$var] = $this->getParametrizedValue($data[$this->getPrimarykey()], 'number_natural'); // ? hardcoded type? + /** + * [getFilterQuery description] + * @param array &$appliedFilters [reference array containing used filters] + * @param string|null $mainAlias [provide an alias for the main table] + * @return string + * @throws exception + */ + public function getFilterQuery(array &$appliedFilters = [], ?string $mainAlias = null): string + { + // provide pseudo main table alias, if needed + $filterQueryArray = $this->getFilterQueryComponents($appliedFilters, $mainAlias); - $query .= " WHERE " . $this->getPrimarykey() . " = " . ':'.$var; - return $query; + // + // HACK: re-join filter groups + // + $grouped = []; + $ungrouped = []; + foreach ($filterQueryArray as $part) { + if (is_array($part['query'])) { + // + // restructure part + // + $rePart = [ + 'conjunction' => $part['conjunction'], + 'query' => [], + ]; + foreach ($part['query'] as $queryComponent) { + if ($queryComponent['group_name'] ?? false) { + $grouped[$queryComponent['group_name']] = $grouped[$queryComponent['group_name']] ?? []; + $grouped[$queryComponent['group_name']]['group_name'] = $queryComponent['group_name']; + $grouped[$queryComponent['group_name']]['conjunction'] = $queryComponent['conjunction']; // this resets it every time + $grouped[$queryComponent['group_name']]['query'] = array_merge($grouped[$queryComponent['group_name']]['query'] ?? [], $queryComponent['query']); + } else { + $rePart['query'][] = $queryComponent; + } + } + if (count($rePart['query']) > 0) { + $ungrouped[] = $rePart; + } + } else { + $ungrouped[] = $part; + } + } + + $filterQueryArray = array_merge($ungrouped, array_values($grouped)); + if ($this->saveLastFilterQueryComponents) { + $this->lastFilterQueryComponents = $filterQueryArray; + } + if (count($filterQueryArray) > 0) { + return ' WHERE ' . self::convertFilterQueryArray($filterQueryArray); + } else { + return ''; + } } /** - * Whether to set *_modified field automatically - * during update - * @var bool + * [getFilterQueryComponents description] + * @param array &$appliedFilters [description] + * @param string|null $currentAlias + * @return array + * @throws exception */ - protected $saveUpdateSetModifiedTimestamp = true; + public function getFilterQueryComponents(array &$appliedFilters = [], string $currentAlias = null): array + { + $where = $this->getFilters($this->filter, $this->flagfilter, $this->filterCollections, $appliedFilters, $currentAlias); + + // get filters from nested models recursively + foreach ($this->nestedModels as $join) { + if ($this->compatibleJoin($join->model)) { + $where = array_merge($where, $join->model->getFilterQueryComponents($appliedFilters, $join->currentAlias)); + } + } + + return $where; + } /** - * [protected description] - * @var \codename\core\value\text\modelfield[] + * {@inheritDoc} */ - protected $modelfieldInstance = []; + protected function compatibleJoin(model $model): bool + { + return parent::compatibleJoin($model) && ($this->db === $model->db); + } /** - * returns a query that performs a save using INSERT - * @param array $data [data] - * @param array &$param [reference array that keeps track of PDO variable names] - * @param bool $replace [use replace on duplicate unique/pkey] - * @return string [query] + * [deepJoin description] + * @param model $model [model currently worked-on] + * @param array &$tableUsage [table usage as reference] + * @param int &$aliasCounter [alias counter as reference] + * @param string|null $parentAlias + * @param array &$params + * @param array &$cte [common table expressions, if any] + * @return string [query part] + * @throws exception */ - protected function saveCreate(array $data, array &$param = array(), bool $replace = false) { + public function deepJoin(model $model, array &$tableUsage = [], int &$aliasCounter = 0, string $parentAlias = null, array &$params = [], array &$cte = []): string + { + if (count($model->getNestedJoins()) == 0) { + return ''; + } + $ret = ''; - // TEMPORARY: SAVE LOG DISABLED - // $this->saveLog('CREATE', $data); + // Loop through nested (children/parents) + foreach ($model->getNestedJoins() as $join) { + $nest = $join->model; - $query = 'INSERT INTO ' . $this->getTableIdentifier() .' '; - $query .= ' ('; - $index = 0; - foreach ($this->config->get('field') as $field) { - if($field == $this->getPrimarykey() || in_array($field, array($this->table . "_modified", $this->table . "_created"))) { + // check model joining compatible + if (!$model->compatibleJoin($nest)) { continue; } - if(array_key_exists($field, $data)) { - if($index > 0) { - $query .= ', '; - } - $index++; - $query .= $field; - } - } - $query .= ') VALUES ('; - $index = 0; - foreach ($this->config->get('field') as $field) { - if($field == $this->getPrimarykey() || in_array($field, array($this->table . "_modified", $this->table . "_created"))) { - continue; + + $alias = null; + $aliasAs = ''; + $cteName = null; + + // preliminary CTE, model itself is recursive + // $cteName = null; + if ($nest->recursive) { + $cteName = '__cte_recursive_' . (count($cte) + 1); + $cte[] = $nest->getRecursiveSqlCteStatement($cteName, $params); + $join->referenceField = $join->model->getPrimaryKey(); +// $join->referenceField = '__anchor'; + $tableUsage[$cteName] = 1; + // Also increase this counter, though this is a CTE + // to correctly keep track of ambiguous fields + $tableUsage["$nest->schema.$nest->table"]++; + $alias = $cteName; } - if(array_key_exists($field, $data)) { - if($index > 0) { - $query .= ', '; + + + if ($nest->recursive || $join instanceof recursive) { + // + // 'WITH ... RECURSIVE' CTE support + // + if ($join instanceof sqlCteStatementInterface) { + $cteAlias = $cteName; // if table is already a CTE, passthrough + $cteName = '__cte_recursive_' . (count($cte) + 1); + if (array_key_exists($cteName, $tableUsage)) { + // name collision + throw new exception('MODEL_SCHEMATIC_SQL_DEEP_JOIN_CTE_NAME_COLLISION', exception::$ERRORLEVEL_ERROR, $cteName); + } else { + $tableUsage[$cteName] = 1; + // Also increase this counter, though this is a CTE + // to correctly keep track of ambiguous fields + $tableUsage["$nest->schema.$nest->table"]++; + } + $cte[] = $join->getSqlCteStatement($cteName, $params, $cteAlias); + $alias = $cteName; + $aliasAs = "AS " . $alias; + } elseif ($join instanceof recursive) { + throw new exception('MODEL_SCHEMATIC_SQL_DEEP_JOIN_UNSUPPORTED_JOIN_RECURSIVE_PLUGIN', exception::$ERRORLEVEL_ERROR, get_class($join)); } + } elseif (array_key_exists("$nest->schema.$nest->table", $tableUsage)) { + $aliasCounter++; + $tableUsage["$nest->schema.$nest->table"]++; + $alias = "a" . $aliasCounter; + $aliasAs = "AS " . $alias; + } else { + $tableUsage["$nest->schema.$nest->table"] = 1; + + if ($nest->isDiscreteModel()) { + // + // CHANGED/ADDED 2020-06-10 + // derived table, explicitly specify alias + // for usage with discrete model feature + // This is necessary in the case of ONE/the first join of this derived table + // + $aliasAs = $nest->table; + } + $alias = $nest->getTableIdentifier(); + } - if (is_object($data[$field]) || is_array($data[$field])) { - $data[$field] = $this->jsonEncode($data[$field]); + + // get join method from plugin + $joinMethod = $join->getJoinMethod(); + + // if $joinMethod == null == DEFAULT -> use current config. + // this should be deprecated or removed... + if ($joinMethod == null) { + $joinMethod = "LEFT JOIN"; + if ($this->rightJoin) { + $joinMethod = "RIGHT JOIN"; } - $index++; + } - $var = $this->getStatementVariable(array_keys($param), $field); + $thisKey = $join->modelField; + $joinKey = $join->referenceField; - // performance hack: store modelfield instance! - if(!isset($this->modelfieldInstance[$field])) { - $this->modelfieldInstance[$field] = \codename\core\value\text\modelfield::getInstance($field); + if (($thisKey == null) || ($joinKey == null)) { + // + // CHANGED/ADDED 2020-06-10 + // We allow thisKey & joinKey to (models be null not directly in relation) + // In this case, additional conditions have to be defined + // See else + // + if (!$this->isDiscreteModel() && !$join->model->isDiscreteModel()) { + throw new exception(self::EXCEPTION_SQL_DEEPJOIN_INVALID_FOREIGNKEY_CONFIG, exception::$ERRORLEVEL_FATAL, [$this->table, $nest->table]); + } elseif (!$join->conditions || count($join->conditions) === 0) { + throw new exception(self::EXCEPTION_SQL_DEEPJOIN_INVALID_FOREIGNKEY_CONFIG, exception::$ERRORLEVEL_FATAL, [$this->table, $nest->table]); } - $fieldInstance = $this->modelfieldInstance[$field]; + } - $param[$var] = $this->getParametrizedValue($this->delimit($fieldInstance, $data[$field]), $this->getFieldtype($fieldInstance)); + $joinComponents = []; + + $useAlias = $parentAlias ?? $this->getTableIdentifier(); // $this->table; + + if ($thisKey === null && $joinKey === null) { + // only rely on conditions +// $cAlias = $alias ?? $useAlias; // TODO: dunno if this is correct. test also reverse and forward joins + } elseif (is_array($thisKey) && is_array($joinKey)) { + // TODO: check for equal array item counts! otherwise: exception + // perform a multi-component join + foreach ($thisKey as $index => $thisKeyValue) { + $joinComponents[] = "$alias.$joinKey[$index] = $useAlias.$thisKeyValue"; + } + } elseif (is_array($thisKey) && !is_array($joinKey)) { + foreach ($thisKey as $index => $thisKeyValue) { + $joinComponents[] = "$alias.$index = $useAlias.$thisKeyValue"; + } + } elseif (!is_array($thisKey) && is_array($joinKey)) { + throw new LogicException('Not implemented multi-component foreign key join'); + } else { + $joinComponents[] = "$alias.$joinKey = $useAlias.$thisKey"; + } - $query .= ':'.$var; + // Determine the specific alias + // if we're doing a reverse join, current alias is simply wrong + // at least when using explicit values as condition parts + // NOTE/CHANGED 2020-09-15: for custom joins, this is wrong + // as the 'opposite site' also doesn't have a fkey reference. + $cAlias = null; + if (!is_array($joinKey) && ($nest->getConfig()->get('foreign>' . $joinKey . '>key') == $thisKey)) { + // + // Back-reference, validated by checking the existence + // of an FKEY config in the nested ref back to THIS model + // + $cAlias = $alias; + } elseif (!is_array($thisKey) && ($this->getConfig()->get('foreign>' . $thisKey . '>key') == $joinKey)) { + // + // Forward reference, validated by checking the existence + // of an FKEY config in THIS model to the nested one + // + $cAlias = $useAlias; + } else { + // neither this nor nested model has a fkey ref - this is a custom join! + $cAlias = $alias; } - } - $query .= " )"; - if($replace) { - $query .= ' ON DUPLICATE KEY UPDATE '; - $parts = []; - foreach ($this->config->get('field') as $field) { - if($field == $this->getPrimarykey() || in_array($field, array($this->table . "_modified", $this->table . "_created"))) { - continue; - } - if(array_key_exists($field, $data)) { - // if (is_object($data[$field]) || is_array($data[$field])) { - // $data[$field] = $this->jsonEncode($data[$field]); - // } + + + // add conditions! + foreach ($join->conditions as $filter) { + $operator = $filter['value'] == null ? ($filter['operator'] == '!=' ? 'IS NOT' : 'IS') : $filter['operator']; + + // + // NOTE/IMPORTANT: + // At the moment, we explicitly DO NOT support PDO Params in conditions + // as we also specify conditions referring to fields instead of values // - // $var = $this->getStatementVariable(array_keys($param), $field); + $value = $filter['value'] == null ? 'NULL' : $filter['value']; + + $tAlias = $cAlias; + // - // // performance hack: store modelfield instance! - // if(!isset($this->modelfieldInstance[$field])) { - // $this->modelfieldInstance[$field] = \codename\core\value\text\modelfield::getInstance($field); - // } - // $fieldInstance = $this->modelfieldInstance[$field]; + // ADDED 2020-09-15 Allow explicit model name for conditions + // To allow filters on both sides // - // $param[$var] = $this->getParametrizedValue($this->delimit($fieldInstance, $data[$field]), $this->getFieldtype($fieldInstance)); - // $parts[] = $field . ' = ' . ':'.$var; + if ($filter['model_name'] ?? false) { + // explicit model override in filter dataset + if ($filter['model_name'] == $this->getIdentifier()) { + $tAlias = $useAlias; + } elseif ($filter['model_name'] == $nest->getIdentifier()) { + $tAlias = $alias; + } else { + throw new exception('INVALID_JOIN_CONDITION_MODEL_NAME', exception::$ERRORLEVEL_ERROR); + } + } + + $joinComponents[] = ($tAlias ? $tAlias . '.' : '') . "{$filter['field']} $operator $value"; + } + + $joinComponentsString = implode(' AND ', $joinComponents); - $parts[] = "{$field} = VALUES({$field})"; - } - } - $query .= implode(',', $parts); + // SQL USE INDEX implementation, limited to one index per table at a time + $useIndex = ''; + if (($nest->useIndex ?? false) && count($nest->useIndex) > 0) { + $useIndex = ' USE INDEX(' . $nest->useIndex[0] . ') '; + } + + // + // CHANGED/ADDED 2020-06-10 Discrete models (empowering sub queries) + // NOTE: we're checking for discrete models here + // as they don't represent a table on its own, but merely an entire subquery + // + if ($cteName !== null) { + $ret .= " $joinMethod $cteName $aliasAs$useIndex ON $joinComponentsString"; + } elseif ($nest->isDiscreteModel() && $nest instanceof discreteModelSchematicSqlInterface) { + $ret .= " $joinMethod {$nest->getDiscreteModelQuery($params)} $aliasAs$useIndex ON $joinComponentsString"; + } else { + $ret .= " $joinMethod {$nest->getTableIdentifier()} $aliasAs$useIndex ON $joinComponentsString"; + } + + // CHANGED 2020-11-26: set alias or fallback to table name, by default + // To ensure correct duplicate field name handling across multiple tables + // CHANGED again: we have to leave this null, if no alias. + // This crashes filter methods, as it overrides the alias in any aspect. + // NOTE: we might have to include schema name, too. + $join->currentAlias = $alias; // ?? $nest->table; + + $ret .= $nest->deepJoin($nest, $tableUsage, $aliasCounter, $join->currentAlias, $params, $cte); } - $query .= ";"; - return $query; + + return $ret; } /** - * get a parametrized value (array) - * for use with PDO - * @param mixed $value [description] - * @param string $fieldtype [description] - * @return array [description] + * custom wrapping override due to PG's case sensitivity + * @param string $identifier [description] + * @return string [description] + * @throws exception */ - protected function getParametrizedValue($value, string $fieldtype) : array { - if($value === null) { - $param = \PDO::PARAM_NULL; // Explicit NULL - } else { - if($fieldtype == 'number') { - $value = (float) $value; - $param = \PDO::PARAM_STR; // explicitly use this one... - } else if(($fieldtype === 'number_natural') || is_int($value)) { - // NOTE: if integer value supplied, explicitly use this as param type - $param = \PDO::PARAM_INT; - } else if($fieldtype == 'boolean') { - // - // Temporary workaround for MySQL being so odd. - // bool == tinyint(1) in MySQL-world. So, we pre-evaluate - // the value to 0 or 1 (NULL being handled above) - // - $value = $value ? 1 : 0; - $param = \PDO::PARAM_INT; - // $param = \PDO::PARAM_BOOL; - } else { - $param = \PDO::PARAM_STR; // Fallback - } - } - return array( - $value, - $param - ); + protected function wrapIdentifier(string $identifier): string + { + return $this->getServicingSqlInstance()->wrapIdentifier($identifier); } /** - * returns an estimated core-framework datatype for a given value - * in case there's no definitive datatype specified - * @param bool|int|string|null $value - * @return string|null + * Returns the current fieldlist as an array of triples (schema, table, field) + * it contains the visible fields of all nested models + * retrieved in a recursive call + * this also respects hiddenFields + * + * @param string|null $alias [optional: alias as prefix for the following fields - table alias!] + * @param array &$params [optional: current pdo params, including values] + * @return array + * @throws exception */ - protected function getFallbackDatatype($value): ?string { - if($value === null) { - return null; // unspecified - } else { - if(is_int($value)) { - return 'number_natural'; - } else if(is_float($value)) { - return 'number'; - } else if(is_bool($value)) { - return 'boolean'; - } else if(is_string($value)) { - return 'text'; + protected function getCurrentFieldlist(?string $alias, array &$params): array + { + // CHANGED 2019-06-17: the main functionality moved to ":: getCurrentFieldlistNonRecursive" + // as we also need it for each model, singularly in ":: getVirtualFieldResult()" + $result = $this->getCurrentFieldlistNonRecursive($alias, $params); + + foreach ($this->nestedModels as $join) { + if ($this->compatibleJoin($join->model)) { + $result = array_merge($result, $join->model->getCurrentFieldlist($join->currentAlias, $params)); + } } - } - throw new exception('INVALID_FALLBACK_PARAMETER_TYPE', exception::$ERRORLEVEL_ERROR); + + return $result; } /** - * json_encode wrapper - * for customizing the output sent to the database - * Reason: pgsql is handling the encoding for itself - * but MySQL is doing strict encoding handling - * @see http://stackoverflow.com/questions/4782319/php-json-encode-utf8-char-problem-mysql - * and esp. @see http://stackoverflow.com/questions/4782319/php-json-encode-utf8-char-problem-mysql/37353316#37353316 + * retrieves the fieldlist of this model + * on a non-recursive basis * - * @param array|object $data [or even an object?] - * @return string [json-encoded string] + * @param string|null $alias [description] + * @param array &$params [description] + * @return array [description] + * @throws exception */ - protected function jsonEncode($data) : string { - return $this->getServicingSqlInstance()->jsonEncode($data); - } + protected function getCurrentFieldlistNonRecursive(?string $alias, array &$params): array + { + $result = []; + if (count($this->fieldlist) == 0 && count($this->hiddenFields) > 0) { + // + // Include all fields but specific ones + // + foreach ($this->getFields() as $fieldName) { + if ($this->config->get('datatype>' . $fieldName) !== 'virtual') { + if (!in_array($fieldName, $this->hiddenFields)) { + if ($alias != null) { + $result[] = [$alias, $this->wrapIdentifier($fieldName)]; + } else { + $result[] = [$this->getTableIdentifier($this->schema, $this->table), $this->wrapIdentifier($fieldName)]; + } + } + } + } + } elseif (count($this->fieldlist) > 0) { + // + // Explicit field list + // + foreach ($this->fieldlist as $field) { + if ($field instanceof calculatedfieldInterface) { + // + // custom field calculation + // + $result[] = [$field->get()]; + } elseif ($field instanceof aggregateInterface) { + // + // pre-defined aggregate function + // + $result[] = [$field->get($alias)]; + } elseif ($field instanceof fulltextInterface) { + // + // pre-defined aggregate function + // + + $var = $this->getStatementVariable(array_keys($params), $field->getField(), '_ft'); + $params[$var] = $this->getParametrizedValue($field->getValue(), 'text'); + $result[] = [$field->get($var, $alias)]; + } elseif ($this->config->get('datatype>' . $field->field->get()) !== 'virtual' && (!in_array($field->field->get(), $this->hiddenFields) || $field->alias)) { + // + // omit virtual fields + // they're not part of the DB. + // + $fieldAlias = $field->alias?->get(); + if ($alias != null) { + if ($fieldAlias) { + $result[] = [$alias, $this->wrapIdentifier($field->field->get()) . ' AS ' . $this->wrapIdentifier($fieldAlias)]; + } else { + $result[] = [$alias, $this->wrapIdentifier($field->field->get())]; + } + } elseif ($fieldAlias) { + $result[] = [$this->getTableIdentifier($field->field->getSchema() ?? $this->schema, $field->field->getTable() ?? $this->table), $this->wrapIdentifier($field->field->get()) . ' AS ' . $this->wrapIdentifier($fieldAlias)]; + } else { + $result[] = [$this->getTableIdentifier($field->field->getSchema() ?? $this->schema, $field->field->getTable() ?? $this->table), $this->wrapIdentifier($field->field->get())]; + } + } + } - /** - * [saveLog description] - * @param string $mode [description] - * @param array $data [description] - * @return [type] [description] - */ - protected function saveLog(string $mode, array $data) { - // if(strpos(get_class($this), 'activitystream') == false) { - // app::writeActivity("MODEL_" . $mode, get_class($this), $data); - // } + // + // add the rest of the data-model-defined fields + // as long as they're not hidden. + // + foreach ($this->getFields() as $fieldName) { + if ($this->config->get('datatype>' . $fieldName) !== 'virtual') { + if (!in_array($fieldName, $this->hiddenFields)) { + if ($alias != null) { + $result[] = [$alias, $this->wrapIdentifier($fieldName)]; + } else { + $result[] = [$this->getTableIdentifier($this->schema, $this->table), $this->wrapIdentifier($fieldName)]; + } + } + } + } + + // + // NOTE: + // array_unique can be used on arrays that contain objects or sub-arrays + // you need to use SORT_REGULAR for this case (!) + // + $result = array_unique($result, SORT_REGULAR); + } elseif (count($this->hiddenFields) === 0) { + // + // The rest of the fields. Simply using a wildcard + // + if ($alias != null) { + $result[] = [$alias, '*']; + } else { + $result[] = [$this->getTableIdentifier($this->schema, $this->table), '*']; + } + } + + return $result; } /** - * - * {@inheritDoc} - * @see \codename\core\modelInterface::save($data) - * - * [save description] - * @param array $data [description] - * @return \codename\core\model [description] + * Returns grouping components + * @param string|null $currentAlias + * @return array + * @throws exception */ - public function save(array $data) : \codename\core\model { - $params = array(); - if (array_key_exists($this->getPrimarykey(), $data) && strlen($data[$this->getPrimarykey()]) > 0) { - $query = $this->saveUpdate($data, $params); - $this->doQuery($query, $params); - if($this->db->affectedRows() !== 1) { - throw new exception('MODEL_SAVE_UPDATE_FAILED', exception::$ERRORLEVEL_ERROR); + public function getGroups(string $currentAlias = null): array + { + $groupArray = []; + + // group by fields + foreach ($this->group as $group) { + if ($group->aliased) { + $groupArray[] = $this->wrapIdentifier($group->field->get()); + } elseif (!$currentAlias) { + $groupArray[] = implode( + '.', + array_filter([ + $this->getTableIdentifier($group->field->getSchema() ?? null, $group->field->getTable() ?? null), + $this->wrapIdentifier($group->field->get()), + ]) + ); + } else { + $groupArray[] = implode( + '.', + array_filter([ + $currentAlias, + $this->wrapIdentifier($group->field->get()), + ]) + ); } - } else { - $query = $this->saveCreate($data, $params); - $this->cachedLastInsertId = null; - $this->doQuery($query, $params); - $this->cachedLastInsertId = $this->db->lastInsertId(); + } + + foreach ($this->getNestedJoins() as $join) { + if ($join->model instanceof sql) { + $groupArray = array_merge($groupArray, $join->model->getGroups($join->currentAlias)); + } + } + + return $groupArray; + } - // - // affected rows might be != 1 (e.g. 2 on MySQL) - // of doing a saveCreate with replace = true - // (in overridden classes) - // This WILL fail at this point. - // - if($this->db->affectedRows() !== 1) { - throw new exception('MODEL_SAVE_CREATE_FAILED', exception::$ERRORLEVEL_ERROR); + /** + * [getAggregateQueryComponents description] + * @param array &$appliedFilters [description] + * @return array [description] + * @throws exception + */ + public function getAggregateQueryComponents(array &$appliedFilters = []): array + { + $aggregate = $this->getFilters($this->aggregateFilter, [], [], $appliedFilters); + + // get filters from nested models recursively + foreach ($this->nestedModels as $join) { + if ($this->compatibleJoin($join->model)) { + $aggregate = array_merge($aggregate, $join->model->getAggregateQueryComponents($appliedFilters)); } } - return $this; + + return $aggregate; } /** - * performs a create or replace (update) - * @param array $data [description] - * @return \codename\core\model [this instance] + * Converts the given array of model_plugin_order instances to the ORDER BY... query string + * @param array $orders + * @return string + * @throws exception */ - public function replace(array $data) : \codename\core\model { - $params = []; - $query = $this->saveCreate($data, $params, true); // saveCreate with $replace = true - $this->doQuery($query, $params); - return $this; + protected function getOrders(array $orders): string + { + // defaults + $order = ''; + $appliedOrders = 0; + + // order fields + foreach ($orders as $myOrder) { + $order .= ($appliedOrders > 0) ? ', ' : ' ORDER BY '; + + $schema = $myOrder->field->getSchema(); + $table = $myOrder->field->getTable(); + $field = $myOrder->field->get(); + + $specifier = []; + if ($schema && $table) { + $specifier[] = $this->getServicingSqlInstance()->getTableIdentifierParametrized($schema, $table); + } elseif ($table) { + $specifier[] = $table; + } else { + // might be local alias + } + $specifier[] = $this->wrapIdentifier($field); + + $order .= implode('.', $specifier) . ' ' . $myOrder->direction . ' '; + $appliedOrders++; + } + + return $order; } /** - * performs an update using the current filters and a given data array - * @param array $data [description] - * @return \codename\core\model [this instance] - */ - public function update(array $data) { - if(count($this->filter) == 0) { - throw new exception('EXCEPTION_MODEL_SCHEMATIC_SQL_UPDATE_NO_FILTERS_DEFINED', exception::$ERRORLEVEL_FATAL); - } - $query = 'UPDATE ' . $this->getTableIdentifier() .' SET '; - $parts = []; - - $param = array(); - foreach ($this->config->get('field') as $field) { - if(in_array($field, array($this->getPrimarykey(), $this->table . "_modified", $this->table . "_created"))) { - continue; - } - - // If it exists, set the field - if(array_key_exists($field, $data)) { - - if (is_object($data[$field]) || is_array($data[$field])) { - $data[$field] = $this->jsonEncode($data[$field]); - } - - $var = $this->getStatementVariable(array_keys($param), $field); - - // performance hack: store modelfield instance! - if(!isset($this->modelfieldInstance[$field])) { - $this->modelfieldInstance[$field] = \codename\core\value\text\modelfield::getInstance($field); - } - $fieldInstance = $this->modelfieldInstance[$field]; - - $param[$var] = $this->getParametrizedValue($this->delimit($fieldInstance, $data[$field]), $this->getFieldtype($fieldInstance)); - $parts[] = $field . ' = ' . ':'.$var; - } - } - - if($this->saveUpdateSetModifiedTimestamp) { - $parts[] = $this->table . "_modified = ".$this->getServicingSqlInstance()->getSaveUpdateSetModifiedTimestampStatement($this); - } - $query .= implode(',', $parts); - - // $params = array(); - $filterQuery = $this->getFilterQuery($param); - - // - // query the datasets's pkey identifiers that are to-be-updated - // and submit each to timemachine - // - if($this->useTimemachine()) { - $timemachineQuery = "SELECT {$this->getPrimaryKey()} FROM " . $this->getTableIdentifier() . ' '; - // NOTE: we have to use a separate array for this - // as we're also storing bound params of the update data in $param above - $timemachineFilterQueryParams = []; - $timemachineFilterQuery = $this->getFilterQuery($timemachineFilterQueryParams); - $timemachineQuery .= $timemachineFilterQuery; - $timemachineQueryResponse = $this->internalQuery($timemachineQuery, $timemachineFilterQueryParams); - $timemachineResult = $this->db->getResult(); - $pkeyValues = array_column($timemachineResult, $this->getPrimaryKey()); - - $tm = \codename\core\timemachine::getInstance($this->getIdentifier()); - foreach($pkeyValues as $id) { - $tm->saveState($id, $data); // supply data to be changed for each entry - } - } - - $query .= $filterQuery; - $this->doQuery($query, $param); - - return $this; + * Converts the given instance of model_plugin_limit to the LIMIT... query string + * @param limit $limit + * @return string + */ + protected function getLimit(limit $limit): string + { + if ($limit->limit > 0) { + return " LIMIT " . $limit->limit . " "; + } + return ''; } /** - * [clearCache description] - * @param string $cacheGroup [description] - * @param string $cacheKey [description] - * @return void + * Converts the given instance of model_plugin_offset to the OFFSET... query string + * @param offset $offset + * @return string */ - protected function clearCache(string $cacheGroup, string $cacheKey) { - $cacheObj = app::getCache(); - $cacheObj->clearKey($cacheGroup, $cacheKey); - return; + protected function getOffset(offset $offset): string + { + if ($offset->offset > 0) { + return " OFFSET " . $offset->offset . " "; + } + return ''; } /** * * {@inheritDoc} + * @param mixed|null $primaryKey + * @return model + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception * @see \codename\core\modelInterface::delete($primaryKey) */ - public function delete($primaryKey = null) : \codename\core\model { - if(!is_null($primaryKey)) { - - // TODO: remove/re-write - if(strpos(get_class($this), 'activitystream') == false) { - app::writeActivity("MODEL_DELETE", get_class($this), $primaryKey); - } - + public function delete(mixed $primaryKey = null): model + { + if (!is_null($primaryKey)) { $this->deleteChildren($primaryKey); - if($this->useTimemachine()) { - $tm = \codename\core\timemachine::getInstance($this->getIdentifier()); - $tm->saveState($primaryKey, [], true); // supply empty array and deletion flag + if ($this->useTimemachine()) { + $tm = timemachine::getInstance($this->getIdentifier()); + $tm->saveState($primaryKey, [], true); // supply empty array and deletion flag } - $query = "DELETE FROM " . $this->getTableIdentifier() . " WHERE " . $this->getPrimarykey() . " = " . $primaryKey; + $query = "DELETE FROM " . $this->getTableIdentifier() . " WHERE " . $this->getPrimaryKey() . " = " . $primaryKey; $this->doQuery($query); return $this; } - if(count($this->filter) == 0) { + if (count($this->filter) == 0) { throw new exception('EXCEPTION_MODEL_SCHEMATIC_SQL_DELETE_NO_FILTERS_DEFINED', exception::$ERRORLEVEL_FATAL); } - // - // Old method: get all entries and delete them in separate queries - // - /* $entries = $this->addField($this->getPrimarykey())->search()->getResult(); - - foreach($entries as $entry) { - $this->delete($entry[$this->getPrimarykey()]); - } */ - // // New method: use the filterquery to construct a single query delete statement // @@ -2096,26 +1939,26 @@ public function delete($primaryKey = null) : \codename\core\model { // prepare an array for values to submit as PDO statement parameters // done by-ref, so the values are arriving right here after // running getFilterQuery() - $params = array(); + $params = []; - // pre-fetch filterquery for regular query and timemachine + // pre-fetch filterquery for a regular query and timemachine $filterQuery = $this->getFilterQuery($params); // - // query the datasets's pkey identifiers that are to-be-deleted + // query the dataset's pkey identifiers that are to-be-deleted // and submit each to timemachine // - if($this->useTimemachine()) { - $timemachineQuery = "SELECT {$this->getPrimaryKey()} FROM " . $this->getTableIdentifier() . ' '; - $timemachineQuery .= $filterQuery; - $timemachineQueryResponse = $this->internalQuery($timemachineQuery, $params); - $timemachineResult = $this->db->getResult(); - $pkeyValues = array_column($timemachineResult, $this->getPrimaryKey()); - - $tm = \codename\core\timemachine::getInstance($this->getIdentifier()); - foreach($pkeyValues as $id) { - $tm->saveState($id, [], true); // supply empty array and deletion flag - } + if ($this->useTimemachine()) { + $timemachineQuery = "SELECT {$this->getPrimaryKey()} FROM " . $this->getTableIdentifier() . ' '; + $timemachineQuery .= $filterQuery; + $this->internalQuery($timemachineQuery, $params); + $timemachineResult = $this->db->getResult(); + $pkeyValues = array_column($timemachineResult, $this->getPrimaryKey()); + + $tm = timemachine::getInstance($this->getIdentifier()); + foreach ($pkeyValues as $id) { + $tm->saveState($id, [], true); // supply empty array and deletion flag + } } $query .= $filterQuery; @@ -2125,842 +1968,782 @@ public function delete($primaryKey = null) : \codename\core\model { } /** - * * {@inheritDoc} - * @see \codename\core\model_interface::copy($primaryKey) + * @param string $query + * @param array $params + * @throws ReflectionException + * @throws exception */ - public function copy($primaryKey) : \codename\core\model { - + protected function internalQuery(string $query, array $params = []): void + { + // perform internal query + $this->db->query($query, $params); } /** - * - * @param string $operator - * @return \codename\core\model\schematic\postgresql + * the current database connection instance + * @return database [description] */ - public function setOperator(string $operator) : \codename\core\model { - $this->filterOperator = $operator; - return $this; + public function getConnection(): database + { + return $this->db; } /** - * returns a PDO variable name - * that is kept safe from duplicates - * using recursive calls to this function - * - * @param array $existingKeys [array of already existing variable names] - * @param string $field [the field base name] - * @param string $add [what is added to the base name] - * @param int $c [some extra factor (counter)] - * @return string [variable name] + * enables overriding/setting the connection + * @param database $db [description] */ - protected function getStatementVariable(array $existingKeys, string $field, string $add = '', int $c = 0) { - if($c === 0) { - $baseName = \str_replace('.', '_dot_', $field . (($add != '') ? ('_' . $add) : '')); - $baseName = \preg_replace('/[^\w]+/', '_', $baseName); - } else { - $baseName = $field; - } - // if($c === 0) { - // $name = preg_replace('/[^\w]+/', '_', $name); - // } - // $name = str_replace('.', '_dot_', $field . (($add != '') ? ('_' . $add) : '') . (($c > 0) ? ('_' . $c) : '')); - $name = $baseName . (($c > 0) ? ('_' . $c) : ''); - if(\in_array($name, $existingKeys)) { - return $this->getStatementVariable($existingKeys, $baseName, $add, ++$c); - } - return $name; + public function setConnectionOverride(database $db): void + { + $this->db = $db; } /** - * [EXCEPTION_SQL_GETFILTERS_INVALID_QUERY description] - * @var string + * {@inheritDoc} + * @return int + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception */ - const EXCEPTION_SQL_GETFILTERS_INVALID_QUERY = 'EXCEPTION_SQL_GETFILTERS_INVALID_QUERY'; + public function getCount(): int + { + // + // Russian Caviar Begin + // HACK/WORKAROUND for shrinking count-only-queries. + // + $this->countingModeOverride = true; + + $this->search(); + $count = $this->db->getResult()[0]['___count'] ?? 0; + + // + // Russian Caviar End + // + $this->countingModeOverride = false; + return $count; + } /** - * Converts the given array of model_plugin_filter instances to the WHERE... query string. - * Is capable of using $flagfilters for binary operations - * Handles named filtercollection groups - * the respective filtercollection(s) (and their filters) - * - * returns a recursive array structure that can be converted to a query string - * - * @param array $filters [array of filters] - * @param array $flagfilters [array of flagfilters] - * @param array $filterCollections [array of filter collections] - * @param array &$appliedFilters [cross-model-instance array of currently applied filters, to keep track of PDO variables] - * @param string|null $currentAlias [current table alias provided during query time] - * @return array + * {@inheritDoc} */ - public function getFilters(array $filters = array(), array $flagfilters = array(), array $filterCollections = array(), array &$appliedFilters = array(), string $currentAlias = null) : array { + public function reset(): void + { + if (!$this->countingModeOverride) { + parent::reset(); + } else { + // do not reset everything if we're in special counting mode. + // reset errorstack. + $this->errorstack->reset(); + } + } - $where = []; + /** + * {@inheritDoc} + */ + public function addUseIndex(array $fields): model + { + $fieldString = (count($fields) === 1 ? $fields[0] : implode(',', $fields)); + $this->useIndex = ['index_' . md5($fieldString)]; + // $this->useIndex = array_values(array_unique($this->useIndex)); + return $this; + } - // Loop through each filter - foreach($filters as $filter) { + /** + * performs a create or replace (update) + * @param array $data [description] + * @return model [this instance] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function replace(array $data): model + { + $params = []; + $query = $this->saveCreate($data, $params, true); // saveCreate with $replace = true + $this->doQuery($query, $params); + return $this; + } - // collect data for a single filter - $filterQuery = [ - 'conjunction' => $filter->conjunction ?? $this->filterOperator, - 'query' => null - ]; + /** + * performs an update using the current filters and a given data array + * @param array $data [description] + * @return model [this instance] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function update(array $data): model + { + if (count($this->filter) == 0) { + throw new exception('EXCEPTION_MODEL_SCHEMATIC_SQL_UPDATE_NO_FILTERS_DEFINED', exception::$ERRORLEVEL_FATAL); + } + $query = 'UPDATE ' . $this->getTableIdentifier() . ' SET '; + $parts = []; - if($filter instanceof \codename\core\model\plugin\filter\filterInterface) { - // handle regular filters + $param = []; + foreach ($this->config->get('field') as $field) { + if (in_array($field, [$this->getPrimaryKey(), $this->table . "_modified", $this->table . "_created"])) { + continue; + } - $filterFieldIdentifier = null; - if($filter instanceof \codename\core\model\plugin\filter) { - if(($schema = $filter->field->getSchema()) && ($table = $filter->field->getTable())) { - // explicit, fully qualified schema & table - $fullQualifier = $this->getTableIdentifier($schema, $table); - $filterFieldIdentifier = $filter->getFieldValue($fullQualifier); - } else { - $filterFieldIdentifier = $filter->getFieldValue($currentAlias); + // If it exists, set the field + if (array_key_exists($field, $data)) { + if (is_object($data[$field]) || is_array($data[$field])) { + $data[$field] = $this->jsonEncode($data[$field]); } - } else { - $filterFieldIdentifier = $filter->getFieldValue($currentAlias); - } - - if(\is_array($filter->value)) { - // filter value is an array (e.g. IN() match) - $values = array(); - $i = 0; - foreach($filter->value as $thisval) { - $var = $this->getStatementVariable(\array_keys($appliedFilters), $filterFieldIdentifier, $i++); - $values[] = ':' . $var; // var = PDO Param - $appliedFilters[$var] = $this->getParametrizedValue($this->delimit($filter->field, $thisval), $this->getFieldtype($filter->field) ?? $this->getFallbackDatatype($thisval)); // values separated from query - } - $string = implode(', ', $values); - $operator = $filter->operator == '=' ? 'IN' : 'NOT IN'; - $filterQuery['query'] = $filterFieldIdentifier . ' ' . $operator . ' ( ' . $string . ') '; - } else { - - // filter value is a singular value - // NOTE: $filter->value == 'null' (equality operator, compared to string) may evaluate to TRUE if you're passing in a positive boolean (!) - // instead, we're now using the identity operator === to explicitly check for a string 'null' - // NOTE: $filter->value == null (equality operator, compared to NULL) may evaluate to TRUE if you're passing in a negative boolean (!) - // instead, we're now using the identity operator === to explicitly check for a real NULL - // @see http://www.php.net/manual/en/types.comparisons.php - - // CHANGED 2020-12-30 removed \is_string($filter->value) && \strlen($filter->value) == 0 || $filter->value === 'null' - // Which converted '' or 'null' to NULL - which is simply wrong or legacy code. - if($filter->value === null) { - // $var = $this->getStatementVariable(array_keys($appliedFilters), $filter->field->getValue()); - $filterQuery['query'] = $filterFieldIdentifier . ' ' . ($filter->operator == '!=' ? 'IS NOT' : 'IS') . ' NULL'; // no param! - // $appliedFilters[$var] = $this->getParametrizedValue(null, $this->getFieldtype($filter->field)); - } else { - $var = $this->getStatementVariable(\array_keys($appliedFilters), $filterFieldIdentifier); - $filterQuery['query'] = $filterFieldIdentifier . ' ' . $filter->operator . ' ' . ':'.$var.' '; // var = PDO Param - $appliedFilters[$var] = $this->getParametrizedValue($filter->value, $this->getFieldtype($filter->field) ?? 'text'); // values separated from query - } - } - } else if($filter instanceof \codename\core\model\plugin\filterlist\filterlistInterface) { - $string = \is_array($filter->value) ? \implode(',', $filter->value) : $filter->value; - - if(\strlen($string) !== 0) { - if(!\preg_match('/^([0-9,]+)$/i',$string)) { - throw new exception(self::EXCEPTION_SQL_GETFILTERS_INVALID_QUERY_VALUE, exception::$ERRORLEVEL_ERROR, $filter); + + $var = $this->getStatementVariable(array_keys($param), $field); + + // performance hack: store modelfield instance! + if (!isset($this->modelfieldInstance[$field])) { + $this->modelfieldInstance[$field] = modelfield::getInstance($field); } - $operator = $filter->operator == '=' ? 'IN' : 'NOT IN'; - $filterQuery['query'] = $filter->getFieldValue($currentAlias) . ' ' . $operator . ' (' . $string . ') '; - } else { - $filterQuery['query'] = 'false'; - } - - } else if ($filter instanceof \codename\core\model\plugin\fieldfilter) { - // handle field-based filters - // this is not something PDO needs separately transmitted variables for - // value IS indeed a field name - // TODO: provide getFieldValue($tableAlias) also for fieldfilters - $filterQuery['query'] = $filter->getLeftFieldValue($currentAlias) . ' ' . $filter->operator . ' ' . $filter->getRightFieldValue($currentAlias); - } else if($filter instanceof \codename\core\model\plugin\managedFilterInterface) { - $variableNames = $filter->getFilterQueryParameters(); - $variableNameMap = []; - foreach($variableNames as $vName => $vValue) { - $variableNameMap[$vName] = $this->getStatementVariable(\array_keys($appliedFilters), $vName); - $appliedFilters[$variableNameMap[$vName]] = $this->getParametrizedValue($vValue, ''); - } - $filterQuery['query'] = $filter->getFilterQuery($variableNameMap, $currentAlias); - } + $fieldInstance = $this->modelfieldInstance[$field]; - // only handle, if query set - if($filterQuery['query'] != null) { - $where[] = $filterQuery; - } else { - throw new exception(self::EXCEPTION_SQL_GETFILTERS_INVALID_QUERY, exception::$ERRORLEVEL_ERROR, $filter); + $param[$var] = $this->getParametrizedValue($this->delimit($fieldInstance, $data[$field]), $this->getFieldtype($fieldInstance)); + $parts[] = $field . ' = ' . ':' . $var; } } - // handle flag filters (bit-oriented) - foreach($flagfilters as $flagfilter) { - - // collect data for a single filter - $filterQuery = [ - 'conjunction' => $flagfilter->conjunction ?? $this->filterOperator, - 'query' => null - ]; - - $flagVar1 = $this->getStatementVariable(array_keys($appliedFilters), $this->table.'_flag'); - $appliedFilters[$flagVar1] = null; // temporary dummy value - $flagVar2 = $this->getStatementVariable(array_keys($appliedFilters), $this->table.'_flag'); - $appliedFilters[$flagVar2] = null; // temporary dummy value - - if($flagfilter < 0) { - $filterQuery['query'] = $this->table.'_flag & ' . ':'.$flagVar1 . ' <> ' . ':'.$flagVar2 . ' '; // var = PDO Param - $appliedFilters[$flagVar1] = $this->getParametrizedValue($flagfilter * -1, 'number_natural'); // values separated from query - $appliedFilters[$flagVar2] = $this->getParametrizedValue($flagfilter * -1, 'number_natural'); // values separated from query - } else { - $filterQuery['query'] = $this->table.'_flag & ' . ':'.$flagVar1 . ' = ' . ':'.$flagVar2 . ' '; // var = PDO Param - $appliedFilters[$flagVar1] = $this->getParametrizedValue($flagfilter, 'number_natural'); // values separated from query - $appliedFilters[$flagVar2] = $this->getParametrizedValue($flagfilter, 'number_natural'); // values separated from query - } - - // we don't have to check for existance of 'query', as it is definitely handled - // by the previous if-else clause - $where[] = $filterQuery; + if ($this->saveUpdateSetModifiedTimestamp) { + $parts[] = $this->table . "_modified = " . $this->getServicingSqlInstance()->getSaveUpdateSetModifiedTimestampStatement($this); } + $query .= implode(',', $parts); - // collect groups of filter(collections) - $t_filtergroups = array(); - - // Loop through each named group - foreach($filterCollections as $groupName => $groupFilterCollection) { - - // handle grouping of filtercollections - // by default, there's only a single group ( e.g. 'default' ) - $t_groups = array(); - - // Loop through each group member (which is a filtercollection) in a named group - foreach($groupFilterCollection as $filterCollection) { - - // collect filters in a filtercollection - $t_filters = array(); - - // Loop through each filter in a filtercollection in a named group - foreach($filterCollection['filters'] as $filter) { - - // collect data for a single filter - $t_filter = [ - 'conjunction' => $filterCollection['operator'], - 'query' => null - ]; - - if($filter instanceof \codename\core\model\plugin\filter\filterInterface) { - - $filterFieldIdentifier = null; - if($filter instanceof \codename\core\model\plugin\filter) { - if(($schema = $filter->field->getSchema()) && ($table = $filter->field->getTable())) { - // explicit, fully qualified schema & table - $fullQualifier = $this->getTableIdentifier($schema, $table); - $filterFieldIdentifier = $filter->getFieldValue($fullQualifier); - } else { - $filterFieldIdentifier = $filter->getFieldValue($currentAlias); - } - } else { - $filterFieldIdentifier = $filter->getFieldValue($currentAlias); - } - - if(\is_array($filter->value)) { - // value is an array - $values = array(); - $i = 0; - foreach($filter->value as $thisval) { - $var = $this->getStatementVariable(\array_keys($appliedFilters), $filterFieldIdentifier, $i++); - $values[] = ':' . $var; // var = PDO Param - $appliedFilters[$var] = $this->getParametrizedValue($this->delimit($filter->field, $thisval), $this->getFieldtype($filter->field) ?? $this->getFallbackDatatype($thisval)); - } - $string = implode(', ', $values); - $operator = $filter->operator == '=' ? 'IN' : 'NOT IN'; - $t_filter['query'] = $filterFieldIdentifier . ' ' . $operator . ' ( ' . $string . ') '; - } else { - // value is a singular value - // NOTE: see other $filter->value == null (equality or identity operator) note and others - // CHANGED 2020-12-30 removed \is_string($filter->value) && \strlen($filter->value) == 0 || $filter->value === 'null' - // Which converted '' or 'null' to NULL - which is simply wrong or legacy code. - if($filter->value === null) { - // $var = $this->getStatementVariable(array_keys($appliedFilters), $filter->field->getValue()); - $t_filter['query'] = $filterFieldIdentifier . ' ' . ($filter->operator == '!=' ? 'IS NOT' : 'IS') . ' NULL'; // var = PDO Param - // $appliedFilters[$var] = $this->getParametrizedValue(null, $this->getFieldtype($filter->field)); - } else { - $var = $this->getStatementVariable(\array_keys($appliedFilters), $filterFieldIdentifier); - $t_filter['query'] = $filterFieldIdentifier . ' ' . $filter->operator . ' ' . ':'.$var.' '; - $appliedFilters[$var] = $this->getParametrizedValue($filter->value, $this->getFieldtype($filter->field) ?? 'text'); - } - } - } else if($filter instanceof \codename\core\model\plugin\managedFilterInterface) { - $variableNames = $filter->getFilterQueryParameters(); - $variableNameMap = []; - foreach($variableNames as $vName => $vValue) { - $variableNameMap[$vName] = $this->getStatementVariable(\array_keys($appliedFilters), $vName); - $appliedFilters[$variableNameMap[$vName]] = $this->getParametrizedValue($vValue, ''); - } - $t_filter['query'] = $filter->getFilterQuery($variableNameMap, $currentAlias); - } + // $params = []; + $filterQuery = $this->getFilterQuery($param); - if($t_filter['query'] != null) { - $t_filters[] = $t_filter; - } else { - throw new exception(self::EXCEPTION_SQL_GETFILTERS_INVALID_QUERY, exception::$ERRORLEVEL_ERROR, $filter); - } + // + // query the dataset's pkey identifiers that are to-be-updated + // and submit each to timemachine + // + if ($this->useTimemachine()) { + $timemachineQuery = "SELECT {$this->getPrimaryKey()} FROM " . $this->getTableIdentifier() . ' '; + // NOTE: we have to use a separate array for this + // as we're also storing bound params of the update data in $param above + $timemachineFilterQueryParams = []; + $timemachineFilterQuery = $this->getFilterQuery($timemachineFilterQueryParams); + $timemachineQuery .= $timemachineFilterQuery; + $this->internalQuery($timemachineQuery, $timemachineFilterQueryParams); + $timemachineResult = $this->db->getResult(); + $pkeyValues = array_column($timemachineResult, $this->getPrimaryKey()); + + $tm = timemachine::getInstance($this->getIdentifier()); + foreach ($pkeyValues as $id) { + $tm->saveState($id, $data); // supply data to be changed for each entry } + } - if(\count($t_filters) > 0) { - // put all collected filters - // into a recursive array structure - $t_groups[] = [ - 'conjunction' => $filterCollection['conjunction'] ?? $this->filterOperator, - 'query' => $t_filters - ]; - } - } + $query .= $filterQuery; + $this->doQuery($query, $param); - // put all collected filtercollections in the named group - // into a recursive array structure - $t_filtergroups[] = [ - 'group_name' => $groupName, - 'conjunction' => $this->filterOperator, - 'query' => $t_groups - ]; - } + return $this; + } - if(\count($t_filtergroups) > 0) { - // put all collected named groups - // into a recursive array structure - $where[] = [ - 'conjunction' => $this->filterOperator, - 'query' => $t_filtergroups - ]; - } + /** + * + * {@inheritDoc} + * @see \codename\core\model_interface::copy($primaryKey) + */ + public function copy(mixed $primaryKey): model + { + return $this; + } - // // get filters from nested models recursively - // foreach($this->nestedModels as $join) { - // if($this->compatibleJoin($join->model)) { - // $where = array_merge($where, $join->model->getFilterQueryComponents($appliedFilters)); - // } - // } + /** + * + * @param string $operator + * @return postgresql + */ + public function setOperator(string $operator): model + { + $this->filterOperator = $operator; + return $this; + } - // return a recursive array structure - // that contains all collected - // - filters (filters, flagfilters, fieldfilters) - // - named groups, containing - // --- filtercollections, and their - // ----- filters - // everything with their conjunction parameter (AND/OR) - // which is constructed on need in ::convertFilterQueryArray() - return $where; + /** + * [setSaveLastFilterQueryComponents description] + * @param bool $state [description] + */ + public function setSaveLastFilterQueryComponents(bool $state): void + { + $this->saveLastFilterQueryComponents = $state; } /** - * [EXCEPTION_SQL_GETFILTERS_INVALID_QUERY_VALUE description] - * @var string + * [getLastFilterQueryComponents description] + * @return array|null */ - const EXCEPTION_SQL_GETFILTERS_INVALID_QUERY_VALUE = 'EXCEPTION_SQL_GETFILTERS_INVALID_QUERY_VALUE'; + public function getLastFilterQueryComponents(): ?array + { + return $this->lastFilterQueryComponents; + } /** - * [getFilterQuery description] - * @param array &$appliedFilters [reference array containing used filters] - * @param string|null $mainAlias [provide an alias for the main table] - * @return string + * {@inheritDoc} + * @see \codename\core\model_interface::withFlag($flagval) */ - public function getFilterQuery(array &$appliedFilters = array(), ?string $mainAlias = null) : string { - - // provide pseudo main table alias, if needed - $filterQueryArray = $this->getFilterQueryComponents($appliedFilters, $mainAlias); - - - // - // HACK: re-join filter groups - // - $grouped = []; - $ungrouped = []; - foreach($filterQueryArray as $part) { - if(\is_array($part['query'])) { - // - // restructure part - // - $rePart = [ - 'conjunction' => $part['conjunction'], - 'query' => [] - ]; - foreach($part['query'] as $queryComponent) { - if($queryComponent['group_name'] ?? false) { - $grouped[$queryComponent['group_name']] = $grouped[$queryComponent['group_name']] ?? []; - $grouped[$queryComponent['group_name']]['group_name'] = $queryComponent['group_name']; - $grouped[$queryComponent['group_name']]['conjunction'] = $queryComponent['conjunction']; // this resets it every time - $grouped[$queryComponent['group_name']]['query'] = array_merge($grouped[$queryComponent['group_name']]['query'] ?? [], $queryComponent['query']); - } else { - $rePart['query'][] = $queryComponent; - } - } - if(\count($rePart['query']) > 0) { - $ungrouped[] = $rePart; - } - } else { - $ungrouped[] = $part; + public function withFlag(int $flagval): model + { + if (!in_array($flagval, $this->flagfilter)) { + $this->flagfilter[] = $flagval; } - } - - $filterQueryArray = array_merge($ungrouped, array_values($grouped)); + return $this; + } + /** + * {@inheritDoc} + */ + public function withoutFlag(int $flagval): model + { + $flagval = $flagval * -1; + if (!in_array($flagval, $this->flagfilter)) { + $this->flagfilter[] = $flagval; + } + return $this; + } - if($this->saveLastFilterQueryComponents) { - $this->lastFilterQueryComponents = $filterQueryArray; - } - if(count($filterQueryArray) > 0) { - return ' WHERE ' . self::convertFilterQueryArray($filterQueryArray); - } else { - return ''; - } + /** + * + * {@inheritDoc} + * @see \codename\core\model_interface::withDefaultFlag($flagval) + */ + public function withDefaultFlag(int $flagval): model + { + if (!in_array($flagval, $this->defaultflagfilter)) { + $this->defaultflagfilter[] = $flagval; + } + $this->flagfilter = array_merge($this->defaultflagfilter, $this->flagfilter); + return $this; } /** - * [protected description] - * @var array + * {@inheritDoc} */ - protected $lastFilterQueryComponents = null; + public function withoutDefaultFlag(int $flagval): model + { + $flagval = $flagval * -1; + if (!in_array($flagval, $this->defaultflagfilter)) { + $this->defaultflagfilter[] = $flagval; + } + $this->flagfilter = array_merge($this->defaultflagfilter, $this->flagfilter); + return $this; + } /** - * [protected description] - * @var bool + * {@inheritDoc} + * @param string $transactionName + * @throws exception */ - protected $saveLastFilterQueryComponents = false; + public function beginTransaction(string $transactionName): void + { + $this->db->beginVirtualTransaction($transactionName); + } /** - * [setSaveLastFilterQueryComponents description] - * @param bool $state [description] + * {@inheritDoc} + * @param string $transactionName + * @throws exception */ - public function setSaveLastFilterQueryComponents(bool $state) { - $this->saveLastFilterQueryComponents = $state; + public function endTransaction(string $transactionName): void + { + $this->db->endVirtualTransaction($transactionName); } /** - * [getLastFilterQueryComponents description] - * @return array|null + * {@inheritDoc} */ - public function getLastFilterQueryComponents() { - return $this->lastFilterQueryComponents; + protected function getCurrentCacheIdentifierParameters(): array + { + $params = parent::getCurrentCacheIdentifierParameters(); + // + // extend cache params by the virtual field result setting + // + $params['virtualfieldresult'] = $this->virtualFieldResult; + return $params; } /** - * [getFilterQueryComponents description] - * @param array &$appliedFilters [description] - * @param string|null $currentAlias + * {@inheritDoc} * @return array + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception */ - public function getFilterQueryComponents(array &$appliedFilters = array(), string $currentAlias = null) : array { - $where = $this->getFilters($this->filter, $this->flagfilter, $this->filterCollections, $appliedFilters, $currentAlias); - - // get filters from nested models recursively - foreach($this->nestedModels as $join) { - if($this->compatibleJoin($join->model)) { - $where = array_merge($where, $join->model->getFilterQueryComponents($appliedFilters, $join->currentAlias)); - } - } + protected function internalGetResult(): array + { + $result = $this->db->getResult(); + if ($this->virtualFieldResult) { + // echo("
" . print_r($result, true) . "
"); - return $where; - } + $tResult = $this->getVirtualFieldResult($result); - /** - * [getAggregateQueryComponents description] - * @param array &$appliedFilters [description] - * @return array [description] - */ - public function getAggregateQueryComponents(array &$appliedFilters = []) : array { - $aggregate = $this->getFilters($this->aggregateFilter, [], [], $appliedFilters); + $result = $this->normalizeRecursivelyByFieldlist($tResult); - // get filters from nested models recursively - foreach($this->nestedModels as $join) { - if($this->compatibleJoin($join->model)) { - $aggregate = array_merge($aggregate, $join->model->getAggregateQueryComponents($appliedFilters)); + // + // Root element virtual fields + // + if (count($this->virtualFields) > 0) { + foreach ($result as &$d) { + // NOTE: at the moment, we already handle virtual fields + // (e.g., a field added through ->addVirtualField) + // in ->getVirtualFieldResult(...) + // at the end, when we reached the original root structure again. + + // + // NOTE/CHANGED 2019-09-10: we now handle virtual fields for the root model right here + // as we wouldn't get normalized structure fields the way we did it before, + // + // we were handling virtual fields inside ::getVirtualFieldResult() + // Which DOES NOT normalize those fields - so, inside a virtualField callback, you'd get JSON strings + // instead of "real" object/array data + // + $d = $this->handleVirtualFields($d); + } + } } - } - - return $aggregate; + return $result; } /** - * [convertFilterQueryArray description] - * @param array $filterQueryArray [description] - * @return string [description] - */ - public static function convertFilterQueryArray(array $filterQueryArray) : string { - $queryPart = ''; - foreach($filterQueryArray as $index => $filterQuery) { - if($index > 0) { - $queryPart .= ' ' . $filterQuery['conjunction'] . ' '; - } - if(\is_array($filterQuery['query'])) { - $queryPart .= self::convertFilterQueryArray($filterQuery['query']); - } else { - $queryPart .= $filterQuery['query']; + * [getVirtualFieldResult description] + * @param array $result [the original resultset] + * @param array &$track [array keeping track of model index/instances] + * @param array $structure + * @param array $trackFields + * @return array [type] [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function getVirtualFieldResult(array $result, array &$track = [], array $structure = [], array &$trackFields = []): array + { + // Construct a field tracking array + // by diving into the whole structure beforehand + // one single time + if (count($trackFields) === 0) { + $this->populateTrackFieldsRecursive($trackFields); } - } - return '(' . $queryPart . ')'; - } - /** - * Converts the given array of model_plugin_order instances to the ORDER BY... query string - * @param array $orders - * @return string - */ - protected function getOrders(array $orders) : string { - // defaults - $order = ''; - $appliedOrders = 0; + foreach ($this->getNestedJoins() as $join) { + // + // NOTE/CHANGED 2020-09-15: exclude "incompatible" models from tracking + // this includes forced virtual joins, as they HAVE to be excluded + // to avoid an 'obiwan' or similar error - index-based re-association + // for virtual resultsets. We treat a forced virtual join as an 'incompatible' model / 'blackbox' + // + if ($this->compatibleJoin($join->model)) { + $track[$join->model->getIdentifier()][] = $join->model; + } - // order fields - foreach($orders as $myOrder) { - $order .= ($appliedOrders > 0) ? ', ' : ' ORDER BY '; - $identifier = array(); + if ($join->model instanceof virtualFieldResultInterface) { + $structureDive = []; - $schema = $myOrder->field->getSchema(); - $table = $myOrder->field->getTable(); - $field = $myOrder->field->get(); + // if virtualFieldResult enabled on this model, + // use vField config from join plugin + if ($this->virtualFieldResult) { + if ($join->virtualField) { + $structureDive = [$join->virtualField]; + } + } - $specifier = []; - if($schema && $table) { - $specifier[] = $this->getServicingSqlInstance()->getTableIdentifierParametrized($schema, $table); - } else if($table) { - $specifier[] = $table; + // + // NOTE/CHANGED 2020-09-15: handle (in-)compatible joins separately + // As stated above, this is for forced virtual joins - we have to treat them as 'incompatible' models + // to avoid index confusion. At this point, we reset the tracking/structure dive, + // as we're 'virtually' diving into a different model resultset + // + if ($this->compatibleJoin($join->model)) { + $result = $join->model->getVirtualFieldResult($result, $track, array_merge($structure, $structureDive), $trackFields); + } else { + // + // CHANGED 2021-04-13: kicked out, as it does not apply + // NOTE: we should keep an eye on this. + // At this point, we're not calling getVirtualFieldResult, as we either have + // - a completely different model technology + // - a forced virtual join + // - sth. else? + // + // >>> Those models handle their results for themselves. + // + // $result = $join->model->getVirtualFieldResult($result); + } + } + } + + // + // Re-normalizing joined data + // This is a completely different approach + // instead of iterating over all vField/children-supporting models + // We iterate over all models - as we have to handle mixed cases, too. + // + // CHANGED 2021-04-13, we include $this (current model) + // to propage/include renormalization for a root model + // (e.g., if joining the same model recursively) + // + $subjects = array_merge([$this], $this->getNestedJoins()); + + foreach ($subjects as $join) { + $vModel = null; + $virtualField = null; + // Re-name/alias the current join model instance + if ($join === $this) { + $vModel = $join; // this (model), root model renormalization } else { - // might be local alias + $vModel = $join->model; + if ($this->virtualFieldResult) { + // handle $join->virtualField + $virtualField = $join->virtualField; + } } - $specifier[] = $field; - $order .= implode('.', $specifier) . ' ' . $myOrder->direction . ' '; - $appliedOrders++; - } + $index = null; + if (count($indexes = array_keys($track[$vModel->getIdentifier()] ?? [], $vModel, true)) === 1) { + $index = $indexes[0]; + } else { + // What happens if we join the same model instance twice or more? + } - return $order; - } + if ($index === null) { + // index is still null -> model not found in currently nested models + // TODO: we might check for virtual field result or so? + // continue; + } + $vModelFieldlist = $vModel->getCurrentAliasedFieldlist(); + $fields = $vModelFieldlist; - /** - * Returns grouping components - * @author Kevin Dargel - * @return array - */ - public function getGroups(string $currentAlias = null) : array { - $groupArray = []; + // determine per-field indexes + // as we might join the same model + // with differing field lists + $fieldValueIndexes = []; - // group by fields - foreach($this->group as $group) { - if($group->aliased) { - $groupArray[] = $group->field->get(); - } else { - if(!$currentAlias) { - $groupArray[] = implode('.', array_filter([ - $group->field->getSchema() ?? null, - $group->field->getTable() ?? null, - $group->field->get() - ])); - } else { - $groupArray[] = implode('.', array_filter([ - $currentAlias, - $group->field->get() - ])); - } + foreach ($fields as $modelField) { + $index = null; + + if ($trackFields[$modelField] ?? false) { + if (count($trackFields[$modelField]) === 1) { + // There's only a single occurrence of this modelfield + // Index is being unset. + } elseif ($vModel->getFieldtype(modelfield::getInstance($modelField)) === 'virtual') { + // Avoid virtual fields + // as we're handling them differently + // And they're not SQL-native. + $index = false; // null; // QUESTION: or false? + } elseif (count($indexes = array_keys($trackFields[$modelField], $vModel, true)) === 1) { // NOTE/CHANGED: $vModel was $join->model before - which is an iteration variable from above! + // this is the expected field index + // when re-normalizing from a FETCH_NAMED PDO result + $index = $indexes[0]; + } + } + + $fieldValueIndexes[$modelField] = $index; } - } - foreach($this->getNestedJoins() as $join) { - if($join->model instanceof \codename\core\model\schematic\sql) { - $groupArray = array_merge($groupArray, $join->model->getGroups($join->currentAlias)); - } - } + // + // Iterate over each dataset of the result + // And apply index renormalization (reversing FETCH_NAMED-based array-style results) + // + foreach ($result as &$dataset) { + $vData = []; + + foreach ($fields as $modelField) { + if (($vIndex = $fieldValueIndexes[$modelField]) !== null) { + // DEBUG Just a test for vIndex === false when working on virtual field + // Doesn't work? + // NOTE: this might have an effect to unsetting virtual fields based on joins + // to NOT display if the respective models are not joined. Hopefully. + // Doesn't apply to the root model, AFAICS. + if ($vIndex === false) { + continue; + } + + // Use index reference determined above + $vData[$modelField] = $dataset[$modelField][$vIndex] ?? null; + } else { + // Use the field value + $vData[$modelField] = $dataset[$modelField] ?? null; + } + } - return $groupArray; - } + // Normalize the data against the respective vModel + $vData = $vModel->normalizeRow($vData); - /** - * Converts the given instance of model_plugin_limit to the LIMIT... query string - * @param \codename\core\model\plugin\limit $limit - * @return string - */ - protected function getLimit(\codename\core\model\plugin\limit $limit) : string { - if ($limit->limit > 0) { - return " LIMIT " . $limit->limit . " "; - } - return ''; - } + // Deep dive to set data in a sub-object path + // $structure might be [], which is simply the root level + $dive = &$dataset; + foreach ($structure as $key) { + $dive[$key] = $dive[$key] ?? []; + $dive = &$dive[$key]; + } - /** - * Converts the given instance of model_plugin_offset to the OFFSET... query string - * @param \codename\core\model\plugin\offset $offset - * @return string - */ - protected function getOffset(\codename\core\model\plugin\offset $offset) : string { - if ($offset->offset > 0) { - return " OFFSET " . $offset->offset . " "; + if ($virtualField !== null) { + // NOTE: Forward merging is bad for this case + // as an array_merge overwrites existing keys with the latter one + // in this case, $dive[$virtualField] contains partial data + // which we HAVE to overwrite, in regard to $vData + + $dive[$virtualField] = array_merge($vData, $dive[$virtualField] ?? []); + } else { + // NOTE: Forward merge + // as $vData contains new information to be overwritten in $dive, + // as far as applicable. See note above. + $dive = array_merge($dive ?? [], $vData); + } + + // handle custom virtual fields + // CHANGED 2019-06-05: we have to trigger virtual field handling + // AFTER diving, because we might be missing all the important fields... + // CHANGED 2020-11-13: we additionally have to check for vModel being 'compatible' + // e.g., JSON data model's virtual fields won't be handled here - causes bugs. + if ($this->compatibleJoin($vModel) && count($vModel->getVirtualFields()) > 0) { + if ($virtualField !== null) { + $dive[$virtualField] = $vModel->handleVirtualFields($dive[$virtualField]); + } else { + $dive = $vModel->handleVirtualFields($dive); + } + } + } } - return ''; - } - /** - * Converts the array of fields into the field list for the query "value1, value2 " - * @param array $fields - * @return string - */ - protected function getFieldlist(array $fields) : string { - $index = 0; - $text = ' '; - if(count($fields) > 0) { - foreach($fields as $field) { - if ($index > 0) { - $text .= ', '; + if (($children = $this->config->get('children')) != null) { + foreach ($children as $field => $config) { + if ($config['type'] === 'collection') { + // check for active collectionmodel / plugin + if (isset($this->collectionPlugins[$field])) { + $collection = $this->collectionPlugins[$field]; + $vModel = $collection->collectionModel; + + // determine to-be-used index for THIS model, as it is the base for the collection? + // $index = + $index = null; + + if ((!isset($track[$this->getIdentifier()])) || count($track[$this->getIdentifier()]) === 0) { + // + } elseif (count($indexes = array_keys($track[$this->getIdentifier()], $this, true)) === 1) { + $index = $indexes[0]; + } + + foreach ($result as &$dataset) { + $filterValue = ($index !== null && is_array($dataset[$collection->getBaseField()])) ? $dataset[$collection->getBaseField()][$index] : $dataset[$collection->getBaseField()]; + + $vModel->addFilter($collection->getCollectionModelBaseRefField(), $filterValue); + $vResult = $vModel->search()->getResult(); + + // new method: deep dive to set data + $dive = &$dataset; + foreach ($structure as $key) { + $dive[$key] = $dive[$key] ?? []; + $dive = &$dive[$key]; + } + $dive[$field] = $vResult; + } + } } - $text .= $field->field->get() . ' '; - $index++; + // TODO: Handle collections? } } - return $text; + + return $result; } /** - * custom wrapping override due to PG's case sensitivity - * @param string $identifier [description] - * @return string [description] + * {@inheritDoc} */ - protected function wrapIdentifier(string $identifier): string { - return $this->getServicingSqlInstance()->wrapIdentifier($identifier); + public function setVirtualFieldResult(bool $state): model + { + $this->virtualFieldResult = $state; + return $this; } /** - * retrieves the fieldlist of this model - * on a non-recursive basis - * - * @param string|null $alias [description] - * @param array &$params [description] - * @return array [description] + * [populateTrackFieldsRecursive description] + * @param array &$trackFields [description] + * @return void [type] [description] + * @throws ReflectionException + * @throws exception */ - protected function getCurrentFieldlistNonRecursive(string $alias = null, array &$params) : array { - $result = array(); - if(\count($this->fieldlist) == 0 && \count($this->hiddenFields) > 0) { - // - // Include all fields but specific ones - // - foreach($this->getFields() as $fieldName) { - if($this->config->get('datatype>'.$fieldName) !== 'virtual') { - if(!in_array($fieldName, $this->hiddenFields)) { - if($alias != null) { - $result[] = array($alias, $this->wrapIdentifier($fieldName)); - } else { - $result[] = array($this->getTableIdentifier($this->schema, $this->table), $this->wrapIdentifier($fieldName)); - } - } - } - } - } else { - if(count($this->fieldlist) > 0) { - // - // Explicit field list - // - foreach($this->fieldlist as $field) { - if($field instanceof \codename\core\model\plugin\calculatedfield\calculatedfieldInterface) { - - // - // custom field calculation - // - $result[] = array($field->get()); - - } else if($field instanceof \codename\core\model\plugin\aggregate\aggregateInterface) { - - // - // pre-defined aggregate function - // - $result[] = array($field->get($alias)); - } else if($field instanceof \codename\core\model\plugin\fulltext\fulltextInterface) { - - // - // pre-defined aggregate function - // - - $var = $this->getStatementVariable(array_keys($params), $field->getField(), '_ft'); - $params[$var] = $this->getParametrizedValue($field->getValue(), 'text'); - $result[] = array($field->get($var, $alias)); - - } else if($this->config->get('datatype>'.$field->field->get()) !== 'virtual' && (!in_array($field->field->get(), $this->hiddenFields) || $field->alias)) { - // - // omit virtual fields - // they're not part of the DB. - // - $fieldAlias = $field->alias !== null ? $field->alias->get() : null; - if($alias != null) { - if($fieldAlias) { - $result[] = [ $alias, $this->wrapIdentifier($field->field->get()) . ' AS ' . $this->wrapIdentifier($fieldAlias) ]; - } else { - $result[] = [ $alias, $this->wrapIdentifier($field->field->get()) ]; - } - } else { - if($fieldAlias) { - $result[] = [ $this->getTableIdentifier($field->field->getSchema() ?? $this->schema, $field->field->getTable() ?? $this->table), $this->wrapIdentifier($field->field->get()) . ' AS ' . $this->wrapIdentifier($fieldAlias) ]; - } else { - $result[] = [ $this->getTableIdentifier($field->field->getSchema() ?? $this->schema, $field->field->getTable() ?? $this->table), $this->wrapIdentifier($field->field->get()) ]; - } - } - } - } - - // - // add the rest of the data-model-defined fields - // as long as they're not hidden. - // - foreach($this->getFields() as $fieldName) { - if($this->config->get('datatype>'.$fieldName) !== 'virtual') { - if(!in_array($fieldName, $this->hiddenFields)) { - if($alias != null) { - $result[] = array($alias, $this->wrapIdentifier($fieldName)); - } else { - $result[] = array($this->getTableIdentifier($this->schema, $this->table), $this->wrapIdentifier($fieldName)); - } - } + protected function populateTrackFieldsRecursive(array &$trackFields): void + { + // Track this model + if (count($trackFields) === 0) { + $vModelFieldlist = $this->getCurrentAliasedFieldlist(); + foreach ($vModelFieldlist as $field) { + $trackFields[$field][] = $this; } - } - - // - // NOTE: - // array_unique can be used on arrays that contain objects or sub-arrays - // you need to use SORT_REGULAR for this case (!) - // - $result = array_unique($result, SORT_REGULAR); + } - } else { - // - // No explicit fieldlist - // No explicit hidden fields - // - if(count($this->hiddenFields) === 0) { - // - // The rest of the fields. Simply using a wildcard + foreach ($this->getNestedJoins() as $join) { + // for field tracking + // we have to make sure to only track + // 'compatible' models: + // - same DB/data technology + // - same DB/data connection* + // - not a forced virtual join + // - ... etc // - if($alias != null) { - $result[] = array($alias, '*'); - } else { - $result[] = array($this->getTableIdentifier($this->schema, $this->table), '*'); + // * = TODO: to be fully implemented. Not sure if we're doing it right, atm. + if ($this->compatibleJoin($join->model) && $join->model instanceof sql) { + $vModelFieldlist = $join->model->getCurrentAliasedFieldlist(); + foreach ($vModelFieldlist as $field) { + // + // exclude virtual fields? + // + if ($join->model->getFieldtype(modelfield::getInstance($field)) === 'virtual') { + continue; + } + + $trackFields[$field][] = $join->model; + } + + // NOTE: compatibility already checked above + $join->model->populateTrackFieldsRecursive($trackFields); } - } else { - // ugh? - } } - } - - return $result; } /** - * Returns the current fieldlist as an array of triples (schema, table, field) - * it contains the visible fields of all nested models - * retrieved in a recursive call - * this also respects hiddenFields - * - * @author Kevin Dargel - * @param string|null $alias [optional: alias as prefix for the following fields - table alias!] - * @param array &$params [optional: current pdo params, including values] - * @return array + * [normalizeRecursivelyByFieldlist description] + * @param array $result [description] + * @return array [description] + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception */ - protected function getCurrentFieldlist(string $alias = null, array &$params) : array { - - // CHANGED 2019-06-17: main functionality moved to ::getCurrentFieldlistNonRecursive - // as we also need it for each model, singularly in ::getVirtualFieldResult() - $result = $this->getCurrentFieldlistNonRecursive($alias, $params); + public function normalizeRecursivelyByFieldlist(array $result): array + { + $fResult = []; - foreach($this->nestedModels as $join) { - if($this->compatibleJoin($join->model)) { - $result = array_merge($result, $join->model->getCurrentFieldlist($join->currentAlias, $params)); - } - } + // + // normalize + // + foreach ($this->getNestedJoins() as $join) { + if ($join->virtualField) { + continue; + } - return $result; - } + /** + * FIXME @Kevin: Weil wegen Baum und sehr, sehr russisch + * @var [type] + */ + if ($join->model instanceof json) { + continue; + } + $normalized = $join->model->normalizeRecursivelyByFieldlist($result); - /** - * [protected description] - * @var int|string|null|bool - */ - protected $cachedLastInsertId = null; + // // METHOD 1: merge manually, row by row + foreach ($normalized as $index => $r) { + // normalize using this model + $fResult[$index] = array_merge(($fResult[$index] ?? []), $r); + } - /** - * returns the last inserted ID, if available - * @return string [description] - */ - public function lastInsertId () { - return $this->cachedLastInsertId; // $this->db->lastInsertId(); - } + // METHOD 2: recursive merge + // NOTE: Actually, this doesn't work right. + // It may split a model's result apart into two array elements in some cases. + // $fResult = array_merge_recursive($fResult, $join->model->normalizeRecursivelyByFieldlist($result)); + } - /** - * gets the current identifier of this model - * in this case (sql), this is the table name - * NOTE: schema is omitted here - * @return string [table name] - */ - public function getIdentifier() : string { - return $this->table; - } + // CHANGED 2021-03-13: build static fieldlist for normalization + // reduces calls to various array functions + // AND: fixes hidden field handling for certain use cases + $currentFieldlist = $this->getInternalIntersectFieldlist(); - /** - * {@inheritDoc} - * @see \codename\core\model_interface::withFlag($flagval) - */ - public function withFlag(int $flagval) : \codename\core\model { - if(!in_array($flagval, $this->flagfilter)) { - $this->flagfilter[] = $flagval; + // + // Normalize using this model's fields + // + foreach ($result as $index => $r) { + // normalize using this model + // CHANGED 2019-05-24: additionally call $this->normalizeRow around normalizeByFieldlist, + // otherwise we might run into issues, e.g. + // - "structure"-type fields are not json_decode'd, if present on the root model + // - ... other things? + // NOTE: as of 2019-09-10 the normalization of structure fields has changed + $fResult[$index] = array_merge(($fResult[$index] ?? []), $this->normalizeRow($this->normalizeByFieldlist($r, $currentFieldlist))); } - return $this; + + return $fResult; } /** - * @inheritDoc + * returns the internal list of fields + * to be expected in the output and used via array intersection + * NOTE: the returned result array is flipped! + * @return array [description] */ - public function withoutFlag(int $flagval): \codename\core\model + protected function getInternalIntersectFieldlist(): array { - $flagval = $flagval * -1; - if(!in_array($flagval, $this->flagfilter)) { - $this->flagfilter[] = $flagval; - } - return $this; - } + $fields = $this->getFields(); + if (count($this->hiddenFields) > 0) { + // remove hidden fields + $diff = array_diff($fields, $this->hiddenFields); + $fields = array_intersect($fields, $diff); + } + // VFR keys + $vfrKeys = []; + if ($this->virtualFieldResult) { + foreach ($this->getNestedJoins() as $join) { + if ($join->virtualField) { + $vfrKeys[] = $join->virtualField; + } + } + } - /** - * - * {@inheritDoc} - * @see \codename\core\model_interface::withDefaultFlag($flagval) - */ - public function withDefaultFlag(int $flagval) : \codename\core\model { - if(!in_array($flagval, $this->defaultflagfilter)) { - $this->defaultflagfilter[] = $flagval; + if (count($this->fieldlist) > 0) { + return array_flip(array_merge($this->getFieldlistArray($this->fieldlist), $fields, $vfrKeys, array_keys($this->virtualFields))); + } else { + return array_flip(array_merge($fields, array_keys($this->virtualFields), $vfrKeys)); } - $this->flagfilter = array_merge($this->defaultflagfilter, $this->flagfilter); - return $this; } /** - * @inheritDoc + * [normalizeByFieldlist description] + * @param array $dataset [description] + * @param array|null $fieldlist [optional, new: static fieldlist] + * @return array [description] */ - public function withoutDefaultFlag(int $flagval): \codename\core\model + public function normalizeByFieldlist(array $dataset, ?array $fieldlist = null): array { - $flagval = $flagval * -1; - if(!in_array($flagval, $this->defaultflagfilter)) { - $this->defaultflagfilter[] = $flagval; - } - $this->flagfilter = array_merge($this->defaultflagfilter, $this->flagfilter); - return $this; + if ($fieldlist) { + // CHANGED 2021-04-13: use provided fieldlist, see above + return array_intersect_key($dataset, $fieldlist); + } elseif (count($this->fieldlist) > 0) { + // return $dataset; + return array_intersect_key($dataset, array_flip(array_merge($this->getFieldlistArray($this->fieldlist), $this->getFields(), array_keys($this->virtualFields)))); + } else { + // return $dataset; + return array_intersect_key($dataset, array_flip(array_merge($this->getFields(), array_keys($this->virtualFields)))); + } } /** - * @inheritDoc + * [saveLog description] + * @param string $mode [description] + * @param array $data [description] + * @return void [type] [description] */ - public function beginTransaction(string $transactionName) + protected function saveLog(string $mode, array $data): void { - $this->db->beginVirtualTransaction($transactionName); } /** - * @inheritDoc + * Converts the array of fields into the field list for the query "value1, value2 " + * @param array $fields + * @return string */ - public function endTransaction(string $transactionName) + protected function getFieldlist(array $fields): string { - $this->db->endVirtualTransaction($transactionName); + $index = 0; + $text = ' '; + if (count($fields) > 0) { + foreach ($fields as $field) { + if ($index > 0) { + $text .= ', '; + } + $text .= $field->field->get() . ' '; + $index++; + } + } + return $text; } } diff --git a/backend/class/model/schematic/sqlite.php b/backend/class/model/schematic/sqlite.php index 85b879e..a188d2e 100644 --- a/backend/class/model/schematic/sqlite.php +++ b/backend/class/model/schematic/sqlite.php @@ -1,55 +1,18 @@ getServicingSqlInstance()->getTableIdentifier($this); - // } - - // /** - // * @inheritDoc - // */ - // protected function getCurrentFieldlistNonRecursive( - // string $alias = null, - // array &$params - // ): array { - // $value = parent::getCurrentFieldlistNonRecursive($alias, $params); - // - // $fields = []; - // foreach($value as $f) { - // if(count($f) === 3) { - // $fields[] = ["`{$f[0]}.$f[1]`", $f[2]]; - // } else { - // $fields[] = $f; - // } - // } - // - // return $fields; - // } + public const string DB_TYPE = 'sqlite'; } diff --git a/backend/class/model/schemeless.php b/backend/class/model/schemeless.php index bfd53ca..c769be3 100755 --- a/backend/class/model/schemeless.php +++ b/backend/class/model/schemeless.php @@ -1,11 +1,14 @@ config = new \codename\core\config([ - 'base_name' => $baseName, - 'base_class' => $baseClass, - 'namespace' => $namespace, - 'base_dir' => $baseDir - ]); - } +abstract class abstractModelModuleLoader extends json implements modelInterface +{ + /** + * {@inheritDoc} + */ + public function getPrimaryKey(): string + { + return 'module_name'; + } - /** - * @inheritDoc - */ - protected function loadConfig(): \codename\core\config - { - return new \codename\core\config([]); - } + /** + * [getModuleClass description] + * @param string $value [description] + * @return string [description] + * @throws ReflectionException + * @throws exception + */ + public function getModuleClass(string $value): string + { + return app::getInheritedClass($this->config->get('base_name') . '_' . $value); + } - /** - * @inheritDoc - */ - public function getPrimarykey() : string - { - return 'module_name'; - } + /** + * [setClassConfig description] + * @param string $baseName [description] + * @param string $baseClass [description] + * @param string $namespace [description] + * @param string $baseDir [description] + */ + protected function setClassConfig(string $baseName, string $baseClass, string $namespace, string $baseDir): void + { + $this->config = new config([ + 'base_name' => $baseName, + 'base_class' => $baseClass, + 'namespace' => $namespace, + 'base_dir' => $baseDir, + ]); + } - /** - * @inheritDoc - */ - protected function internalQuery(string $query, array $params = array()) - { - $classes = \codename\core\helper\classes::getImplementationsInNamespace( - $this->config->get('base_class'), - $this->config->get('namespace'), - $this->config->get('base_dir') - ); + /** + * {@inheritDoc} + */ + protected function loadConfig(): config + { + return new config([]); + } - $translateInstance = app::getTranslate(); + /** + * {@inheritDoc} + * @param string $query + * @param array $params + * @return array + * @throws ReflectionException + * @throws exception + */ + protected function internalQuery(string $query, array $params = []): array + { + $classes = classes::getImplementationsInNamespace( + $this->config->get('base_class'), + $this->config->get('namespace'), + $this->config->get('base_dir') + ); - $result = []; + $translateInstance = app::getTranslate(); - foreach($classes as $r) { - $name = $r['name']; - $class = app::getInheritedClass($this->config->get('base_name').'_'.$name); - $reflectionClass = (new \ReflectionClass($class)); + $result = []; - $displayName = null; - if($reflectionClass->implementsInterface('\\codename\\core\\model\\schemeless\\moduleLoaderInterface')) { - $displayName = $translateInstance->translate($class::getTranslationKey()); - } else { - $displayName = $name; - } + foreach ($classes as $r) { + $name = $r['name']; + $class = app::getInheritedClass($this->config->get('base_name') . '_' . $name); + $reflectionClass = (new ReflectionClass($class)); - $result[$name] = [ - 'module_name' => $name, - 'module_displayname' => $displayName - ]; - } + if ($reflectionClass->implementsInterface('\\codename\\core\\model\\schemeless\\moduleLoaderInterface')) { + $displayName = $translateInstance->translate($class::getTranslationKey()); + } else { + $displayName = $name; + } - if(count($this->filter) > 0) { - $result = $this->filterResults($result); - } + $result[$name] = [ + 'module_name' => $name, + 'module_displayname' => $displayName, + ]; + } - return $result; - } + if (count($this->filter) > 0) { + $result = $this->filterResults($result); + } - /** - * [getModuleClass description] - * @param string $value [description] - * @return string [description] - */ - public function getModuleClass(string $value) : string { - return app::getInheritedClass($this->config->get('base_name').'_'.$value); - } + uasort($result, function ($a, $b) { + if ($a['module_displayname'] === $b['module_displayname']) { + return 0; + } + return ($a['module_displayname'] < $b['module_displayname']) ? -1 : 1; + }); + return $result; + } } diff --git a/backend/class/model/schemeless/dynamic.php b/backend/class/model/schemeless/dynamic.php index ad1e480..8a7f3df 100644 --- a/backend/class/model/schemeless/dynamic.php +++ b/backend/class/model/schemeless/dynamic.php @@ -1,189 +1,214 @@ appname] - * @return model - * @todo refactor the constructor for no method args - */ - public function __CONSTRUCT(array $modeldata = array()) { - parent::__CONSTRUCT($modeldata); - $this->errorstack = new \codename\core\errorstack('VALIDATION'); - $this->appname = $this->modeldata->get('app') ?? app::getApp(); - return $this; - } - - /** - * [setConfig description] - * @param string $file [data source file, .json] - * @param string $name [model name for getting the config itself] - * @return model [description] - */ - public function setConfig(string $prefix, string $name, array $config = null) : model { - $this->prefix = $prefix; - $this->name = $name; - $this->config = $config ?? $this->loadConfig(); - return $this; - } - - /** - * loads a new config file (uncached) - * @return \codename\core\config - */ - protected function loadConfig() : \codename\core\config { - if($this->modeldata->exists('appstack')) { - return new \codename\core\config\json('config/model/' . $this->prefix . '_' . $this->name . '.json', true, false, $this->modeldata->get('appstack')); - } else { - return new \codename\core\config\json('config/model/' . $this->prefix . '_' . $this->name . '.json', true); - } - } - - /** - * @inheritDoc - */ - public function getIdentifier() : string - { - return $this->name; - } - - /** - * @inheritDoc - */ - public function search() : model - { - return $this; - } - - /** - * @inheritDoc - */ - protected function internalQuery(string $query, array $params = array()) - { - return; - } - - /** - * @inheritDoc - */ - public function delete($primaryKey = null) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function save(array $data) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function copy($primaryKey) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - protected function internalGetResult(): array - { - return $this->doQuery(''); - } - - /** - * @inheritDoc - */ - protected function compatibleJoin(\codename\core\model $model): bool - { - return false; // ? - } - - /** - * @inheritDoc - */ - protected function doQuery(string $query, array $params = array()) - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * [filterResults description] - * @param array $data [description] - * @return array [description] - */ - protected function filterResults(array $data) : array { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * [mapResults description] - * @param array $data [description] - * @return array [description] - */ - protected function mapResults(array $data) : array { - return $data; - } - - /** - * @inheritDoc - */ - public function withFlag(int $flagval) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function withoutFlag(int $flagval) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function withDefaultFlag(int $flagval) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function withoutDefaultFlag(int $flagval) : model - { - throw new \LogicException('Not implemented'); // TODO - } +class dynamic extends schemeless implements modelInterface +{ + /** + * Contains the driver to use for this model and the plugins + * @var string $type + */ + public const string DB_TYPE = 'dynamic'; + + /** + * I contain the name of the model to use + * @var string $name + */ + protected string $name = ''; + + /** + * I contain the prefix of the model to use + * @var string $prefix + */ + protected string $prefix = ''; + + /** + * Creates an instance + * @param array $modeldata [e.g., app => appname] + * @throws ReflectionException + * @throws exception + * @todo refactor the constructor for no method args + */ + public function __construct(array $modeldata = []) + { + parent::__construct($modeldata); + $this->errorstack = new errorstack('VALIDATION'); + $this->appname = $this->modeldata->get('app') ?? app::getApp(); + return $this; + } + + /** + * [setConfig description] + * @param string $prefix + * @param string $name [model name for getting the config itself] + * @param array|null $config + * @return model [description] + * @throws ReflectionException + * @throws exception + */ + public function setConfig(string $prefix, string $name, array $config = null): model + { + $this->prefix = $prefix; + $this->name = $name; + $this->config = $config ?? $this->loadConfig(); + return $this; + } + + /** + * loads a new config file (uncached) + * @return config + * @throws ReflectionException + * @throws exception + */ + protected function loadConfig(): config + { + if ($this->modeldata->exists('appstack')) { + return new \codename\core\config\json('config/model/' . $this->prefix . '_' . $this->name . '.json', true, false, $this->modeldata->get('appstack')); + } else { + return new \codename\core\config\json('config/model/' . $this->prefix . '_' . $this->name . '.json', true); + } + } + + /** + * {@inheritDoc} + */ + public function getIdentifier(): string + { + return $this->name; + } + + /** + * {@inheritDoc} + */ + public function search(): model + { + return $this; + } + + /** + * {@inheritDoc} + */ + public function delete(mixed $primaryKey = null): model + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * {@inheritDoc} + */ + public function save(array $data): model + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * {@inheritDoc} + */ + public function copy(mixed $primaryKey): model + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * {@inheritDoc} + */ + public function withFlag(int $flagval): model + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * {@inheritDoc} + */ + public function withoutFlag(int $flagval): model + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * {@inheritDoc} + */ + public function withDefaultFlag(int $flagval): model + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * {@inheritDoc} + */ + public function withoutDefaultFlag(int $flagval): model + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * {@inheritDoc} + */ + protected function internalQuery(string $query, array $params = []) + { + } + + /** + * {@inheritDoc} + * @return array + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception + */ + protected function internalGetResult(): array + { + $this->doQuery(''); + return $this->result; + } + + /** + * {@inheritDoc} + */ + protected function doQuery(string $query, array $params = []): void + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * {@inheritDoc} + */ + protected function compatibleJoin(model $model): bool + { + return false; // ? + } + + /** + * [filterResults description] + * @param array $data [description] + * @return array [description] + */ + protected function filterResults(array $data): array + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * [mapResults description] + * @param array $data [description] + * @return array [description] + */ + protected function mapResults(array $data): array + { + return $data; + } } diff --git a/backend/class/model/schemeless/json.php b/backend/class/model/schemeless/json.php index 0e15daf..71ff759 100644 --- a/backend/class/model/schemeless/json.php +++ b/backend/class/model/schemeless/json.php @@ -1,337 +1,368 @@ appname] - * @return model - * @todo refactor the constructor for no method args - */ - public function __CONSTRUCT(array $modeldata = array()) { - parent::__CONSTRUCT($modeldata); - $this->errorstack = new \codename\core\errorstack('VALIDATION'); - $this->appname = $this->modeldata->get('app') ?? app::getApp(); - return $this; - } - - /** - * [setConfig description] - * @param string $file [data source file, .json] - * @param string $name [model name for getting the config itself] - * @return model [description] - */ - public function setConfig(string $file = null, string $prefix, string $name) : model { - $this->file = $file; - $this->prefix = $prefix; - $this->name = $name; - $this->config = $this->loadConfig(); - return $this; - } - - /** - * loads a new config file (uncached) - * @return \codename\core\config - */ - protected function loadConfig() : \codename\core\config { - if($this->modeldata->exists('appstack')) { - return new \codename\core\config\json('config/model/' . $this->prefix . '_' . $this->name . '.json', true, false, $this->modeldata->get('appstack')); - } else { - return new \codename\core\config\json('config/model/' . $this->prefix . '_' . $this->name . '.json', true); +abstract class json extends schemeless implements modelInterface +{ + /** + * Contains the driver to use for this model and the plugins + * @var string $type + */ + public const string DB_TYPE = 'json'; + /** + * [protected description] + * @var array + */ + protected static array $t_data = []; + /** + * Contains the schema this model is based upon + * @var null|string + */ + public ?string $schema = null; + /** + * Contains the table this model is based upon + * @var null|string + */ + public ?string $table = null; + /** + * I contain the prefix of the model to use + * @var string $prefix + */ + public string $prefix = ''; + /** + * I contain the path to the XML file that is used + * @var null|string $file + */ + protected ?string $file = null; + /** + * I contain the name of the model to use + * @var string $name + */ + protected $name = ''; + + /** + * Creates an instance + * @param array $modeldata [e.g., app => appname] + * @throws ReflectionException + * @throws exception + * @todo refactor the constructor for no method args + */ + public function __construct(array $modeldata = []) + { + parent::__construct($modeldata); + $this->errorstack = new errorstack('VALIDATION'); + $this->appname = $this->modeldata->get('app') ?? app::getApp(); + return $this; + } + + /** + * [setConfig description] + * @param null|string $file [data source file, .json] + * @param string $prefix + * @param string $name [model name for getting the config itself] + * @return model [description] + * @throws ReflectionException + * @throws exception + */ + public function setConfig(?string $file, string $prefix, string $name): model + { + $this->file = $file; + $this->prefix = $prefix; + $this->name = $name; + $this->config = $this->loadConfig(); + return $this; } - } - - /** - * @inheritDoc - */ - public function getIdentifier() : string - { - return $this->name; - } - - /** - * @inheritDoc - */ - public function search() : model - { - $this->doQuery(''); - return $this; - } - - /** - * @inheritDoc - */ - public function delete($primaryKey = null) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function save(array $data) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function copy($primaryKey) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - protected function internalGetResult(): array - { - return $this->result; - } - - /** - * [protected description] - * @var array - */ - protected static $t_data = []; - - /** - * @inheritDoc - */ - protected function internalQuery(string $query, array $params = array()) - { - $identifier = $this->file . '_' . ($this->modeldata->exists('appstack') ? '1' : '0'); - if(!isset(static::$t_data[$identifier])) { - if($this->modeldata->exists('appstack')) { - $inherit = $this->modeldata->get('inherit') ?? false; - // traverse (custom) appstack, if we defined it - static::$t_data[$identifier] = (new \codename\core\config\json($this->file, true, $inherit, $this->modeldata->get('appstack')))->get(); - } else { - static::$t_data[$identifier] = (new \codename\core\config\json($this->file))->get(); - } - - // map PKEY (index) to a real field - $pkey = $this->getPrimaryKey(); - array_walk(static::$t_data[$identifier], function(&$item, $key) use ($pkey) { - if(!isset($item[$pkey])) { - $item[$pkey] = $key; + + /** + * loads a new config file (uncached) + * @return config + * @throws ReflectionException + * @throws exception + */ + protected function loadConfig(): config + { + if ($this->modeldata->exists('appstack')) { + return new config\json('config/model/' . $this->prefix . '_' . $this->name . '.json', true, false, $this->modeldata->get('appstack')); + } else { + return new config\json('config/model/' . $this->prefix . '_' . $this->name . '.json', true); } - }); } - $data = static::$t_data[$identifier]; + /** + * {@inheritDoc} + */ + public function getIdentifier(): string + { + return $this->name; + } - if(count($this->virtualFields) > 0) { - foreach($data as &$d) { - foreach($this->virtualFields as $field => $function) { - $d[$field] = $function($d); - } - } + /** + * {@inheritDoc} + * @return model + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception + */ + public function search(): model + { + $this->doQuery(''); + return $this; + } + + /** + * {@inheritDoc} + */ + public function delete(mixed $primaryKey = null): model + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * {@inheritDoc} + */ + public function save(array $data): model + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * {@inheritDoc} + */ + public function copy(mixed $primaryKey): model + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * {@inheritDoc} + */ + public function withFlag(int $flagval): model + { + throw new LogicException('Not implemented'); // TODO } - if((count($this->filter) > 0) || (count($this->filterCollections) > 0)) { - $data = $this->filterResults($data); + /** + * {@inheritDoc} + */ + public function withoutFlag(int $flagval): model + { + throw new LogicException('Not implemented'); // TODO } - return $this->mapResults($data); - } - - /** - * [filterResults description] - * @param array $data [description] - * @return array [description] - */ - protected function filterResults(array $data) : array { - // - // special hack - // to highly speed up filtering for json/array key filtering - // - if(count($this->filter) === 1) { - foreach($this->filter as $filter) { - if($filter->field->get() == $this->getPrimarykey() && $filter->operator == '=') { - if(is_array($filter->value)) { - $data = array_values(array_intersect_key($data, array_flip($filter->value))); + /** + * {@inheritDoc} + */ + public function withDefaultFlag(int $flagval): model + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * {@inheritDoc} + */ + public function withoutDefaultFlag(int $flagval): model + { + throw new LogicException('Not implemented'); // TODO + } + + /** + * {@inheritDoc} + */ + protected function internalGetResult(): array + { + return $this->result; + } + + /** + * {@inheritDoc} + * @param string $query + * @param array $params + * @return array + * @throws ReflectionException + * @throws exception + */ + protected function internalQuery(string $query, array $params = []): array + { + $identifier = $this->file . '_' . ($this->modeldata->exists('appstack') ? '1' : '0'); + if (!isset(static::$t_data[$identifier])) { + if ($this->modeldata->exists('appstack')) { + $inherit = $this->modeldata->get('inherit') ?? false; + // traverse (custom) appstack, if we defined it + static::$t_data[$identifier] = (new config\json($this->file, true, $inherit, $this->modeldata->get('appstack')))->get(); } else { - $data = isset($data[$filter->value]) ? [$data[$filter->value]] : []; + static::$t_data[$identifier] = (new config\json($this->file))->get(); } - } + + // map PKEY (index) to a real field + $pkey = $this->getPrimaryKey(); + array_walk(static::$t_data[$identifier], function (&$item, $key) use ($pkey) { + if (!isset($item[$pkey])) { + $item[$pkey] = $key; + } + }); } - } - - $filteredData = $data; - - if(count($this->filter) >= 1) { - $filteredData = array_filter($filteredData, function($entry) { - $pass = null; - foreach($this->filter as $filter) { - if($pass === false && $filter->conjunction === 'AND') { - continue; - } - - if($filter instanceof \codename\core\model\plugin\filter\executableFilterInterface) { - if($pass === null) { - $pass = $filter->matches($entry); - } else { - if($filter->conjunction === 'OR') { - $pass = $pass || $filter->matches($entry); - } else { - $pass = $pass && $filter->matches($entry); - } + + $data = static::$t_data[$identifier]; + + if (count($this->virtualFields) > 0) { + foreach ($data as &$d) { + foreach ($this->virtualFields as $field => $function) { + $d[$field] = $function($d); } - } else { - // we may warn for incompatible filters? - } - } - - // - // NOTE/TODO: What to do, when pass === null ? - // - return $pass; - }); - } - - if(count($this->filterCollections) > 0) { - $filteredData = array_filter($filteredData, function($entry) { - $collectionsPass = null; - - foreach($this->filterCollections as $groupName => $filtercollections) { - $groupPass = null; - - foreach($filtercollections as $filtercollection) { - $groupOperator = $filtercollection['operator']; - $conjunction = $filtercollection['conjunction']; - $pass = null; - - foreach($filtercollection['filters'] as $filter) { - - // NOTE: singular filter conjunctions in collections default to NULL - // as we have an overriding group operator - // so, only use the explicit filter conjunction, if given - $filterConjunction = $filter->conjunction ?? $groupOperator; - - // TODO: Group Operator? - if($pass === false && $filterConjunction === 'AND') { - continue; - } - - if($filter instanceof \codename\core\model\plugin\filter\executableFilterInterface) { - if($pass === null) { - $pass = $filter->matches($entry); + } + } + + if ((count($this->filter) > 0) || (count($this->filterCollections) > 0)) { + $data = $this->filterResults($data); + } + + return $this->mapResults($data); + } + + /** + * [filterResults description] + * @param array $data [description] + * @return array [description] + * @throws exception + */ + protected function filterResults(array $data): array + { + // + // special hack + // to highly speed up filtering for json/array key filtering + // + if (count($this->filter) === 1) { + foreach ($this->filter as $filter) { + if ($filter->field->get() == $this->getPrimaryKey() && $filter->operator == '=') { + if (is_array($filter->value)) { + $data = array_values(array_intersect_key($data, array_flip($filter->value))); } else { - if($filterConjunction === 'OR') { - $pass = $pass || $filter->matches($entry); - } else { - $pass = $pass && $filter->matches($entry); - } + $data = isset($data[$filter->value]) ? [$data[$filter->value]] : []; } - } else { - // we may warn for incompatible filters? - } - } - - if($groupPass === null) { - $groupPass = $pass; - } else { - if($conjunction === 'OR') { - $groupPass = $groupPass || $pass; - } else { - $groupPass = $groupPass && $pass; } - } } + } - if($collectionsPass === null) { - $collectionsPass = $groupPass; - } else { - $collectionsPass = $collectionsPass && $groupPass; - } - } - - return $collectionsPass; - }); - } - - return array_values($filteredData); - } - - /** - * @inheritDoc - */ - protected function compatibleJoin(\codename\core\model $model) : bool - { - return false; - } - - /** - * [mapResults description] - * @param array $data [description] - * @return array [description] - */ - protected function mapResults(array $data) : array { - return $data; - } - - /** - * @inheritDoc - */ - public function withFlag(int $flagval) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function withoutFlag(int $flagval) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function withDefaultFlag(int $flagval) : model - { - throw new \LogicException('Not implemented'); // TODO - } - - /** - * @inheritDoc - */ - public function withoutDefaultFlag(int $flagval) : model - { - throw new \LogicException('Not implemented'); // TODO - } + $filteredData = $data; + + if (count($this->filter) >= 1) { + $filteredData = array_filter($filteredData, function ($entry) { + $pass = null; + foreach ($this->filter as $filter) { + if ($pass === false && $filter->conjunction === 'AND') { + continue; + } + + if ($filter instanceof executableFilterInterface) { + if ($pass === null) { + $pass = $filter->matches($entry); + } elseif ($filter->conjunction === 'OR') { + $pass = $pass || $filter->matches($entry); + } else { + $pass = $pass && $filter->matches($entry); + } + } else { + // we may warn for incompatible filters? + } + } + + // + // NOTE/TODO: What to do, when pass === null ? + // + return $pass; + }); + } + + if (count($this->filterCollections) > 0) { + $filteredData = array_filter($filteredData, function ($entry) { + $collectionsPass = null; + + foreach ($this->filterCollections as $filtercollections) { + $groupPass = null; + + foreach ($filtercollections as $filtercollection) { + $groupOperator = $filtercollection['operator']; + $conjunction = $filtercollection['conjunction']; + $pass = null; + + foreach ($filtercollection['filters'] as $filter) { + // NOTE: singular filter conjunctions in collections default to NULL + // as we have an overriding group operator + // so, only use the explicit filter conjunction, if given + $filterConjunction = $filter->conjunction ?? $groupOperator; + + // TODO: Group Operator? + if ($pass === false && $filterConjunction === 'AND') { + continue; + } + + if ($filter instanceof executableFilterInterface) { + if ($pass === null) { + $pass = $filter->matches($entry); + } elseif ($filterConjunction === 'OR') { + $pass = $pass || $filter->matches($entry); + } else { + $pass = $pass && $filter->matches($entry); + } + } else { + // we may warn for incompatible filters? + } + } + + if ($groupPass === null) { + $groupPass = $pass; + } elseif ($conjunction === 'OR') { + $groupPass = $groupPass || $pass; + } else { + $groupPass = $groupPass && $pass; + } + } + + if ($collectionsPass === null) { + $collectionsPass = $groupPass; + } else { + $collectionsPass = $collectionsPass && $groupPass; + } + } + + return $collectionsPass; + }); + } + + return array_values($filteredData); + } + + /** + * [mapResults description] + * @param array $data [description] + * @return array [description] + */ + protected function mapResults(array $data): array + { + return $data; + } + + /** + * {@inheritDoc} + */ + protected function compatibleJoin(model $model): bool + { + return false; + } } diff --git a/backend/class/model/schemeless/moduleLoaderInterface.php b/backend/class/model/schemeless/moduleLoaderInterface.php index 0b204c0..686a1db 100644 --- a/backend/class/model/schemeless/moduleLoaderInterface.php +++ b/backend/class/model/schemeless/moduleLoaderInterface.php @@ -1,15 +1,15 @@ errorstack = new \codename\core\errorstack('VALIDATION'); + public function __construct(array $modeldata) + { + parent::__construct($modeldata); + $this->errorstack = new errorstack('VALIDATION'); $this->appname = $modeldata['app']; return $this; } @@ -38,55 +48,86 @@ public function __CONSTRUCT(array $modeldata) { /** * @todo DOCUMENTATION */ - public function search() : \codename\core\model { + public function search(): model + { return $this; } /** * @todo DOCUMENTATION */ - public function save(array $data) : \codename\core\model { + public function save(array $data): model + { return $this; } /** * @todo DOCUMENTATION */ - public function copy($primaryKey) : \codename\core\model { + public function copy(mixed $primaryKey): model + { return $this; } /** * @todo DOCUMENTATION */ - public function delete($primaryKey = null) : \codename\core\model { + public function delete(mixed $primaryKey = null): model + { return $this; } /** * @todo DOCUMENTATION */ - public function delimit(modelfield $field, $value = NULL): string { + public function delimit(modelfield $field, $value = null): string + { return $value; } + /** + * @return array + * @throws Exception + */ + public function getResult(): array + { + $this->doQuery('null'); + return $this->result; + } + + /** + * @param string $query + * @param array $params + * @throws Exception + */ + protected function doQuery(string $query, array $params = []): void + { + $data = xml2array::createArray(file_get_contents($this->file))['modelEntries']['entry']; + + if (count($this->filter) > 0) { + $data = $this->filterResults($data); + } + + $this->result = $this->mapResults($data); + } + /** * @todo DOCUMENTATION */ - protected function filterResults(array $data) : array { - $filteredData = array(); - foreach($data as $entry) { + protected function filterResults(array $data): array + { + $filteredData = []; + foreach ($data as $entry) { $pass = true; - foreach($this->filter as $filter) { - if(!$pass) { + foreach ($this->filter as $filter) { + if (!$pass) { continue; } - if(!array_key_exists($filter->field, $entry) || $entry[$filter->field] !== $filter->value) { + if (!array_key_exists($filter->field, $entry) || $entry[$filter->field] !== $filter->value) { $pass = false; - continue; } } - if(!$pass) { + if (!$pass) { continue; } $filteredData[] = $entry; @@ -95,12 +136,15 @@ protected function filterResults(array $data) : array { } /** - * @todo DOCUMENTATION + * @param array $data + * @return array + * @throws \codename\core\exception */ - protected function mapResults(array $data) : array { - $results = array(); + protected function mapResults(array $data): array + { + $results = []; foreach ($data as $result) { - $result[$this->getPrimarykey()] = $result['@attributes']['id']; + $result[$this->getPrimaryKey()] = $result['@attributes']['id']; unset($result['@attributes']); $results[] = $result; } @@ -110,38 +154,21 @@ protected function mapResults(array $data) : array { /** * @todo DOCUMENTATION */ - protected function doQuery(string $query, array $params = array()) : array { - $data = \codename\core\xml2array::createArray(file_get_contents($this->file))['modelEntries']['entry']; - - if(count($this->filter) > 0) { - $data = $this->filterResults($data); - } - - return $this->mapResults($data); - } - - /** - * @todo DOCUMENTATION - */ - public function getResult() : array { - return $this->doQuery('null'); - } - - /** - * @todo DOCUMENTATION - */ - public function withFlag(int $flagval) : \codename\core\model { + public function withFlag(int $flagval): model + { return $this; } /** - * @todo DOCUMENTATION + * @param string $name + * @return $this + * @throws ReflectionException + * @throws \codename\core\exception */ - public function setConfig(string $name) { - $this->file = \codename\core\app::getInheritedPath('data/xml/' . $name . '.xml'); + public function setConfig(string $name): static + { + $this->file = app::getInheritedPath('data/xml/' . $name . '.xml'); $this->config = new \codename\core\config\json('config/model/' . $name . '.json'); return $this; } - - } diff --git a/backend/class/model/servicing.php b/backend/class/model/servicing.php index 3f7e1cf..1b403f4 100644 --- a/backend/class/model/servicing.php +++ b/backend/class/model/servicing.php @@ -1,4 +1,5 @@ schema . '.' . $model->table; - } + /** + * [getTableIdentifier description] + * @param \codename\core\model\schematic\sql $model [description] + * @return string [description] + */ + public function getTableIdentifier(\codename\core\model\schematic\sql $model): string + { + return $model->schema . '.' . $model->table; + } - /** - * [getSaveUpdateSetModifiedTimestampStatement description] - * @param \codename\core\model\schematic\sql $model [description] - * @return string [description] - */ - public function getSaveUpdateSetModifiedTimestampStatement(\codename\core\model\schematic\sql $model): string { - return 'now()'; - } + /** + * [getSaveUpdateSetModifiedTimestampStatement description] + * @param \codename\core\model\schematic\sql $model [description] + * @return string [description] + */ + public function getSaveUpdateSetModifiedTimestampStatement(\codename\core\model\schematic\sql $model): string + { + return 'now()'; + } - public function wrapIdentifier($identifier) { - return $identifier; - } + /** + * @param $identifier + * @return mixed + */ + public function wrapIdentifier($identifier): mixed + { + return $identifier; + } - public function getTableIdentifierParametrized($schema, $table) { - return $schema . '.' . $table; - } + /** + * @param $schema + * @param $table + * @return string + */ + public function getTableIdentifierParametrized($schema, $table): string + { + return $schema . '.' . $table; + } - public function jsonEncode($data): string { - return json_encode($data); - } + /** + * @param $data + * @return string + */ + public function jsonEncode($data): string + { + return json_encode($data); + } } diff --git a/backend/class/model/servicing/sql/mysql.php b/backend/class/model/servicing/sql/mysql.php index 317af19..183fa13 100644 --- a/backend/class/model/servicing/sql/mysql.php +++ b/backend/class/model/servicing/sql/mysql.php @@ -1,13 +1,17 @@ schema . '"."' . $model->table . '"'; + } + + /** + * @param $schema + * @param $table + * @return string + */ + public function getTableIdentifierParametrized($schema, $table): string + { + return '"' . $schema . '"."' . $table . '"'; + } + + /** + * @param $identifier + * @return string + */ + public function wrapIdentifier($identifier): string + { + return '"' . $identifier . '"'; + } } diff --git a/backend/class/model/servicing/sql/sqlite.php b/backend/class/model/servicing/sql/sqlite.php index 3bf78a1..0304580 100644 --- a/backend/class/model/servicing/sql/sqlite.php +++ b/backend/class/model/servicing/sql/sqlite.php @@ -1,42 +1,50 @@ schema . '.' . $model->table.'`'; - } + /** + * {@inheritDoc} + */ + public function getTableIdentifier(sql $model): string + { + // + // SQLite doesn't support schema.table syntax, as there's only one database, + // therefore, we 'fake' it by using `schema.table` + // + return '`' . $model->schema . '.' . $model->table . '`'; + } - /** - * @inheritDoc - */ - public function getSaveUpdateSetModifiedTimestampStatement(\codename\core\model\schematic\sql $model): string { - // - // SQLite implementation differs from other SQL databases - // - return 'datetime(\'now\')'; - } + /** + * {@inheritDoc} + */ + public function getSaveUpdateSetModifiedTimestampStatement(sql $model): string + { + // + // SQLite's implementation differs from other SQL databases + // + return 'datetime(\'now\')'; + } - /** - * @inheritDoc - */ - public function getTableIdentifierParametrized($schema, $table) - { - return '`'.$schema . '.' . $table.'`'; - } + /** + * @param $schema + * @param $table + * @return string + */ + public function getTableIdentifierParametrized($schema, $table): string + { + return '`' . $schema . '.' . $table . '`'; + } - /** - * @inheritDoc - */ - public function jsonEncode($data): string - { - return json_encode($data, JSON_UNESCAPED_UNICODE); - } + /** + * @param $data + * @return string + */ + public function jsonEncode($data): string + { + return json_encode($data, JSON_UNESCAPED_UNICODE); + } } diff --git a/backend/class/model/timemachineInterface.php b/backend/class/model/timemachineInterface.php index e8e18ed..08134a1 100644 --- a/backend/class/model/timemachineInterface.php +++ b/backend/class/model/timemachineInterface.php @@ -1,21 +1,23 @@ observers[] = $observer; - return; } /** * * {@inheritDoc} - * @see \codename\core\observable\observableInterface::detach() + * @see observableInterface::detach */ - public function detach(\codename\core\observer $observer) { - $this->observers = array_diff($this->observers, array($observer)); - return; + public function detach(observer $observer): void + { + $this->observers = array_diff($this->observers, [$observer]); } /** * * {@inheritDoc} - * @see \codename\core\observable\observableInterface::notify() + * @see observableInterface::notify */ - public function notify(string $type = '') { - foreach($this->observers as $observer) { + public function notify(string $type = ''): void + { + foreach ($this->observers as $observer) { $observer->update($this, $type); } - return; } - - } diff --git a/backend/class/observable/observableInterface.php b/backend/class/observable/observableInterface.php index 0c48ad6..5ad56d9 100755 --- a/backend/class/observable/observableInterface.php +++ b/backend/class/observable/observableInterface.php @@ -1,31 +1,33 @@ save(array( - 'queue_class' => $class, - 'queue_method' => $method, - 'queue_identifier' => $identifier, - 'queue_data' => json_encode($actions), - 'queue_flag' => 0 - )); - return; + public function add(string $class, string $method, string $identifier, array $actions): void + { + app::getModel('queue')->save([ + 'queue_class' => $class, + 'queue_method' => $method, + 'queue_identifier' => $identifier, + 'queue_data' => json_encode($actions), + 'queue_flag' => 0, + ]); } - - /** - * - * {@inheritDoc} - * @see \codename\core\queue_interface::load($class, $identifier) - */ - public function load(string $class, string $identifier = '') { - $model = app::getModel('queue'); - $model->addFilter('queue_class', $class)->setLimit(1); - - if(strlen($identifier) > 0) { - $model->addFilter('queue_identifier', $identifier); - } - - $data = $model->addFilter('queue_flag', 0)->search()->getResult(); - - if(count($data) == 0) { - return null; - } - return $data[0]; - } - + /** - * + * * {@inheritDoc} + * @param string $id + * @throws ReflectionException + * @throws exception * @see \codename\core\queue_interface::remove($entry) */ - public function remove(string $id) { + public function remove(string $id): void + { app::getModel('queue')->delete($id); } - + /** - * + * * {@inheritDoc} + * @param string $class + * @param string $identifier + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception * @see \codename\core\queue_interface::lock($class, $identifier) */ - public function lock(string $class, string $identifier) { + public function lock(string $class, string $identifier): void + { $data = $this->load($class, $identifier); - if(count($data) == 0) { + if (count($data) == 0) { return; } $data['queue_flag'] = 1; app::getModel('queue')->save($data); - return; } - + /** - * + * * {@inheritDoc} + * @param string $class + * @param string $identifier + * @return mixed + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + * @see \codename\core\queue_interface::load($class, $identifier) + */ + public function load(string $class, string $identifier = ''): mixed + { + $model = app::getModel('queue'); + $model->addFilter('queue_class', $class)->setLimit(1); + + if (strlen($identifier) > 0) { + $model->addFilter('queue_identifier', $identifier); + } + + $data = $model->addFilter('queue_flag', 0)->search()->getResult(); + + if (count($data) == 0) { + return null; + } + return $data[0]; + } + + /** + * + * {@inheritDoc} + * @param string $class + * @param string $identifier + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception * @see \codename\core\queue_interface::unlock($class, $identifier) */ - public function unlock(string $class, string $identifier) { + public function unlock(string $class, string $identifier): void + { $data = $this->load($class, $identifier); - if(count($data) == 0) { + if (count($data) == 0) { return; } $data = $data[0]; $data['queue_flag'] = 0; app::getModel('queue')->save($data); - return; - } - + /** - * + * * {@inheritDoc} + * @param string $class + * @return array + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception * @see \codename\core\queue_interface::list($class) */ - public function listElements(string $class = '') : array { + public function listElements(string $class = ''): array + { return app::getModel('queue')->addFilter('queue_class', $class)->search()->getResult(); } - } diff --git a/backend/class/queue/queueInterface.php b/backend/class/queue/queueInterface.php index d16487a..31f09d6 100755 --- a/backend/class/queue/queueInterface.php +++ b/backend/class/queue/queueInterface.php @@ -1,66 +1,66 @@ The $class argument defines what queue worker must be used - *
The $method argument defines the name of the callable method in the worker - *
The $identifier is an identifier for the worker (e.g. when it is a model, this is the primary key - *
The $actions is an array of things the worker will do (e.g. overwrite fields) + * The $class argument defines what queue worker must be used + * The $method argument defines the name of the callable method in the worker + * The $identifier is an identifier for the worker (e.g., when it is a model, this is the primary key + * The $actions is an array of things the worker will do (e.g., overwrite fields) * @param string $class * @param string $method * @param string $identifier * @param array $actions * @return void */ - public function add(string $class, string $method, string $identifier, array $actions); - + public function add(string $class, string $method, string $identifier, array $actions): void; + /** * I will load an entry from a queue list. - *
The $class argument defines what queue the object will be loaded for - *
The $identifier argument is optional. You might want to load one specific entry from the queue here + * The $class argument defines what queue the object will be loaded for + * The $identifier argument is optional. You might want to load one specific entry from the queue here * @param string $class * @param string $identifier - * @return void + * @return mixed */ - public function load(string $class, string $identifier = ''); - + public function load(string $class, string $identifier = ''): mixed; + /** * I will remove an entry from a queue list - *
The $id argument defines what element to delete + * The $id argument defines what element to delete * @param string $id * @return void */ - public function remove(string $id); - + public function remove(string $id): void; + /** * I will lock one object in the queue list, so that it will be ignored by the 'load' method - *
The $class argument is the queue the object is located in - *
The $identifier argument defines the specific element in the queue + * The $class argument is the queue the object is located in + * The $identifier argument defines the specific element in the queue * @param string $class * @param string $identifier * @return void */ - public function lock(string $class, string $identifier); - + public function lock(string $class, string $identifier): void; + /** * I will remove the lock status from the given object on the queue list - *
The $class argument is the queue the object is located in - *
The $identifier argument defines the specific element in the queue + * The $class argument is the queue the object is located in + * The $identifier argument defines the specific element in the queue * @param string $class * @param string $identifier * @return void */ - public function unlock(string $class, string $identifier); + public function unlock(string $class, string $identifier): void; /** * I will list all the queue entries. - *
The $class argument can be added to the method if you want a list of objects in this queue + * The $class argument can be added to the method if you want a list of objects in this queue * @param string $class * @return array */ - public function listElements(string $class = '') : array; - + public function listElements(string $class = ''): array; } diff --git a/backend/class/request.php b/backend/class/request.php index 58cd0d6..6c7f031 100755 --- a/backend/class/request.php +++ b/backend/class/request.php @@ -1,38 +1,21 @@ array( - * 'arrkey1' => array( - * 'arrkey2' => 123 - * ) - * ) - * ) - * - */ + /** + * we're creating the cli request array recursively + * to allow + * + * --arg[arrkey1][arrkey2]=123 + * + * resulting in + * + * array( + * 'arg' => array( + * 'arrkey1' => array( + * 'arrkey2' => 123 + * ) + * ) + * ) + * + */ - $rec = &$cliData; + $rec = &$cliData; - if(strlen($param) > 0) { - foreach($fullparam as $p) { - if(!array_key_exists($p, $rec)) { - // if it's the last param we're parsing, simply set value - if($p == end($fullparam)) { - $rec[$p] = $e[1] ?? null; - break; - } else { - // begin creating a new sub-structure - $rec[$p] = array(); - } - } else { - // if it's the last param we're parsing, simply set value - if($p == end($fullparam)) { - $rec[$p] = $e[1] ?? null; - break; - } - } + if (strlen($param) > 0) { + foreach ($fullparam as $p) { + if (!array_key_exists($p, $rec)) { + // if it's the last param we're parsing, simply set value + if ($p == end($fullparam)) { + $rec[$p] = $e[1] ?? null; + break; + } else { + // begin creating a new substructure + $rec[$p] = []; + } + } elseif ($p == end($fullparam)) { + // if it's the last param we're parsing, simply set value + $rec[$p] = $e[1] ?? null; + break; + } - // dig deeper - $rec = &$rec[$p]; + // dig deeper + $rec = &$rec[$p]; + } + } } - } - } - } - $this->addData($cliData); - $this->setData('lang', "de_DE"); - return $this; + $this->addData($cliData); + $this->setData('lang', "de_DE"); + return $this; } - } - diff --git a/backend/class/request/filesInterface.php b/backend/class/request/filesInterface.php index 0df6497..6c5701f 100644 --- a/backend/class/request/filesInterface.php +++ b/backend/class/request/filesInterface.php @@ -1,14 +1,16 @@ addData($_GET); - $this->addData($_POST); - $this->setData('lang', "de_DE"); + parent::__construct(); + $this->addData($_GET); + $this->addData($_POST); + $this->setData('lang', "de_DE"); } - } diff --git a/backend/class/request/https.php b/backend/class/request/https.php index b41594b..52c2040 100755 --- a/backend/class/request/https.php +++ b/backend/class/request/https.php @@ -1,11 +1,12 @@ status = $this->getDefaultStatus(); - $this->addData(array( - 'context' => app::getRequest()->getData('context'), - 'view' => app::getRequest()->getData('view'), - 'action' => app::getRequest()->getData('action'), - 'template' => app::getRequest()->getData('template') - )); + $this->addData([ + 'context' => app::getRequest()->getData('context'), + 'view' => app::getRequest()->getData('view'), + 'action' => app::getRequest()->getData('action'), + 'template' => app::getRequest()->getData('template'), + ]); // set appserver header $this->setHeader("APP-SRV: " . gethostname()); @@ -92,75 +100,73 @@ public function __CONSTRUCT() { * [getDefaultStatus description] * @return string */ - protected function getDefaultStatus() { - return self::STATUS_SUCCESS; + protected function getDefaultStatus(): string + { + return self::STATUS_SUCCESS; } /** - * [protected description] - * @var [type] + * sets a header + * mostly for http responses + * @param string $header */ - protected $status = null; + abstract public function setHeader(string $header); /** * [setStatus description] * @param string $status [description] */ - public function setStatus(string $status) { - $this->status = $status; + public function setStatus(string $status): void + { + $this->status = $status; } - /** - * sets a header - * mostly for http responses - * @param string $header - */ - public abstract function setHeader(string $header); - /** * [setRedirect description] - * @param string $string [description] - * @param [type] $context [description] - * @param [type] $view [description] - * @param [type] $action [description] + * @param string $string [description] + * @param string|null $context + * @param string|null $view + * @param string|null $action */ - public function setRedirect(string $string, string $context = null, string $view = null, string $action = null) { - return; + public function setRedirect(string $string, string $context = null, string $view = null, string $action = null) + { } /** * perform the configured redirect */ - public function doRedirect() { - return; + public function doRedirect() + { } /** * [getStatuscode description] * @return int [description] */ - public function getStatuscode() : int { - return $this->translateStatus(); + public function getStatuscode(): int + { + return $this->translateStatus(); } /** - * translate current internal status to a responsetype specific one + * translate current internal status to a response type specific one * @var [type] */ - protected abstract function translateStatus(); + abstract protected function translateStatus(); /** * Push output to whatever we're outputting to. * Depends on the response type (inherited class) * @return void */ - public abstract function pushOutput(); + abstract public function pushOutput(): void; /** * Returns the output content of this response * @return string */ - public function getOutput() : string { + public function getOutput(): string + { return $this->output; } @@ -169,18 +175,18 @@ public function getOutput() : string { * @param string $output * @return void */ - public function setOutput(string $output) { + public function setOutput(string $output): void + { $this->output = $output; - return; } /** * [displayException description] - * @param \Exception $e [description] - * @return [type] [description] + * @param \Exception $e [description] + * @return void [type] [description] */ - public function displayException(\Exception $e) { + public function displayException(\Exception $e): void + { echo($e->getMessage()); } - } diff --git a/backend/class/response/callback.php b/backend/class/response/callback.php index 053c750..d51b540 100755 --- a/backend/class/response/callback.php +++ b/backend/class/response/callback.php @@ -1,58 +1,61 @@ addJs("joNotify('$subject', '$text', '$image', '$sound'"); + } /** * @todo DOCUMENTATION */ - public function addJs(string $js) { + public function addJs(string $js): void + { $jsdo = $this->getData('jsdo'); - if(is_null($jsdo)) { - $jsdo = array(); + if (is_null($jsdo)) { + $jsdo = []; } $jsdo[] = $js; $this->setData('jsdo', $jsdo); - return; } /** - * @todo DOCUMENTATION - */ - public function addNotification(string $subject, string $text, string $image, string $sound) { - $this->addJs("joNotify('{$subject}', '{$text}', '{$image}', '{$sound}'"); - return; - } - - /** - * @inheritDoc + * {@inheritDoc} */ public function setHeader(string $header) { - throw new \LogicException('Not implemented'); // TODO + throw new LogicException('Not implemented'); // TODO } /** - * @inheritDoc + * {@inheritDoc} */ - protected function translateStatus() + public function pushOutput(): void { - throw new \LogicException('Not implemented'); // TODO + throw new LogicException('Not implemented'); // TODO } /** - * @inheritDoc + * {@inheritDoc} */ - public function pushOutput() + protected function translateStatus(): mixed { - throw new \LogicException('Not implemented'); // TODO + throw new LogicException('Not implemented'); // TODO } - } diff --git a/backend/class/response/cli.php b/backend/class/response/cli.php index 1ce5a68..fafc710 100755 --- a/backend/class/response/cli.php +++ b/backend/class/response/cli.php @@ -1,77 +1,79 @@ getOutput(); + app::setExitCode($this->translateStatus()); + } - /** - * @inheritDoc - */ - protected function translateStatus() - { - $translate = array( - self::STATUS_SUCCESS => 0, - self::STATUS_INTERNAL_ERROR => 1, - self::STATUS_NOTFOUND => 1 // ? - ); - return $translate[$this->status]; - } + /** + * {@inheritDoc} + */ + protected function translateStatus(): int + { + $translate = [ + self::STATUS_SUCCESS => 0, + self::STATUS_INTERNAL_ERROR => 1, + self::STATUS_NOTFOUND => 1, // ? + ]; + return $translate[$this->status]; + } - /** - * @inheritDoc - * output to cli/console - */ - public function pushOutput() - { - echo $this->getOutput(); - app::setExitCode($this->translateStatus()); - } + /** + * {@inheritDoc} + */ + public function setHeader(string $header) + { + } - /** - * @inheritDoc - */ - public function setHeader(string $header) - { - return; // do nothing - } + /** + * {@inheritDoc} + */ + public function displayException(\Exception $e): void + { + $formatter = new clicolors(); - /** - * @inheritDoc - */ - public function displayException(\Exception $e) - { - $formatter = new \codename\core\helper\clicolors(); + // log to stderr + error_log(print_r($e, true)); - // log to stderr - error_log(print_r($e, true), 0); + if (defined('CORE_ENVIRONMENT') + // && CORE_ENVIRONMENT != 'production' + ) { + echo $formatter->getColoredString("Hicks", 'red') . chr(10); + echo $formatter->getColoredString("{$e->getMessage()} (Code: {$e->getCode()})", 'yellow') . chr(10) . chr(10); - if(defined('CORE_ENVIRONMENT') - // && CORE_ENVIRONMENT != 'production' - ) { - echo $formatter->getColoredString("Hicks", 'red') . chr(10); - echo $formatter->getColoredString("{$e->getMessage()} (Code: {$e->getCode()})", 'yellow') . chr(10) . chr(10); + if (app::getRequest()->getData('verbose')) { + if ($e instanceof exception && !is_null($e->info)) { + echo $formatter->getColoredString("Information", 'cyan') . chr(10); + echo chr(10); + print_r($e->info); + echo chr(10); + } - if(app::getRequest()->getData('verbose')) { - if($e instanceof \codename\core\exception && !is_null($e->info)) { - echo $formatter->getColoredString("Information", 'cyan') . chr(10); - echo chr(10); - print_r($e->info); - echo chr(10); + echo $formatter->getColoredString("Stacktrace", 'cyan') . chr(10); + echo chr(10); + print_r($e->getTrace()); + echo chr(10); + } + die(); } - - echo $formatter->getColoredString("Stacktrace", 'cyan') . chr(10); - echo chr(10); - print_r($e->getTrace()); - echo chr(10); - } - die(); } - return; - } - } diff --git a/backend/class/response/http.php b/backend/class/response/http.php index 75c084a..7412dca 100755 --- a/backend/class/response/http.php +++ b/backend/class/response/http.php @@ -1,165 +1,112 @@ translateStatusToHttpStatus(); - } - - /** - * [translateStatusToHttpStatus description] - * @return int [description] + * CDN prefixes and matching rules + * @var array */ - protected function translateStatusToHttpStatus() : int { - $translate = array( - self::STATUS_SUCCESS => 200, - self::STATUS_INTERNAL_ERROR => 500, - self::STATUS_NOTFOUND => 404, - self::STATUS_FORBIDDEN => 403, - self::STATUS_UNAUTHENTICATED => 401, - self::STATUS_REQUEST_SIZE_TOO_LARGE => 413, - self::STATUS_BAD_REQUEST => 400, - ); - return $translate[$this->status] ?? 418; // fallback: teapot - } - + protected array $cdnPrefixes = []; /** - * @inheritDoc - * simply output/echo to HTTP stream + * Contains data for redirecting the user after finishing the request + * @var array|string|null */ - public function pushOutput() - { - http_response_code($this->translateStatusToHttpStatus()); - echo $this->getOutput(); - } + protected string|array|null $redirect = null; /** - * @inheritDoc + * {@inheritDoc} */ - public function setHeader(string $header) + public function __construct(array $data = []) { - header($header); + parent::__construct(); } /** * [reset description] - * @return \codename\core\response [description] - */ - public function reset(): \codename\core\response { - $this->data = []; - return $this; - } - - /** - * Helper to set HTTP status codes - * @param int $statusCode - * @param string $statusText - * @return \codename\core\response + * @return response [description] */ - public function setStatuscode(int $statusCode, string $statusText) : \codename\core\response { - $this->statusCode = $statusCode; - $this->statusText = $statusText; - return $this; - } - - /** - * You are requesting a resource for the front-end to load additionally. - *
I'm afraid that I don't know the type of resource you requested - * @var string - */ - CONST EXCEPTION_REQUIRERESOURCE_INVALIDRESOURCETYPE = 'EXCEPTION_REQUIRERESOURCE_INVALIDRESOURCETYPE'; - - /** - * You are requesting a resource for the front-end to load additionally. - *
I'm afraid that I did not find the desired resource on the file system. - * @var string - */ - CONST EXCEPTION_REQUIRERESOURCE_RESOURCENOTFOUND = 'EXCEPTION_REQUIRERESOURCE_RESOURCENOTFOUND'; - - /** - * @inheritDoc - */ - public function __construct(array $data = array()) + public function reset(): response { - parent::__construct($data); + $this->data = []; + return $this; } - /** - * CDN prefixes and matching rules - * @var array - */ - protected $cdnPrefixes = array(); - /** * sets a cdn prefix * * @param [type] $prefix [description] * @param [type] $target [description] */ - public function setCDNResourcePrefix($prefix, $target) { - $this->cdnPrefixes[$prefix] = $target; + public function setCDNResourcePrefix($prefix, $target): void + { + $this->cdnPrefixes[$prefix] = $target; } - /** - * Contains data for redirecting the user after finishing the request - * @var array|string - */ - protected $redirect = null; - /** * Redirects the user at some point to the given destination. * Either pass a valid URL to the function (including protocol!) or pass the app/context/view/action data * - * @param string $string [description] + * @param string $string [description] * @param string|null $context [description] - * @param string|null $view [description] - * @param string|null $action [description] + * @param string|null $view [description] + * @param string|null $action [description] */ - public function setRedirect(string $string, ?string $context = null, ?string $view = null, ?string $action = null) { - if(strpos($string, '://') != false || strpos($string, '/') === 0) { + public function setRedirect(string $string, ?string $context = null, ?string $view = null, ?string $action = null): void + { + if (strpos($string, '://') || str_starts_with($string, '/')) { $this->redirect = $string; return; } - $this->redirect = array( - 'app' => $string, - 'context' => $context, - 'view' => $view, - 'action' => $action - ); - return; + $this->redirect = [ + 'app' => $string, + 'context' => $context, + 'view' => $view, + 'action' => $action, + ]; } /** * Sets parameters used for redirection * @param array $param [description] */ - public function setRedirectArray(array $param) { - $this->redirect = $param; - return; + public function setRedirectArray(array $param): void + { + $this->redirect = $param; } /** @@ -167,108 +114,117 @@ public function setRedirectArray(array $param) { * @return void * @todo make a makeUrl function for the parameters */ - public function doRedirect() { - if(is_null($this->redirect)) { + public function doRedirect(): void + { + if (is_null($this->redirect)) { return; } - if(is_string($this->redirect)) { + if (is_string($this->redirect)) { $this->setHeader("Location: " . $this->redirect); } - if(is_array($this->redirect)) { + if (is_array($this->redirect)) { $url = '/?' . http_build_query($this->redirect); $this->setHeader("Location: " . $url); } - return; + } + + /** + * {@inheritDoc} + */ + public function setHeader(string $header): void + { + header($header); } /** * I store the requirement of additional frontend resources in the response container * @param string $type - * @param string $path + * @param string $content * @param int $priority [last = -1, everything else: add at index] * @return bool + * @throws ReflectionException + * @throws exception */ - public function requireResource(string $type, string $content, int $priority = -1) : bool { - if(!in_array($type, array('js', 'css', 'script', 'style', 'head'))) { - throw new \codename\core\exception(self::EXCEPTION_REQUIRERESOURCE_INVALIDRESOURCETYPE, \codename\core\exception::$ERRORLEVEL_FATAL, $type); + public function requireResource(string $type, string $content, int $priority = -1): bool + { + if (!in_array($type, ['js', 'css', 'script', 'style', 'head'])) { + throw new exception(self::EXCEPTION_REQUIRERESOURCE_INVALIDRESOURCETYPE, exception::$ERRORLEVEL_FATAL, $type); } - if(!array_key_exists($type, $this->resources)) { - $this->resources[$type] = array(); + if (!array_key_exists($type, $this->resources)) { + $this->resources[$type] = []; } - if(($type == 'script') || ($type == 'style') || ($type == 'head')) { - if($priority >= 0) { - // insert at given position - array_splice($this->resources[$type], $priority, 0, $content); - } else { - // add to end - $this->resources[$type][] = $content; - } - return true; + if (($type == 'script') || ($type == 'style') || ($type == 'head')) { + if ($priority >= 0) { + // insert at given position + array_splice($this->resources[$type], $priority, 0, $content); + } else { + // add to end + $this->resources[$type][] = $content; + } + return true; } - if(strpos('://', $content) === false) { + if (!str_contains('://', $content)) { // // Local asset check // - if(app::getInstance('filesystem_local')->fileAvailable($file = app::getHomedir() . $content)) { - // - // Local asset available - // Current style: load inline, automatically. - // - if($type === 'css') { - if(pathinfo($file, PATHINFO_EXTENSION) == 'css') { - return self::requireResource('style', file_get_contents($file)); - } else { - // exception - throw new \codename\core\exception('EXCEPTION_REQUIRERESOURCE_DISALLOWED', \codename\core\exception::$ERRORLEVEL_FATAL, $content); - } - } else if('js') { - if(pathinfo($file, PATHINFO_EXTENSION) == 'js') { - return self::requireResource('script', file_get_contents($file)); + if (app::getInstance('filesystem_local')->fileAvailable($file = app::getHomedir() . $content)) { + // + // Local asset available + // Current style: load inline, automatically. + // + if ($type === 'css') { + if (pathinfo($file, PATHINFO_EXTENSION) == 'css') { + return self::requireResource('style', file_get_contents($file)); + } else { + // exception + throw new exception('EXCEPTION_REQUIRERESOURCE_DISALLOWED', exception::$ERRORLEVEL_FATAL, $content); + } + } elseif ('js') { + if (pathinfo($file, PATHINFO_EXTENSION) == 'js') { + return self::requireResource('script', file_get_contents($file)); + } else { + // exception + throw new exception('EXCEPTION_REQUIRERESOURCE_DISALLOWED', exception::$ERRORLEVEL_FATAL, $content); + } } else { - // exception - throw new \codename\core\exception('EXCEPTION_REQUIRERESOURCE_DISALLOWED', \codename\core\exception::$ERRORLEVEL_FATAL, $content); + // error! + throw new exception('EXCEPTION_REQUIRERESOURCE_DISALLOWED', exception::$ERRORLEVEL_FATAL, $content); } - } else { - // error! - throw new \codename\core\exception('EXCEPTION_REQUIRERESOURCE_DISALLOWED', \codename\core\exception::$ERRORLEVEL_FATAL, $content); - } } // - // NOTE: if not found locally, it might me an external asset - continue. + // NOTE: if not found locally, it might be an external asset - continue. // - } else if(!app::getInstance('filesystem_local')->fileAvailable(CORE_WEBROOT . $content)) { - throw new \codename\core\exception(self::EXCEPTION_REQUIRERESOURCE_RESOURCENOTFOUND, \codename\core\exception::$ERRORLEVEL_FATAL, $content); + } elseif (!app::getInstance('filesystem_local')->fileAvailable(app::getHomedir() . $content)) { + throw new exception(self::EXCEPTION_REQUIRERESOURCE_RESOURCENOTFOUND, exception::$ERRORLEVEL_FATAL, $content); } - if(count($this->cdnPrefixes) > 0 && strpos('://', $content) === false && in_array($type, array('js', 'css'))) { - foreach($this->cdnPrefixes as $prefix => $target) { - if(strpos($content, $prefix) === 0) { - $content = $target . ( strpos($content, '/') === 0 ? '' : '/' ) . $content; - break; + if (count($this->cdnPrefixes) > 0 && !str_contains('://', $content)) { + foreach ($this->cdnPrefixes as $prefix => $target) { + if (str_starts_with($content, $prefix)) { + $content = $target . (str_starts_with($content, '/') ? '' : '/') . $content; + break; + } } - } } - if($priority >= 0) { - // check for correct position and fix, if needed - if(in_array($content, $this->resources[$type]) && (($pos = array_search($content, $this->resources[$type])) !== $priority)) { - if($pos !== false) { - // remove from old position - unset($this->resources[$type][$pos]); + if ($priority >= 0) { + // check for the correct position and fix, if needed + if (in_array($content, $this->resources[$type]) && (($pos = array_search($content, $this->resources[$type])) !== $priority)) { + if ($pos !== false) { + // remove from old position + unset($this->resources[$type][$pos]); + } } - } - // insert at given index (priority) - array_splice($this->resources[$type], $priority, 0, $content); - } else { - // add to end - if(!in_array($content, $this->resources[$type])) { + // insert at given index (priority) + array_splice($this->resources[$type], $priority, 0, $content); + } elseif (!in_array($content, $this->resources[$type])) { + // add to end $this->resources[$type][] = $content; - } } return true; @@ -279,11 +235,40 @@ public function requireResource(string $type, string $content, int $priority = - * @param string $type * @return array */ - public function getResources(string $type) : array { - if(isset($this->resources[$type])) { + public function getResources(string $type): array + { + if (isset($this->resources[$type])) { return $this->resources[$type]; } - return array(); + return []; + } + + /** + * Will show a desktop notification on the browser if the client allowed it. + * @param string $subject + * @param string $text + * @param string $image + * @param string $sound + * @return void + * @throws ReflectionException + * @throws exception + * @see ./www/public/library/templates/shared/javascript/alpha_engine.js :: doCallback($url, callback()); + */ + public function addNotification(string $subject, string $text, string $image, string $sound): void + { + $file = CORE_WEBROOT . $image; + if (!app::getFilesystem()->fileAvailable($file)) { + app::getLog('debug')->debug("Cannot send notification, the image $file is not available!"); + return; + } + + $file = CORE_WEBROOT . $sound; + if (!app::getFilesystem()->fileAvailable($file)) { + app::getLog('debug')->debug("Cannot send notification, the sound $file is not available!"); + return; + } + + $this->addJs("joNotify('$subject', '$text', '$image', '$sound');"); } /** @@ -291,89 +276,112 @@ public function getResources(string $type) : array { * @param string $js * @return void */ - public function addJs(string $js) { + public function addJs(string $js): void + { $jsdo = $this->getData('jsdo'); - if(is_null($jsdo)) { - $jsdo = array(); + if (is_null($jsdo)) { + $jsdo = []; } $jsdo[] = $js; $this->setData('jsdo', $jsdo); - return; } /** - * Will show a desktop notification on the browser if the client allowed it. - * @see ./www/public/library/templates/shared/javascript/alpha_engine.js :: doCallback($url, callback()); - * @param string $subject - * @param string $text - * @param string $image - * @param string $sound - * @return void + * {@inheritDoc} */ - public function addNotification(string $subject, string $text, string $image, string $sound) { - $file = CORE_WEBROOT . $image; - if(!app::getFilesystem()->fileAvailable($file)) { - app::getLog('debug')->debug("Cannot send notification, the image {$file} is not available!"); - return; + public function displayException(\Exception $e): void + { + $this->setStatuscode(500, "Internal Server Error"); + + // log to stderr + // NOTE: we log twice, as the second one might be killed + // by memory exhaustion + if ($e instanceof exception && !is_null($e->info)) { + $info = print_r($e->info, true); + } else { + $info = ''; } - $file = CORE_WEBROOT . $sound; - if(!app::getFilesystem()->fileAvailable($file)) { - app::getLog('debug')->debug("Cannot send notification, the sound {$file} is not available!"); - return; + error_log("[SAFE ERROR LOG] " . "{$e->getMessage()} (Code: {$e->getCode()}) in File: {$e->getFile()}:{$e->getLine()}, Info: $info"); + + if ( + defined('CORE_ENVIRONMENT') + // && CORE_ENVIRONMENT != 'production' + ) { + echo "

Hicks!

"; + echo "
{$e->getMessage()} (Code: {$e->getCode()})
"; + + if ($e instanceof exception && !is_null($e->info)) { + echo "
Information:
"; + echo "
";
+                print_r($e->info);
+                echo "
"; + } + + // + // CHANGED 2019-09-02: handle sensitive exceptions differently + // + if (!($e instanceof sensitiveException)) { + echo "
Stacktrace:
"; + echo "
";
+                print_r($e->getTrace());
+                echo "
"; + } + die(); } - $this->addJs("joNotify('{$subject}', '{$text}', '{$image}', '{$sound}');"); - return; + $this->pushOutput(); } /** - * @inheritDoc + * Helper to set HTTP status codes + * @param int $statusCode + * @param string $statusText + * @return response */ - public function displayException(\Exception $e) + public function setStatuscode(int $statusCode, string $statusText): response { - $this->setStatuscode(500, "Internal Server Error"); - - // log to stderr - // NOTE: we log twice, as the second one might be killed - // by memory exhaustion - if($e instanceof \codename\core\exception && !is_null($e->info)) { - $info = print_r($e->info, true); - } else { - $info = ''; - } - - error_log("[SAFE ERROR LOG] "."{$e->getMessage()} (Code: {$e->getCode()}) in File: {$e->getFile()}:{$e->getLine()}, Info: {$info}"); - - if( - defined('CORE_ENVIRONMENT') - // && CORE_ENVIRONMENT != 'production' - ) { - echo "

Hicks!

"; - echo "
{$e->getMessage()} (Code: {$e->getCode()})
"; - - if($e instanceof \codename\core\exception && !is_null($e->info)) { - echo "
Information:
"; - echo "
";
-            print_r($e->info);
-            echo "
"; - } + $this->statusCode = $statusCode; + $this->statusText = $statusText; + return $this; + } - // - // CHANGED 2019-09-02: handle sensitive exceptions differently - // - if(!($e instanceof \codename\core\sensitiveException)) { - echo "
Stacktrace:
"; - echo "
";
-          print_r($e->getTrace());
-          echo "
"; - } - die(); - } + /** + * {@inheritDoc} + * output/echo to HTTP stream + */ + public function pushOutput(): void + { + http_response_code($this->translateStatusToHttpStatus()); + echo $this->getOutput(); + } - $this->pushOutput(); + /** + * [translateStatusToHttpStatus description] + * @return int [description] + */ + protected function translateStatusToHttpStatus(): int + { + $translate = [ + self::STATUS_SUCCESS => 200, + self::STATUS_INTERNAL_ERROR => 500, + self::STATUS_NOTFOUND => 404, + self::STATUS_ACCESS_DENIED => 403, + self::STATUS_FORBIDDEN => 403, + self::STATUS_UNAUTHENTICATED => 401, + self::STATUS_REQUEST_SIZE_TOO_LARGE => 413, + self::STATUS_BAD_REQUEST => 400, + ]; + return $translate[$this->status] ?? 418; // fallback: teapot } + /** + * {@inheritDoc} + */ + protected function translateStatus(): int + { + return $this->translateStatusToHttpStatus(); + } } diff --git a/backend/class/response/https.php b/backend/class/response/https.php index 337a3e9..da9d63d 100755 --- a/backend/class/response/https.php +++ b/backend/class/response/https.php @@ -1,11 +1,12 @@ translateStatusToHttpStatus()); - echo(json_encode($this->getData())); - } - - /** - * @inheritDoc - */ - public function displayException(\Exception $e) - { - $this->getResponse()->setStatuscode(500, "Internal Server Error"); - - // log to stderr - error_log(print_r($e, true), 0); - - if(defined('CORE_ENVIRONMENT') && CORE_ENVIRONMENT != 'production') { - // TODO: optimize / check output? - print_r(json_encode($e)); - die(); - } else { - // TODO: show exception ? +class json extends http +{ + /** + * {@inheritDoc} + * @param Exception $e + * @throws \codename\core\exception + */ + public function displayException(Exception $e): void + { + app::getResponse()->setStatuscode(500, "Internal Server Error"); + + // log to stderr + error_log(print_r($e, true)); + + if (defined('CORE_ENVIRONMENT') && CORE_ENVIRONMENT != 'production') { + // TODO: optimize / check output? + print_r(json_encode($e)); + die(); + } else { + // TODO: show exception ? + } + + + $this->pushOutput(); } - - $this->pushOutput(); - } - + /** + * {@inheritDoc} + */ + public function pushOutput(): void + { + http_response_code($this->translateStatusToHttpStatus()); + echo(json_encode($this->getData())); + } } diff --git a/backend/class/sensitiveException.php b/backend/class/sensitiveException.php deleted file mode 100644 index 63c718b..0000000 --- a/backend/class/sensitiveException.php +++ /dev/null @@ -1,43 +0,0 @@ -encapsulatedException = $encapsulatedException; - $this->message = $encapsulatedException->getMessage(); - $this->line = $encapsulatedException->getLine(); - $this->file = $encapsulatedException->getFile(); - if($encapsulatedException instanceof \codename\core\exception) { - $this->info = $encapsulatedException->info; - } - } - - /** - * [protected description] - * @var \Exception - */ - protected $encapsulatedException = null; - - /** - * [getEncapsulatedException description] - * @return \Exception [description] - */ - public function getEncapsulatedException() : \Exception { - return $this->encapsulatedException; - } - -} diff --git a/backend/class/session.php b/backend/class/session.php index 7b2c2d0..c1b72ce 100755 --- a/backend/class/session.php +++ b/backend/class/session.php @@ -1,11 +1,14 @@ set($this->getCacheGroup(), $this->getCacheKey(), $data); return $this; } + /** + * Returns the cache group name for sessions (e.g., a prefix) + * @return string [description] + */ + protected function getCacheGroup(): string + { + return 'SESSION'; + } + + /** + * Returns the cache key for sessions. + * Contains the application name and some kind of session identifier + * (e.g., cookie value) + * @return string + * @throws ReflectionException + * @throws exception + */ + protected function getCacheKey(): string + { + return "SESSION_" . app::getApp() . "_" . session_id(); + } + /** * * {@inheritDoc} - * @see \codename\core\session\sessionInterface::destroy() + * @throws ReflectionException + * @throws exception + * @see sessionInterface::destroy */ - public function destroy() { + public function destroy(): void + { app::getCache()->clearKey($this->getCacheGroup(), $this->getCacheKey()); // reset internal data array // to be rebuild on next call - $this->data = null; - return; + $this->data = []; } - private function makeData() { - if(is_null($this->data) || count($this->data) == 0) { - $this->data = app::getCache()->get($this->getCacheGroup(), $this->getCacheKey()); + /** + * + * {@inheritDoc} + * @param string $key + * @param mixed $data + * @throws ReflectionException + * @throws exception + * @see session::setData + */ + public function setData(string $key, mixed $data): void + { + $cacheData = app::getCache()->get($this->getCacheGroup(), $this->getCacheKey()); + if (!is_array($cacheData)) { + return; + } + if (strlen($key) == 0) { + return; + } + if (!array_key_exists($key, $cacheData)) { + return; } - return $this->data; + $cacheData[$key] = $data; + app::getCache()->set($this->getCacheGroup(), $this->getCacheKey(), $cacheData); + // reset internal data array + // to be rebuild on next call + $this->data = []; + } + + /** + * @return bool + * @throws ReflectionException + * @throws exception + */ + public function identify(): bool + { + $data = $this->getData(); + $this->data = $data; + return (is_array($data) && count($data) != 0); } /** * Return the value of the given key. Either pass a direct name, or use a tree to navigate through the data set - *
->get('my>config>key') + * ->get('my>config>key') * @param string $key - * @return mixed|null + * @return mixed + * @throws ReflectionException + * @throws exception */ - public function getData(string $key = '') { + public function getData(string $key = ''): mixed + { $this->makeData(); - if(strlen($key) == 0) { + if (strlen($key) == 0) { return $this->data; } - if(strpos($key, '>') === false) { - if($this->isDefined($key)) { + if (!str_contains($key, '>')) { + if ($this->isDefined($key)) { return $this->data[$key]; } return null; } $myConfig = $this->data; - foreach(explode('>', $key) as $myKey) { - if(!array_key_exists($myKey, $myConfig)) { + foreach (explode('>', $key) as $myKey) { + if (!array_key_exists($myKey, $myConfig)) { return null; } $myConfig = $myConfig[$myKey]; @@ -81,76 +153,41 @@ public function getData(string $key = '') { } /** - * - * {@inheritDoc} - * @see \codename\core\session::setData() + * @return void + * @throws ReflectionException + * @throws exception */ - public function setData(string $key, $value) { - $data = app::getCache()->get($this->getCacheGroup(), $this->getCacheKey()); - if(!is_array($data)) { - return null; - } - if(strlen($key) == 0) { - return $data; - } - if(!array_key_exists($key, $data)) { - return null; + private function makeData(): void + { + if (count($this->data) == 0) { + $this->data = app::getCache()->get($this->getCacheGroup(), $this->getCacheKey()) ?? []; } - $data[$key] = $value; - app::getCache()->set($this->getCacheGroup(), $this->getCacheKey(), $data); - // reset internal data array - // to be rebuild on next call - $this->data = null; - return; } /** * * {@inheritDoc} - * @see \codename\core\session::isDefined() + * @param string $key + * @return bool + * @throws ReflectionException + * @throws exception + * @see session::isDefined */ - public function isDefined(string $key) : bool { + public function isDefined(string $key): bool + { $data = app::getCache()->get($this->getCacheGroup(), $this->getCacheKey()); - if(!is_array($data)) { + if (!is_array($data)) { return false; } return array_key_exists($key, $data); } /** - * @todo DOCUMENTATION - */ - public function identify() : bool { - $data = $this->getData(); - $this->data = $data; - return (is_array($data) && count($data) != 0); - } - - /** - * Returns the cache key for sessions. - * Contains the application name and some kind of session identifier - * (e.g. cookie value) - * @return string - */ - protected function getCacheKey() : string{ - return "SESSION_" . app::getApp() . "_" . session_id(); - } - - /** - * Returns the cache group name for sessions (e.g. a prefix) - * @return string [description] - */ - protected function getCacheGroup(): string { - return 'SESSION'; - } - - /** - * @inheritDoc + * {@inheritDoc} */ - public function invalidate($sessionId) + public function invalidate(int|string $sessionId): void { - throw new \LogicException('Not implemented'); // TODO - // TODO/CHECK: app::getCache()->clearKey($this->getCachegroup(), "SESSION"); + throw new LogicException('Not implemented'); // TODO + // TODO/CHECK: app::getCache()->clearKey($this->getCacheGroup(), "SESSION"); } - } diff --git a/backend/class/session/database.php b/backend/class/session/database.php index 37905d8..ad792c0 100755 --- a/backend/class/session/database.php +++ b/backend/class/session/database.php @@ -1,132 +1,170 @@ sessionModel = app::getModel('session'); - } - +class database extends session implements sessionInterface +{ /** * session model - * @var \codename\core\model + * @var model */ - protected $sessionModel = null; - + protected model $sessionModel; /** * name of the cookie to use for session identification * @var string */ - protected $cookieName = 'core-session'; - + protected string $cookieName = 'core-session'; /** * lifetime of the cookie * used for identifying the session * @var string */ - protected $cookieLifetime = '+1 day'; - + protected string $cookieLifetime = '+1 day'; /** * maximum session lifetime - * static, cannot be prolonged + * static cannot be prolonged * @var string */ - protected $sessionLifetime = '12 hours'; + protected string $sessionLifetime = '12 hours'; + /** + * contains the underlying session model entry + * @var null|datacontainer + */ + protected ?datacontainer $sessionEntry = null; + /** + * contains the underlying session model entry + * @var null|datacontainer + */ + protected ?datacontainer $sessionData = null; /** - * updates validity for a session - * @param string $until [description] - * @return \codename\core\session [description] + * {@inheritDoc} + * @param array $data + * @throws ReflectionException + * @throws exception */ - public function setValidUntil(string $until) : \codename\core\session { - if($this->sessionEntry != null) { - // update id-based session model entry - $dataset = $this->myModel()->load($this->sessionEntry->getData('session_id')); + public function __construct(array $data = []) + { + parent::__construct($data); + $this->sessionModel = app::getModel('session'); + } - // - // CHANGED 2021-05-18: drop usage of entryLoad/Update/Save - // as it may overwrite data with current sessionEntry - // (e.g. grace period, if driver supports it in an overridden class) - // - if(!empty($dataset)) { - $this->myModel()->save([ - $this->myModel()->getPrimarykey() => $this->sessionEntry->getData('session_id'), - 'session_valid_until' => $until - ]); + /** + * updates validity for a session + * @param string $until [description] + * @return session [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function setValidUntil(string $until): session + { + if ($this->sessionEntry != null) { + // update id-based session model entry + $dataset = $this->myModel()->load($this->sessionEntry->getData('session_id')); + + // + // CHANGED 2021-05-18: drop usage of entryLoad/Update/Save + // as it may overwrite data with current sessionEntry + // (e.g., grace period, if a driver supports it in an overridden class) + // + if (!empty($dataset)) { + $this->myModel()->save([ + $this->myModel()->getPrimaryKey() => $this->sessionEntry->getData('session_id'), + 'session_valid_until' => $until, + ]); + } else { + throw new exception("SESSION_DATABASE_SETVALIDUNTIL_SESSION_DOES_NOT_EXIST", exception::$ERRORLEVEL_ERROR); + } } else { - throw new \codename\core\exception("SESSION_DATABASE_SETVALIDUNTIL_SESSION_DOES_NOT_EXIST", \codename\core\exception::$ERRORLEVEL_ERROR); + throw new exception("SESSION_DATABASE_SETVALIDUNTIL_INVALID_SESSIONENTRY", exception::$ERRORLEVEL_ERROR, $until); } - } else { - throw new \codename\core\exception("SESSION_DATABASE_SETVALIDUNTIL_INVALID_SESSIONENTRY", \codename\core\exception::$ERRORLEVEL_ERROR, $until); - } - return $this; + return $this; } /** - * [closePhpSession description] - * @return void + * @return model + * @todo DOCUMENTATION */ - protected function closePhpSession() { - // close session directly after, as we don't need it anymore. - // this enables concurrent, non-blocking requests - // but we can't write to $_SESSION anymore from now on - // which is ok, because this is the database session driver - if(session_status() !== PHP_SESSION_NONE) { - session_write_close(); - } + protected function myModel(): model + { + return $this->sessionModel; + } + + /** + * {@inheritDoc} + */ + public function getData(string $key = ''): mixed + { + $value = $this->sessionData?->getData($key); + if ($value == null && $this->sessionEntry != null) { + $value = $this->sessionEntry->getData($key); + } + return $value; } /** * * {@inheritDoc} + * @param array $data + * @return session + * @throws DateMalformedStringException + * @throws RandomException + * @throws ReflectionException + * @throws exception * @see \codename\core\session_interface::start($data) */ - public function start(array $data) : \codename\core\session { - + public function start(array $data): session + { // save prior to serialization - $this->sessionData = new \codename\core\datacontainer($data['session_data']); + $this->sessionData = new datacontainer($data['session_data']); - if(session_status() === PHP_SESSION_NONE) { - @session_start(); + if (session_status() === PHP_SESSION_NONE) { + @session_start(); } // // custom cookie handling // - if(!isset($_COOKIE[$this->cookieName])) { - $sessionIdentifier = bin2hex(random_bytes(16)); - - $options = [ - 'expires' => strtotime($this->cookieLifetime), - 'path' => '/', - 'domain' => $_SERVER['SERVER_NAME'], - ]; - - if(!$this->handleCookie($this->cookieName, $sessionIdentifier, $options)) { - throw new exception('COOKIE_SETTING_UGH', exception::$ERRORLEVEL_FATAL); - } - - // - // FAKE that the cookie existed on request. - // just for this instance. needed. - // - $_COOKIE[$this->cookieName] = $sessionIdentifier; - - $data['session_sessionid'] = $sessionIdentifier; + if (!isset($_COOKIE[$this->cookieName])) { + $sessionIdentifier = bin2hex(random_bytes(16)); + + $options = [ + 'expires' => strtotime($this->cookieLifetime), + 'path' => '/', + 'domain' => $_SERVER['SERVER_NAME'], + ]; + + if (!$this->handleCookie($this->cookieName, $sessionIdentifier, $options)) { + throw new exception('COOKIE_SETTING_UGH', exception::$ERRORLEVEL_FATAL); + } + + // + // FAKE that the cookie existed on request. + // just for this instance. needed. + // + $_COOKIE[$this->cookieName] = $sessionIdentifier; + + $data['session_sessionid'] = $sessionIdentifier; } else { - $data['session_sessionid'] = $_COOKIE[$this->cookieName]; + $data['session_sessionid'] = $_COOKIE[$this->cookieName]; } // close the PHP Session for allowing better performance @@ -135,73 +173,53 @@ public function start(array $data) : \codename\core\session { $this->myModel()->save($data); - // use identify() to fill datacontainers + // use identify() to fill datacontainer $this->identify(); return $this; } /** * [handleCookie description] - * @param string $cookieName [description] + * @param string $cookieName [description] * @param string $cookieValue [description] - * @param array $options [description] + * @param array $options [description] * @return bool [success] */ - protected function handleCookie(string $cookieName, string $cookieValue, array $options = []): bool { - return setcookie($cookieName, $cookieValue, $options); + protected function handleCookie(string $cookieName, string $cookieValue, array $options = []): bool + { + return setcookie($cookieName, $cookieValue, $options); } /** - * - * {@inheritDoc} - * @see \codename\core\session_interface::destroy() + * [closePhpSession description] + * @return void */ - public function destroy() { - // CHANGED 2021-09-24: PHP8 warning exception lead to this possible bug: - // unset cookie and trying to destroy a session may lead to destroying NULL-session ids - if(!($_COOKIE[$this->cookieName] ?? false)) { - return; - } - - $sess = $this->myModel() - ->addFilter('session_sessionid', $_COOKIE[$this->cookieName]) - ->addFilter('session_valid', true) - ->search()->getResult(); - - if(count($sess) == 0) { - return; - } - - // - // Invalidate each session entry - // - foreach($sess as $session) { - // - // CHANGED 2021-05-18: drop usage of entryLoad/Update/Save - // (e.g. grace period, if driver supports it in an overridden class) - // - $this->myModel()->save([ - $this->myModel()->getPrimarykey() => $session['session_id'], - 'session_valid' => false - ]); + protected function closePhpSession(): void + { + // close session directly after, as we don't need it anymore. + // this enables concurrent, non-blocking requests, + // but we can't write to $_SESSION anymore from now on + // which is OK, because this is the database session driver + if (session_status() !== PHP_SESSION_NONE) { + session_write_close(); } - - setcookie ($this->cookieName, "", 1, '/', $_SERVER['SERVER_NAME']); - return; } /** * [identify description] * @return bool [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception */ - public function identify() : bool { - + public function identify(): bool + { // close the PHP Session for allowing better performance // by non-blocking session files $this->closePhpSession(); - if(!isset($_COOKIE[$this->cookieName])) { - return false; + if (!isset($_COOKIE[$this->cookieName])) { + return false; } $model = $this->myModel(); @@ -217,126 +235,139 @@ public function identify() : bool { // session_valid_until must be either null or above current time // $model->addFilterCollection([ - [ 'field' => 'session_valid_until', 'operator' => '>=', 'value' => \codename\core\helper\date::getCurrentDateTimeAsDbdate() ], - [ 'field' => 'session_valid_until', 'operator' => '=', 'value' => null ] + ['field' => 'session_valid_until', 'operator' => '>=', 'value' => date::getCurrentDateTimeAsDbDate()], + ['field' => 'session_valid_until', 'operator' => '=', 'value' => null], ], 'OR'); // // if enabled, this defines a maximum session lifetime // - if($this->sessionLifetime) { - // flexible date, depending on keepalive - $model->addFilter('session_created', (new \DateTime('now'))->sub(\DateInterval::createFromDateString($this->sessionLifetime))->format('Y-m-d H:i:s'), '>='); + if ($this->sessionLifetime) { + // flexible date, depending on keepalive + $model->addFilter('session_created', (new DateTime('now'))->sub(DateInterval::createFromDateString($this->sessionLifetime))->format('Y-m-d H:i:s'), '>='); } $data = $model->search()->getResult(); - if(count($data) == 0) { + if (count($data) == 0) { $this->destroy(); return false; } $data = $data[0]; - $this->sessionEntry = new \codename\core\datacontainer($data); + $this->sessionEntry = new datacontainer($data); $sessData = $data['session_data']; - if(is_array($sessData)) { - $this->sessionData = new \codename\core\datacontainer($sessData); + if (is_array($sessData)) { + $this->sessionData = new datacontainer($sessData); } return true; } /** - * contains the underlying session model entry - * @var \codename\core\datacontainer + * + * {@inheritDoc} + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + * @see \codename\core\session_interface::destroy() */ - protected $sessionEntry = null; + public function destroy(): void + { + // CHANGED 2021-09-24: PHP8 warning exception lead to this possible bug: + // unset cookie and trying to destroy a session may lead to destroying NULL-session ids + if (!($_COOKIE[$this->cookieName] ?? false)) { + return; + } - /** - * contains the underlying session model entry - * @var \codename\core\datacontainer - */ - protected $sessionData = null; + $sess = $this->myModel() + ->addFilter('session_sessionid', $_COOKIE[$this->cookieName]) + ->addFilter('session_valid', true) + ->search()->getResult(); - /** - * @inheritDoc - */ - public function isDefined(string $key): bool - { - return ($this->sessionData && $this->sessionData->isDefined($key)) - || ($this->sessionEntry && $this->sessionEntry->isDefined($key)); - } + if (count($sess) == 0) { + return; + } - /** - * @inheritDoc - */ - public function getData(string $key = '') - { - $value = null; - if($this->sessionData != null) { - $value = $this->sessionData->getData($key); - } - if($value == null && $this->sessionEntry != null) { - $value = $this->sessionEntry->getData($key); - } - return $value; + // + // Invalidate each session entry + // + foreach ($sess as $session) { + // + // CHANGED 2021-05-18: drop usage of entryLoad/Update/Save + // (e.g., grace period, if a driver supports it in an overridden class) + // + $this->myModel()->save([ + $this->myModel()->getPrimaryKey() => $session['session_id'], + 'session_valid' => false, + ]); + } + + setcookie($this->cookieName, "", 1, '/', $_SERVER['SERVER_NAME']); } /** - * @inheritDoc + * {@inheritDoc} */ - public function setData(string $key, $data) + public function isDefined(string $key): bool { - $this->sessionData->setData($key, $data); - - if($this->sessionEntry != null) { - // update id-based session model entry - // CHANGED 2021-05-18: drop usage of entryLoad/Update/Save - // (e.g. grace period, if driver supports it in an overridden class) - // - $this->myModel()->save([ - $this->myModel()->getPrimarykey() => $this->sessionEntry->getData('session_id'), - 'session_data' => $this->sessionData->getData(), - ]); - } else { - throw new \codename\core\exception("SESSION_DATABASE_SETDATA_INVALID_SESSIONENTRY", \codename\core\exception::$ERRORLEVEL_ERROR, $data); - } + return ($this->sessionData && $this->sessionData->isDefined($key)) + || ($this->sessionEntry && $this->sessionEntry->isDefined($key)); } /** - * @todo DOCUMENTATION - * @return \codename\core\model + * {@inheritDoc} + * @param string $key + * @param mixed $data + * @throws exception */ - protected function myModel() : \codename\core\model { - return $this->sessionModel; + public function setData(string $key, mixed $data): void + { + $this->sessionData->setData($key, $data); + + if ($this->sessionEntry != null) { + // update id-based session model entry + // CHANGED 2021-05-18: drop usage of entryLoad/Update/Save + // (e.g., grace period, if a driver supports it in an overridden class) + // + $this->myModel()->save([ + $this->myModel()->getPrimaryKey() => $this->sessionEntry->getData('session_id'), + 'session_data' => $this->sessionData->getData(), + ]); + } else { + throw new exception("SESSION_DATABASE_SETDATA_INVALID_SESSIONENTRY", exception::$ERRORLEVEL_ERROR, $data); + } } /** - * @inheritDoc + * {@inheritDoc} + * @param int|string $sessionId + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception */ - public function invalidate($sessionId) + public function invalidate(int|string $sessionId): void { - if($sessionId) { - $sessions = $this->myModel() - ->addFilter('session_sessionid', $sessionId) - ->addFilter('session_valid', true) - ->search()->getResult(); - - // invalidate each session entry - foreach($sessions as $session) { - // - // CHANGED 2021-05-18: drop usage of entryLoad/Update/Save - // (e.g. grace period, if driver supports it in an overridden class) - // - $this->myModel()->save([ - $this->myModel()->getPrimarykey() => $session[$this->myModel()->getPrimarykey()], - 'session_valid' => false, - ]); + if (!empty($sessionId)) { + $sessions = $this->myModel() + ->addFilter('session_sessionid', $sessionId) + ->addFilter('session_valid', true) + ->search()->getResult(); + + // invalidate each session entry + foreach ($sessions as $session) { + // + // CHANGED 2021-05-18: drop usage of entryLoad/Update/Save + // (e.g., grace period, if a driver supports it in an overridden class) + // + $this->myModel()->save([ + $this->myModel()->getPrimaryKey() => $session[$this->myModel()->getPrimaryKey()], + 'session_valid' => false, + ]); + } + } else { + throw new exception('EXCEPTION_SESSION_INVALIDATE_NO_SESSIONID_PROVIDED', exception::$ERRORLEVEL_ERROR); } - } else { - throw new exception('EXCEPTION_SESSION_INVALIDATE_NO_SESSIONID_PROVIDED', exception::$ERRORLEVEL_ERROR); - } } - } diff --git a/backend/class/session/dummy.php b/backend/class/session/dummy.php index ab1feb5..ffe0d52 100755 --- a/backend/class/session/dummy.php +++ b/backend/class/session/dummy.php @@ -1,4 +1,5 @@ datacontainer = new \codename\core\datacontainer([]); + parent::__construct($data); + $this->datacontainer = new datacontainer([]); } + /** * * {@inheritDoc} * @see \codename\core\session_interface::start($data) */ - public function start(array $data) : \codename\core\session { - $this->datacontainer = new \codename\core\datacontainer($data); + public function start(array $data): \codename\core\session + { + $this->datacontainer = new datacontainer($data); return $this; } @@ -37,18 +43,9 @@ public function start(array $data) : \codename\core\session { * {@inheritDoc} * @see \codename\core\session_interface::destroy() */ - public function destroy() { - $this->datacontainer = new \codename\core\datacontainer(); - return; - } - - /** - * - * {@inheritDoc} - * @see \codename\core\session_interface::getData($key) - */ - public function getData(string $key='') { - return $this->datacontainer->getData($key); + public function destroy(): void + { + $this->datacontainer = new datacontainer(); } /** @@ -56,8 +53,9 @@ public function getData(string $key='') { * {@inheritDoc} * @see \codename\core\session_interface::setData($key, $value) */ - public function setData(string $key, $value) { - return $this->datacontainer->setData($key, $value); + public function setData(string $key, mixed $data): void + { + $this->datacontainer->setData($key, $data); } /** @@ -65,23 +63,34 @@ public function setData(string $key, $value) { * {@inheritDoc} * @see \codename\core\session_interface::isDefined($key) */ - public function isDefined(string $key) : bool { + public function isDefined(string $key): bool + { return $this->datacontainer->isDefined($key); } /** * */ - public function identify() : bool { + public function identify(): bool + { return count($this->datacontainer->getData()) > 0; } /** - * @inheritDoc + * + * {@inheritDoc} + * @see \codename\core\session_interface::getData($key) */ - public function invalidate($sessionId) + public function getData(string $key = ''): mixed { - throw new \LogicException('This session driver does not support Session Invalidation for foreign sessions'); + return $this->datacontainer->getData($key); } + /** + * {@inheritDoc} + */ + public function invalidate(int|string $sessionId): void + { + throw new LogicException('This session driver does not support Session Invalidation for foreign sessions'); + } } diff --git a/backend/class/session/session.php b/backend/class/session/session.php index 4e0b095..56e154e 100755 --- a/backend/class/session/session.php +++ b/backend/class/session/session.php @@ -1,106 +1,112 @@ =') ) { - return session_status() === PHP_SESSION_ACTIVE ? true : false; - } else { - return session_id() === '' ? false : true; - } - } - return false; - } - +class session extends \codename\core\session implements sessionInterface +{ /** * * {@inheritDoc} * @see \codename\core\session_interface::start($data) */ - public function start(array $data) : \codename\core\session { - if(!$this->isSessionStarted()) { - @session_start(); - $_SESSION = $data; + public function start(array $data): \codename\core\session + { + if (!$this->isSessionStarted()) { + @session_start(); + $_SESSION = $data; } - // Don't forget to set some headers needed for CORS + // Remember to set some headers needed for CORS // in your app. return $this; } /** - * - * {@inheritDoc} - * @see \codename\core\session_interface::destroy() + * [isSessionStarted description] + * @return bool */ - public function destroy() { - // unset($_SESSION); - session_destroy(); - setcookie ("PHPSESSID", "", time() - 3600); - return; + protected function isSessionStarted(): bool + { + if (php_sapi_name() !== 'cli') { + if (version_compare(phpversion(), '5.4.0', '>=')) { + return session_status() === PHP_SESSION_ACTIVE; + } else { + return !(session_id() === ''); + } + } + return false; } /** * * {@inheritDoc} - * @see \codename\core\session_interface::getData($key) + * @see \codename\core\session_interface::destroy() */ - public function getData(string $key='') { - if(strlen($key) == 0) { - return $_SESSION; - } - if(!$this->isDefined($key)) { - return null; - } - return $_SESSION[$key]; + public function destroy(): void + { + // unset($_SESSION); + session_destroy(); + setcookie("PHPSESSID", "", time() - 3600); } /** * * {@inheritDoc} - * @see \codename\core\session_interface::setData($key, $value) + * @see \codename\core\session_interface::setData($key, $data) */ - public function setData(string $key, $value) { - $_SESSION[$key] = $value; - return; + public function setData(string $key, mixed $data): void + { + $_SESSION[$key] = $data; } /** * [identify description] * @return bool [description] */ - public function identify() : bool { + public function identify(): bool + { $data = $this->getData(); return (is_array($data) && count($data) != 0); } + /** + * + * {@inheritDoc} + * @see \codename\core\session_interface::getData($key) + */ + public function getData(string $key = ''): mixed + { + if (strlen($key) == 0) { + return $_SESSION; + } + if (!$this->isDefined($key)) { + return null; + } + return $_SESSION[$key]; + } + /** * * {@inheritDoc} * @see \codename\core\session_interface::isDefined($key) */ - public function isDefined(string $key) : bool { + public function isDefined(string $key): bool + { return isset($_SESSION[$key]); } /** - * @inheritDoc + * {@inheritDoc} */ - public function invalidate($sessionId) + public function invalidate(int|string $sessionId): void { - // TODO: we might kill sessions via file deletion? - throw new \LogicException('This session driver does not support Session Invalidation for foreign sessions (at the moment)'); + // TODO: we might kill sessions via file deletion? + throw new LogicException('This session driver does not support Session Invalidation for foreign sessions (at the moment)'); } - } diff --git a/backend/class/session/sessionInterface.php b/backend/class/session/sessionInterface.php index 38a3b69..cef51e8 100755 --- a/backend/class/session/sessionInterface.php +++ b/backend/class/session/sessionInterface.php @@ -1,4 +1,5 @@ task = $task; - } +abstract class taskrunner +{ + /** + * current task + * @var array + */ + protected array $task; - /** - * determines the state if the given task/taskrunner - * can be executed by a machine - * you should implement a custom method here - * that checks various parameters to determine - * the executability, e.g. - * - machine/server name - * - connectivity? - * - whatever - * - * @return bool - */ - public abstract function isExecutable() : bool; + /** + * Creates a new taskrunner + * + * @param array $task [task dataset/entry] + */ + public function __construct(array $task) + { + $this->task = $task; + } - /** - * returns the link parameters - * needed for human interaction - * in case this task must be run by a human - * this may contain a link array like - * [ - * 'context' => 'order', - * 'view' => 'check', - * 'order_id' => $this->task['task_data']['order_id'] - * ] - * - * @return array - */ - public abstract function getLinkParameters() : array; + /** + * determines the state if the given task/taskrunner + * can be executed by a machine + * you should implement a custom method here + * that checks various parameters to determine + * the executability, e.g. + * - machine/server name + * - connectivity? + * - whatever + * + * @return bool + */ + abstract public function isExecutable(): bool; - /** - * executes the routines - * specific for this taskrunner - * using the given task - * - * @return void - */ - public abstract function run(); + /** + * returns the link parameters + * needed for human interaction + * in case this task must be run by a human + * this may contain a link array like + * [ + * 'context' => 'order', + * 'view' => 'check', + * 'order_id' => $this->task['task_data']['order_id'] + * ] + * + * @return array + */ + abstract public function getLinkParameters(): array; - /** - * returns the possible outcomes - * of running this taskrunner - * returns an array of strings (taskrunner names) - * - * @return string[] - */ - public abstract function getPossibleOutputTasks(): array; + /** + * executes the routines + * specific for this taskrunner + * using the given task + * + * @return void + */ + abstract public function run(): void; - /** - * lock the current task - * - * @return bool [success] - */ - protected abstract function lock() : bool; + /** + * returns the possible outcomes + * of running this taskrunner + * returns an array of strings (taskrunner names) + * + * @return string[] + */ + abstract public function getPossibleOutputTasks(): array; - /** - * unlock the current task - * - * @return bool [success] - */ - protected abstract function unlock() : bool; + /** + * lock the current task + * + * @return bool [success] + */ + abstract protected function lock(): bool; - /** - * marks a task as completed - * - * @return bool [success] - */ - protected abstract function complete() : bool; + /** + * unlock the current task + * + * @return bool [success] + */ + abstract protected function unlock(): bool; - /** - * marks a task as started/sets the start time to now - * - * @return bool [success] - */ - protected abstract function start() : bool; + /** + * marks a task as completed + * + * @return bool [success] + */ + abstract protected function complete(): bool; - /** - * returns a unique id for this taskrunner - * @return string - */ - protected abstract function getTaskrunnerId() : string; + /** + * marks a task as started/sets the start time to now + * + * @return bool [success] + */ + abstract protected function start(): bool; - /** - * creates a new task - * @param array $data [task] - */ - protected abstract function createTask(array $data); + /** + * returns a unique id for this taskrunner + * @return string + */ + abstract protected function getTaskrunnerId(): string; + /** + * creates a new task + * @param array $data [task] + */ + abstract protected function createTask(array $data); } diff --git a/backend/class/tasks/taskschedulerInterface.php b/backend/class/tasks/taskschedulerInterface.php index 2987499..6226d32 100644 --- a/backend/class/tasks/taskschedulerInterface.php +++ b/backend/class/tasks/taskschedulerInterface.php @@ -1,4 +1,5 @@ configValidator != null) { + $validator = app::getValidator($this->configValidator); + if (count($validator->validate($config)) > 0) { + throw new exception("CORE_TEMPLATEENGINE_CONFIG_VALIDATION_FAILED", exception::$ERRORLEVEL_FATAL, $config); + } + } - /** - * [__construct description] - * @param array $config [description] - */ - public function __construct(array $config = array()) - { - // validate config on need - if($this->configValidator != null) { - $validator = app::getValidator($this->configValidator); - if(count($errors = $validator->validate($config)) > 0) { - throw new exception("CORE_TEMPLATEENGINE_CONFIG_VALIDATION_FAILED", exception::$ERRORLEVEL_FATAL, $config); - } + $this->config = new config($config); } - $this->config = new config($config); - } - - /** - * Returns the path for storing (temporary) assets - * for rendering or output - * @return string [description] - */ - public function getAssetsPath(): string { - throw new \LogicException('Not implemented'); - } - - /** - * [getConfig description] - * @return config [description] - */ - public function getConfig(): config { - return $this->config; - } + /** + * Returns the path for storing (temporary) assets + * for rendering or output + * @return string [description] + */ + public function getAssetsPath(): string + { + throw new LogicException('Not implemented'); + } - /** - * [render description] - * @param string $referencePath [path to view, without file extension] - * @param datacontainer $data [data container / data context] - * @return string [rendered view] - */ - public abstract function render(string $referencePath, $data = null) : string; + /** + * [getConfig description] + * @return config [description] + */ + public function getConfig(): config + { + return $this->config; + } - /** - * [renderView description] - * @param string $viewPath [path to view, without file extension] - * @param datacontainer $data [data container / data context] - * @return string [rendered view] - */ - public abstract function renderView(string $viewPath, $data = null) : string; + /** + * [render description] + * @param string $referencePath [path to view, without a file extension] + * @param object|array|null $data [data container / data context] + * @return string [rendered view] + */ + abstract public function render(string $referencePath, object|array|null $data = null): string; - /** - * [renderTemplate description] - * @param string $templatePath [path to template, without file extension] - * @param datacontainer $data [data container / data context] - * @return string [rendered template] - */ - public abstract function renderTemplate(string $templatePath, $data = null) : string; + /** + * [renderView description] + * @param string $viewPath [path to view, without a file extension] + * @param object|array|null $data [data container / data context] + * @return string [rendered view] + */ + abstract public function renderView(string $viewPath, object|array|null $data = null): string; + /** + * [renderTemplate description] + * @param string $templatePath [path to template, without a file extension] + * @param object|array|null $data [data container / data context] + * @return string [rendered template] + */ + abstract public function renderTemplate(string $templatePath, object|array|null $data = null): string; } diff --git a/backend/class/timemachine.php b/backend/class/timemachine.php index 9a4d62c..7a9f03f 100644 --- a/backend/class/timemachine.php +++ b/backend/class/timemachine.php @@ -1,236 +1,250 @@ * @since 2017-03-08 */ -class timemachine { - - /** - * @var model - */ - protected $timemachineModel = null; - - /** - * a model capable of using the timemachine - * @var model - */ - protected $capableModel = null; - - /** - * get a timemachine instance for a given model name - * - * @param string $capableModelName [description] - * @param string $app [description] - * @param string $vendor [description] - * @return timemachine [description] - */ - public static function getInstance(string $capableModelName, string $app = '', string $vendor = '') : timemachine { - $identifier = $capableModelName.'-'.$vendor.'-'.$app; - return self::$instances[$identifier] ?? (self::$instances[$identifier] = new self(app::getModel($capableModelName, $app, $vendor))); - } - - /** - * instance cache - * @var timemachine[] - */ - protected static $instances = []; - - /** - * creates a new instance of the timemachine - * please use ::getInstance() instead! - * - * @param model $capableModel [description] - */ - public function __construct(model $capableModel) - { - if(!$capableModel instanceof timemachineInterface) { - throw new exception('MODEL_DOES_NOT_IMPLEMENT_TIMEMACHINE_INTERFACE', exception::$ERRORLEVEL_FATAL, $capableModel->getIdentifier()); - } - if(!$capableModel->isTimemachineEnabled()) { - throw new exception('MODEL_IS_NOT_TIMEMACHINE_ENABLED', exception::$ERRORLEVEL_FATAL, $capableModel->getIdentifier()); - } +class timemachine +{ + /** + * instance cache + * @var timemachine[] + */ + protected static array $instances = []; + /** + * @var null|model + */ + protected ?model $timemachineModel = null; + /** + * a model capable of using the timemachine + * @var null|model + */ + protected model|null $capableModel = null; + + /** + * creates a new instance of the timemachine + * please use ":: getInstance()" instead! + * + * @param model $capableModel [description] + * @throws exception + */ + public function __construct(model $capableModel) + { + if (!$capableModel instanceof timemachineInterface) { + throw new exception('MODEL_DOES_NOT_IMPLEMENT_TIMEMACHINE_INTERFACE', exception::$ERRORLEVEL_FATAL, $capableModel->getIdentifier()); + } + if (!$capableModel->isTimemachineEnabled()) { + throw new exception('MODEL_IS_NOT_TIMEMACHINE_ENABLED', exception::$ERRORLEVEL_FATAL, $capableModel->getIdentifier()); + } - // set the source model (model capable of using the timemachine) - $this->capableModel = $capableModel; + // set the source model (model capable of using the timemachine) + $this->capableModel = $capableModel; - // set the associated timemachine model - // this model is used for storing the delta data - $this->timemachineModel = $capableModel->getTimemachineModel(); + // set the associated timemachine model + // this model is used for storing the delta data + $this->timemachineModel = $capableModel->getTimemachineModel(); - if(!($this->timemachineModel instanceof timemachineModelInterface)) { - throw new exception('TIMEMACHINE_MODEL_DOES_NOT_IMPLEMENT_TIMEMACHINEMODELINTERFACE', exception::$ERRORLEVEL_FATAL, $this->timemachineModel->getIdentifier()); - } - } - - /** - * returns a dataset at a given point in time - * - * @param int $identifier [description] - * @param int $timestamp [description] - * @return array [description] - */ - public function getHistoricData(int $identifier, int $timestamp) : array - { - $delta = $this->getDeltaData($identifier, $timestamp); - $current = $this->getCurrentData($identifier); - $historic = array_replace($current, $delta); - return $historic; - } - - /** - * returns the fields excluded from timemachine tracking - * @return string[] - */ - protected function getExcludedFields() : array - { - // by default, exclude the primarykey - // and both mandatory fields when using schematic models: ..._created, ..._modified - $excludedFields = array( - $this->capableModel->getPrimarykey(), - $this->capableModel->getIdentifier() .'_created', - $this->capableModel->getIdentifier() .'_modified' - ); - - // TODO: provide an interface for excluding fields via capableModel - - return $excludedFields; - } - - /** - * [getDeltaData description] - * @param int $identifier [the primary key] - * @param int $timestamp [the oldest timestamp we're retrieving the data for] - * @return array [delta data] - */ - public function getDeltaData(int $identifier, int $timestamp) : array - { - $history = $this->getHistory($identifier, $timestamp); - $excludedFields = $this->getExcludedFields(); - - $delta = array(); - foreach($history as $state) { - $h = $state[$this->timemachineModel->getIdentifier() . '_data']; - foreach($h as $key => $value) { - if(!in_array($key, $excludedFields)) { - if((!array_key_exists($key, $delta)) || ($delta[$key] != $value)) { // TODO: CHECK - // value differs or even the key doesn't exist - $delta[$key] = $value; - } + if (!($this->timemachineModel instanceof timemachineModelInterface)) { + throw new exception('TIMEMACHINE_MODEL_DOES_NOT_IMPLEMENT_TIMEMACHINEMODELINTERFACE', exception::$ERRORLEVEL_FATAL, $this->timemachineModel->getIdentifier()); } - } - } - return $delta; - } - - /** - * returns a history of all changes done to an entry in descending order - * optionally, until a specific timestamp - * @param int $identifier [id/primary key value] - * @param int $timestamp [unix timestamp, default 0 for ALL/until now] - * @return array - */ - public function getHistory(int $identifier, int $timestamp = 0) : array - { - $this->timemachineModel - ->addFilter($this->timemachineModel->getIdentifier() . '_model', $this->capableModel->getIdentifier()) - ->addFilter($this->timemachineModel->getIdentifier() . '_ref', $identifier) - ->addOrder($this->timemachineModel->getIdentifier() . '_created', 'DESC'); - - if($timestamp !== 0) { - // return all entries newer than a specific state - // to go through all entries in descending order - $this->timemachineModel->addFilter($this->timemachineModel->getIdentifier() . '_created', \codename\core\helper\date::getTimestampAsDbdate($timestamp), '>='); } - // get the history (all respective timemachine entries) for the requested time range - $history = $this->timemachineModel->search()->getResult(); + /** + * get a timemachine instance for a given model name + * + * @param string $capableModelName [description] + * @param string $app [description] + * @param string $vendor [description] + * @return timemachine [description] + * @throws ReflectionException + * @throws exception + */ + public static function getInstance(string $capableModelName, string $app = '', string $vendor = ''): timemachine + { + $identifier = $capableModelName . '-' . $vendor . '-' . $app; + return self::$instances[$identifier] ?? (self::$instances[$identifier] = new self(app::getModel($capableModelName, $app, $vendor))); + } - // retrieve target datatypes - $datatype = $this->capableModel->config->get('datatype'); + /** + * returns a dataset at a given point in time + * + * @param int $identifier [description] + * @param int $timestamp [description] + * @return array [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function getHistoricData(int $identifier, int $timestamp): array + { + $delta = $this->getDeltaData($identifier, $timestamp); + $current = $this->getCurrentData($identifier); + return array_replace($current, $delta); + } - foreach($history as &$r) { - foreach($r as $key => &$value) { - if(array_key_exists($key, $datatype)) { - if(strpos($datatype[$key], 'structu') !== false ) { - $value = app::object2array(json_decode($value, false)/*, 512, JSON_UNESCAPED_UNICODE)*/); - } + /** + * [getDeltaData description] + * @param int $identifier [the primary key] + * @param int $timestamp [the oldest timestamp we're retrieving the data for] + * @return array [delta data] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function getDeltaData(int $identifier, int $timestamp): array + { + $history = $this->getHistory($identifier, $timestamp); + $excludedFields = $this->getExcludedFields(); + + $delta = []; + foreach ($history as $state) { + $h = $state[$this->timemachineModel->getIdentifier() . '_data']; + foreach ($h as $key => $value) { + if (!in_array($key, $excludedFields)) { + if ((!array_key_exists($key, $delta)) || ($delta[$key] != $value)) { // TODO: CHECK + // value differs or even the key doesn't exist + $delta[$key] = $value; + } + } + } } - } + return $delta; } - return $history; - } - - /** - * returns the current dataset - * - * @param int $identifier [description] - * @return array [description] - */ - public function getCurrentData(int $identifier) : array - { - $current = $this->capableModel->load($identifier); - return $current; - } - - /** - * saves the delta-based state of a given model and entry - * and returns the respective entry id or NULL (empty delta) - * @param int $identifier [description] - * @param array $newData [description] - * @param bool $deletion [whether the corresponding dataset is going to be deleted - this stores a full snapshot] - * @return int|null [description] - */ - public function saveState(int $identifier, array $newData, bool $deletion = false) : ?int - { - $data = $this->getCurrentData($identifier); - $delta = array(); - $excludedFields = $this->getExcludedFields(); - - if($deletion) { - // - // for deletion: store the full dataset - // - $delta = $data; - } else { - // - // generic delta calculation - // - foreach($newData as $key => $value) { - if(!in_array($key, $excludedFields)) { - if((!array_key_exists($key, $data)) || ($data[$key] != $value)) { - // value differs or even the key doesn't exist - $delta[$key] = $data[$key] ?? null; // store EXISTING/old data (!) - } + /** + * returns a history of all changes done to an entry in descending order + * optionally, until a specific timestamp + * @param int $identifier [id/primary key value] + * @param int $timestamp [unix timestamp, default 0 for ALL/until now] + * @return array + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception + */ + public function getHistory(int $identifier, int $timestamp = 0): array + { + $this->timemachineModel + ->addFilter($this->timemachineModel->getIdentifier() . '_model', $this->capableModel->getIdentifier()) + ->addFilter($this->timemachineModel->getIdentifier() . '_ref', $identifier) + ->addOrder($this->timemachineModel->getIdentifier() . '_created', 'DESC'); + + if ($timestamp !== 0) { + // return all entries newer than a specific state + // to go through all entries in descending order + $this->timemachineModel->addFilter($this->timemachineModel->getIdentifier() . '_created', date::getTimestampAsDbdate($timestamp), '>='); } - } + + // get the history (all respective timemachine entries) for the requested time range + $history = $this->timemachineModel->search()->getResult(); + + // retrieve target datatype + $datatype = $this->capableModel->config->get('datatype'); + + foreach ($history as &$r) { + foreach ($r as $key => &$value) { + if (array_key_exists($key, $datatype)) { + if (str_contains($datatype[$key], 'structu')) { + $value = app::object2array(json_decode($value, false)/*, 512, JSON_UNESCAPED_UNICODE)*/); + } + } + } + } + + return $history; + } + + /** + * returns the fields excluded from timemachine tracking + * @return string[] + * @throws exception + */ + protected function getExcludedFields(): array + { + // by default, exclude the primarykey + // and both mandatory fields when using schematic models: ..._created, ..._modified + // TODO: provide an interface for excluding fields via capableModel + + return [ + $this->capableModel->getPrimaryKey(), + $this->capableModel->getIdentifier() . '_created', + $this->capableModel->getIdentifier() . '_modified', + ]; } - // do not story empty deltas (no difference) - if(count($delta) === 0) { - return null; + /** + * returns the current dataset + * + * @param int $identifier [description] + * @return array [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function getCurrentData(int $identifier): array + { + return $this->capableModel->load($identifier); } - // - // CHANGED 2020-02-27: omit instanceof for timemachine model, - // as it might cost cycles - // and it is implemented/checked in the constructor anyways - // - $this->timemachineModel->save([ - $this->timemachineModel->getModelField() => $this->capableModel->getIdentifier(), - $this->timemachineModel->getRefField() => $identifier, - $this->timemachineModel->getDataField() => $delta - ]); - return $this->timemachineModel->lastInsertId(); - } + /** + * saves the delta-based state of a given model and entry + * and returns the respective entry id or NULL (empty delta) + * @param int $identifier [description] + * @param array $newData [description] + * @param bool $deletion [whether the corresponding dataset is going to be deleted - this stores a full snapshot] + * @return int|null [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function saveState(int $identifier, array $newData, bool $deletion = false): ?int + { + $data = $this->getCurrentData($identifier); + $delta = []; + $excludedFields = $this->getExcludedFields(); + + if ($deletion) { + // + // for deletion: store the full dataset + // + $delta = $data; + } else { + // + // generic delta calculation + // + foreach ($newData as $key => $value) { + if (!in_array($key, $excludedFields)) { + if ((!array_key_exists($key, $data)) || ($data[$key] != $value)) { + // value differs or even the key doesn't exist + $delta[$key] = $data[$key] ?? null; // store EXISTING/old data (!) + } + } + } + } + // do not story empty deltas (no difference) + if (count($delta) === 0) { + return null; + } + + // + // CHANGED 2020-02-27: omit instanceof for a timemachine model, + // as it might cost cycles, + // and it is implemented/checked in the constructor anyway + // + $this->timemachineModel->save([ + $this->timemachineModel->getModelField() => $this->capableModel->getIdentifier(), + $this->timemachineModel->getRefField() => $identifier, + $this->timemachineModel->getDataField() => $delta, + ]); + return $this->timemachineModel->lastInsertId(); + } } diff --git a/backend/class/transaction.php b/backend/class/transaction.php index 14f6676..bee9e10 100644 --- a/backend/class/transaction.php +++ b/backend/class/transaction.php @@ -1,82 +1,87 @@ name = $name; - $this->transactionables = $transactionables; - } +class transaction +{ + /** + * [protected description] + * @var string|null [type] + */ + protected ?string $name = null; + /** + * [protected description] + * @var array + */ + protected array $transactionables = []; + /** + * [protected description] + * @var bool [type] + */ + protected bool $started = false; - /** - * [protected description] - * @var transactionableInterface[] - */ - protected $transactionables = []; - - /** - * [protected description] - * @var [type] - */ - protected $started = false; - - /** - * [addTransactionable description] - * @param transactionableInterface $transactionable [description] - * @return transaction [description] - */ - public function addTransactionable(transactionableInterface $transactionable) : transaction { - $this->transactionables[] = $transactionable; - // add a transactionable after transaction has been started - if($this->started) { - $transactionable->beginTransaction($this->name); + /** + * [__construct description] + * @param string $name [description] + * @param array $transactionables [description] + */ + public function __construct(string $name, array $transactionables = []) + { + $this->name = $name; + $this->transactionables = $transactionables; } - return $this; - } - /** - * [start description] - * @return transaction [description] - */ - public function start() : transaction { - if($this->started) { - throw new exception('EXCEPTION_TRANSACTION_START_ALREADY_STARTED', exception::$ERRORLEVEL_FATAL, $this->name); + /** + * [addTransactionable description] + * @param transactionableInterface $transactionable [description] + * @return transaction [description] + */ + public function addTransactionable(transactionableInterface $transactionable): transaction + { + $this->transactionables[] = $transactionable; + // add a transactionable after transaction has been started + if ($this->started) { + $transactionable->beginTransaction($this->name); + } + return $this; } - $this->started = true; - foreach($this->transactionables as $transactionable) { - $transactionable->beginTransaction($this->name); - } - return $this; - } - /** - * [end description] - * @return transaction [description] - */ - public function end() : transaction { - if(!$this->started) { - throw new exception('EXCEPTION_TRANSACTION_END_NOT_STARTED', exception::$ERRORLEVEL_FATAL, $this->name); + /** + * [start description] + * @return transaction [description] + * @throws exception + */ + public function start(): transaction + { + if ($this->started) { + throw new exception('EXCEPTION_TRANSACTION_START_ALREADY_STARTED', exception::$ERRORLEVEL_FATAL, $this->name); + } + $this->started = true; + foreach ($this->transactionables as $transactionable) { + $transactionable->beginTransaction($this->name); + } + return $this; } - foreach($this->transactionables as $transactionable) { - $transactionable->endTransaction($this->name); + + /** + * [end description] + * @return transaction [description] + * @throws exception + */ + public function end(): transaction + { + if (!$this->started) { + throw new exception('EXCEPTION_TRANSACTION_END_NOT_STARTED', exception::$ERRORLEVEL_FATAL, $this->name); + } + foreach ($this->transactionables as $transactionable) { + $transactionable->endTransaction($this->name); + } + $this->started = false; + return $this; } - $this->started = false; - return $this; - } } diff --git a/backend/class/transaction/transactionableInterface.php b/backend/class/transaction/transactionableInterface.php index 76cf0e1..ef8e6b4 100644 --- a/backend/class/transaction/transactionableInterface.php +++ b/backend/class/transaction/transactionableInterface.php @@ -1,20 +1,20 @@ config = $config; - } + protected array $cachedTranslations = []; /** - * [getPrefix description] - * @return string [description] + * @param mixed $config */ - public function getPrefix() : string { - return ''; + public function __construct(mixed $config = []) + { + $this->config = $config; } /** * * {@inheritDoc} - * @see \codename\core\translate_interface::translate($key, $data) + * @param string $key + * @param array $data + * @return string + * @throws ReflectionException + * @throws exception + * @see translate_interface::translate, $data) */ - public function translate(string $key, array $data = array()) : string { + public function translate(string $key, array $data = []): string + { $cacheGroup = 'TRANSLATION_' . app::getApp() . '_' . $this->getPrefix() . '_'; - $text = $this->cachedTranslations[$cacheGroup.$key] ?? null; + $text = $this->cachedTranslations[$cacheGroup . $key] ?? null; - if($text) { - return $text; + if ($text) { + return $text; } else { - $text = app::getCache()->get($cacheGroup, $key); - if($text) { - $this->cachedTranslations[$cacheGroup.$key] = $text; - } + $text = app::getCache()->get($cacheGroup, $key); + if ($text) { + $this->cachedTranslations[$cacheGroup . $key] = $text; + } } - if(strlen($text) > 0) { + if (strlen($text) > 0) { return $text; } app::getCache()->set($cacheGroup, $key, $text = $this->getTranslation($key)); - $this->cachedTranslations[$cacheGroup.$key] = $text; + $this->cachedTranslations[$cacheGroup . $key] = $text; return $text; } /** - * [protected description] - * @var string[] + * [getPrefix description] + * @return string [description] */ - protected $cachedTranslations = []; - + public function getPrefix(): string + { + return ''; + } } diff --git a/backend/class/translate/dummy.php b/backend/class/translate/dummy.php index 8aa647c..3117a39 100644 --- a/backend/class/translate/dummy.php +++ b/backend/class/translate/dummy.php @@ -1,40 +1,41 @@ getPrefix() . '/' . $name . '.json'; - if(array_key_exists($stackname, $this->instances)) { - return $this->instances[$stackname]; - } else { - $this->instances[$stackname] = new \codename\core\config([]); - } - return $this->instances[$stackname]; + public function getAllTranslations(string $prefix): ?array + { + return $this->getSourceInstance($prefix) ? $this->getSourceInstance($prefix)->get() : null; } /** - * @inheritDoc + * [getSourceInstance description] + * @param string $name [description] + * @return config|null [description] */ - public function getAllTranslations(string $prefix): ?array + protected function getSourceInstance(string $name): ?config { - return $this->getSourceInstance($prefix) ? $this->getSourceInstance($prefix)->get() : null; + $stackname = 'translation/' . $this->getPrefix() . '/' . $name . '.json'; + if (array_key_exists($stackname, $this->instances)) { + return $this->instances[$stackname]; + } else { + $this->instances[$stackname] = new config([]); + } + return $this->instances[$stackname]; } /** - * @inheritDoc + * {@inheritDoc} */ public function getPrefix(): string { - return app::getRequest()->getData('lang'); + return app::getRequest()->getData('lang'); } - } diff --git a/backend/class/translate/json.php b/backend/class/translate/json.php index 6e8c12d..30ed4a1 100755 --- a/backend/class/translate/json.php +++ b/backend/class/translate/json.php @@ -1,49 +1,108 @@ getSourceInstance($prefix) ? $this->getSourceInstance($prefix)->get() : null; + } + + /** + * [getSourceInstance description] + * @param string $name [description] + * @return config|null [description] + * @throws ReflectionException + * @throws exception */ - protected $instances = array(); + protected function getSourceInstance(string $name): ?config + { + $stackname = 'translation/' . $this->getPrefix() . '/' . $name . '.json'; + if (array_key_exists($stackname, $this->instances)) { + return $this->instances[$stackname]; + } else { + $instance = null; + try { + $instance = new config\json( + $stackname, + $this->config['inherit'] ?? false, + $this->config['inherit'] ?? false + ); + } catch (\Exception $e) { + // allow nonexisting hierarchies - but otherwise, really throw the exception. + if ($e->getCode() !== config\json::EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND) { + throw $e; + } + } + $this->instances[$stackname] = $instance; + } + return $this->instances[$stackname]; + } + + /** + * {@inheritDoc} + */ + public function getPrefix(): string + { + return app::getRequest()->getData('lang'); + } /** * translates a key in the format DATAFILE.SOMEKEY * Where the first key part (before the dot) is some kind of prefix * that is used for identifying the datasource fiel * - * @param string $key [description] + * @param string $key [description] * @return string [description] + * @throws ReflectionException + * @throws exception */ - protected function getTranslation(string $key) : string { + protected function getTranslation(string $key): string + { $keystr = $key; - // Split into maximum of 2 elements (dots may exist afterwards) + // Split into a maximum of 2 elements (dots may exist afterward) $key = explode('.', $key, 2); - if(count($key) != 2) { + if (count($key) != 2) { throw new exception('EXCEPTION_TRANSLATE_JSON_MISSING_DOT', exception::$ERRORLEVEL_ERROR, $keystr); } - if(empty($key[1])) { - throw new exception('EXCEPTION_TRANSLATE_JSON_INVALID_KEY_REQUESTED', exception::$ERRORLEVEL_ERROR, $keystr); + if (empty($key[1])) { + throw new exception('EXCEPTION_TRANSLATE_JSON_INVALID_KEY_REQUESTED', exception::$ERRORLEVEL_ERROR, $keystr); } $key[0] = strtolower($key[0]); @@ -55,58 +114,13 @@ protected function getTranslation(string $key) : string { $value = $keyname; // use instance, if not null - otherwise, let it fall back. - $v = $instance !== null ? $instance->get($key[1]) : null; - if($v != null) { - $value = $v; + $v = $instance?->get($key[1]); + if ($v != null) { + $value = $v; } else { - \codename\core\app::getHook()->fire(\codename\core\hook::EVENT_TRANSLATE_TRANSLATION_KEY_MISSING, $key); + app::getHook()->fire(hook::EVENT_TRANSLATE_TRANSLATION_KEY_MISSING, $key); } return $value; } - - /** - * [getSourceInstance description] - * @param string $name [description] - * @return \codename\core\config|null [description] - */ - protected function getSourceInstance(string $name) : ?\codename\core\config { - $stackname = 'translation/' . $this->getPrefix() . '/' . $name . '.json'; - if(array_key_exists($stackname, $this->instances)) { - return $this->instances[$stackname]; - } else { - $instance = null; - try { - $instance = new \codename\core\config\json( - $stackname, - $this->config['inherit'] ?? false, - $this->config['inherit'] ?? false - ); - } catch (\Exception $e) { - // allow nonexisting hierarchies - but otherwise, really throw the exception. - if($e->getCode() !== \codename\core\config\json::EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND) { - throw $e; - } - } - $this->instances[$stackname] = $instance; - } - return $this->instances[$stackname]; - } - - /** - * @inheritDoc - */ - public function getAllTranslations(string $prefix): ?array - { - return $this->getSourceInstance($prefix) ? $this->getSourceInstance($prefix)->get() : null; - } - - /** - * @inheritDoc - */ - public function getPrefix(): string - { - return app::getRequest()->getData('lang'); - } - } diff --git a/backend/class/translate/translateInterface.php b/backend/class/translate/translateInterface.php index 8c34ae5..5aff61e 100755 --- a/backend/class/translate/translateInterface.php +++ b/backend/class/translate/translateInterface.php @@ -1,26 +1,26 @@ $value) - { - if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) - { - $merged[$key] = self::array_merge_recursive_ex($merged[$key], $value); - } else if (is_numeric($key)) - { - if (!in_array($value, $merged)) - $merged[] = $value; - } else - $merged[$key] = $value; - } +class utils +{ + /** + * Merges/unifies two arrays recursively + * @see http://stackoverflow.com/questions/25712099/php-multidimensional-array-merge-recursive + * @param array $array1 + * @param array $array2 + * @return array + */ + public static function array_merge_recursive_ex(array $array1, array $array2): array + { + $merged = $array1; - return $merged; - } + foreach ($array2 as $key => $value) { + if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) { + $merged[$key] = self::array_merge_recursive_ex($merged[$key], $value); + } elseif (is_numeric($key)) { + if (!in_array($value, $merged)) { + $merged[] = $value; + } + } else { + $merged[$key] = $value; + } + } -} \ No newline at end of file + return $merged; + } +} diff --git a/backend/class/validator.php b/backend/class/validator.php index 94c79cc..7344578 100755 --- a/backend/class/validator.php +++ b/backend/class/validator.php @@ -1,40 +1,55 @@ errorstack = new \codename\core\errorstack('VALIDATION'); + public function __construct(bool $nullAllowed = true) + { + $this->errorstack = new errorstack('VALIDATION'); $this->nullAllowed = $nullAllowed; return $this; } + /** + * Performs validation and directly returns the state of validation (true/false) + * @param mixed|null $value + * @return bool + */ + final public function isValid(mixed $value): bool + { + return (count($this->validate($value)) == 0); + } + /** * * {@inheritDoc} - * @see \codename\core\validator_interface::validate($value) + * @see validator_interface::validate */ - public function validate($value) : array { + public function validate(mixed $value): array + { if (is_null($value) && !$this->nullAllowed) { $this->errorstack->addError('VALIDATOR', 'VALUE_IS_NULL'); } @@ -42,28 +57,20 @@ public function validate($value) : array { } /** - * Returns the errors that occured during validation of this value + * Returns the errors that occurred during validation of this value * @return array */ - final public function getErrors() : array { + final public function getErrors(): array + { return $this->errorstack->getErrors(); } /** - * Performs validation and directly returns the state of validation (true/false) - * @param mixed|null $value - * @return bool - */ - final public function isValid($value) : bool { - return (count($this->validate($value)) == 0); - } - - /** - * @inheritDoc + * {@inheritDoc} */ - public function reset() : \codename\core\validator + public function reset(): validator { - $this->errorstack->reset(); - return $this; + $this->errorstack->reset(); + return $this; } } diff --git a/backend/class/validator/boolean.php b/backend/class/validator/boolean.php index fcb7758..a3dda55 100644 --- a/backend/class/validator/boolean.php +++ b/backend/class/validator/boolean.php @@ -1,26 +1,29 @@ errorstack->addError('VALUE', 'VALUE_NOT_BOOLEAN'); } - + return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/boolean/number.php b/backend/class/validator/boolean/number.php index d5de46a..b36e604 100644 --- a/backend/class/validator/boolean/number.php +++ b/backend/class/validator/boolean/number.php @@ -1,36 +1,38 @@ errorstack->addError('VALUE', 'VALUE_NOT_NUMERIC_BOOLEAN'); + if (is_numeric($value) || is_integer($value)) { + if ($value != 0 && $value != 1) { + $this->errorstack->addError('VALUE', 'VALUE_NOT_NUMERIC_BOOLEAN'); + } return $this->errorstack->getErrors(); - } else { - return $this->errorstack->getErrors(); - } } - if(!is_bool($value)) { + if (!is_bool($value)) { $this->errorstack->addError('VALUE', 'VALUE_NOT_BOOLEAN'); return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/file.php b/backend/class/validator/file.php index 620c4ef..0b1aef3 100755 --- a/backend/class/validator/file.php +++ b/backend/class/validator/file.php @@ -1,50 +1,60 @@ errorstack->getErrors()) > 0) { + if (count($this->errorstack->getErrors()) > 0) { return $this->errorstack->getErrors(); } - if(!app::getFilesystem()->fileAvailable($value)) { + if (!app::getFilesystem()->fileAvailable($value)) { $this->errorstack->addError('VALUE', 'FILE_NOT_FOUND', $value); return $this->errorstack->getErrors(); } $mimetype = $this->getMimetype($value); - if(!in_array($mimetype, $this->mime_whitelist)) { + if (!in_array($mimetype, $this->mime_whitelist)) { $this->errorstack->addError('VALUE', 'FORBIDDEN_MIME_TYPE', $mimetype); return $this->errorstack->getErrors(); } @@ -57,39 +67,11 @@ public function validate($value) : array { * @param string $file * @return string */ - protected function getMimetype(string $file) : string { + protected function getMimetype(string $file): string + { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimetype = finfo_file($finfo, $file); finfo_close($finfo); return $mimetype; } - - /** - * Returns the uploaded file's ending by analyzing it's MIME type - * @return string - */ - public function getFiletype() : string { - switch($this->getMimetype($this->upload['tmp_name'])) { - case 'image/jpeg' : return 'jpg'; break; - case 'image/gif' : return 'gif'; break; - case 'image/png' : return 'png'; break; - case 'image/fif' : return 'fif'; break; - case 'image/ief' : return 'ief'; break; - case 'image/tiff' : return 'tiff'; break; - case 'image/vasa' : return 'vasa'; break; - case 'image/x-icon' : return 'ico'; break; - case 'application/pdf' : return 'pdf'; break; - default: return 'fil'; break; - } - } - - /** - * Returns the salted MD5 of the given file's checksum - * @param string $salt - * @return string - */ - public function getMd5(string $salt = null) : string { - return md5(md5_file($this->upload['tmp_name']) . $salt); - } - } diff --git a/backend/class/validator/file/image.php b/backend/class/validator/file/image.php index 86a03c6..ee815e3 100755 --- a/backend/class/validator/file/image.php +++ b/backend/class/validator/file/image.php @@ -1,28 +1,31 @@ nullAllowed = $nullAllowed; + public function __construct(bool $nullAllowed = true, float $minvalue = null, float $maxvalue = null, int $maxprecision = null) + { + parent::__construct($nullAllowed); $this->minvalue = $minvalue; $this->maxvalue = $maxvalue; $this->maxprecision = $maxprecision; - $this->errorstack = new \codename\core\errorstack('VALIDATION'); return $this; } @@ -48,30 +50,30 @@ public function __CONSTRUCT(bool $nullAllowed = true, float $minvalue = null, fl * {@inheritDoc} * @see \codename\core\validator_interface::validate($value) */ - public function validate($value) : array { + public function validate(mixed $value): array + { parent::validate($value); - if(!is_numeric($value)) { + if (!is_numeric($value)) { $this->errorstack->addError('VALUE', 'VALUE_NOT_A_NUMBER', $value); return $this->errorstack->getErrors(); } - if(!is_null($this->minvalue) && $value < $this->minvalue) { + if (!is_null($this->minvalue) && $value < $this->minvalue) { $this->errorstack->addError('VALUE', 'VALUE_TOO_SMALL', $value); return $this->errorstack->getErrors(); } - if(!is_null($this->maxvalue) && $value > $this->maxvalue) { - $this->errorstack->addError('VALUE', 'VALUE_TOO_BIG', array('VAL' => $value, 'MAX' => $this->maxvalue)); + if (!is_null($this->maxvalue) && $value > $this->maxvalue) { + $this->errorstack->addError('VALUE', 'VALUE_TOO_BIG', ['VAL' => $value, 'MAX' => $this->maxvalue]); return $this->errorstack->getErrors(); } - if(!is_null($this->maxprecision) && round($value, $this->maxprecision) != $value) { + if (!is_null($this->maxprecision) && round($value, $this->maxprecision) != $value) { $this->errorstack->addError('VALUE', 'VALUE_TOO_PRECISE', $value); return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/number/integer.php b/backend/class/validator/number/integer.php index 54ddb60..edd0729 100644 --- a/backend/class/validator/number/integer.php +++ b/backend/class/validator/number/integer.php @@ -1,18 +1,22 @@ -errorstack->getErrors()) > 0) { - return $this->errorstack->getErrors(); - } - - // $value needs to be casted to a float, first. - if(round($value, 2) !== (float)$value) { - $this->errorstack->addError('VALUE', 'TOO_MANY_DIGITS_AFTER_COMMA', $value); - return $this->errorstack->getErrors(); - } - return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/number/natural.php b/backend/class/validator/number/natural.php index 921edf2..aabaeaa 100755 --- a/backend/class/validator/number/natural.php +++ b/backend/class/validator/number/natural.php @@ -1,11 +1,15 @@ errorstack->getErrors(); } - - if(is_null($value)) { + + if (is_null($value)) { return $this->errorstack->getErrors(); } - + if (!is_array($value)) { $this->errorstack->addError('VALUE', 'VALUE_NOT_A_ARRAY', $value); return $this->errorstack->getErrors(); } - + $this->checkKeys($value); return $this->errorstack->getErrors(); @@ -43,13 +47,12 @@ public function validate($value) : array { * @param array $value * @return void */ - protected function checkKeys(array $value) { + protected function checkKeys(array $value): void + { foreach ($this->arrKeys as $myKey) { - if(strlen($myKey) > 0 && !array_key_exists($myKey, $value)) { - $this->errorstack->addError('VALUE', 'ARRAY_MISSING_KEY', array('value'=>$value, 'key' => $myKey)); + if (strlen($myKey) > 0 && !array_key_exists($myKey, $value)) { + $this->errorstack->addError('VALUE', 'ARRAY_MISSING_KEY', ['value' => $value, 'key' => $myKey]); } } - return; } - } diff --git a/backend/class/validator/structure/address.php b/backend/class/validator/structure/address.php index 34d4792..ae2e5d8 100755 --- a/backend/class/validator/structure/address.php +++ b/backend/class/validator/structure/address.php @@ -1,23 +1,26 @@ errorstack->getErrors(); } - if(is_null($value)) { + if (is_null($value)) { return $this->errorstack->getErrors(); } - if(count($errors = app::getValidator('number_natural')->reset()->validate($value['success'])) > 0) { + if (count($errors = app::getValidator('number_natural')->reset()->validate($value['success'])) > 0) { $this->errorstack->addError('VALUE', 'INVALID_SUCCESS_IDENTIFIER', $errors); return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/api/codename/serviceprovider.php b/backend/class/validator/structure/api/codename/serviceprovider.php index 541f117..f0c95cc 100644 --- a/backend/class/validator/structure/api/codename/serviceprovider.php +++ b/backend/class/validator/structure/api/codename/serviceprovider.php @@ -1,47 +1,56 @@ errorstack->getErrors(); } - if(is_null($value)) { + if (is_null($value)) { return $this->errorstack->getErrors(); } - if(count($errors = app::getValidator('text_endpoint')->reset()->validate($value['host'])) > 0) { + if (count($errors = app::getValidator('text_endpoint')->reset()->validate($value['host'])) > 0) { $this->errorstack->addError('VALUE', 'HOST_INVALID', $errors); } - if(count($errors = app::getValidator('number_port')->reset()->validate($value['port'])) > 0) { + if (count($errors = app::getValidator('number_port')->reset()->validate($value['port'])) > 0) { $this->errorstack->addError('VALUE', 'PORT_INVALID', $errors); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/api/codename/ssis/applist.php b/backend/class/validator/structure/api/codename/ssis/applist.php index e64d331..a59ef98 100644 --- a/backend/class/validator/structure/api/codename/ssis/applist.php +++ b/backend/class/validator/structure/api/codename/ssis/applist.php @@ -1,30 +1,40 @@ errorstack->addError('VALUE', 'APPLIST_EMPTY', $value); return $this->errorstack->getErrors(); } - - foreach($value as $key => $appobject) { - if(count($errors = app::getValidator('structure_api_codename_ssis_appobject')->validate($appobject)) > 0) { + + foreach ($value as $appobject) { + if (count($errors = app::getValidator('structure_api_codename_ssis_appobject')->validate($appobject)) > 0) { $this->errorstack->addError('VALUE', 'INVALID_APPOBJECT', $errors); return $this->errorstack->getErrors(); } @@ -32,5 +42,4 @@ public function validate($value) : array { return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/api/codename/ssis/appobject.php b/backend/class/validator/structure/api/codename/ssis/appobject.php index 72c8eb9..d49522c 100644 --- a/backend/class/validator/structure/api/codename/ssis/appobject.php +++ b/backend/class/validator/structure/api/codename/ssis/appobject.php @@ -1,25 +1,28 @@ errorstack->addError('VALUE', 'GROUPLIST_EMPTY', $value); return $this->errorstack->getErrors(); } - - foreach($value as $key => $groupobject) { - if(count($errors = app::getValidator('structure_api_codename_ssis_groupobject')->validate($groupobject)) > 0) { + + foreach ($value as $groupobject) { + if (count($errors = app::getValidator('structure_api_codename_ssis_groupobject')->validate($groupobject)) > 0) { $this->errorstack->addError('VALUE', 'INVALID_GROUPOBJECT', $errors); return $this->errorstack->getErrors(); } @@ -32,5 +42,4 @@ public function validate($value) : array { return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/api/codename/ssis/groupobject.php b/backend/class/validator/structure/api/codename/ssis/groupobject.php index 39cd45c..2acca12 100644 --- a/backend/class/validator/structure/api/codename/ssis/groupobject.php +++ b/backend/class/validator/structure/api/codename/ssis/groupobject.php @@ -1,22 +1,25 @@ errorstack->addError('VALUE', 'APPSTACK_EMPTY', $value); return $this->errorstack->getErrors(); } - - if(count($errors = app::getValidator('structure_api_codename_ssis_userobject')->validate($value['user'])) > 0) { + + if (count($errors = app::getValidator('structure_api_codename_ssis_userobject')->validate($value['user'])) > 0) { $this->errorstack->addError('VALUE', 'INVALID_USEROBJET', $errors); return $this->errorstack->getErrors(); } - if(count($errors = app::getValidator('structure_api_codename_ssis_grouplist')->validate($value['group'])) > 0) { + if (count($errors = app::getValidator('structure_api_codename_ssis_grouplist')->validate($value['group'])) > 0) { $this->errorstack->addError('VALUE', 'INVALID_GROUPLIST', $errors); return $this->errorstack->getErrors(); } - if(count($errors = app::getValidator('structure_api_codename_ssis_applist')->validate($value['app'])) > 0) { + if (count($errors = app::getValidator('structure_api_codename_ssis_applist')->validate($value['app'])) > 0) { $this->errorstack->addError('VALUE', 'INVALID_APPLIST', $errors); return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/api/codename/ssis/userobject.php b/backend/class/validator/structure/api/codename/ssis/userobject.php index 9fb702a..a074c54 100644 --- a/backend/class/validator/structure/api/codename/ssis/userobject.php +++ b/backend/class/validator/structure/api/codename/ssis/userobject.php @@ -1,50 +1,57 @@ errorstack->addError('VALUE', 'APPSTACK_EMPTY', $value); return $this->errorstack->getErrors(); } - - if(count($errors = app::getValidator('structure_api_codename_ssis_userobject')) > 0) { + + if (count($errors = app::getValidator('structure_api_codename_ssis_userobject')->reset()->validate($value)) > 0) { $this->errorstack->addError('VALUE', 'INVALID_USEROBJECT', $errors); return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/appstack.php b/backend/class/validator/structure/appstack.php index 1ad3642..c640bee 100755 --- a/backend/class/validator/structure/appstack.php +++ b/backend/class/validator/structure/appstack.php @@ -1,24 +1,29 @@ errorstack->getErrors(); } - if(is_null($value)) { + if (is_null($value)) { return $this->errorstack->getErrors(); } @@ -28,5 +33,4 @@ public function validate($value) : array { return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/cart.php b/backend/class/validator/structure/cart.php index c8b50b4..265bf26 100755 --- a/backend/class/validator/structure/cart.php +++ b/backend/class/validator/structure/cart.php @@ -1,25 +1,36 @@ errorstack->getErrors(); } - if(is_null($value)) { + if (is_null($value)) { return $this->errorstack->getErrors(); } - foreach($value as $product) { - if(count($errors = app::getValidator('structure_product')->validate($product)) > 0) { + foreach ($value as $product) { + if (count($errors = app::getValidator('structure_product')->validate($product)) > 0) { $this->errorstack->addError('VALUE', 'INVALID_PRODUCT_FOUND', $errors); break; } @@ -27,5 +38,4 @@ public function validate($value) : array { return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/config.php b/backend/class/validator/structure/config.php index 9fa97e5..310d0f5 100755 --- a/backend/class/validator/structure/config.php +++ b/backend/class/validator/structure/config.php @@ -1,11 +1,15 @@ errorstack->getErrors(); } - if(is_null($value)) { + if (is_null($value)) { return $this->errorstack->getErrors(); } - foreach($value['context'] as $context) { - if($context['custom'] ?? false) { - continue; + foreach ($value['context'] as $context) { + if ($context['custom'] ?? false) { + continue; } - if(count($errors = \codename\core\app::getValidator('structure_config_context')->validate($context)) > 0) { + if (count($errors = \codename\core\app::getValidator('structure_config_context')->validate($context)) > 0) { $this->errorstack->addError('VALUE', 'KEY_CONTEXT_INVALID', $errors); return $this->errorstack->getErrors(); } } - if(count($errors = \codename\core\app::getValidator('text_templatename')->reset()->validate($value['defaulttemplate'])) > 0) { + if (count($errors = \codename\core\app::getValidator('text_templatename')->reset()->validate($value['defaulttemplate'])) > 0) { $this->errorstack->addError('VALUE', 'KEY_DEFAULTTEMPLATE_INVALID', $errors); return $this->errorstack->getErrors(); } - if(count($errors = \codename\core\app::getValidator('text_contextname')->reset()->validate($value['defaultcontext'])) > 0) { + if (count($errors = \codename\core\app::getValidator('text_contextname')->reset()->validate($value['defaultcontext'])) > 0) { $this->errorstack->addError('VALUE', 'KEY_DEFAULTCONTEXT_INVALID', $errors); return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/config/bucket.php b/backend/class/validator/structure/config/bucket.php index f8636f8..7682421 100755 --- a/backend/class/validator/structure/config/bucket.php +++ b/backend/class/validator/structure/config/bucket.php @@ -1,11 +1,15 @@ errorstack->getErrors()) > 0) { + if (count($this->errorstack->getErrors()) > 0) { return $this->errorstack->getErrors(); } - if(isset($value['public']) && !is_bool($value['public'])) { + if (isset($value['public']) && !is_bool($value['public'])) { $this->errorstack->addError('VALUE', 'PUBLIC_KEY_INVALID'); return $this->errorstack->getErrors(); } - if(isset($value['public']) && $value['public'] && !array_key_exists('baseurl', $value)) { + if (isset($value['public']) && $value['public'] && !array_key_exists('baseurl', $value)) { $this->errorstack->addError('VALUE', 'BASEURL_NOT_FOUND'); return $this->errorstack->getErrors(); } - if(count($errors = app::getValidator('structure_config_ftp')->reset()->validate($value['ftpserver'] ?? null)) > 0) { + if (count($errors = app::getValidator('structure_config_ftp')->reset()->validate($value['ftpserver'] ?? null)) > 0) { $this->errorstack->addError('VALUE', 'FTP_CONTAINER_INVALID', $errors); return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/config/bucket/local.php b/backend/class/validator/structure/config/bucket/local.php index 57df69b..e2daff1 100755 --- a/backend/class/validator/structure/config/bucket/local.php +++ b/backend/class/validator/structure/config/bucket/local.php @@ -1,48 +1,58 @@ errorstack->getErrors(); } - if(is_null($value)) { + if (is_null($value)) { return $this->errorstack->getErrors(); } - if(isset($value['public']) && !is_bool($value['public'])) { + if (isset($value['public']) && !is_bool($value['public'])) { $this->errorstack->addError('VALUE', 'PUBLIC_KEY_NOT_FOUND'); return $this->errorstack->getErrors(); } - if(isset($value['public']) && $value['public'] && !array_key_exists('baseurl', $value)) { + if (isset($value['public']) && $value['public'] && !array_key_exists('baseurl', $value)) { $this->errorstack->addError('VALUE', 'BASEURL_NOT_FOUND'); return $this->errorstack->getErrors(); } - - if(!app::getFilesystem()->dirAvailable($value['basedir'])) { + + if (!app::getFilesystem()->dirAvailable($value['basedir'])) { $this->errorstack->addError('VALUE', 'DIRECTORY_NOT_FOUND', $value['basedir']); return $this->errorstack->getErrors(); } - + return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/config/bucket/s3.php b/backend/class/validator/structure/config/bucket/s3.php index 415002d..37c14f7 100644 --- a/backend/class/validator/structure/config/bucket/s3.php +++ b/backend/class/validator/structure/config/bucket/s3.php @@ -1,30 +1,32 @@ errorstack->getErrors()) > 0) { - return $this->errorstack->getErrors(); - } + return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/config/bucket/sftp.php b/backend/class/validator/structure/config/bucket/sftp.php index 76a8fcf..9ecfeee 100644 --- a/backend/class/validator/structure/config/bucket/sftp.php +++ b/backend/class/validator/structure/config/bucket/sftp.php @@ -1,46 +1,56 @@ errorstack->getErrors()) > 0) { + if (count($this->errorstack->getErrors()) > 0) { return $this->errorstack->getErrors(); } - if(isset($value['public']) && !is_bool($value['public'])) { + if (isset($value['public']) && !is_bool($value['public'])) { $this->errorstack->addError('VALUE', 'PUBLIC_KEY_INVALID'); return $this->errorstack->getErrors(); } - if(isset($value['public']) && $value['public'] && !array_key_exists('baseurl', $value)) { + if (isset($value['public']) && $value['public'] && !array_key_exists('baseurl', $value)) { $this->errorstack->addError('VALUE', 'BASEURL_NOT_FOUND'); return $this->errorstack->getErrors(); } - if(count($errors = app::getValidator('structure_config_sftp')->reset()->validate($value['sftpserver'] ?? null)) > 0) { + if (count($errors = app::getValidator('structure_config_sftp')->reset()->validate($value['sftpserver'] ?? null)) > 0) { $this->errorstack->addError('VALUE', 'SFTP_CONTAINER_INVALID', $errors); return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/config/context.php b/backend/class/validator/structure/config/context.php index 023803a..ea26a31 100755 --- a/backend/class/validator/structure/config/context.php +++ b/backend/class/validator/structure/config/context.php @@ -1,20 +1,23 @@ errorstack->getErrors(); } - if(is_null($value)) { + if (is_null($value)) { return $this->errorstack->getErrors(); } - if(count($errors = app::getValidator('structure_config_crud_pagination')->reset()->validate($value['pagination'])) > 0) { + if (count($errors = app::getValidator('structure_config_crud_pagination')->reset()->validate($value['pagination'] ?? null)) > 0) { $this->errorstack->addError('VALUE', 'PAGINATION_CONFIGURATION_INVALID', $errors); return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/config/crud/action.php b/backend/class/validator/structure/config/crud/action.php index 36f50e7..41fbbbc 100755 --- a/backend/class/validator/structure/config/crud/action.php +++ b/backend/class/validator/structure/config/crud/action.php @@ -1,36 +1,41 @@ errorstack->getErrors(); } - if(is_null($value)) { + if (is_null($value)) { return $this->errorstack->getErrors(); } @@ -38,5 +43,4 @@ public function validate($value) : array { return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/config/crud/pagination.php b/backend/class/validator/structure/config/crud/pagination.php index bed8036..7993c5a 100755 --- a/backend/class/validator/structure/config/crud/pagination.php +++ b/backend/class/validator/structure/config/crud/pagination.php @@ -1,53 +1,62 @@ errorstack->getErrors(); } - if(is_null($value)) { + if (is_null($value)) { return $this->errorstack->getErrors(); } - if(count($errors = app::getValidator('number_natural')->reset()->validate($value['limit'])) > 0) { + if (count($errors = app::getValidator('number_natural')->reset()->validate($value['limit'])) > 0) { $this->errorstack->addError('VALUE', 'INVALID_LIMIT', $errors); return $this->errorstack->getErrors(); } - - if($value['limit'] <= 0) { + + if ($value['limit'] <= 0) { $this->errorstack->addError('VALUE', 'LIMIT_TOO_SMALL', $value['limit']); return $this->errorstack->getErrors(); } - - if($value['limit'] >= 501) { + + if ($value['limit'] >= 501) { $this->errorstack->addError('VALUE', 'LIMIT_TOO_HIGH', $value['limit']); return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/config/environment.php b/backend/class/validator/structure/config/environment.php index 1dd0190..73f16cc 100755 --- a/backend/class/validator/structure/config/environment.php +++ b/backend/class/validator/structure/config/environment.php @@ -1,22 +1,25 @@ errorstack->getErrors(); - } - - if(is_null($value)) { - return $this->errorstack->getErrors(); - } - - // check field names - if(!empty($value['field'])) { - if (!is_array($value['field'])) { - $this->errorstack->addError('VALUE', 'KEY_FIELD_NOT_A_ARRAY', $value); + if (count(parent::validate($value)) != 0) { return $this->errorstack->getErrors(); } - if (!is_array($value['datatype'])) { - $this->errorstack->addError('VALUE', 'KEY_DATATYPE_NOT_A_ARRAY', $value); + + if (is_null($value)) { return $this->errorstack->getErrors(); } - foreach($value['field'] as $field) { + // check field names + if (!empty($value['field'])) { + if (!is_array($value['field'])) { + $this->errorstack->addError('VALUE', 'KEY_FIELD_NOT_A_ARRAY', $value); + return $this->errorstack->getErrors(); + } + if (!is_array($value['datatype'])) { + $this->errorstack->addError('VALUE', 'KEY_DATATYPE_NOT_A_ARRAY', $value); + return $this->errorstack->getErrors(); + } - // validate modelfield - if(count($errors = app::getValidator('text_modelfield')->reset()->validate($field)) > 0) { - $this->errorstack->addError('VALUE', 'KEY_FIELD_INVALID', $errors); - return $this->errorstack->getErrors(); - } else { - // validate datatype config existance AND its validity - if(!array_key_exists($field, $value['datatype'])) { - $this->errorstack->addError('VALUE', 'DATATYPE_CONFIG_MISSING', $field); - } else { - // validate datatype? + foreach ($value['field'] as $field) { + // validate modelfield + if (count($errors = app::getValidator('text_modelfield')->reset()->validate($field)) > 0) { + $this->errorstack->addError('VALUE', 'KEY_FIELD_INVALID', $errors); + return $this->errorstack->getErrors(); + } elseif (!array_key_exists($field, $value['datatype'])) { + // validate datatype config existence AND its validity + $this->errorstack->addError('VALUE', 'DATATYPE_CONFIG_MISSING', $field); + } else { + // validate datatype? + } } - } } - } - // check primary key existance - // we expect an array! - if(!empty($value['primary'])) { - if (!is_array($value['primary'])) { - $this->errorstack->addError('VALUE', 'KEY_PRIMARY_NOT_A_ARRAY', $value); - return $this->errorstack->getErrors(); - } + // check primary key existence + // we expect an array! + if (!empty($value['primary'])) { + if (!is_array($value['primary'])) { + $this->errorstack->addError('VALUE', 'KEY_PRIMARY_NOT_A_ARRAY', $value); + return $this->errorstack->getErrors(); + } - foreach($value['primary'] as $primary) { - if(!in_array($primary, $value['field'])) { - $this->errorstack->addError('VALUE', 'PRIMARY_KEY_NOT_CONTAINED_IN_FIELD_ARRAY', $primary); - } + foreach ($value['primary'] as $primary) { + if (!in_array($primary, $value['field'])) { + $this->errorstack->addError('VALUE', 'PRIMARY_KEY_NOT_CONTAINED_IN_FIELD_ARRAY', $primary); + } + } } - } - return $this->getErrors(); + return $this->getErrors(); } - } diff --git a/backend/class/validator/structure/config/modelfilter.php b/backend/class/validator/structure/config/modelfilter.php index f7ca222..525a958 100644 --- a/backend/class/validator/structure/config/modelfilter.php +++ b/backend/class/validator/structure/config/modelfilter.php @@ -1,21 +1,24 @@ errorstack->getErrors(); - } - - if(is_null($value)) { - return $this->errorstack->getErrors(); - } - - $textEmailErrors = []; - - // Check email addresses - if(($value['recipient'] ?? false) && count($errors = app::getValidator('text_email')->reset()->validate($value['recipient'])) > 0) { - $this->errorstack->addError('VALUE', 'INVALID_EMAIL_ADDRESS', $errors); - $textEmailErrors = array_merge($textEmailErrors, $errors); - } - if(($value['cc'] ?? false) && count($errors = app::getValidator('text_email')->reset()->validate($value['cc'])) > 0) { - $this->errorstack->addError('VALUE', 'INVALID_EMAIL_ADDRESS', $errors); - $textEmailErrors = array_merge($textEmailErrors, $errors); - } - if(($value['bcc'] ?? false) && count($errors = app::getValidator('text_email')->reset()->validate($value['bcc'])) > 0) { - $this->errorstack->addError('VALUE', 'INVALID_EMAIL_ADDRESS', $errors); - $textEmailErrors = array_merge($textEmailErrors, $errors); - } - if(($value['reply-to'] ?? false) && count($errors = app::getValidator('text_email')->reset()->validate($value['reply-to'])) > 0) { - $this->errorstack->addError('VALUE', 'INVALID_EMAIL_ADDRESS', $errors); - $textEmailErrors = array_merge($textEmailErrors, $errors); - } - - // Check body length - if(!($value['body'] ?? false) || strlen($value['body']) == 0) { // or bigger than?? - $this->errorstack->addError('VALUE', 'INVALID_EMAIL_BODY', $textEmailErrors); - } - // Check body length - if(!($value['subject'] ?? false) || strlen($value['subject']) == 0) { // or bigger than?? - $this->errorstack->addError('VALUE', 'INVALID_EMAIL_SUBJECT', $textEmailErrors); - } - - // @TODO check template (for existance/validity?) - - return array_merge($textEmailErrors, $this->errorstack->getErrors()); + if (count(parent::validate($value)) != 0) { + return $this->errorstack->getErrors(); + } + + if (is_null($value)) { + return $this->errorstack->getErrors(); + } + + $textEmailErrors = []; + + // Check email addresses + if (($value['recipient'] ?? false) && count($errors = app::getValidator('text_email')->reset()->validate($value['recipient'])) > 0) { + $this->errorstack->addError('VALUE', 'INVALID_EMAIL_ADDRESS', $errors); + $textEmailErrors = array_merge($textEmailErrors, $errors); + } + if (($value['cc'] ?? false) && count($errors = app::getValidator('text_email')->reset()->validate($value['cc'])) > 0) { + $this->errorstack->addError('VALUE', 'INVALID_EMAIL_ADDRESS', $errors); + $textEmailErrors = array_merge($textEmailErrors, $errors); + } + if (($value['bcc'] ?? false) && count($errors = app::getValidator('text_email')->reset()->validate($value['bcc'])) > 0) { + $this->errorstack->addError('VALUE', 'INVALID_EMAIL_ADDRESS', $errors); + $textEmailErrors = array_merge($textEmailErrors, $errors); + } + if (($value['reply-to'] ?? false) && count($errors = app::getValidator('text_email')->reset()->validate($value['reply-to'])) > 0) { + $this->errorstack->addError('VALUE', 'INVALID_EMAIL_ADDRESS', $errors); + $textEmailErrors = array_merge($textEmailErrors, $errors); + } + + // Check body length + if (!($value['body'] ?? false) || strlen($value['body']) == 0) { // or bigger than?? + $this->errorstack->addError('VALUE', 'INVALID_EMAIL_BODY', $textEmailErrors); + } + // Check body length + if (!($value['subject'] ?? false) || strlen($value['subject']) == 0) { // or bigger than?? + $this->errorstack->addError('VALUE', 'INVALID_EMAIL_SUBJECT', $textEmailErrors); + } + + // @TODO check template (for existence/validity?) + + return array_merge($textEmailErrors, $this->errorstack->getErrors()); } } diff --git a/backend/class/validator/structure/product.php b/backend/class/validator/structure/product.php index ab733a8..4e93ea2 100755 --- a/backend/class/validator/structure/product.php +++ b/backend/class/validator/structure/product.php @@ -1,16 +1,19 @@ elementValidator = app::getValidator('text_telephone'); - } - - /** - * @inheritDoc - */ - public function validate($value) : array - { - if(count(parent::validate($value)) != 0) { - return $this->errorstack->getErrors(); - } + /** + * [protected description] + * @var validator + */ + protected validator $elementValidator; - if(is_null($value)) { - return $this->errorstack->getErrors(); + /** + * {@inheritDoc} + * @param bool $nullAllowed + * @throws ReflectionException + * @throws exception + */ + public function __construct(bool $nullAllowed = true) + { + parent::__construct($nullAllowed); + $this->elementValidator = app::getValidator('text_telephone'); } - if(is_array($value)) { - foreach($value as $phoneNumber) { - if(count($errors = $this->elementValidator->reset()->validate($phoneNumber)) > 0) { - $this->errorstack->addError('VALUE', 'INVALID_PHONE_NUMBER', $errors); + /** + * {@inheritDoc} + */ + public function validate(mixed $value): array + { + if (count(parent::validate($value)) != 0) { + return $this->errorstack->getErrors(); + } + + if (is_null($value)) { + return $this->errorstack->getErrors(); + } + + if (is_array($value)) { + foreach ($value as $phoneNumber) { + if (count($errors = $this->elementValidator->reset()->validate($phoneNumber)) > 0) { + $this->errorstack->addError('VALUE', 'INVALID_PHONE_NUMBER', $errors); + } + } } - } - } - return $this->getErrors(); - } + return $this->getErrors(); + } } diff --git a/backend/class/validator/structure/upload.php b/backend/class/validator/structure/upload.php index 57d1e56..5fcaf91 100755 --- a/backend/class/validator/structure/upload.php +++ b/backend/class/validator/structure/upload.php @@ -1,22 +1,25 @@ errorstack->getErrors()) > 0) { + if (count($this->errorstack->getErrors()) > 0) { return $this->errorstack->getErrors(); } - if(count($errors = app::getValidator('file_image')->reset()->validate($value['tmp_name'])) > 0) { + if (count($errors = app::getValidator('file_image')->reset()->validate($value['tmp_name'])) > 0) { $this->errorstack->addError('VALUE', 'IMAGE_INVALID', $errors); return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/structure/upload/image/jpg.php b/backend/class/validator/structure/upload/image/jpg.php index d1d8a8a..1d79ccb 100755 --- a/backend/class/validator/structure/upload/image/jpg.php +++ b/backend/class/validator/structure/upload/image/jpg.php @@ -1,11 +1,15 @@ minlength = (int) $minlength; - $this->maxlength = (int) $maxlength; + public function __construct(bool $nullAllowed = false, int $minlength = 0, int $maxlength = 0, string $allowedchars = '', string $forbiddenchars = '') + { + parent::__construct($nullAllowed); + $this->minlength = $minlength; + $this->maxlength = $maxlength; $this->allowedchars = $allowedchars; $this->forbiddenchars = $forbiddenchars; @@ -67,22 +70,23 @@ public function __CONSTRUCT(bool $nullAllowed = false, int $minlength = 0, int $ * {@inheritDoc} * @see \codename\core\validator_interface::validate($value) */ - public function validate($value) : array { - if(count(parent::validate($value)) != 0) { + public function validate(mixed $value): array + { + if (count(parent::validate($value)) != 0) { return $this->getErrors(); } - if(!is_string($value)) { + if (!is_string($value)) { $this->errorstack->addError('VALUE', 'VALUE_NOT_A_STRING', $value); return $this->errorstack->getErrors(); } - if($this->getMinlength() > 0 && strlen($value) < $this->getMinlength()) { + if ($this->getMinlength() > 0 && strlen($value) < $this->getMinlength()) { $this->errorstack->addError('VALUE', 'STRING_TOO_SHORT', $value); return $this->errorstack->getErrors(); } - if($this->getMaxlength() > 0 && strlen($value) > $this->getMaxlength()) { + if ($this->getMaxlength() > 0 && strlen($value) > $this->getMaxlength()) { $this->errorstack->addError('VALUE', 'STRING_TOO_LONG', $value); return $this->errorstack->getErrors(); } @@ -90,36 +94,20 @@ public function validate($value) : array { // search forbidden characters if (strlen($this->getAllowedchars()) > 0) { - // match characters that are NOT in allowed chars - if(preg_match('/[^'.$this->getQuotedAllowedchars().']/', $value, $matches) !== 0) { - $this->errorstack->addError('VALUE', 'STRING_CONTAINS_INVALID_CHARACTERS', array('value' => $value, 'matches' => $matches)); - return $this->errorstack->getErrors(); - } + // match characters that are NOT in allowed chars + if (preg_match('/[^' . $this->getQuotedAllowedchars() . ']/', $value, $matches) !== 0) { + $this->errorstack->addError('VALUE', 'STRING_CONTAINS_INVALID_CHARACTERS', ['value' => $value, 'matches' => $matches]); + return $this->errorstack->getErrors(); + } } if (strlen($this->getForbiddenchars()) > 0) { - // match characters that are explicitly in forbidden chars - if(preg_match('/['.$this->getQuotedForbiddenchars().']/', $value, $matches) !== 0) { - $this->errorstack->addError('VALUE', 'STRING_CONTAINS_INVALID_CHARACTERS', array('value' => $value, 'matches' => $matches)); - return $this->errorstack->getErrors(); - } - } - - /* - for($position = 0; $position <= strlen($value)-1; $position++) { - if (strlen($this->getAllowedchars()) > 0) { - if(strpos($this->getAllowedchars(), $value[$position]) === false) { - $this->errorstack->addError('VALUE', 'STRING_CONTAINS_INVALID_CHARACTERS', array('value' => $value, 'position' => $position)); - break; - } - } - if (strlen($this->getForbiddenchars()) > 0) { - if(strpos($this->getForbiddenchars(), $value[$position]) !== false) { - $this->errorstack->addError('VALUE', 'STRING_CONTAINS_INVALID_CHARACTERS', array('value' => $value, 'position' => $position)); - break; - } + // match characters that are explicitly in forbidden chars + if (preg_match('/[' . $this->getQuotedForbiddenchars() . ']/', $value, $matches) !== 0) { + $this->errorstack->addError('VALUE', 'STRING_CONTAINS_INVALID_CHARACTERS', ['value' => $value, 'matches' => $matches]); + return $this->errorstack->getErrors(); } - }*/ + } return $this->errorstack->getErrors(); } @@ -128,15 +116,17 @@ public function validate($value) : array { * Returns the minimum length property * @return int */ - protected function getMinlength() : int { - return (int) $this->minlength; + protected function getMinlength(): int + { + return $this->minlength; } /** * Returns the max length property * @return int */ - protected function getMaxlength() : int { + protected function getMaxlength(): int + { return $this->maxlength; } @@ -144,7 +134,8 @@ protected function getMaxlength() : int { * Returns the allowed characters * @return string */ - protected function getAllowedchars() : string { + protected function getAllowedchars(): string + { return $this->allowedchars; } @@ -152,18 +143,20 @@ protected function getAllowedchars() : string { * Returns the preq_quoted allowed characters * @return string */ - protected function getQuotedAllowedchars() : string { - if($this->quotedAllowedchars == null) { - $this->quotedAllowedchars = preg_quote($this->getAllowedchars(), '//'); + protected function getQuotedAllowedchars(): string + { + if ($this->quotedAllowedchars == null) { + $this->quotedAllowedchars = preg_quote($this->getAllowedchars(), '//'); } return $this->quotedAllowedchars; } /** - * Returns the forbidden cahracters + * Returns the forbidden characters * @return string */ - protected function getForbiddenchars() : string { + protected function getForbiddenchars(): string + { return $this->forbiddenchars; } @@ -171,11 +164,11 @@ protected function getForbiddenchars() : string { * Returns the preq_quoted forbidden characters * @return string */ - protected function getQuotedForbiddenchars() : string { - if($this->quotedForbiddenchars == null) { - $this->quotedForbiddenchars = preg_quote($this->getForbiddenchars(), '//'); + protected function getQuotedForbiddenchars(): string + { + if ($this->quotedForbiddenchars == null) { + $this->quotedForbiddenchars = preg_quote($this->getForbiddenchars(), '//'); } return $this->quotedForbiddenchars; } - } diff --git a/backend/class/validator/text/apploader.php b/backend/class/validator/text/apploader.php index 6b0cfc1..99ae0f0 100644 --- a/backend/class/validator/text/apploader.php +++ b/backend/class/validator/text/apploader.php @@ -1,16 +1,20 @@ errorstack->getErrors(); - } - - /** - * @see https://github.com/ronanguilloux/IsoCodes/blob/master/src/IsoCodes/SwiftBic.php - */ - $regexp = '/^([a-zA-Z]){4}([a-zA-Z]){2}([0-9a-zA-Z]){2}([0-9a-zA-Z]{3})?$/'; - $bicValid = (bool) preg_match($regexp, $value); + if (count(parent::validate($value)) != 0) { + return $this->errorstack->getErrors(); + } - if($bicValid !== true) { - $this->errorstack->addError('VALUE', 'VALUE_NOT_A_BIC', $value); - } + /** + * @see https://github.com/ronanguilloux/IsoCodes/blob/master/src/IsoCodes/SwiftBic.php + */ + $regexp = '/^([a-zA-Z]){4}([a-zA-Z]){2}([0-9a-zA-Z]){2}([0-9a-zA-Z]){3}?$/'; + $bicValid = (bool)preg_match($regexp, $value); - return $this->errorstack->getErrors(); - } + if ($bicValid !== true) { + $this->errorstack->addError('VALUE', 'VALUE_NOT_A_BIC', $value); + } + return $this->errorstack->getErrors(); + } } diff --git a/backend/class/validator/text/bucketname.php b/backend/class/validator/text/bucketname.php index 778d4c8..dc4d516 100644 --- a/backend/class/validator/text/bucketname.php +++ b/backend/class/validator/text/bucketname.php @@ -1,16 +1,20 @@ errorstack->getErrors(); - } + if (count(parent::validate($value)) != 0) { + return $this->errorstack->getErrors(); + } - // HEX Color Regex - // @see https://stackoverflow.com/questions/43706082/validation-hex-and-rgba-colors-using-regex-in-php - // #[a-zA-Z0-9]{6} - $regexp = '/^#[a-zA-Z0-9]{6}$/'; - $isValid = (bool) preg_match($regexp, $value); + // HEX Color Regex + // @see https://stackoverflow.com/questions/43706082/validation-hex-and-rgba-colors-using-regex-in-php + // #[a-zA-Z0-9]{6} + $regexp = '/^#[a-zA-Z0-9]{6}$/'; + $isValid = (bool)preg_match($regexp, $value); - if($isValid !== true) { - $this->errorstack->addError('VALUE', 'VALUE_NOT_HEX_STRING', $value); - } + if ($isValid !== true) { + $this->errorstack->addError('VALUE', 'VALUE_NOT_HEX_STRING', $value); + } - return $this->getErrors(); + return $this->getErrors(); } - } diff --git a/backend/class/validator/text/color/rgb.php b/backend/class/validator/text/color/rgb.php index e8a5d24..6a799a3 100644 --- a/backend/class/validator/text/color/rgb.php +++ b/backend/class/validator/text/color/rgb.php @@ -1,40 +1,44 @@ errorstack->getErrors(); - } + if (count(parent::validate($value)) != 0) { + return $this->errorstack->getErrors(); + } - // RGB Regex - // @see https://stackoverflow.com/questions/43706082/validation-hex-and-rgba-colors-using-regex-in-php - // rgb\((?:\s*\d+\s*,){2}\s*[\d]+\) - $regexp = '/^rgb\((?:\s*\d+\s*,){2}\s*[\d]+\)$/'; - $isValid = (bool) preg_match($regexp, $value); + // RGB Regex + // @see https://stackoverflow.com/questions/43706082/validation-hex-and-rgba-colors-using-regex-in-php + // rgb\((?:\s*\d+\s*,){2}\s*[\d]+\) + $regexp = '/^rgb\((?:\s*\d+\s*,){2}\s*[\d]+\)$/'; + $isValid = (bool)preg_match($regexp, $value); - if($isValid !== true) { - $this->errorstack->addError('VALUE', 'VALUE_NOT_RGB_STRING', $value); - } + if ($isValid !== true) { + $this->errorstack->addError('VALUE', 'VALUE_NOT_RGB_STRING', $value); + } - return $this->getErrors(); + return $this->getErrors(); } - } diff --git a/backend/class/validator/text/color/rgba.php b/backend/class/validator/text/color/rgba.php index 2682aff..554d22d 100644 --- a/backend/class/validator/text/color/rgba.php +++ b/backend/class/validator/text/color/rgba.php @@ -1,44 +1,48 @@ errorstack->getErrors(); - } + if (count(parent::validate($value)) != 0) { + return $this->errorstack->getErrors(); + } - // RGBA Regex - // @see https://stackoverflow.com/questions/43706082/validation-hex-and-rgba-colors-using-regex-in-php - // but this was wrong, spaces after the last comma caused mis-validation - // rgba\((\s*\d+\s*,\s*){3}[\d\.]+\) - $regexp = '/^rgba\((\s*\d+\s*,\s*){3}[\d\.]+\)$/'; - $isValid = (bool) preg_match($regexp, $value); + // RGBA Regex + // @see https://stackoverflow.com/questions/43706082/validation-hex-and-rgba-colors-using-regex-in-php + // but this was wrong, spaces after the last comma caused mis-validation + // rgba\((\s*\d+\s*,\s*){3}[\d\.]+\) + $regexp = '/^rgba\((\s*\d+\s*,\s*){3}[\d\.]+\)$/'; + $isValid = (bool)preg_match($regexp, $value); - if($isValid !== true) { - $this->errorstack->addError('VALUE', 'VALUE_NOT_RGBA_STRING', [ - '$value' => $value, - '$isValid' => $isValid - ]); - } + if ($isValid !== true) { + $this->errorstack->addError('VALUE', 'VALUE_NOT_RGBA_STRING', [ + '$value' => $value, + '$isValid' => $isValid, + ]); + } - return $this->getErrors(); + return $this->getErrors(); } - } diff --git a/backend/class/validator/text/colorhexadecimal.php b/backend/class/validator/text/colorhexadecimal.php index b6e37ef..fba8053 100644 --- a/backend/class/validator/text/colorhexadecimal.php +++ b/backend/class/validator/text/colorhexadecimal.php @@ -1,16 +1,20 @@ nullAllowed = true; - if(count(parent::validate($value)) != 0) { + if (count(parent::validate($value)) != 0) { return $this->errorstack->getErrors(); } $datearr = explode('-', $value); - - if(count($datearr) != 3) { + + if (count($datearr) != 3) { $this->errorstack->addError('VALUE', 'INVALID_COUNT_AREAS', $value); return $this->errorstack->getErrors(); } - + // search invalid characters - if(strlen($datearr[0]) != 4) { + if (strlen($datearr[0]) != 4) { $this->errorstack->addError('VALUE', 'INVALID_YEAR', $value); return $this->errorstack->getErrors(); } - + // search invalid characters - if(strlen($datearr[1]) != 2) { + if (strlen($datearr[1]) != 2) { $this->errorstack->addError('VALUE', 'INVALID_MONTH', $value); return $this->errorstack->getErrors(); } - if(!checkdate($datearr[1],$datearr[2],$datearr[0])) { + if (!checkdate($datearr[1], $datearr[2], $datearr[0])) { $this->errorstack->addError('VALUE', 'INVALID_DATE', $value); return $this->errorstack->getErrors(); } - + return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/text/datetime/relative.php b/backend/class/validator/text/datetime/relative.php index b537043..95a7c84 100644 --- a/backend/class/validator/text/datetime/relative.php +++ b/backend/class/validator/text/datetime/relative.php @@ -1,6 +1,12 @@ errorstack->getErrors(); +class relative extends text implements validatorInterface +{ + /** + * {@inheritDoc} + */ + public function validate(mixed $value): array + { + if (count(parent::validate($value)) != 0) { + return $this->errorstack->getErrors(); + } + try { + new DateTime($value); + } catch (Exception) { + $this->errorstack->addError('VALUE', 'INVALID_RELATIVE_DATETIME', $value); + } + return $this->errorstack->getErrors(); } - try { - $dtObj = new \DateTime($value); - } catch (\Exception $e) { - $this->errorstack->addError('VALUE', 'INVALID_RELATIVE_DATETIME', $value); - } - return $this->errorstack->getErrors(); - } - } diff --git a/backend/class/validator/text/domain.php b/backend/class/validator/text/domain.php index 8fc2ec8..a08ac4b 100755 --- a/backend/class/validator/text/domain.php +++ b/backend/class/validator/text/domain.php @@ -1,24 +1,27 @@ errorstack->getErrors(); } $domainarr = explode('.', $value); - if(count($domainarr) < 2) { + if (count($domainarr) < 2) { $this->errorstack->addError('VALUE', 'NO_PERIOD_FOUND', $value); return $this->errorstack->getErrors(); } - if(gethostbyname($value) == $value) { + if (gethostbyname($value) == $value) { $this->errorstack->addError('VALUE', 'DOMAIN_NOT_RESOLVED', $value); return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/text/dummy.php b/backend/class/validator/text/dummy.php index b63f134..8bc4908 100644 --- a/backend/class/validator/text/dummy.php +++ b/backend/class/validator/text/dummy.php @@ -1,15 +1,18 @@ errorstack->getErrors(); } - if(strlen($value) == 0) { + if (strlen($value) == 0) { return $this->errorstack->getErrors(); } - if(!(strpos($value, '@') > 0)) { + if (!(strpos($value, '@') > 0)) { $this->errorstack->addError('VALUE', 'EMAIL_AT_NOT_FOUND', $value); return $this->errorstack->getErrors(); } $address = explode('@', $value); - if(count($address) != 2) { + if (count($address) != 2) { $this->errorstack->addError('VALUE', 'EMAIL_AT_NOT_UNIQUE', $value); return $this->errorstack->getErrors(); } - if(strlen($address[1]) == 0) { + if (strlen($address[1]) == 0) { $this->errorstack->addError('VALUE', 'EMAIL_DOMAIN_INVALID', $value); return $this->errorstack->getErrors(); } - if(in_array($address[1], $this->forbiddenHosts)) { + if (in_array($address[1], $this->forbiddenHosts)) { $this->errorstack->addError('VALUE', 'EMAIL_DOMAIN_BLOCKED', $value); return $this->errorstack->getErrors(); } if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) { - $this->errorstack->addError('VALUE', 'EMAIL_INVALID', $value); - return $this->errorstack->getErrors(); + $this->errorstack->addError('VALUE', 'EMAIL_INVALID', $value); + return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/text/endpoint.php b/backend/class/validator/text/endpoint.php index eb51ecc..e55ef57 100644 --- a/backend/class/validator/text/endpoint.php +++ b/backend/class/validator/text/endpoint.php @@ -1,27 +1,32 @@ errorstack->getErrors()) > 0) { return $this->errorstack->getErrors(); } - if(strpos($value, '://') === false) { + if (!str_contains($value, '://')) { $this->errorstack->addError('VALUE', 'NO_PROTOCOL_FOUND', $value); return $this->errorstack->getErrors(); } - if(!in_array(($protocol = explode(':', $value, 2)[0]), $this->allowedProtocols)) { - $this->errorstack->addError('VALUE', 'PROTOCOL_NOT_ALLOWED', $protocol); - return $this->errorstack->getErrors(); + if (!in_array(($protocol = explode(':', $value, 2)[0]), $this->allowedProtocols)) { + $this->errorstack->addError('VALUE', 'PROTOCOL_NOT_ALLOWED', $protocol); + return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/text/fax.php b/backend/class/validator/text/fax.php index 1ab355e..9f2b78f 100755 --- a/backend/class/validator/text/fax.php +++ b/backend/class/validator/text/fax.php @@ -1,16 +1,20 @@ nullAllowed = true; - if(count(parent::validate($value)) != 0) { + if (count(parent::validate($value)) != 0) { return $this->errorstack->getErrors(); } - if(strpos($value, '/') != 0) { + if (strpos($value, '/') != 0) { $this->errorstack->addError('VALUE', 'MUST_BEGIN_WITH_SLASH', $value); return $this->errorstack->getErrors(); } - if(substr($value, -1) === '/') { + if (str_ends_with($value, '/')) { $this->errorstack->addError('VALUE', 'MUST_NOT_END_WITH_SLASH', $value); return $this->errorstack->getErrors(); } @@ -42,5 +46,4 @@ public function validate($value) : array { return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/text/filepath/relative.php b/backend/class/validator/text/filepath/relative.php index 6caf976..bfce616 100644 --- a/backend/class/validator/text/filepath/relative.php +++ b/backend/class/validator/text/filepath/relative.php @@ -1,15 +1,19 @@ nullAllowed = true; - if(count(parent::validate($value)) != 0) { + if (count(parent::validate($value)) != 0) { return $this->errorstack->getErrors(); } - if(strpos($value, '/') === 0) { + if (str_starts_with($value, '/')) { $this->errorstack->addError('VALUE', 'MUST_NOT_BEGIN_WITH_SLASH', $value); return $this->errorstack->getErrors(); } - if(substr($value, -1) === '/') { + if (str_ends_with($value, '/')) { $this->errorstack->addError('VALUE', 'MUST_NOT_END_WITH_SLASH', $value); return $this->errorstack->getErrors(); } @@ -38,5 +42,4 @@ public function validate($value) : array { return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/text/hostname.php b/backend/class/validator/text/hostname.php index d01597e..69ce3ff 100644 --- a/backend/class/validator/text/hostname.php +++ b/backend/class/validator/text/hostname.php @@ -1,11 +1,15 @@ errorstack->getErrors(); } - } diff --git a/backend/class/validator/text/iban.php b/backend/class/validator/text/iban.php index 4e8cd42..44d7712 100755 --- a/backend/class/validator/text/iban.php +++ b/backend/class/validator/text/iban.php @@ -1,78 +1,169 @@ 28,'ad'=>24,'at'=>20,'az'=>28,'bh'=>22,'be'=>16,'ba'=>20,'br'=>29,'bg'=>22,'cr'=>21,'hr'=>21, - 'cy'=>28,'cz'=>24,'dk'=>18,'do'=>28,'ee'=>20,'fo'=>18,'fi'=>18,'fr'=>27,'ge'=>22,'de'=>22,'gi'=>23,'gr'=>27,'gl'=>18, - 'gt'=>28,'hu'=>28,'is'=>26,'ie'=>22,'il'=>23,'it'=>27,'jo'=>30,'kz'=>20,'kw'=>30,'lv'=>21,'lb'=>28,'li'=>21,'lt'=>20, - 'lu'=>20,'mk'=>19,'mt'=>31,'mr'=>27,'mu'=>30,'mc'=>27,'md'=>24,'me'=>22,'nl'=>18,'no'=>15,'pk'=>24,'ps'=>29,'pl'=>28, - 'pt'=>25,'qa'=>29,'ro'=>24,'sm'=>27,'sa'=>24,'rs'=>22,'sk'=>24,'si'=>19,'es'=>24,'se'=>24,'ch'=>21,'tn'=>24,'tr'=>26, - 'ae'=>23,'gb'=>22,'vg'=>24); - + protected array $countries = [ + 'al' => 28, + 'ad' => 24, + 'at' => 20, + 'az' => 28, + 'bh' => 22, + 'be' => 16, + 'ba' => 20, + 'br' => 29, + 'bg' => 22, + 'cr' => 21, + 'hr' => 21, + 'cy' => 28, + 'cz' => 24, + 'dk' => 18, + 'do' => 28, + 'ee' => 20, + 'fo' => 18, + 'fi' => 18, + 'fr' => 27, + 'ge' => 22, + 'de' => 22, + 'gi' => 23, + 'gr' => 27, + 'gl' => 18, + 'gt' => 28, + 'hu' => 28, + 'is' => 26, + 'ie' => 22, + 'il' => 23, + 'it' => 27, + 'jo' => 30, + 'kz' => 20, + 'kw' => 30, + 'lv' => 21, + 'lb' => 28, + 'li' => 21, + 'lt' => 20, + 'lu' => 20, + 'mk' => 19, + 'mt' => 31, + 'mr' => 27, + 'mu' => 30, + 'mc' => 27, + 'md' => 24, + 'me' => 22, + 'nl' => 18, + 'no' => 15, + 'pk' => 24, + 'ps' => 29, + 'pl' => 28, + 'pt' => 25, + 'qa' => 29, + 'ro' => 24, + 'sm' => 27, + 'sa' => 24, + 'rs' => 22, + 'sk' => 24, + 'si' => 19, + 'es' => 24, + 'se' => 24, + 'ch' => 21, + 'tn' => 24, + 'tr' => 26, + 'ae' => 23, + 'gb' => 22, + 'vg' => 24, + ]; + /** - * Contains the values for the Checksum calculation based on the country + * Contains the values for the Checksum calculation based on the country * @var array */ - protected $chars = array('a'=>10,'b'=>11,'c'=>12,'d'=>13,'e'=>14,'f'=>15,'g'=>16,'h'=>17,'i'=>18,'j'=>19,'k'=>20,'l'=>21, - 'm'=>22,'n'=>23,'o'=>24,'p'=>25,'q'=>26,'r'=>27,'s'=>28,'t'=>29,'u'=>30,'v'=>31,'w'=>32,'x'=>33,'y'=>34,'z'=>35); - + protected array $chars = [ + 'a' => 10, + 'b' => 11, + 'c' => 12, + 'd' => 13, + 'e' => 14, + 'f' => 15, + 'g' => 16, + 'h' => 17, + 'i' => 18, + 'j' => 19, + 'k' => 20, + 'l' => 21, + 'm' => 22, + 'n' => 23, + 'o' => 24, + 'p' => 25, + 'q' => 26, + 'r' => 27, + 's' => 28, + 't' => 29, + 'u' => 30, + 'v' => 31, + 'w' => 32, + 'x' => 33, + 'y' => 34, + 'z' => 35, + ]; + /** - * + * */ - public function __CONSTRUCT() { - return parent::__CONSTRUCT(true, 15, 30, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', ''); + public function __construct() + { + return parent::__construct(true, 15, 30, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'); } - + /** * * {@inheritDoc} * @see \codename\core\validator_interface::validate($value) */ - public function validate($value) : array { - if(count(parent::validate($value)) != 0) { + public function validate(mixed $value): array + { + if (count(parent::validate($value)) != 0) { return $this->errorstack->getErrors(); } - + $value = strtolower($value); - + $countrycode = substr($value, 0, 2); - - if(!array_key_exists($countrycode, $this->countries)) { + + if (!array_key_exists($countrycode, $this->countries)) { $this->errorstack->addError('VALUE', 'IBAN_COUNTRY_NOT_FOUND', $value); return $this->errorstack->getErrors(); } - + // check the country's IBAN code length - if(strlen($value) != $this->countries[substr($value,0,2)]){ - $this->errorstack->addError('VALUE', 'IBAN_LENGH_NOT_MATCHING_COUNTRY', $value); + if (strlen($value) != $this->countries[substr($value, 0, 2)]) { + $this->errorstack->addError('VALUE', 'IBAN_LENGTH_NOT_MATCHING_COUNTRY', $value); return $this->errorstack->getErrors(); } - + // Do some magic - $MovedChar = substr($value, 4).substr($value,0,4); + $MovedChar = substr($value, 4) . substr($value, 0, 4); $MovedCharArray = str_split($MovedChar); $NewString = ''; - - foreach($MovedCharArray AS $key => $value){ - if(!is_numeric($MovedCharArray[$key]) && array_key_exists($MovedCharArray[$key], $this->chars)) { - $MovedCharArray[$key] = $this->chars[$MovedCharArray[$key]]; + + foreach ($MovedCharArray as $key => $value) { + if (!is_numeric($value) && array_key_exists($value, $this->chars)) { + $MovedCharArray[$key] = $this->chars[$value]; } $NewString .= $MovedCharArray[$key]; } - - if(bcmod($NewString, '97') != 1) { + + if (bcmod($NewString, '97') != 1) { $this->errorstack->addError('VALUE', 'IBAN_CHECKSUM_FAILED', $value); return $this->errorstack->getErrors(); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/text/ipv4.php b/backend/class/validator/text/ipv4.php index 7ecbabe..4edf22c 100755 --- a/backend/class/validator/text/ipv4.php +++ b/backend/class/validator/text/ipv4.php @@ -1,13 +1,18 @@ errorstack->getErrors(); } - - if(!filter_var($value, FILTER_VALIDATE_IP)) { + + if (!filter_var($value, FILTER_VALIDATE_IP)) { $this->errorstack->addError('VALUE', 'VALUE_NOT_AN_IP', $value); } - + return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/text/json.php b/backend/class/validator/text/json.php index c7b8783..de70cbe 100755 --- a/backend/class/validator/text/json.php +++ b/backend/class/validator/text/json.php @@ -1,13 +1,18 @@ errorstack->getErrors(); } - if(strlen($value) == 0) { + if (strlen($value) == 0) { return $this->errorstack->getErrors(); } $data = json_decode($value); - if(is_null($data)) { + if (is_null($data)) { $this->errorstack->addError('VALUE', 'JSON_INVALID', $value); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/text/mac.php b/backend/class/validator/text/mac.php index 52ead10..bf23171 100755 --- a/backend/class/validator/text/mac.php +++ b/backend/class/validator/text/mac.php @@ -1,13 +1,18 @@ errorstack->getErrors(); } - if(!filter_var($value, FILTER_VALIDATE_MAC)) { + if (!filter_var($value, FILTER_VALIDATE_MAC)) { $this->errorstack->addError('VALUE', 'VALUE_NOT_A_MACADDRESS', $value); } return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/text/methodname.php b/backend/class/validator/text/methodname.php index 154e61c..2e99a71 100755 --- a/backend/class/validator/text/methodname.php +++ b/backend/class/validator/text/methodname.php @@ -1,16 +1,20 @@ errorstack->getErrors(); } - - $complexity = array( - 'UPPERCASE' => 'A B C D E F G H I J K L M N O P Q R S T U V W X Y Z', - 'LOWERCASE' => 'a b c d e f g h i j k l m n o p q r s t u v w x y z', - 'NUMERIC' => '0 1 2 3 4 5 6 7 8 9', - 'SPECIAL' => '! § $ % & / ( ) = ? [ ] | { } @ . ; : _ - # * " , \' ' - ); - - foreach($complexity as $type => $string) { + + $complexity = [ + 'UPPERCASE' => 'A B C D E F G H I J K L M N O P Q R S T U V W X Y Z', + 'LOWERCASE' => 'a b c d e f g h i j k l m n o p q r s t u v w x y z', + 'NUMERIC' => '0 1 2 3 4 5 6 7 8 9', + 'SPECIAL' => '! § $ % & / ( ) = ? [ ] | { } @ . ; : _ - # * " , \' ', + ]; + + foreach ($complexity as $type => $string) { $found = false; - foreach(explode(' ', $string) as $char) { - if(strlen($char) == 0) { + foreach (explode(' ', $string) as $char) { + if (strlen($char) == 0) { continue; } - if(strpos($value, $char) !== false) { + if (str_contains($value, $char)) { $found = true; break; } } - if(!$found) { + if (!$found) { $this->errorstack->addError('VALUE', 'PASSWORD_' . $type . '_CHARACTER_NOT_FOUND', $value); } } - + return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/text/personalde.php b/backend/class/validator/text/personalde.php index 53c2163..1483731 100755 --- a/backend/class/validator/text/personalde.php +++ b/backend/class/validator/text/personalde.php @@ -1,6 +1,10 @@ errorstack->getErrors(); - } - - $c = explode(':', $value); - if(count($c) >= 2 && count($c) <= 3) { - $hours = $c[0]; - $minutes = $c[1]; - $seconds = $c[2] ?? 0; - if($hours < 0 || $hours > 23) { - $this->errorstack->addError('VALUE', 'VALUE_INVALID_TIME_HOURS', $value); + if (count(parent::validate($value)) != 0) { + return $this->errorstack->getErrors(); } - if($minutes < 0 || $minutes > 59) { - $this->errorstack->addError('VALUE', 'VALUE_INVALID_TIME_MINUTES', $value); - } - if($seconds < 0 || $seconds > 59) { - $this->errorstack->addError('VALUE', 'VALUE_INVALID_TIME_SECONDS', $value); + + $c = explode(':', $value); + if (count($c) >= 2 && count($c) <= 3) { + $hours = $c[0]; + $minutes = $c[1]; + $seconds = $c[2] ?? 0; + if ($hours < 0 || $hours > 23) { + $this->errorstack->addError('VALUE', 'VALUE_INVALID_TIME_HOURS', $value); + } + if ($minutes < 0 || $minutes > 59) { + $this->errorstack->addError('VALUE', 'VALUE_INVALID_TIME_MINUTES', $value); + } + if ($seconds < 0 || $seconds > 59) { + $this->errorstack->addError('VALUE', 'VALUE_INVALID_TIME_SECONDS', $value); + } + } else { + $this->errorstack->addError('VALUE', 'VALUE_INVALID_TIME_STRING', $value); } - } else { - $this->errorstack->addError('VALUE', 'VALUE_INVALID_TIME_STRING', $value); - } - return $this->errorstack->getErrors(); + return $this->errorstack->getErrors(); } - } diff --git a/backend/class/validator/text/timestamp.php b/backend/class/validator/text/timestamp.php index 406830a..6794f6e 100644 --- a/backend/class/validator/text/timestamp.php +++ b/backend/class/validator/text/timestamp.php @@ -1,16 +1,20 @@ setAllowedWeekdays($allowedWeekdays); - parent::__CONSTRUCT($nullAllowed, 1, 32, '0123456789 :-.'); - return $this; - } - - /** - * [setAllowedWeekdays description] - * @param array $allowedWeekdays [description] - */ - public function setAllowedWeekdays(array $allowedWeekdays = array()) { - foreach($allowedWeekdays as &$v) { - $v = intval($v); - } - $this->allowedWeekdays = $allowedWeekdays; - foreach($this->allowedWeekdays as $d) { - if(!in_array($d, array(self::MONDAY, self::TUESDAY, self::WEDNESDAY, self::THURSDAY, self::FRIDAY, self::SATURDAY, self::SUNDAY))) { - // error? - } - } - } - - /** - * @inheritDoc - */ - public function validate($value): array - { - if(count(parent::validate($value)) != 0) { - return $this->errorstack->getErrors(); +use codename\core\validator\text; +use codename\core\validator\validatorInterface; + +class weekday extends text implements validatorInterface +{ + /** + * Numeric representation of MONDAY, see: ISO-8601 + * @var int + */ + public const int MONDAY = 1; + + /** + * Numeric representation of TUESDAY, see: ISO-8601 + * @var int + */ + public const int TUESDAY = 2; + + /** + * Numeric representation of WEDNESDAY, see: ISO-8601 + * @var int + */ + public const int WEDNESDAY = 3; + + /** + * Numeric representation of THURSDAY, see: ISO-8601 + * @var int + */ + public const int THURSDAY = 4; + + /** + * Numeric representation of FRIDAY, see: ISO-8601 + * @var int + */ + public const int FRIDAY = 5; + + /** + * Numeric representation of SATURDAY, see: ISO-8601 + * @var int + */ + public const int SATURDAY = 6; + + /** + * Numeric representation of SUNDAY, see: ISO-8601 + * @var int + */ + public const int SUNDAY = 7; + + /** + * array of allowed weekdays (ISO-8601) + * @var int[] + */ + protected array $allowedWeekdays = []; + + /** + * + * {@inheritDoc} + * @see \codename\core\validator_text::__construct($nullAllowed, $minlength, $maxlength, $allowedchars, $forbiddenchars) + */ + public function __construct(bool $nullAllowed = false, array $allowedWeekdays = []) + { + $this->setAllowedWeekdays($allowedWeekdays); + parent::__construct($nullAllowed, 1, 32, '0123456789 :-.'); + return $this; } - if(count($this->allowedWeekdays) === 0) { - $this->errorstack->addError('VALUE', 'ALLOWED_WEEKDAYS_NOT_SET', $this->allowedWeekdays); + /** + * [setAllowedWeekdays description] + * @param array $allowedWeekdays [description] + */ + public function setAllowedWeekdays(array $allowedWeekdays = []): void + { + foreach ($allowedWeekdays as &$v) { + $v = intval($v); + } + $this->allowedWeekdays = $allowedWeekdays; + foreach ($this->allowedWeekdays as $d) { + if (!in_array($d, [self::MONDAY, self::TUESDAY, self::WEDNESDAY, self::THURSDAY, self::FRIDAY, self::SATURDAY, self::SUNDAY])) { + // error? + } + } } - if(!in_array(date('N', strtotime($value)), $this->allowedWeekdays)) { - $this->errorstack->addError('VALUE', 'WEEKDAY_NOT_ALLOWED', $value); - } + /** + * {@inheritDoc} + */ + public function validate(mixed $value): array + { + if (count(parent::validate($value)) != 0) { + return $this->errorstack->getErrors(); + } + + if (count($this->allowedWeekdays) === 0) { + $this->errorstack->addError('VALUE', 'ALLOWED_WEEKDAYS_NOT_SET', $this->allowedWeekdays); + } - return $this->errorstack->getErrors(); - } + if (!in_array(date('N', strtotime($value)), $this->allowedWeekdays)) { + $this->errorstack->addError('VALUE', 'WEEKDAY_NOT_ALLOWED', $value); + } + return $this->errorstack->getErrors(); + } } diff --git a/backend/class/validator/text/timezone.php b/backend/class/validator/text/timezone.php index 32e2b37..95056bd 100644 --- a/backend/class/validator/text/timezone.php +++ b/backend/class/validator/text/timezone.php @@ -1,44 +1,46 @@ 0) { - return $this->getErrors(); - } - - try { - $dtz = new \DateTimeZone($value); + if (count(parent::validate($value)) > 0) { + return $this->getErrors(); + } - if($dtz === false) { - $this->errorstack->addError('VALUE', 'INVALID_TIMEZONE', $value); + try { + new DateTimeZone($value); + } catch (Exception) { + $this->errorstack->addError('VALUE', 'INVALID_TIMEZONE', $value); } - } catch (\Exception $e) { - $this->errorstack->addError('VALUE', 'INVALID_TIMEZONE', $value); - } - return $this->getErrors(); + return $this->getErrors(); } - } diff --git a/backend/class/validator/text/url.php b/backend/class/validator/text/url.php index d706d4a..493c1b1 100755 --- a/backend/class/validator/text/url.php +++ b/backend/class/validator/text/url.php @@ -1,6 +1,10 @@ validator)->reset()->validate($value)) > 0) { - throw new \codename\core\exception(self::EXCEPTION_CONSTRUCT_INVALIDDATATYPE, \codename\core\exception::$ERRORLEVEL_FATAL, $errors); + public function __construct(mixed $value) + { + if (count($errors = app::getValidator($this->validator)->reset()->validate($value)) > 0) { + throw new exception(self::EXCEPTION_CONSTRUCT_INVALIDDATATYPE, exception::$ERRORLEVEL_FATAL, $errors); } $this->value = $value; unset($this->validator); @@ -42,10 +49,10 @@ public function __construct($value) { /** * * {@inheritDoc} - * @see \codename\core\value\valueInterface::get() + * @see valueInterface::get */ - public function get() { + public function get(): mixed + { return $this->value; } - } diff --git a/backend/class/value/structure.php b/backend/class/value/structure.php index d2f9a59..11d8b44 100644 --- a/backend/class/value/structure.php +++ b/backend/class/value/structure.php @@ -1,10 +1,14 @@ value['app_name']; } - + /** * Returns the port number of the service provider * @return int */ - public function getSecret() : int { + public function getSecret(): int + { return $this->value['app_secret']; } - } diff --git a/backend/class/value/structure/api/codename/serviceprovider.php b/backend/class/value/structure/api/codename/serviceprovider.php index 467ed79..1323b0c 100644 --- a/backend/class/value/structure/api/codename/serviceprovider.php +++ b/backend/class/value/structure/api/codename/serviceprovider.php @@ -1,24 +1,28 @@ value['host']; } @@ -26,7 +30,8 @@ public function getHost() : string { * Returns the port number of the service provider * @return int */ - public function getPort() : int { + public function getPort(): int + { return $this->value['port']; } @@ -34,8 +39,8 @@ public function getPort() : int { * Will return the complete URL to the service provider's base * @return string */ - public function getUrl() : string { + public function getUrl(): string + { return $this->value['host'] . ':' . $this->value['port']; } - } diff --git a/backend/class/value/structure/api/codename/ssis/sessionobject.php b/backend/class/value/structure/api/codename/ssis/sessionobject.php index 4e40af9..16fbe79 100644 --- a/backend/class/value/structure/api/codename/ssis/sessionobject.php +++ b/backend/class/value/structure/api/codename/ssis/sessionobject.php @@ -1,12 +1,14 @@ field = $exp[0]; - } elseif(count($exp) === 2) { - $this->table = $exp[0]; - $this->field = $exp[1]; - } elseif(count($exp) === 3) { - $this->schema = $exp[0]; - $this->table = $exp[1]; - $this->field = $exp[2]; - } else { - // throw exception - } - return $this; + parent::__construct($value); + $exp = explode('.', $value); + if (count($exp) === 1) { + $this->field = $exp[0]; + } elseif (count($exp) === 2) { + $this->table = $exp[0]; + $this->field = $exp[1]; + } elseif (count($exp) === 3) { + $this->schema = $exp[0]; + $this->table = $exp[1]; + $this->field = $exp[2]; + } else { + // throw exception + } + return $this; } /** * creates a new text_modelfield_virtual value object - * @param string $field [description] - * @return \codename\core\value\text\modelfield [description] + * @param string $field [description] + * @return modelfield [description] + * @throws ReflectionException + * @throws exception */ - public static function getInstance(string $field) : \codename\core\value\text\modelfield { - return self::$cached[$field] ?? self::$cached[$field] = new self($field); + public static function getInstance(string $field): modelfield + { + return self::$cached[$field] ?? self::$cached[$field] = new self($field); } /** - * @var modelfield[] + * {@inheritDoc} */ - protected static $cached = array(); - - - protected $field = null; + public function get(): mixed + { + return $this->field; + } /** - * @inheritDoc + * @return string|null */ - public function get() + public function getTable(): ?string { - return $this->field; + return $this->table; } - protected $table = null; - - public function getTable() { - return $this->table; - } - - protected $schema = null; - - public function getSchema() { - return $this->schema; + /** + * @return string|null + */ + public function getSchema(): ?string + { + return $this->schema; } - public function getValue() { - return $this->value; + /** + * @return mixed + */ + public function getValue(): mixed + { + return $this->value; } - } diff --git a/backend/class/value/text/modelfield/dummy.php b/backend/class/value/text/modelfield/dummy.php index c24cb05..2b5c28a 100644 --- a/backend/class/value/text/modelfield/dummy.php +++ b/backend/class/value/text/modelfield/dummy.php @@ -1,25 +1,32 @@ Pass the $queue object that is responsible for the management of queue entries - *
Pass an array of $options + * Pass the $queue object that is responsible for the management of queue entries + * Pass an array of $options */ - public function __construct(\codename\core\queue $queue, array $options = array()) { + public function __construct(queue $queue, array $options = []) + { $this->queue = $queue; $this->pause = array_key_exists('pause', $options) ? $options['pause'] : 1; $this->sleep = array_key_exists('sleep', $options) ? $options['sleep'] : 0; - return; } /** * * {@inheritDoc} - * @see \codename\core\worker_interface::start() + * @param string $class + * @throws ReflectionException + * @throws exception + * @see worker_interface::start */ - public function start(string $class) { + public function start(string $class): void + { $this->running = true; - while($this->running) { - $queueentry = $this->queue->load($class); - if(count($queueentry) == 0) { + while ($this->running) { + $queueEntry = $this->queue->load($class); + if (count($queueEntry) == 0) { continue; } - $this->work($queueentry); + $this->work($queueEntry); sleep($this->sleep); } } @@ -73,52 +79,54 @@ public function start(string $class) { /** * * {@inheritDoc} - * @see \codename\core\worker_interface::stop() + * @param array $queue + * @throws ReflectionException + * @throws exception + * @see worker_interface::work */ - public function stop() { - + public function work(array $queue): void + { + app::getQueue()->lock($queue['queue_class'], $queue['queue_identifier']); + call_user_func( + [$queue['queue_class'], $queue['queue_method']], + ['identifier' => $queue['queue_identifier'], 'data' => json_decode($queue['queue_data'])] + ); + app::getQueue()->remove($queue['queue_id']); } /** * * {@inheritDoc} - * @see \codename\core\worker_interface::pause() + * @see worker_interface::stop */ - public function pause() { - + public function stop(): void + { } /** * * {@inheritDoc} - * @see \codename\core\worker_interface::resume() + * @see worker_interface::pause */ - public function resume() { - + public function pause(): void + { } /** * * {@inheritDoc} - * @see \codename\core\worker_interface::skip() + * @see worker_interface::resume */ - public function skip() { - + public function resume(): void + { } /** * * {@inheritDoc} - * @see \codename\core\worker_interface::work() + * @see worker_interface::skip */ - public function work(array $queue) { - app::getQueue()->lock($queue['queue_class'], $queue['queue_identifier']); - call_user_func( - array($queue['queue_class'], $queue['queue_method']), - array('identifier' => $queue['queue_identifier'], 'data' => json_decode($queue['queue_data'])) - ); - app::getQueue()->remove($queue['queue_id']); - return; + public function skip(): void + { } - } diff --git a/backend/class/worker/crud.php b/backend/class/worker/crud.php index 4f321dd..911bfb9 100755 --- a/backend/class/worker/crud.php +++ b/backend/class/worker/crud.php @@ -1,43 +1,50 @@ Made for bulk deleting, updating entries in a model + * Made for bulk deleting, updating entries in a model * @package core * @since 2016-06-14 */ -class crud { - +class crud +{ /** * Update the fields passed in $queueentry['data'] of the model entry identified by $queueentry['identifier'] * @param array $queueentry * @return void + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception */ - public function bulk_update(array $queueentry) { - if(!array_key_exists('identifier', $queueentry)) { + public function bulk_update(array $queueentry): void + { + if (!array_key_exists('identifier', $queueentry)) { echo 'No identifier'; return; } - if(!array_key_exists('data', $queueentry)) { + if (!array_key_exists('data', $queueentry)) { echo 'No data-object'; return; } $identifier = explode(':', $queueentry['identifier']); - if(count($identifier) != 2) { + if (count($identifier) != 2) { echo 'Invalid Identifier for this action'; return; } $data = app::getModel($identifier[0])->load($identifier[1]); - foreach($queueentry['data'] as $key => $value) { + foreach ($queueentry['data'] as $key => $value) { $data[$key] = $value; } app::getModel($identifier[0])->save($data); - return; } - } diff --git a/backend/class/worker/workerInterface.php b/backend/class/worker/workerInterface.php index 90af3d7..bd8c9b4 100755 --- a/backend/class/worker/workerInterface.php +++ b/backend/class/worker/workerInterface.php @@ -1,42 +1,44 @@ formatOutput = $format_output; - self::$encoding = $encoding; - } + private static ?DOMDocument $xml = null; + /** + * @var string + */ + private static string $encoding = 'UTF-8'; /** * Convert an XML to Array - * @param string $node_name - name of the root node to be converted - * @param array $arr - aray to be converterd - * @return \DOMDocument + * @param $input_xml + * @return DOMDocument|array + * @throws \Exception */ - public static function &createArray($input_xml) { + public static function &createArray($input_xml): DOMDocument|array + { $xml = self::getXMLRoot(); - if(is_string($input_xml)) { + if (is_string($input_xml)) { $parsed = $xml->loadXML($input_xml); - if(!$parsed) { + if (!$parsed) { throw new \Exception('[xml2array] Error parsing the XML string.'); } } else { - if(get_class($input_xml) != 'DOMDocument') { + if (get_class($input_xml) != 'DOMDocument') { throw new \Exception('[xml2array] The input XML object should be of type: DOMDocument.'); } $xml = self::$xml = $input_xml; } $array[$xml->documentElement->tagName] = self::convert($xml->documentElement); - self::$xml = null; // clear the xml node in the class for 2nd time use. + self::$xml = null; // clear the XML node in the class for 2nd time use. return $array; } + /** + * @return DOMDocument|null + */ + private static function getXMLRoot(): ?DOMDocument + { + if (empty(self::$xml)) { + self::init(); + } + return self::$xml; + } + + /** + * Initialize the root XML node [optional] + * @param string $version + * @param string $encoding + * @param bool $format_output + */ + public static function init(string $version = '1.0', string $encoding = 'UTF-8', bool $format_output = true): void + { + self::$encoding = $encoding; + self::$xml = new DOMDocument($version, self::$encoding); + self::$xml->formatOutput = $format_output; + } + + /* + * Get the root XML node, if there isn't one, create it. + */ + /** * Convert an Array to XML * @param mixed $node - XML as a string or as an object of DOMDocument * @return mixed */ - private static function &convert($node) { - $output = array(); + private static function &convert(mixed $node): mixed + { + $output = []; switch ($node->nodeType) { case XML_CDATA_SECTION_NODE: @@ -80,47 +107,44 @@ private static function &convert($node) { case XML_ELEMENT_NODE: // for each child node, call the covert function recursively - for ($i=0, $m=$node->childNodes->length; $i<$m; $i++) { + for ($i = 0, $m = $node->childNodes->length; $i < $m; $i++) { $child = $node->childNodes->item($i); $v = self::convert($child); - if(isset($child->tagName)) { + if (isset($child->tagName)) { $t = $child->tagName; - // assume more nodes of same kind are coming - if(!isset($output[$t])) { - $output[$t] = array(); + // assume more nodes of the same kind are coming + if (!isset($output[$t])) { + $output[$t] = []; } $output[$t][] = $v; - } else { - //check if it is not an empty text node - if($v !== '') { - $output = $v; - } + } elseif ($v !== '') { + $output = $v; } } - if(is_array($output)) { + if (is_array($output)) { // if only one node of its kind, assign it directly instead if array($value); foreach ($output as $t => $v) { - if(is_array($v) && count($v)==1) { + if (is_array($v) && count($v) == 1) { $output[$t] = $v[0]; } } - if(empty($output)) { + if (empty($output)) { //for empty nodes $output = ''; } } // loop through the attributes and collect them - if($node->attributes->length) { - $a = array(); - foreach($node->attributes as $attrName => $attrNode) { - $a[$attrName] = (string) $attrNode->value; + if ($node->attributes->length) { + $a = []; + foreach ($node->attributes as $attrName => $attrNode) { + $a[$attrName] = (string)$attrNode->value; } - // if its an leaf node, store the value in @value instead of directly storing it. - if(!is_array($output)) { - $output = array('@value' => $output); + // if it's a leaf node, store the value in @value instead of directly storing it. + if (!is_array($output)) { + $output = ['@value' => $output]; } $output['@attributes'] = $a; } @@ -128,15 +152,4 @@ private static function &convert($node) { } return $output; } - - /* - * Get the root XML node, if there isn't one, create it. - */ - private static function getXMLRoot(){ - if(empty(self::$xml)) { - self::init(); - } - return self::$xml; - } } -?> diff --git a/buildspec.test.yml b/buildspec.unittest.yml similarity index 89% rename from buildspec.test.yml rename to buildspec.unittest.yml index 7615a5f..51b8c30 100644 --- a/buildspec.test.yml +++ b/buildspec.unittest.yml @@ -2,21 +2,21 @@ version: '0.2' phases: install: runtime-versions: - php: '7.3' + php: '8.1' commands: # pre-build custom image/container - - docker-compose -f docker-compose.unittest.yml build unittest-php73 + - docker-compose -f docker-compose.unittest.yml build unittest-php81 # install using integrated composer - NOTE: passthrough env var 'COMPOSER_AUTH' - - docker-compose -f docker-compose.unittest.yml run --no-deps -e COMPOSER_AUTH unittest-php73 composer update + - docker-compose -f docker-compose.unittest.yml run --no-deps -e COMPOSER_AUTH unittest-php81 composer update # alternatively: install on agent, w/o platform checks # - composer update --ignore-platform-reqs --no-progress --no-interaction build: commands: - - docker-compose -f docker-compose.unittest.yml up unittest-php73 + - docker-compose -f docker-compose.unittest.yml up unittest-php81 # full run including coverage report - # - docker-compose -f docker-compose.unittest.yml run unittest-php73 vendor/bin/phpunit + # - docker-compose -f docker-compose.unittest.yml run unittest-php81 vendor/bin/phpunit post_build: commands: diff --git a/composer.json b/composer.json index 1abf68c..be20dc7 100755 --- a/composer.json +++ b/composer.json @@ -1,35 +1,43 @@ { - "name": "codename/core", - "description": "This is THE core framework.", - "type": "library", - "keywords": ["codename", "core", "framework"], - "authors": [ - { - "name": "Kevin Dargel", - "role": "Software Developer" - } - ], - "suggest": { - "phpmailer/phpmailer": "for using the respective mail client", - "aws/aws-sdk-php": "For using S3 buckets" - }, - "require-dev" : { - "phpunit/phpunit" : "^9.0", - "vimeo/psalm" : "^4.0", - "mikey179/vfsstream" : "^1.6", - "phpmailer/phpmailer" : "^6.0", - "aws/aws-sdk-php" : "3.*", - "guzzlehttp/guzzle" : "^7.0", - "codename/core-test" : "*" - }, - "autoload": { - "psr-4": { - "codename\\core\\": "backend/class/" - } - }, - "autoload-dev": { - "psr-4": { - "codename\\core\\tests\\": "tests/" - } + "name": "codename/core", + "description": "This is THE core framework.", + "type": "library", + "keywords": [ + "codename", + "core", + "framework" + ], + "authors": [ + { + "name": "Kevin Dargel", + "role": "Software Developer" } + ], + "suggest": { + "phpmailer/phpmailer": "for using the respective mail client", + "aws/aws-sdk-php": "For using S3 buckets" + }, + "require": { + "php": "^8.3" + }, + "require-dev": { + "aws/aws-sdk-php": "3.*", + "codename/core-test": "*", + "friendsofphp/php-cs-fixer": "^3.15", + "guzzlehttp/guzzle": "^7.0", + "mikey179/vfsstream": "^1.6", + "phpmailer/phpmailer": "^6.0", + "phpunit/phpunit": "^10.0", + "vimeo/psalm": "^5.0" + }, + "autoload": { + "psr-4": { + "codename\\core\\": "backend/class/" + } + }, + "autoload-dev": { + "psr-4": { + "codename\\core\\tests\\": "tests/" + } + } } diff --git a/config/environment.json b/config/environment.json index 6f68eed..8f2aa1f 100644 --- a/config/environment.json +++ b/config/environment.json @@ -1,27 +1,27 @@ { - "production" : { - "templateengine" : { - "default" : { - "driver" : "simple" + "production": { + "templateengine": { + "default": { + "driver": "simple" } }, - "translate" : { - "exception" : { - "driver" : "json", - "inherit" : true + "translate": { + "exception": { + "driver": "json", + "inherit": true } } }, - "dev" : { - "templateengine" : { - "default" : { - "driver" : "simple" + "dev": { + "templateengine": { + "default": { + "driver": "simple" } }, - "translate" : { - "exception" : { - "driver" : "json", - "inherit" : true + "translate": { + "exception": { + "driver": "json", + "inherit": true } } } diff --git a/docker-compose.unittest.yml b/docker-compose.unittest.yml index 412ccf4..67d9f13 100644 --- a/docker-compose.unittest.yml +++ b/docker-compose.unittest.yml @@ -2,13 +2,14 @@ version: '3' services: - # PHP 7.3 CLI - unittest-php73: - profiles: ["php73"] + # PHP 8.1 CLI + unittest-php81: + profiles: [ "php81" ] build: context: . - dockerfile: php73-cli.Dockerfile + dockerfile: php81-cli.Dockerfile environment: + XDEBUG_MODE: coverage unittest_core_db_mysql_host: unittest-mysql unittest_core_db_mysql_user: root unittest_core_db_mysql_pass: root @@ -31,46 +32,15 @@ services: # performance optimization, use 'delegated' for authoritative container view # improves performance when writing coverage reports and reduces IO load on host - '.:/usr/var/workspace:delegated' - # include self for self-testing, but readonly, emulate a 'core-app' like structure - # - './.:/usr/var/workspace/vendor/codename/core:ro' - # PHP 7.4 CLI - unittest-php74: - profiles: ["php74"] + # PHP 8.2 CLI + unittest-php82: + profiles: [ "php82" ] build: context: . - dockerfile: php74-cli.Dockerfile - environment: - unittest_core_db_mysql_host: unittest-mysql - unittest_core_db_mysql_user: root - unittest_core_db_mysql_pass: root - unittest_core_db_mysql_database: database - unittest_core_cache_memcached_host: unittest-memcached - unittest_core_cache_memcached_port: 11211 - command: vendor/bin/phpunit - working_dir: /usr/var/workspace - depends_on: - - unittest-mysql - - unittest-memcached - links: - - unittest-mysql - - unittest-memcached - - unittest-smtp - - unittest-ftp - - unittest-sftp - - unittest-s3 - volumes: - # performance optimization, use 'delegated' for authoritative container view - # improves performance when writing coverage reports and reduces IO load on host - - '.:/usr/var/workspace:delegated' - - # PHP 8.0 CLI - unittest-php80: - profiles: ["php80"] - build: - context: . - dockerfile: php80-cli.Dockerfile + dockerfile: php82-cli.Dockerfile environment: + XDEBUG_MODE: coverage unittest_core_db_mysql_host: unittest-mysql unittest_core_db_mysql_user: root unittest_core_db_mysql_pass: root @@ -145,7 +115,7 @@ services: # MySQL/MariaDB unittest-mysql: - image: mariadb:10.3 + image: mariadb:10.6 environment: - MYSQL_ROOT_PASSWORD=root - MYSQL_DATABASE=database diff --git a/docs/bucket.md b/docs/bucket.md index 01b1acf..5640080 100644 --- a/docs/bucket.md +++ b/docs/bucket.md @@ -2,29 +2,29 @@ A bucket abstracts a data (file) storage and provides the following methods: -|Method |Description| -|--------|-----------| -filePush |push (put) a local file to a remote location in the bucket -filePull |pull (retrieve) a remote file in a bucket to the local machine -fileAvailable|checks if a given path/file is available (exists) in the bucket (and if it is a file) -fileDelete|removes a file in a bucket at the given path -fileMove|moves a remote file to another location in the bucket -fileGetInfo|retrieves more information (metadata) about a file in the bucket -dirList|retrieves available directories in the bucket at a given location (bucket root by default) -dirAvailable|checks whether a directory exists at a given location (and if it is a directory) -isFile|checks whether a given location/path is a file or not +| Method | Description | +|---------------|--------------------------------------------------------------------------------------------| +| filePush | push (put) a local file to a remote location in the bucket | +| filePull | pull (retrieve) a remote file in a bucket to the local machine | +| fileAvailable | checks if a given path/file is available (exists) in the bucket (and if it is a file) | +| fileDelete | removes a file in a bucket at the given path | +| fileMove | moves a remote file to another location in the bucket | +| fileGetInfo | retrieves more information (metadata) about a file in the bucket | +| dirList | retrieves available directories in the bucket at a given location (bucket root by default) | +| dirAvailable | checks whether a directory exists at a given location (and if it is a directory) | +| isFile | checks whether a given location/path is a file or not | The experience using buckets is highly oriented at using S3 buckets. Directories are created implicitly by pushing a file to a specific location. The generic `bucketInterface` does not assume any specific technology behind a given bucket, but makes sure you can use differing platforms in differing runtime environments transparently. At the time of writing, the following bucket drivers are available: -|Driver|Description| -|------|-----------| -local|Local filesystem bucket (directory) -ftp|FTP(S) connection -sftp|SFTP connection -s3|S3 Bucket Client +| Driver | Description | +|--------|-------------------------------------| +| local | Local filesystem bucket (directory) | +| ftp | FTP(S) connection | +| sftp | SFTP connection | +| s3 | S3 Bucket Client | -Every driver is capable of abstracting directories and access (similar to `chroot`) by passing configuration keys like `basedir`. +Every driver is capable of abstracting directories and access (similar to `chroot`) bypassing configuration keys like `basedir`. All methods (like `->filePull(...)`) adhere to this and act relative to the current basedir as a 'root' directory. diff --git a/docs/configuration.md b/docs/configuration.md index 6650061..6de4449 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -8,16 +8,17 @@ ## The app.json An app.json file could look like this: -~~~js + +~~~json { "defaultcontext": "start", - "defaulttemplateengine": "", // to be configured in environment.json - "defaulttemplate": "blank", // provided by core package + "defaulttemplateengine": "", + "defaulttemplate": "blank", "context": { "start": { - "defaultview" : "default", // name of the view below + "defaultview" : "default", "view": { - "default": { "public" : true } // Access modifier "public" skips authentication steps + "default": { "public" : true } } }, "example": { @@ -36,6 +37,7 @@ An app.json file could look like this: This defines two available contexts, defaulting to 'start', if nothing is given. Assuming a web-app purpose running on an Apache Webserver using mod_rewrite, you could call your APIs/URIs like: + - http://example.host/ - http://example.host/start (which is equal to the previous URL due to `defaultcontext`-Fallback) - http://example.host/example @@ -50,19 +52,19 @@ If you're using CLI for your application, this would equal to ## Possible configuration elements -|Key/Object Path|Type|Required|Description | -|---------------|----|--------|--------| -defaultcontext|string|Yes|Default context to use, if not set -defaulttemplateengine|string|Yes|Template engine to use, if not overridden in context-specific configuration, depends on environment.json -defaulttemplate|string|Yes|Template to use, if not specified by context-specific configuration -extensions|string[]||Core-Extensions to load -context|object|Yes|Key-Value-style, named contexts and their respective configuration -context.\|object|Yes|Single context configuration -context.\.defaultview|string|Yes|View to use, if not set -context.\.view|object|Yes|Key-Value-style view configurations -context.\.view.\|object|Yes|Key-Value-style view configurations -context.\.view.\.public|bool/null||Public accessibility (skipping authentication) -context.\.view.\._security.group|string||User group access +| Key/Object Path | Type | Required | Description | +|-------------------------------------------------------------|-----------|----------|----------------------------------------------------------------------------------------------------------| +| defaultcontext | string | Yes | Default context to use, if not set | +| defaulttemplateengine | string | Yes | Template engine to use, if not overridden in context-specific configuration, depends on environment.json | +| defaulttemplate | string | Yes | Template to use, if not specified by context-specific configuration | +| extensions | string[] | | Core-Extensions to load | | +| context | object | Yes | Key-Value-style, named contexts and their respective configuration | +| context.\ | object | Yes | Single context configuration | +| context.\.defaultview | string | Yes | View to use, if not set | +| context.\.view | object | Yes | Key-Value-style view configurations | +| context.\.view.\ | object | Yes | Key-Value-style view configurations | +| context.\.view.\.public | bool/null | | Public accessibility (skipping authentication) | | +| context.\.view.\._security.group | string | | User group access | | ## The environment.json @@ -70,12 +72,10 @@ The environment.json file defines one or more 'environments' for your applicatio This could be a configuration for running in a local dev environment and additionally a 'production' use configuration. Production credentials should never be committed, please use environment variables to configure your application at runtime. -```js +```json { "dev" : { "database" : { - // database connection "default" - // for use in models "default" : { "driver" : "mysql", "host" : "db", @@ -87,7 +87,6 @@ Production credentials should never be committed, please use environment variabl } }, "auth": { - // ... leave empty, until having experience with auth drivers }, "templateengine" : { "default" : { @@ -118,17 +117,17 @@ Production credentials should never be committed, please use environment variabl "errormessage": { "driver": "system", "data": { - "name": "some error log" + "name": "some error log", "minlevel" : -3 } - }, - // more might be required for minimum setup + } } } } ``` Essential keys per environment are: + * **database** (if needed): Database connection configuration * **auth** (if needed): Authentication drivers/clients * **templateengine**: Templating engines to make available @@ -140,19 +139,18 @@ Essential keys per environment are: * **log**: log drivers/clients Every key/section defines a **named** client for a specific purpose. -E.g. you could have a **cache** like -```js -... +E.g., you could have a **cache** like +```json +{ "cache": { "mycache": { "driver": "memcached", "host": "some-memcached.host.internal", "port": 11211 } - }, - -... + } +} ``` Which would be accessible via `app::getCache('mycache')`. diff --git a/docs/context.md b/docs/context.md index bda14d8..b9b0ad7 100644 --- a/docs/context.md +++ b/docs/context.md @@ -2,29 +2,28 @@ A context is comparable to the concept of a 'controller', but represents an "API Endpoint" on its own. The core-provided default 'context' assumes the existence of 'views' and 'actions'. -A contexts consists of the respective config in the app.json (to make it available) and a backing class: +Contexts consist of the respective config in the app.json (to make it available) and a backing class: ~~~php class start extends \codename\core\context { - public function view_default(): void { // a default view } - public function action_special(): void { // an action } - } ~~~ ## Naming convention For every context, regardless of implementation, this is obligatory: + - Contexts must be named all lowercase, w/o special characters - Contexts must be placed in backend/class/**context/** (depending on autoloading configuration) Depending on the used context base class (e.g. `\codename\core\context`), the following may apply: + - Views in a Context are functions prefixed with **view_** and the respective view names. - View names may contain underscores, but must be named all lowercase, too. - Actions in a Context are functions prefixed with **action_** and the respective action name. @@ -50,48 +49,40 @@ If the context class is based on `\codename\core\context` (or similar, derived o ## Simple I/O example -The following example assumes existence of a model named 'test' and some minimal application configuration. +The following example assumes the existence of a model named 'test' and some minimal application configuration. This is meant as ~~~php namespace codename\example\context; - /** * A basic context class */ class testcontext extends \codename\core\context { - /** * The default view function of this context * @return void */ public function view_default(): void { - // get request parameter $someParameter = $this->getRequest()->getData('some_parameter'); - // get model $model = $this->getModel('test'); - // store data $model->save([ 'test_data' => $someParameter ]); - // get the ID/PKEY value we just created $id = $model->lastInsertId(); - // load the freshly created dataset $dataset = $model->load($id); - // put into response $this->getResponse()->setData('output_key', $dataset); } - } ~~~ If you're using a REST-enabled application and call your endpoint (`/testcontext/default?some_parameter=abc`) the response could look like: + ~~~json { "success": 1, diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 0862558..a70d8d3 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -2,7 +2,7 @@ ## Application run -The application lifecycle is started by initializing a new app instance and calling the ::run() method: +The application lifecycle is started by initializing a new app instance and calling the ":: run()" method: ~~~php (new \codename\example\app())->run(); @@ -15,19 +15,20 @@ The following execution steps (including sent hooks/events) are performed: - (*event*) hook::EVENT_APP_RUN_MAIN - app::**handleAccess()** - app::**mainRun()** - - if context is an instance of **customContextInterface** - - app::**doContextRun()** (executes context instance's ::run() method) - - otherwise, regular context - - app::**doAction()** (executes the requested **action**) - - app::**doView()** (executes the requested **view** or falls back to default view) - - app::**doShow()** (renders content using templateengine) - - app::**doOutput()** (outputs content via response instance) + - if context is an instance of **customContextInterface** + - app::**doContextRun()** (executes context instance's ::run() method) + - otherwise, regular context + - app::**doAction()** (executes the requested **action**) + - app::**doView()** (executes the requested **view** or falls back to default view) + - app::**doShow()** (renders content using templateengine) + - app::**doOutput()** (outputs content via response instance) - (*event*) hook::EVENT_APP_RUN_END - exit/return [![](https://mermaid.ink/img/eyJjb2RlIjoiZ3JhcGggVERcbiAgYXBwX3J1bltcImFwcDo6cnVuKClcIl1cbiAgbWFrZV9yZXF1ZXN0W1wiYXBwOjptYWtlUmVxdWVzdCgpXCJdXG4gIGhhbmRsZV9hY2Nlc3NbXCJhcHA6OmhhbmRsZUFjY2VzcygpXCJdXG4gIG1haW5fcnVuW1wiYXBwOjptYWluUnVuKClcIl1cbiAgZW9hW1wiRW5kIG9mIGFwcGxpY2F0aW9uIGV4ZWN1dGlvblwiXVxuXG4gIGRvX2NvbnRleHRfcnVuW1wiYXBwOjpkb0NvbnRleHRSdW4oKVwiXVxuICBkb19hY3Rpb25bXCJhcHA6OmRvQWN0aW9uKClcIl1cbiAgZG9fdmlld1tcImFwcDo6ZG9WaWV3KClcIl1cbiAgZG9fc2hvd1tcImFwcDo6ZG9TaG93KClcIl1cbiAgZG9fb3V0cHV0W1wiYXBwOjpkb091dHB1dCgpXCJdXG5cbiAgYXBwX3J1bi0tPnxFVkVOVF9BUFBfUlVOX1NUQVJUfCBtYWtlX3JlcXVlc3RcbiAgbWFrZV9yZXF1ZXN0LS0-fEVWRU5UX0FQUF9SVU5fTUFJTnwgaGFuZGxlX2FjY2Vzc1xuICBoYW5kbGVfYWNjZXNzLS0-bWFpbl9ydW5cblxuICBzdWJncmFwaCBtYWluX3J1bl9pbnRlcm5hbFtcImFwcDo6bWFpblJ1bigpIGludGVybmFsIHByb2Nlc3NpbmdcIl1cbiAgICBtYWluX3J1bi0tPmNvbnRleHRfdHlwZVtcIkNvbnRleHQgVHlwZSBkZXRlY3Rpb25cIl1cbiAgICBjb250ZXh0X3R5cGUtLT58aWYgUmVndWxhciBjb250ZXh0fCBkb19hY3Rpb25cbiAgICBjb250ZXh0X3R5cGUtLT58aWYgQ3VzdG9tQ29udGV4dEludGVyZmFjZXwgZG9fY29udGV4dF9ydW5cbiAgICBkb19hY3Rpb24tLT5kb192aWV3XG4gICAgZG9fdmlldy0tPmRvX3Nob3dcbiAgICBkb19jb250ZXh0X3J1bi0tPmRvX3Nob3dcbiAgICBkb19zaG93LS0-ZG9fb3V0cHV0XG4gIGVuZFxuXG4gIGRvX291dHB1dC0tPnxFVkVOVF9BUFBfUlVOX0VORHwgZW9hIiwibWVybWFpZCI6eyJ0aGVtZSI6ImRlZmF1bHQifSwidXBkYXRlRWRpdG9yIjpmYWxzZSwiYXV0b1N5bmMiOnRydWUsInVwZGF0ZURpYWdyYW0iOmZhbHNlfQ)](https://mermaid.live/edit#eyJjb2RlIjoiZ3JhcGggVERcbiAgYXBwX3J1bltcImFwcDo6cnVuKClcIl1cbiAgbWFrZV9yZXF1ZXN0W1wiYXBwOjptYWtlUmVxdWVzdCgpXCJdXG4gIGhhbmRsZV9hY2Nlc3NbXCJhcHA6OmhhbmRsZUFjY2VzcygpXCJdXG4gIG1haW5fcnVuW1wiYXBwOjptYWluUnVuKClcIl1cbiAgZW9hW1wiRW5kIG9mIGFwcGxpY2F0aW9uIGV4ZWN1dGlvblwiXVxuXG4gIGRvX2NvbnRleHRfcnVuW1wiYXBwOjpkb0NvbnRleHRSdW4oKVwiXVxuICBkb19hY3Rpb25bXCJhcHA6OmRvQWN0aW9uKClcIl1cbiAgZG9fdmlld1tcImFwcDo6ZG9WaWV3KClcIl1cbiAgZG9fc2hvd1tcImFwcDo6ZG9TaG93KClcIl1cbiAgZG9fb3V0cHV0W1wiYXBwOjpkb091dHB1dCgpXCJdXG5cbiAgYXBwX3J1bi0tPnxFVkVOVF9BUFBfUlVOX1NUQVJUfCBtYWtlX3JlcXVlc3RcbiAgbWFrZV9yZXF1ZXN0LS0-fEVWRU5UX0FQUF9SVU5fTUFJTnwgaGFuZGxlX2FjY2Vzc1xuICBoYW5kbGVfYWNjZXNzLS0-bWFpbl9ydW5cblxuICBzdWJncmFwaCBtYWluX3J1bl9pbnRlcm5hbFtcImFwcDo6bWFpblJ1bigpIGludGVybmFsIHByb2Nlc3NpbmdcIl1cbiAgICBtYWluX3J1bi0tPmNvbnRleHRfdHlwZVtcIkNvbnRleHQgVHlwZSBkZXRlY3Rpb25cIl1cbiAgICBjb250ZXh0X3R5cGUtLT58aWYgUmVndWxhciBjb250ZXh0fCBkb19hY3Rpb25cbiAgICBjb250ZXh0X3R5cGUtLT58aWYgQ3VzdG9tQ29udGV4dEludGVyZmFjZXwgZG9fY29udGV4dF9ydW5cbiAgICBkb19hY3Rpb24tLT5kb192aWV3XG4gICAgZG9fdmlldy0tPmRvX3Nob3dcbiAgICBkb19jb250ZXh0X3J1bi0tPmRvX3Nob3dcbiAgICBkb19zaG93LS0-ZG9fb3V0cHV0XG4gIGVuZFxuXG4gIGRvX291dHB1dC0tPnxFVkVOVF9BUFBfUlVOX0VORHwgZW9hIiwibWVybWFpZCI6IntcbiAgXCJ0aGVtZVwiOiBcImRlZmF1bHRcIlxufSIsInVwZGF0ZUVkaXRvciI6ZmFsc2UsImF1dG9TeW5jIjp0cnVlLCJ1cGRhdGVEaWFncmFtIjpmYWxzZX0) (Mermaid source:) + ```mermaid graph TD app_run["app::run()"] @@ -35,17 +36,14 @@ graph TD handle_access["app::handleAccess()"] main_run["app::mainRun()"] eoa["End of application execution"] - do_context_run["app::doContextRun()"] do_action["app::doAction()"] do_view["app::doView()"] do_show["app::doShow()"] do_output["app::doOutput()"] - app_run-->|EVENT_APP_RUN_START| make_request make_request-->|EVENT_APP_RUN_MAIN| handle_access handle_access-->main_run - subgraph main_run_internal["app::mainRun() internal processing"] main_run-->context_type["Context Type detection"] context_type-->|if Regular context| do_action @@ -55,7 +53,6 @@ graph TD do_context_run-->do_show do_show-->do_output end - do_output-->|EVENT_APP_RUN_END| eoa ``` @@ -72,24 +69,25 @@ The `bootstrapInstance` class provides multiple methods for the `app` class and ### Data container -The `\codename\core\datacontainer` is an essential base class that is used for request, response, some configuration data and other instances. +The `\codename\core\datacontainer` is an essential base class used for request, response, some configuration data and other instances. A data container enables you to interact with nested data. - **getData($key = '')** - - Key can be *empty*/not explicitly set as argument (retrieves **all** data as associative array) - - Key can be a regular string (retrieves this specific key) - - Key can be separated by `>` which enables you to 'dive' into deeper object structures, e.g. `rootkey>subkey>another` - - returns null if key was not found (or is explicitly null) + - Key can be *empty*/not explicitly set as argument (retrieves **all** data as an associative array) + - Key can be a regular string (retrieves this specific key) + - Key can be separated by `>` which enables you to 'dive' into deeper object structures, e.g. `rootkey>subkey>another` + - returns null if key was not found (or is explicitly null) - **setData($key, $value)** - - Key can be a regular string (sets this specific key) - - Key can be separated by `>` which enables you to 'dive' into deeper object structures, e.g. `rootkey>subkey>another` - - `$value` can be anything (null, primitive, instance) + - Key can be a regular string (sets this specific key) + - Key can be separated by `>` which enables you to 'dive' into deeper object structures, e.g. `rootkey>subkey>another` + - `$value` can be anything (null, primitive, instance) - **isDefined($key)** - - Checks whether $key is defined (`null` is interpreted as is-defined/**true**!) + - Checks whether $key is defined (`null` is interpreted as is-defined/**true**!) ### Request instance -The request instance contains data which was used calling the application. In case of a Web application, this is usually HTTP-related data (query parameters, parsed payload, binary data, etc.). In case of a CLI application, this contains the arguments the application was launched with. A request instance is a `datacontainer`, see methods above. +The request instance contains data which was used calling the application. In the case of a Web application, this is usually HTTP-related data (query parameters, parsed payload, binary data, etc.). In the case of a CLI application, this contains the arguments the application was launched with. A request instance is a `datacontainer`, see methods above. ### Response instance -The response instance is the object that stores data to be used for output. It is meant to contain rendering parameters/base data for a template to be rendered. In case of pure REST-applications this might also contain just data to be serialized as JSON string. A response instance is a `datacontainer`, see methods above. + +The response instance is the object that stores data to be used for output. It is meant to contain rendering parameters/base data for a template to be rendered. In the case of pure REST-applications, this might also contain just data to be serialized as JSON string. A response instance is a `datacontainer`, see methods above. diff --git a/docs/model.md b/docs/model.md index 69f7e7d..c335418 100644 --- a/docs/model.md +++ b/docs/model.md @@ -2,16 +2,18 @@ A 'model' defines the structure and properties of a specific dataset. At the same time, it also defines methods to access this data in various ways: + - searching (querying) - filtering - storing/updating data - building ad-hoc data models (e.g. joins) -It does **not** define a value-object on its own. Usually, datasets are kept 'PHP-native' in (assocative) arrays. +It does **not** define a value-object on its own. Usually, datasets are kept 'PHP-native' in (associative) arrays. A model consists of + - a model configuration (usually a file like `config/model/_.json`) -- a backing class (f.e. in `backend/class/model/.php` - note this does't contain the schema name at all) +- a backing class (f.e. in `backend/class/model/.php` - note this doesn't contain the schema name at all) ## Basic model structure @@ -42,17 +44,19 @@ A model consists of ~~~ You can define a 'model' by creating a JSON file (usually placed in your project folder in `config/model/` and named `_.json`). -There's a good reason to store the model configuration as a separate file like this - to allow **architect** to find it, build it and not having to parse PHP classes for annotations. +There's a good reason to store the model configuration as a separate file like this—to allow **architect** to find it, build it and not having to parse PHP classes for annotations. The most used (and required) keys in a model are: + - **field**: an array of fields in this model -- **primary**: an array of (one) field(s) that represent the primary key +- **primary**: an array of (one) field(s) that represents the primary key - **datatype**: an object that defines a datatype for a given field (see available/basic data types below) Additionally, you have to define some more (optional) keys: + - **connection** (*string*): if you want to assign this model to an explicit connection/database in your application -- **options** (*object*): additional field properties like `length` or DB-specific modificators -- **default** (*object*): default values for fields, if they're not set during creation of a dataset +- **options** (*object*): additional field properties like `length` or DB-specific modification +- **default** (*object*): default values for fields if they're not set during the creation of a dataset - **notnull** (*object*): - **index** (*array*): an array of single-field or multi-component indexes - **foreign** (*object*): and object of foreign keys @@ -63,16 +67,16 @@ Additionally, you have to define some more (optional) keys: The framework defines some basic data types. Here, we show the (My-)SQL equivalent, to improve understanding. -|Data type |PHP type |MySQL equivalent (default) | Notes | -|--------- |-------- |-------------- |-------- | -|number_natural |int/integer |`INT(11)` |`BIGINT` if primary key -|number |float/double |`NUMERIC`, `DECIMAL` |`precision` and `length` available as options -|boolean |bool |`TINYINT(1)`/`BOOLEAN` | -|text |string |`TEXT`, `MEDIUMTEXT`, `LONGTEXT` |`length` available as option -|text_date |string (ISO-date) |`DATE` -|text_timestamp |string (ISO-datetime) |`DATETIME` -|structure |array, associative array/object |`text` (formatted as json)|internally handled as regular `text` field -|virtual |*(none)* |*(none)*|reserved field for ORM-use +| Data type | PHP type | MySQL equivalent (default) | Notes | +|----------------|---------------------------------|----------------------------------|-----------------------------------------------| +| number_natural | int/integer | `INT(11)` | `BIGINT` if primary key | +| number | float/double | `NUMERIC`, `DECIMAL` | `precision` and `length` available as options | +| boolean | bool | `TINYINT(1)`/`BOOLEAN` | | +| text | string | `TEXT`, `MEDIUMTEXT`, `LONGTEXT` | `length` available as option | +| text_date | string (ISO-date) | `DATE` | | +| text_timestamp | string (ISO-datetime) | `DATETIME` | | +| structure | array, associative array/object | `text` (formatted as json) | internally handled as regular `text` field | +| virtual | *(none)* | *(none)* | reserved field for ORM-use | The available datatypes directly correlate to available validators. `text` fields may also extend to types like `text_timestamp`, which will @@ -81,14 +85,15 @@ For example, a `text_timestamp` field will be created as a `DATETIME` ### Implicit default convention -When building database structures using **architect** there are some default conventions that are applied, if there's no special configuration (e.g. in options). +When building database structures using **architect** there are some default conventions that are applied, if there's no special configuration (e.g., in options). + - a **primary key** being a **number_natural** is created as a `BIGINT(20)` with `AUTO_INCREMENT` - a **text** field is created as a medium-length text field - if `length` is given in options, it will be a `VARCHAR(n)` - a field used as/in a **foreign** key constraint is automatically adapted to the datatype of the field the key references ### Limitations -- a text field contained in an index must have a definitive length +- a text field contained in an index must have a definitive length ## Using a model @@ -97,28 +102,28 @@ When building database structures using **architect** there are some default con - Many methods on a model instance return the instance itself (`return $this;`). This allows chaining of commands, which is especially useful if you define complex filtering or join a lot of models and want to keep your code 'fluent' for instructions that belong together. - Regular filters get reset/removed after executing a query. -- ..._created and ..._modified fields are mandatory to be defined, if you bootstrap your application using **architect**. +- ..._created and ..._modified fields are mandatory to be defined if you bootstrap your application using **architect**. ### Essential model methods This is just a short overview and explanation of the most essential and most-used methods on a model without the overhead of describing required and optional arguments. -|Method|Description| -|------|-----------| -->**search** ()|Executes a query -->**getResult** ()|Returns the current/latest query result -->**load** (...)|Loads a dataset by ID/primary key -->**addFilter** (...)| Applies a filter to the next query -->**addFilterCollection** (...)| Applies a collecton of filters to the next query -->**addDefaultFilter** (...)| Applies a filter to **all** following queries -->**addDefaultFilterCollection** (...)| Applies a collecton of filters to **all** following queries -->**addField** (...)| includes a specific field (column) in the resultset -->**hideField** (...)| excludes a specific field (column) in the resultset -->**save** (...)| stores given data -->**addModel** (...)| adds (joins) another compatible model, effectively making the model more complex -->**setVirtualFieldResult** (...)| enables virtual fields in a model (and full ORM functionality) -->**saveWithChildren** (...)|stores given data, ORM-enabled +| Method | Description | +|----------------------------------------|----------------------------------------------------------------------------------| +| ->**search** () | Executes a query | +| ->**getResult** () | Returns the current/latest query result | +| ->**load** (...) | Loads a dataset by ID/primary key | +| ->**addFilter** (...) | Applies a filter to the next query | +| ->**addFilterCollection** (...) | Applies a collection of filters to the next query | +| ->**addDefaultFilter** (...) | Applies a filter to **all** following queries | +| ->**addDefaultFilterCollection** (...) | Applies a collection of filters to **all** following queries | +| ->**addField** (...) | includes a specific field (column) in the resultset | +| ->**hideField** (...) | excludes a specific field (column) in the resultset | +| ->**save** (...) | stores given data | +| ->**addModel** (...) | adds (joins) another compatible model, effectively making the model more complex | +| ->**setVirtualFieldResult** (...) | enables virtual fields in a model (and full ORM functionality) | +| ->**saveWithChildren** (...) | stores given data, ORM-enabled | ### Data retrieval (querying/searching) @@ -127,21 +132,23 @@ We assume you have a minimal core app running. ~~~php // Get a fresh instance of the model $model = app::getModel('stuff'); - // Query the data and return resultset $result = $model->search()->getResult(); - -// Do something with the result, e.g. output +// Do something with the result, e.g., output print_r($result); ~~~ -This will query all the data available in the model - in case of a MySQL/MariaDB, this is similar to querying: +This will query all the data available in the model—in case of MySQL/MariaDB, this is similar to querying: + ~~~sql SELECT * FROM stuff ~~~ + By default, all fields defined in the model are retrieved for the output resultset. Manually added columns in a table that are 'unknown' to the model won't appear. ## Further reading + Please continue to + - [Model filters](model/model_filters.md) for defining your data searches. - [Complex models](model/complex_models.md) for adding/joining models and building more complex models. diff --git a/docs/model/complex_models.md b/docs/model/complex_models.md index ff1f198..617aadb 100644 --- a/docs/model/complex_models.md +++ b/docs/model/complex_models.md @@ -2,6 +2,7 @@ In addition to the example model `stuff` from the model basics, let's assume we have a secondary model `part` with the following config: + ~~~json { "field" : [ @@ -39,11 +40,9 @@ If you have your model class(es) ready, you can now do something like this: ~~~php $model = app::getModel('stuff') ->addModel(app::getModel('part')); - print_r($model->search()->getResult()); - // example resultset -// for clarity as a PHP array +// for clarity as a PHP array, // We assume just one entry in `stuff` and `part` respectively $expectedExampleResultset = [ [ @@ -60,14 +59,15 @@ $expectedExampleResultset = [ ]; ~~~ -This effectively performs a LEFT JOIN (in SQL-terms) with both models: +This effectively performs a LEFT JOIN (in SQL terms) with both models: + ~~~sql SELECT * FROM `stuff` LEFT JOIN `part` ON part_id = stuff_part_id ~~~ -For doing a quick-and-dirty model-building, this is absolutely sufficient, but the order of joins/models added becomes highly important for complex, ORM-supported models: +For doing a quick-and-dirty model-building, this is absolutely enough, but the order of joins/models added becomes highly important for complex, ORM-supported models: ~~~php $model = app::getModel('customer')->setVirtualFieldResult(true) @@ -83,6 +83,7 @@ $model = app::getModel('customer')->setVirtualFieldResult(true) ~~~ Given some imagination and common sense, this could yield datasets like this (_created and _modified fields left out for the sake of readability): + ~~~php [ 'customer_id' => 234, @@ -124,12 +125,14 @@ Given some imagination and common sense, this could yield datasets like this (_c ~~~ We assume several models in this example: + * **customer**: a customer dataset containing the name(s) and a reference to an `address` dataset * **address**: an address * **invoice**: an invoice containing date, paid status and net sum, referencing the associated customer in a FKEY * **invoiceitem**: a single item on an invoice, referencing the invoice in a FKEY Effectively leveraging the ORM featureset of the core framework and abstracting all the 'heavy lifting' work behind the scenes: + * **customer_address** is a virtual (non-existing) field that is dynamically filled with the **address** dataset identified by the FKEY **customer_address_id** (Reference: `customer.customer_address_id`->`address.address_id`) * **customer_invoices** is a virtual (non-existing) collection field that is dynamically filled with an **array** of **invoice** datasets diff --git a/docs/model/model_filters.md b/docs/model/model_filters.md index 6cbf9df..2b3ea63 100644 --- a/docs/model/model_filters.md +++ b/docs/model/model_filters.md @@ -6,30 +6,30 @@ You can apply filters and filter collections to a model using the following meth Available operators: -|Operator |Name |Effect (primitive value) |Effect (null value) |Effect array value | -|-----------|-------------- |----- |------ |------------- | -|`=` |Equal |Equality comparison |Equality comparison e.g. `IS NULL` |Equality comparison against a set of values, e.g. `IN (...)` -|`!=` |Not-Equal |Inequality comparison |Inequality comparison e.g. `IS NOT NULL` |Inequality comparison against a set of values, e.g.`NOT IN (...)` -|`>` |Greater than |Greater than comparison |*invalid* |*invalid* -|`>=` |Greater than or equal |Greater than or equal comparison |*invalid* |*invalid* -|`<` |Less than |Less than comparison |*invalid* |*invalid* -|`<=` |Less than or equal |Less than or equal comparison |*invalid* |*invalid* -|`LIKE` |Similarity |`LIKE '...'` (case-insensitive) |*invalid* |*invalid* +| Operator | Name | Effect (primitive value) | Effect (null value) | Effect array value | +|----------|-----------------------|----------------------------------|------------------------------------------|-------------------------------------------------------------------| +| `=` | Equal | Equality comparison | Equality comparison e.g. `IS NULL` | Equality comparison against a set of values, e.g. `IN (...)` | +| `!=` | Not-Equal | Inequality comparison | Inequality comparison e.g. `IS NOT NULL` | Inequality comparison against a set of values, e.g.`NOT IN (...)` | +| `>` | Greater than | Greater than comparison | *invalid* | *invalid* | +| `>=` | Greater than or equal | Greater than or equal comparison | *invalid* | *invalid* | +| `<` | Less than | Less than comparison | *invalid* | *invalid* | +| `<=` | Less than or equal | Less than or equal comparison | *invalid* | *invalid* | +| `LIKE` | Similarity | `LIKE '...'` (case-insensitive) | *invalid* | *invalid* | ## Filter methods * **addFilter**($field, $value, $operator = '=', $conjunction = null) - * Required arguments: - * `$field` (string): the field to apply the filter to - * `$value`: the filter value (possible values: a primitive, null or an array, depending on operator) - * Optional arguments - * `$operator` (*optional*, default: `=`): a valid operator - * `$conjunction` (*optional*, default: `AND`): boolean conjunction for this filter (`AND` or `OR`) - -* **addFiltercollection**($filters, $groupOperator = 'AND', $groupName = 'default', $conjunction = null) + * Required arguments: + * `$field` (string): the field to apply the filter to + * `$value`: the filter value (possible values: a primitive, null or an array, depending on operator) + * Optional arguments + * `$operator` (*optional*, default: `=`): a valid operator + * `$conjunction` (*optional*, default: `AND`): boolean conjunction for this filter (`AND` or `OR`) + +* **addFilterCollection**($filters, $groupOperator = 'AND', $groupName = 'default', $conjunction = null) Required arguments: - * `$filters` (array): an **array** of filters, composed of items like: `[ 'field' => 'field-name', 'operator' => '=', 'value' => 123 ]` - * `$groupOperator`: boolean conjunction between every single filter in `$filters` + * `$filters` (array): an **array** of filters, composed of items like: `[ 'field' => 'field-name', 'operator' => '=', 'value' => 123 ]` + * `$groupOperator`: boolean conjunction between every single filter in `$filters` Optional arguments: * `$groupName` (*optional*, default: `default`): a named group for controlling boolean logic and cross-model filtering @@ -39,70 +39,82 @@ Available operators: * **addDefaultFilter**($field, $value, $operator = '=', $conjunction = null) the same as `addFilter`, but kept alive across multiple `search()`es. Non-removable once set. -* **addDefaultFiltercollection**($filters, $groupOperator = 'AND', $groupName = 'default', $conjunction = null) - the same as `addFiltercollection`, but kept alive across multiple `search()`es. Non-removable once set. +* **addDefaultFilterCollection**($filters, $groupOperator = 'AND', $groupName = 'default', $conjunction = null) + the same as `addFilterCollection`, but kept alive across multiple `search()`es. Non-removable once set. ## Filtering examples and SQL equivalents For these examples, we assume the above `stuff` model being a MySQL-driven model and initialized like: + ~~~php $model = app::getModel('stuff') ~~~ + The following examples list **Simple equality filter**, will match all entries with stuff_name = 'my-stuff': + ~~~php $model ->addFilter('stuff_name', 'my-stuff') ->search()->getResult(); ~~~ + ~~~sql SELECT * FROM stuff WHERE stuff_name = 'my_stuff' ~~~ -**Array equality filter**, will match all entries with stuff_name being 'my-stuff' or 'other-stuff' +**Array equality filter** will match all entries with stuff_name being 'my-stuff' or 'other-stuff' + ~~~php $model ->addFilter('stuff_name', [ 'my-stuff', 'other-stuff' ]) ->search()->getResult(); ~~~ + ~~~sql SELECT * FROM stuff WHERE stuff_name IN ('my_stuff', 'other-stuff') ~~~ **Not-Null filter**, will match all entries with stuff_name NOT being NULL: + ~~~php $model ->addFilter('stuff_name', null, '!=') ->search()->getResult(); ~~~ + ~~~sql SELECT * FROM stuff WHERE stuff_name IS NOT NULL ~~~ -**GTE filter**, will match all entries with stuff_id being greater than or equal to 123 +**GTE filter** will match all entries with stuff_id being greater than or equal to 123 + ~~~php $model ->addFilter('stuff_id', 123, '>=') ->search()->getResult(); ~~~ + ~~~sql SELECT * FROM stuff WHERE stuff_id >= 123 ~~~ **Simple filtercollection**, this example should yield the same result as above (see **Array equality filter**), all entries with stuff_name being 'my-stuff' or 'other-stuff' + ~~~php $model - ->addFiltercollection([ + ->addFilterCollection([ [ 'field' => 'stuff_name', 'operator' => '=', 'value' => 'my-stuff' ] [ 'field' => 'stuff_name', 'operator' => '=', 'value' => 'other-stuff' ] ], 'OR') ->search()->getResult(); ~~~ + ~~~sql SELECT * FROM stuff WHERE (stuff_name = 'my_stuff' OR stuff_name = 'other-stuff') @@ -110,22 +122,24 @@ WHERE (stuff_name = 'my_stuff' OR stuff_name = 'other-stuff') **Complex filter collection** with two or more groups. Filter groups as a whole are always assumed to be concatenated via `AND`. + ~~~php $model - ->addFiltercollection([ + ->addFilterCollection([ [ 'field' => 'stuff_name', 'operator' => '=', 'value' => 'my-stuff' ] [ 'field' => 'stuff_name', 'operator' => '=', 'value' => 'other-stuff' ] ], 'OR', 'first-group') - ->addFiltercollection([ + ->addFilterCollection([ [ 'field' => 'stuff_id', 'operator' => '>=', 'value' => 123 ] [ 'field' => 'stuff_name', 'operator' => '=', 'value' => 'some-stuff' ] ], 'AND', 'second-group', 'OR') - ->addFiltercollection([ + ->addFilterCollection([ [ 'field' => 'stuff_id', 'operator' => '<', 'value' => 123 ] [ 'field' => 'stuff_name', 'operator' => '=', 'value' => 'more-stuff' ] ], 'AND', 'second-group', 'OR') ->search()->getResult(); ~~~ + ~~~sql SELECT * FROM stuff WHERE diff --git a/docs/model/storing_model_data.md b/docs/model/storing_model_data.md index 75e9703..cda46ee 100644 --- a/docs/model/storing_model_data.md +++ b/docs/model/storing_model_data.md @@ -1,22 +1,29 @@ # Storing model data ## Basics + Two methods can be used for storing a single dataset in your model: + * ->**save** (*array* $data): simple data storage on the current (root) model * ->**saveWithChildren** (*array* $data): for cases with enabled ORM, if you want to store complex model datasets (children and collections) ### Notable facts -* These methods will ignore all fields dataset that are not part of the model. + +* These methods will ignore all fields datasets that are not part of the model. * Fields that are defined in the model, but *not defined in the dataset*, will not be set or changed explicitly (if you create a new dataset the respective field's value is set to the default value; usually `null`, if not defined explicitly). ## Validation + The validation, if desired, can be performed beforehand via + ~~~php $returnsBool = $model->isValid($dataset); ~~~ ## Creating a dataset + To create a single, **new** dataset in your model, call your model like this: + ~~~php $model->save([ 'stuff_name' => 'some-string', @@ -24,16 +31,19 @@ $model->save([ ]); $id = $model->lastInsertId(); ~~~ + Essentially, you're leaving out the primary key (`stuff_id` in this case) which will cause a new dataset to be created. In this example, we're also retrieving the PKEY value of the entry we just created. ## Updating a dataset If you want to update an existing (known) entry, perform this: + ~~~php $model->save([ 'stuff_id' => 2, // Existing pkey value 'stuff_name' => 'other-string', ]); ~~~ + If you define the primary key (here: `stuff_id`) in the associative array and give it a meaningful value, this will advise the model to update an existing dataset with the given identifier value. diff --git a/docs/naming_and_inheritance.md b/docs/naming_and_inheritance.md index ed73ff3..e9165af 100644 --- a/docs/naming_and_inheritance.md +++ b/docs/naming_and_inheritance.md @@ -3,44 +3,44 @@ The core framework defines a standard that bases on PSR-4 regarding class naming and inheritance. This also implies heavy usage of Composer-autoloading. -- For cross-project inheritable classes, we're using lowercase class names, except it is found to be appropriate for proper names (e.g. the mail driver for PHPMailer). +- For cross-project inheritable classes, we're using lowercase class names, except it is found to be appropriate for proper names (e.g., the mail driver for PHPMailer). - It is not prohibited if it makes sense to give an advanced naming by using **camelCasing**. - For interfaces, there is no general naming rule, but **camelCasing** mentioning ...***Interface*** last, is current practice. The core framework establishes a secondary form of inheritance in your application. -While regular class inheritance (via extends and implements) defines the building blocks for your code, you can define and override drivers and modules in every application layer as you wish, as well as **configuration elements**, templates, translation files, etc. +While regular class inheritance (via extents and implements) defines the building blocks for your code, you can define and override drivers and modules in every application layer as you wish, as well as **configuration elements**, templates, translation files, etc. + +As the core framework facilitates and embraces abstraction and modularization, the base namespace (e.g., in directory `backend/class/`) is divided into several 'categories' (base drivers/interfaces) for various purposes: -As the core framework facilitates and embraces abstraction and modularization, the base namespace (e.g. in directory `backend/class/`) is divided into several 'categories' (base drivers/interfaces) for various purposes: - auth: auth drivers implementing authInterface and/or relying on auth base class - bucket: bucket drivers implementing bucketInterface - database: database drivers - mail: mail client drivers - model: various model drivers - request: request drivers/base classes (e.g. http or cli) -- response: response drivers/base classes (e.g. http, cli or even a specialized json-response) +- response: response drivers/base classes (e.g., http, cli or even a specialized json-response) - session: session drivers - validator: validators - etc. -This inheritance paradigm **does not** compete with regular inheritance (e.g. via *extends*), but in fact, does not make assumptions about the real inheritance tree, except the base class (or **category** it belongs to, e.g. *bucket*). - +This inheritance paradigm **does not** compete with regular inheritance (e.g., via *extends*), but in fact, does not make assumptions about the real inheritance tree, except the base class (or **category** it belongs to, e.g. *bucket*). ## Cross-app inheritance The core framework enables you to override drivers, clients and classes which are compatible with the respective naming schema. -The app class itselfs provides you `app::getInheritedClass` which expects a string in the form of +The app class itself provides you `app::getInheritedClass` which expects a string in the form of `bucket_ftp_special` which would translate to the partial namespace `bucket\ftp\special`. This partial namespace is now being searched in the appstack, from top (your app) to bottom (the core framework base), see below. The first found matching implementation is the to-be-chosen implementation. Your application may define a file in `config/parent.app` with the content `_` that instructs the framework to directly inject this respective app into the appstack as immediate parent of your application. - ## The appstack Your application is always part of a superior structure called **the appstack**. This appstack can be interpreted as an ordered array of application/library fragments, e.g.: + ~~~php // // app::getAppstack() could return: @@ -58,30 +58,31 @@ The core framework itself is always the 'last resort' and underlying structure. This gives you the power to: - **Create a custom implementation of a driver class** like `\codename\core\bucket\ftp` in `\examplevendor\exampleapp\bucket\ftp`. -- **Define custom configurations** in `config/somefile.json` in multiple appstack items that *can* be merged together recursively or override each other. +- **Define custom configurations** in `config/somefile.json` in multiple appstack items that *can* be merged recursively or override each other. - **Define inheritable views and templates** (in frontend/view/... and frontend/template/... respectively) - ## Appstack items Appstack items consist of two required properties: + - **vendor**: the vendor's name - **app**: the app's name By default, this is used to build a base namespace for the given app: `\\library @@ -89,5 +90,5 @@ graph TD library-->app2 ``` -As both 'leaf' applications might define different aspects to interact with your application and your data, both applications can have differing implementations for some parts of the ecosystem - or leave some out, completely. +As both 'leaf' applications might define different aspects to interact with your application and your data, both applications can have differing implementations for some parts of the ecosystem—or leave some out, completely. At the base, both applications share their common library and its appstack. diff --git a/docs/validator.md b/docs/validator.md index ebf7000..875c3c3 100644 --- a/docs/validator.md +++ b/docs/validator.md @@ -1,24 +1,24 @@ # Validator -A validator validates a given value - which might be a primitive, or even complex, nested data. +A validator validates a given value—which might be a primitive, or even complex, nested data. At the same time, a validator implicitly defines a 'data type' (which can be a logical type). For example, strings are usually meant to be of type `text` - which equals the validator `codename\core\validator\text`. If your string is a ISO-formatted datetime (f.e. `2021-11-22 12:34:56`), you can use the validator `text_timestamp` (class: `codename\core\validator\text\timestamp`), which is the default one to use for this kind of logical type (and for historical reasons). -Defining this kind of field in a model will help the **architect** to create an appropriate column in your database, as we still handle those datetime values as strings in PHP, but we leverage DB-provided, optimized fields/types/columns for this, if available. +Defining this kind of field in a model will help the **architect** to create an appropriate column in your database, as we still handle those datetime values as strings in PHP. However, we leverage DB-provided, optimized fields/types/columns for this, if available. Some basic validators are already provided in the core framework, here's an overview of some important ones and some to give an impression of logical (sub-)types. -|Validator/Data type|Class (rel. to codename\core\validator)|Description| -|---------|-------------------------------------------|-----------| -boolean|`boolean`|Bool validation -text|`text`|Text validation -text_email|`text\email`|Email validation -text_json|`text\json`|JSON string validation -text_color_hex|`text\color\hex`|Color string in HEX-format (f.e. `#FF00FF`) -text_timestamp|`text\timestamp`|ISO-datetime formatted string -text_date|`text\date`|ISO-date formatted string -number|`number`|Base number/numerical validator, includes floats/doubles/decimals -number_natural|`number\natural`|Though misleading name, integer validation (no fractions/real parts) -structure|`structure`|Arrays, associative arrays +| Validator/Data type | Class (rel. to codename\core\validator) | Description | +|---------------------|-----------------------------------------|----------------------------------------------------------------------| +| boolean | `boolean` | Bool validation | +| text | `text` | Text validation | +| text_email | `text\email` | Email validation | +| text_json | `text\json` | JSON string validation | +| text_color_hex | `text\color\hex` | Color string in HEX-format (f.e. `#FF00FF`) | +| text_timestamp | `text\timestamp` | ISO-datetime formatted string | +| text_date | `text\date` | ISO-date formatted string | +| number | `number` | Base number/numerical validator, includes floats/doubles/decimals | +| number_natural | `number\natural` | Though misleading name, integer validation (no fractions/real parts) | +| structure | `structure` | Arrays, associative arrays | diff --git a/php74-cli.Dockerfile b/php74-cli.Dockerfile deleted file mode 100644 index 39f7f87..0000000 --- a/php74-cli.Dockerfile +++ /dev/null @@ -1,55 +0,0 @@ -FROM php:7.4-cli - -# get apt-get lists for the first time -RUN apt-get update - -## install zip extension using debian buster repo (which is now available) -## we need zip-1.14 or higher and libzip 1.2 or higher for ZIP encryption support -RUN apt-get update && apt-get install -y zlib1g-dev libzip-dev \ - && pecl install zip \ - # && docker-php-ext-configure zip --with-libzip \ # not required for PHP 7.4+ - && docker-php-ext-install zip - -## configure and install php-intl extension (and dependencies) -## also needs zlib1g-dev previously installed -RUN apt-get update && apt-get install -y libicu-dev \ - && docker-php-ext-install intl - -# install some php extensions -RUN docker-php-ext-install pdo pdo_mysql opcache bcmath - -# install gmp -RUN apt-get -y install libgmp-dev && \ - docker-php-ext-install gmp - -# install calendar (for usage of holiday determination functions) -RUN docker-php-ext-install calendar - -# -# install libmemcached and the php extension -# -RUN apt-get update && apt-get install -y \ - libz-dev \ - libmemcached-dev \ - libssl-dev \ - libcurl4-openssl-dev \ - curl - -RUN pecl install memcached-3.1.5 \ - && docker-php-ext-enable memcached - -# install ssh2 ext and deps -# see https://github.com/docker-library/php/issues/767 -RUN apt-get update \ - && apt-get install -y libssh2-1-dev libssh2-1 \ - && pecl install ssh2-1.3.1 \ - && docker-php-ext-enable ssh2 - -# RUN pecl install xdebug \ -# && docker-php-ext-enable xdebug - -RUN pecl install pcov-1.0.9 \ - && docker-php-ext-enable pcov - -# Programmatically install composer -RUN curl -sS https://getcomposer.org/installer | php && mv composer.phar /usr/local/bin/composer diff --git a/php73-cli.Dockerfile b/php81-cli.Dockerfile similarity index 83% rename from php73-cli.Dockerfile rename to php81-cli.Dockerfile index b30cef7..58cefd6 100644 --- a/php73-cli.Dockerfile +++ b/php81-cli.Dockerfile @@ -1,13 +1,12 @@ -FROM php:7.3-cli-bullseye +FROM php:8.1.22-cli # get apt-get lists for the first time RUN apt-get update -## install zip extension using debian buster repo (which is now available) +## install zip extension ## we need zip-1.14 or higher and libzip 1.2 or higher for ZIP encryption support RUN apt-get update && apt-get install -y zlib1g-dev libzip-dev \ && pecl install zip \ - && docker-php-ext-configure zip --with-libzip \ && docker-php-ext-install zip ## configure and install php-intl extension (and dependencies) @@ -16,7 +15,7 @@ RUN apt-get update && apt-get install -y libicu-dev \ && docker-php-ext-install intl # install some php extensions -RUN docker-php-ext-install pdo pdo_mysql opcache bcmath +RUN docker-php-ext-install pdo pdo_mysql opcache zip bcmath # install gmp RUN apt-get -y install libgmp-dev && \ @@ -35,7 +34,7 @@ RUN apt-get update && apt-get install -y \ libcurl4-openssl-dev \ curl -RUN pecl install memcached-3.1.5 \ +RUN pecl install memcached-3.2.0 \ && docker-php-ext-enable memcached # install ssh2 ext and deps @@ -48,7 +47,7 @@ RUN apt-get update \ # RUN pecl install xdebug \ # && docker-php-ext-enable xdebug -RUN pecl install pcov-1.0.9 \ +RUN pecl install pcov-1.0.11 \ && docker-php-ext-enable pcov # Programmatically install composer diff --git a/php80-cli.Dockerfile b/php82-cli.Dockerfile similarity index 82% rename from php80-cli.Dockerfile rename to php82-cli.Dockerfile index 79907be..50c79fe 100644 --- a/php80-cli.Dockerfile +++ b/php82-cli.Dockerfile @@ -1,13 +1,12 @@ -FROM php:8.0-cli +FROM php:8.2.9-cli # get apt-get lists for the first time RUN apt-get update -## install zip extension using debian buster repo (which is now available) +## install zip extension ## we need zip-1.14 or higher and libzip 1.2 or higher for ZIP encryption support RUN apt-get update && apt-get install -y zlib1g-dev libzip-dev \ && pecl install zip \ - # && docker-php-ext-configure zip --with-libzip \ # not required for PHP 7.4+ && docker-php-ext-install zip ## configure and install php-intl extension (and dependencies) @@ -16,7 +15,7 @@ RUN apt-get update && apt-get install -y libicu-dev \ && docker-php-ext-install intl # install some php extensions -RUN docker-php-ext-install pdo pdo_mysql opcache bcmath +RUN docker-php-ext-install pdo pdo_mysql opcache zip bcmath # install gmp RUN apt-get -y install libgmp-dev && \ @@ -35,7 +34,7 @@ RUN apt-get update && apt-get install -y \ libcurl4-openssl-dev \ curl -RUN pecl install memcached-3.1.5 \ +RUN pecl install memcached-3.2.0 \ && docker-php-ext-enable memcached # install ssh2 ext and deps @@ -48,7 +47,7 @@ RUN apt-get update \ # RUN pecl install xdebug \ # && docker-php-ext-enable xdebug -RUN pecl install pcov-1.0.9 \ +RUN pecl install pcov-1.0.11 \ && docker-php-ext-enable pcov # Programmatically install composer diff --git a/phpcs.xml b/phpcs.xml index 73b533d..8644693 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -1,21 +1,21 @@ - The default CoreFramework coding standard + The default CoreFramework coding standard - - - + + + - - + + - - + + \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml index 5ec24c3..df21c2f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,19 +1,20 @@ - + - - - + - + backend/class/ - - - + @@ -53,6 +54,6 @@ - + diff --git a/psalm.xml b/psalm.xml index 580e128..a1b8601 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,15 +1,14 @@ - - - - - - + + + + + + diff --git a/tests/appTest.php b/tests/appTest.php index 6cbd251..80722f8 100644 --- a/tests/appTest.php +++ b/tests/appTest.php @@ -1,66 +1,98 @@ createApp(); - $app->getAppstack(); +class appTest extends base +{ + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testGetInheritedClassNonexisting(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage(app::EXCEPTION_GETINHERITEDCLASS_CLASSFILENOTFOUND); + app::getInheritedClass('nonexisting'); + } - static::setEnvironmentConfig([ - 'test' => [ - // 'database' => [ - // 'default' => [ - // 'driver' => 'sqlite', - // 'database_file' => ':memory:', - // ] - // ], - 'cache' => [ - 'default' => [ - 'driver' => 'memory' - ] - ], - 'filesystem' =>[ - 'local' => [ - 'driver' => 'local', - ] - ], - 'log' => [ - 'default' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ] - ], - ] - ]); - } + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testGetInheritedClassExisting(): void + { + $class = app::getInheritedClass('database'); + static::assertEquals('\\codename\\core\\database', $class); + } - /** - * [testGetInheritedClassNonexisting description] - */ - public function testGetInheritedClassNonexisting(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage(app::EXCEPTION_GETINHERITEDCLASS_CLASSFILENOTFOUND); - $class = app::getInheritedClass('nonexisting'); - } + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws ErrorException + * @throws Throwable + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws exception + */ + protected function setUp(): void + { + $app = static::createApp(); + $app::getAppstack(); - /** - * [testGetInheritedClassExisting description] - */ - public function testGetInheritedClassExisting(): void { - $class = app::getInheritedClass('database'); - $this->assertEquals('\\codename\\core\\database', $class); - } + static::setEnvironmentConfig([ + 'test' => [ + // 'database' => [ + // 'default' => [ + // 'driver' => 'sqlite', + // 'database_file' => ':memory:', + // ] + // ], + 'cache' => [ + 'default' => [ + 'driver' => 'memory', + ], + ], + 'filesystem' => [ + 'local' => [ + 'driver' => 'local', + ], + ], + 'log' => [ + 'default' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + ], + ], + ]); + } } diff --git a/tests/autoload.php b/tests/autoload.php index 58c66ed..db21dcd 100644 --- a/tests/autoload.php +++ b/tests/autoload.php @@ -4,11 +4,11 @@ * This is a per-project autoloading file * For initializing the local project and enabling it for development purposes * - * you need to build up your fullstack autoloading structure + * You need to build up your fullstack autoloading structure * using composer install / composer update - * e.g. for /composer.json + * e.g., for /composer.json * - * and you need to build a local composer classmap + * And you need to build a local composer classmap * that enables the usage of composer's 'autoload-dev' setting * just for this project * @@ -17,53 +17,56 @@ */ // Default fixed environment for unit tests -define('CORE_ENVIRONMENT', 'test'); +use codename\core\app; +use codename\core\test\overrideableApp; + +const CORE_ENVIRONMENT = 'test'; // cross-project autoloader -$globalBootstrap = realpath(__DIR__.'/../../../../bootstrap-cli.php'); -if(file_exists($globalBootstrap)) { - echo("Including autoloader at " . $globalBootstrap . chr(10) ); - require_once $globalBootstrap; +$globalBootstrap = realpath(__DIR__ . '/../../../../bootstrap-cli.php'); +if (file_exists($globalBootstrap)) { + echo("Including autoloader at " . $globalBootstrap . chr(10)); + require_once $globalBootstrap; } else { - // die("ERROR: No global bootstrap.cli.php found. You might want to initialize your cross-project autoloader using the root composer.json first." . chr(10) ); + die("ERROR: No global bootstrap.cli.php found. You might want to initialize your cross-project autoloader using the root composer.json first." . chr(10)); } // local autoloader -$localAutoload = realpath(__DIR__.'/../vendor/autoload.php'); -if(file_exists($localAutoload)) { - echo("Including autoloader at " . $localAutoload . chr(10) ); - require_once $localAutoload; +$localAutoload = realpath(__DIR__ . '/../vendor/autoload.php'); +if (file_exists($localAutoload)) { + echo("Including autoloader at " . $localAutoload . chr(10)); + require_once $localAutoload; } else { - // die("ERROR: No local vendor/autoloader.php found. Please call \"composer dump-autoload\" in this directory." . chr(10) ); + die("ERROR: No local vendor/autoloader.php found. Please call \"composer dump-autoload --dev\" in this directory." . chr(10)); } // // This allows having only a local autoloader and no global one -// (e.g. single-project unit testing) +// (e.g., single-project unit testing) // -if(!file_exists($globalBootstrap) && !file_exists($localAutoload)){ - die("ERROR: No global bootstrap.cli.php or local vendor/autoloader.php found. You might want to initialize your cross-project or single-project autoloader first." . chr(10) ); +if (!file_exists($globalBootstrap) && !file_exists($localAutoload)) { + die("ERROR: No global bootstrap.cli.php or local vendor/autoloader.php found. You might want to initialize your cross-project or single-project autoloader first." . chr(10)); } -if(!$globalBootstrap) { - // Fallback to this project's vendor dir (and add a slash at the end - because realpath doesn't add it) - DEFINE("CORE_VENDORDIR", realpath(DIRNAME(__FILE__) . '/../vendor/').'/'); +if (!$globalBootstrap) { + // Fallback to this project's vendor dir (and add a slash at the end - because realpath doesn't add it) + define("CORE_VENDORDIR", realpath(dirname(__FILE__) . '/../vendor/') . '/'); } // Explicitly reset any appdata left // or implicitly re-init base data. -\codename\core\test\overrideableApp::reset(); +overrideableApp::reset(); // -// Special quirk for single-project unit testing +// Special quirk for single-project unit testing, // We need to override the homedir for this app // as the framework itself assumes it resides in composer's vendor dir // // Additionally, we need to do this every time the appstack gets initialized in the tests // and only if this app is used, somehow. // -\codename\core\app::getHook()->add(\codename\core\app::EVENT_APP_APPSTACK_AVAILABLE, function() { - \codename\core\test\overrideableApp::__modifyAppstackEntry('codename', 'core', [ - 'homedir' => realpath(__DIR__.'/../'), // One dir up (project root) - ]); +app::getHook()->add(app::EVENT_APP_APPSTACK_AVAILABLE, function () { + overrideableApp::__modifyAppstackEntry('codename', 'core', [ + 'homedir' => realpath(__DIR__ . '/../'), // One dir up (project root) + ]); }); diff --git a/tests/base.php b/tests/base.php index 01bc7b9..62188c9 100644 --- a/tests/base.php +++ b/tests/base.php @@ -1,18 +1,18 @@ expectException(exception::class); + // Pass an empty configuration array + $this->getBucket([]); + } -abstract class abstractBucketTest extends base { - - /** - * @inheritDoc - */ - protected function setUp(): void - { - $app = static::createApp(); - $app->getAppstack(); - - - static::setEnvironmentConfig([ - 'test' => [ - 'filesystem' =>[ - 'local' => [ - 'driver' => 'local', - ] - ], - 'log' => [ - 'errormessage' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ], - 'debug' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ] - ], - ] - ]); - - // init test files - $bucket = $this->getBucket(); - if(!$bucket->filePush(__DIR__.'/testdata/testfile.ext', 'testfile.ext')) { - $this->addWarning('Initial test setup failed'); + /** + * [getBucket description] + * @param array|null $config + * @return bucket [description] + */ + abstract public function getBucket(?array $config = null): bucket; + + /** + * @return void + */ + protected function testFileAvailableFalse(): void + { + $bucket = $this->getBucket(); + static::assertFalse($bucket->fileAvailable('non-existing-file')); + } + + /** + * Tests file availability in the bucket + * NOTE: needs to be placed, first! + * @return void + */ + protected function testFileAvailableTrue(): void + { + $bucket = $this->getBucket(); + static::assertTrue($bucket->fileAvailable('testfile.ext')); + } + + /** + * makes sure VFS works + * and tries to pull a file to a directory + * where we have no access to. + * @return void + */ + protected function testVfsLocalDirNotWritableFilePull(): void + { + $bucket = $this->getBucket(); + + $writableLocalDir = $this->vfsRoot->url() . '/writable-dir'; + mkdir($writableLocalDir, 0777, true); + $writableLocalFile = $writableLocalDir . '/file1.txt'; + static::assertTrue($bucket->filePull('testfile.ext', $writableLocalFile)); + + // + // Emulate dir that is owned by another user + // and not writable for the current one. + // + $notWritablePath = $this->vfsRoot->url() . '/not-writable-dir'; + mkdir($notWritablePath, 0600, true); + $notWritableDir = $this->vfsRoot->getChild('not-writable-dir'); + $notWritableDir->chown('other-user'); + + $notWritableLocalFile = $notWritablePath . '/file2.txt'; + static::assertFalse($bucket->filePull('testfile.ext', $notWritableLocalFile)); + } + + /** + * limits local VFS's quota (disk space) + * and tries to pull a file + * @return void + */ + protected function testVfsLocalDirQuotaLimitedFilePull(): void + { + vfsStream::setQuota(1); // ultra-low quota + $bucket = $this->getBucket(); + $localFileTarget = $this->vfsRoot->url() . '/file.txt'; + static::assertFalse($bucket->filePull('testfile.ext', $localFileTarget)); + } + + /** + * @return void + */ + protected function testFilePushSuccessful(): void + { + $bucket = $this->getBucket(); + static::assertTrue($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'pushed_file.ext')); + static::assertTrue($bucket->fileDelete('pushed_file.ext')); + } + + /** + * @return void + */ + protected function testFilePushNestedSuccessful(): void + { + $bucket = $this->getBucket(); + static::assertTrue($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'nested1/nested2/pushed_file.ext')); + static::assertTrue($bucket->fileDelete('nested1/nested2/pushed_file.ext')); + } + + /** + * Tries to go up one dir (like cd..), + * but in the 'allowed' space - anyway, this should not be possible. + * @return void + */ + protected function testFilePushMaliciousDirUpFails(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage(bucket::BUCKET_EXCEPTION_BAD_PATH); + $bucket = $this->getBucket(); + static::assertFalse($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'nested1/../pushed_file.ext')); } - // create a VFS for testing various things - // e.g. erroneous local storage location - // or (if applicable) broken 'remote' storage (local bucket) - $this->vfsRoot = \org\bovigo\vfs\vfsStream::setup('vfs-test'); - \org\bovigo\vfs\vfsStream::setQuota(\org\bovigo\vfs\Quota::UNLIMITED); - } - - /** - * VFS for helping with mocking erroneous storage - * @var \org\bovigo\vfs\vfsStreamDirectory - */ - protected $vfsRoot = null; - - /** - * @inheritDoc - */ - protected function tearDown(): void - { - $this->getBucket()->fileDelete('testfile.ext'); - } - - /** - * [getBucket description] - * @param array|null $config - * @return \codename\core\bucket [description] - */ - public abstract function getBucket(?array $config = null): \codename\core\bucket; - - /** - * [testInvalidEmptyConfiguration description] - */ - public function testInvalidEmptyConfiguration(): void { - $this->expectException(\codename\core\exception::class); - // Simply pass an empty configuration array - $bucket = $this->getBucket([]); - } - - /** - * [testFileAvailableFalse description] - */ - public function testFileAvailableFalse(): void { - $bucket = $this->getBucket(); - $this->assertFalse($bucket->fileAvailable('non-existing-file')); - } - - /** - * Tests file availability in the bucket - * NOTE: needs to be placed, first! - */ - public function testFileAvailableTrue(): void { - $bucket = $this->getBucket(); - $this->assertTrue($bucket->fileAvailable('testfile.ext')); - } - - /** - * makes sure VFS works - * and tries to pull a file to a directory - * where we have no access to. - */ - public function testVfsLocalDirNotWritableFilePull(): void { - $bucket = $this->getBucket(); - - $writableLocalDir = $this->vfsRoot->url() . '/writable-dir'; - mkdir($writableLocalDir, 0777, true); - $writableLocalFile = $writableLocalDir . '/file1.txt'; - $this->assertTrue($bucket->filePull('testfile.ext', $writableLocalFile)); - - // - // Emulate dir that is owned by another user - // and not writable for the current one. - // - $notWritablePath = $this->vfsRoot->url() . '/not-writable-dir'; - mkdir($notWritablePath, 0600, true); - $notWritableDir = $this->vfsRoot->getChild('not-writable-dir'); - $notWritableDir->chown('other-user'); - - $notWritableLocalFile = $notWritablePath . '/file2.txt'; - $this->assertFalse($bucket->filePull('testfile.ext', $notWritableLocalFile)); - } - - /** - * limits local VFS's quota (disk space) - * and tries to pull a file - */ - public function testVfsLocalDirQuotaLimitedFilePull(): void { - \org\bovigo\vfs\vfsStream::setQuota(1); // ultra-low quota - $bucket = $this->getBucket(); - $localFileTarget = $this->vfsRoot->url() . '/file.txt'; - $this->assertFalse($bucket->filePull('testfile.ext', $localFileTarget)); - } - - /** - * [testFilePush description] - */ - public function testFilePushSuccessful(): void { - $bucket = $this->getBucket(); - $this->assertTrue($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'pushed_file.ext')); - $this->assertTrue($bucket->fileDelete('pushed_file.ext')); - } - - /** - * [testFilePushNestedSuccessful description] - */ - public function testFilePushNestedSuccessful(): void { - $bucket = $this->getBucket(); - $this->assertTrue($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'nested1/nested2/pushed_file.ext')); - $this->assertTrue($bucket->fileDelete('nested1/nested2/pushed_file.ext')); - } - - /** - * Tries to go up one dir (like cd ..) - * but in the 'allowed' space - anyways, this should not be possible. - */ - public function testFilePushMaliciousDirUpFails(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage(\codename\core\bucket::BUCKET_EXCEPTION_BAD_PATH); - $bucket = $this->getBucket(); - $this->assertFalse($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'nested1/../pushed_file.ext')); - } - - /** - * [testFilePushMaliciousDirFails description] - */ - public function testFilePushMaliciousDirTraversalFails(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage(\codename\core\bucket::BUCKET_EXCEPTION_BAD_PATH); - $bucket = $this->getBucket(); - $this->assertFalse($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'nested1/../../pushed_file.ext')); - } - - /** - * [testFilePushMaliciousMultipleDirTraversalFails description] - */ - public function testFilePushMaliciousMultipleDirTraversalFails(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage(\codename\core\bucket::BUCKET_EXCEPTION_BAD_PATH); - $bucket = $this->getBucket(); - $this->assertFalse($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'nested1/../../../pushed_file.ext')); - } - - /** - * [testFilePushAlreadyExists description] - */ - public function testFilePushAlreadyExists(): void { - $bucket = $this->getBucket(); - $this->assertTrue($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'pushed_file_existing.ext')); - $this->assertFalse($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'pushed_file_existing.ext')); - $this->assertTrue($bucket->fileDelete('pushed_file_existing.ext')); - } - - /** - * [testFilePullSuccessful description] - */ - public function testFilePullSuccessful(): void { - $bucket = $this->getBucket(); - $pulledPath = sys_get_temp_dir().'/testFilePullSuccessful'; - $this->assertTrue($bucket->filePull('testfile.ext', $pulledPath)); - $this->assertEquals(file_get_contents(__DIR__.'/testdata/testfile.ext'), file_get_contents($pulledPath)); - $this->assertTrue(unlink($pulledPath)); - } - - /** - * [testFilePullAlreadyExists description] - */ - public function testFilePullAlreadyExists(): void { - $bucket = $this->getBucket(); - $pulledPath = sys_get_temp_dir().'/testFilePullAlreadyExists'; - $this->assertTrue($bucket->filePull('testfile.ext', $pulledPath)); - $this->assertFalse($bucket->filePull('testfile.ext', $pulledPath)); - $this->assertTrue(unlink($pulledPath)); - } - - /** - * [testFilePullNonexisting description] - */ - public function testFilePullNonexisting(): void { - $bucket = $this->getBucket(); - $pulledPath = sys_get_temp_dir().'/testFilePullNonexisting'; - $this->assertFalse($bucket->filePull('nonexisting.ext', $pulledPath)); - } - - /** - * [testFilePushMissingLocalFile description] - */ - public function testFilePushMissingLocalFile(): void { - $bucket = $this->getBucket(); - $this->assertFalse($bucket->filePush(__DIR__.'/testdata/nonexisting-file.ext', 'pushed_file2.ext')); - } - - /** - * Tests file moving in the bucket - */ - public function testFileMoveSuccess(): void { - $bucket = $this->getBucket(); - $this->assertTrue($bucket->fileMove('testfile.ext', 'testfile_moved.ext')); - $this->assertTrue($bucket->fileMove('testfile_moved.ext', 'testfile.ext')); - } - - /** - * Tests fileMove into a subdir - * NOTE: might leave garbage data (e.g. subdir) in the bucket - */ - public function testFileMoveNestedSuccess(): void { - $bucket = $this->getBucket(); - $this->assertTrue($bucket->fileMove('testfile.ext', 'subdir/testfile_moved.ext')); - $this->assertTrue($bucket->fileMove('subdir/testfile_moved.ext', 'testfile.ext')); - } - - /** - * [testFileAvailableOnDir description] - */ - public function testFileAvailableOnDir(): void { - $bucket = $this->getBucket(); - // push a file first to create the dir implicitly - $this->assertTrue($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'some-dir/file.ext')); - - // test for fileAvailable on the directory - // should return false - $this->assertFalse($bucket->fileAvailable('some-dir')); - - // delete it afterwards - $this->assertTrue($bucket->fileDelete('some-dir/file.ext')); - } - - /** - * Tests try to delete nonexistant file - * NOTE: bucket behaviour is defined to return TRUE - * if the respective object (file) does not exist. - */ - public function testFileDeleteNonexistant(): void { - $bucket = $this->getBucket(); - $this->assertTrue($bucket->fileDelete('non-existant.ext')); - } - - /** - * Tests try to move a nonexistant file - */ - public function testFileMoveNonexistantFailed(): void { - $bucket = $this->getBucket(); - $this->assertFalse($bucket->fileMove('non-existant.ext', 'non-existant2.ext')); - } - - /** - * [testFileMoveAlreadyExistsFailed description] - */ - public function testFileMoveAlreadyExistsFailed(): void { - $bucket = $this->getBucket(); - $this->assertTrue($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'filemove_already_exists_test.ext')); - $this->assertTrue($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'testfile_moveme.ext')); - // try moving (renaming) to a location that already exists - $this->assertFalse($bucket->fileMove('testfile_moveme.ext', 'filemove_already_exists_test.ext')); - $this->assertTrue($bucket->fileDelete('filemove_already_exists_test.ext')); - $this->assertTrue($bucket->fileDelete('testfile_moveme.ext')); - } - - /** - * [testFileMoveAlreadyExistsNestedFailed description] - */ - public function testFileMoveAlreadyExistsNestedFailed(): void { - $bucket = $this->getBucket(); - $this->assertTrue($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'nested/filemove_already_exists_nested_test.ext')); - $this->assertTrue($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'nested2/testfile_nested_moveme.ext')); - // try moving (renaming) to a location that already exists - $this->assertFalse($bucket->fileMove('nested2/testfile_nested_moveme.ext', 'nested/filemove_already_exists_nested_test.ext')); - $this->assertTrue($bucket->fileDelete('nested/filemove_already_exists_nested_test.ext')); - $this->assertTrue($bucket->fileDelete('nested2/testfile_nested_moveme.ext')); - } - - /** - * [testDirListSuccessful description] - */ - public function testDirListSuccessful(): void { - $bucket = $this->getBucket(); - - $this->assertTrue($bucket->dirAvailable('')); - $res = $bucket->dirList(''); - - // We expect at least one file - // 'subdir' might be unavailable, if a another test failed - $this->assertGreaterThanOrEqual(1, count($res)); - - foreach($res as $r) { - if($r == 'subdir') { - $this->assertFalse($bucket->isFile($r)); - } else if($r == 'testfile.ext') { - $this->assertTrue($bucket->isFile($r)); - } else { - // $this->addWarning('Unexpected extra file/dir: ' . $r); - } + /** + * @return void + */ + protected function testFilePushMaliciousDirTraversalFails(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage(bucket::BUCKET_EXCEPTION_BAD_PATH); + $bucket = $this->getBucket(); + static::assertFalse($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'nested1/../../pushed_file.ext')); } - } - - /** - * [testIsFile description] - */ - public function testIsFile(): void { - $bucket = $this->getBucket(); - $bucket->filePush(__DIR__.'/testdata/testfile.ext', 'test-is-file/file.ext'); - $this->assertFalse($bucket->isFile('test-is-file')); - $this->assertTrue($bucket->isFile('test-is-file/file.ext')); - $bucket->fileDelete('test-is-file/file.ext'); - } - - /** - * [testDirListNestedSuccessful description] - */ - public function testDirListNestedSuccessful(): void { - $bucket = $this->getBucket(); - - // first, place a file in the subdir - $this->assertTrue($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'subdir/testDirListNestedSuccessful')); - - $this->assertTrue($bucket->dirAvailable('subdir')); - $res = $bucket->dirList('subdir'); - - $this->assertCount(1, $res); - $this->assertEquals('subdir/testDirListNestedSuccessful', $res[0]); - - // delete the test file afterwards - $this->assertTrue($bucket->fileDelete('subdir/testDirListNestedSuccessful')); - } - - /** - * [testDirAvailableOnFile description] - */ - public function testDirAvailableOnFile(): void { - $bucket = $this->getBucket(); - $this->assertFalse($bucket->dirAvailable('testfile.ext')); - - } - - - /** - * [testDirListNonexisting description] - */ - public function testDirListNonexisting(): void { - $bucket = $this->getBucket(); - $this->assertFalse($bucket->dirAvailable('nonexisting')); - $res = $bucket->dirList('nonexisting'); - $this->assertEmpty($res); - - } + /** + * @return void + */ + protected function testFilePushMaliciousMultipleDirTraversalFails(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage(bucket::BUCKET_EXCEPTION_BAD_PATH); + $bucket = $this->getBucket(); + static::assertFalse($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'nested1/../../../pushed_file.ext')); + } + + /** + * @return void + */ + protected function testFilePushAlreadyExists(): void + { + $bucket = $this->getBucket(); + static::assertTrue($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'pushed_file_existing.ext')); + static::assertFalse($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'pushed_file_existing.ext')); + static::assertTrue($bucket->fileDelete('pushed_file_existing.ext')); + } + + /** + * @return void + */ + protected function testFilePullSuccessful(): void + { + $bucket = $this->getBucket(); + $pulledPath = sys_get_temp_dir() . '/testFilePullSuccessful'; + static::assertTrue($bucket->filePull('testfile.ext', $pulledPath)); + static::assertEquals(file_get_contents(__DIR__ . '/testdata/testfile.ext'), file_get_contents($pulledPath)); + static::assertTrue(unlink($pulledPath)); + } + + /** + * @return void + */ + protected function testFilePullAlreadyExists(): void + { + $bucket = $this->getBucket(); + $pulledPath = sys_get_temp_dir() . '/testFilePullAlreadyExists'; + static::assertTrue($bucket->filePull('testfile.ext', $pulledPath)); + static::assertFalse($bucket->filePull('testfile.ext', $pulledPath)); + static::assertTrue(unlink($pulledPath)); + } + + /** + * @return void + */ + protected function testFilePullNonexisting(): void + { + $bucket = $this->getBucket(); + $pulledPath = sys_get_temp_dir() . '/testFilePullNonexisting'; + static::assertFalse($bucket->filePull('nonexisting.ext', $pulledPath)); + } + + /** + * @return void + */ + protected function testFilePushMissingLocalFile(): void + { + $bucket = $this->getBucket(); + static::assertFalse($bucket->filePush(__DIR__ . '/testdata/nonexisting-file.ext', 'pushed_file2.ext')); + } + + /** + * Tests file moving in the bucket + * @return void + */ + protected function testFileMoveSuccess(): void + { + $bucket = $this->getBucket(); + static::assertTrue($bucket->fileMove('testfile.ext', 'testfile_moved.ext')); + static::assertTrue($bucket->fileMove('testfile_moved.ext', 'testfile.ext')); + } + + /** + * Tests fileMove into a subdir + * NOTE: might leave garbage data (e.g., subdir) in the bucket + * @return void + */ + protected function testFileMoveNestedSuccess(): void + { + $bucket = $this->getBucket(); + static::assertTrue($bucket->fileMove('testfile.ext', 'subdir/testfile_moved.ext')); + static::assertTrue($bucket->fileMove('subdir/testfile_moved.ext', 'testfile.ext')); + } + + /** + * @return void + */ + protected function testFileAvailableOnDir(): void + { + $bucket = $this->getBucket(); + // push a file first to create the dir implicitly + static::assertTrue($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'some-dir/file.ext')); + + // test for fileAvailable on the directory + // should return false + static::assertFalse($bucket->fileAvailable('some-dir')); + + // delete it afterwards + static::assertTrue($bucket->fileDelete('some-dir/file.ext')); + } + + /** + * Tests try to delete nonexistent file + * NOTE: bucket behavior is defined to return TRUE + * if the respective object (file) does not exist. + * @return void + */ + protected function testFileDeleteNonexistent(): void + { + $bucket = $this->getBucket(); + static::assertTrue($bucket->fileDelete('non-existent.ext')); + } + + /** + * Tests try to move a nonexistent file + * @return void + */ + protected function testFileMoveNonexistentFailed(): void + { + $bucket = $this->getBucket(); + static::assertFalse($bucket->fileMove('non-existent.ext', 'non-existent2.ext')); + } + + /** + * @return void + */ + protected function testFileMoveAlreadyExistsFailed(): void + { + $bucket = $this->getBucket(); + static::assertTrue($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'filemove_already_exists_test.ext')); + static::assertTrue($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'testfile_moveme.ext')); + // try moving (renaming) to a location that already exists + static::assertFalse($bucket->fileMove('testfile_moveme.ext', 'filemove_already_exists_test.ext')); + static::assertTrue($bucket->fileDelete('filemove_already_exists_test.ext')); + static::assertTrue($bucket->fileDelete('testfile_moveme.ext')); + } + + /** + * @return void + */ + protected function testFileMoveAlreadyExistsNestedFailed(): void + { + $bucket = $this->getBucket(); + static::assertTrue($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'nested/filemove_already_exists_nested_test.ext')); + static::assertTrue($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'nested2/testfile_nested_moveme.ext')); + // try moving (renaming) to a location that already exists + static::assertFalse($bucket->fileMove('nested2/testfile_nested_moveme.ext', 'nested/filemove_already_exists_nested_test.ext')); + static::assertTrue($bucket->fileDelete('nested/filemove_already_exists_nested_test.ext')); + static::assertTrue($bucket->fileDelete('nested2/testfile_nested_moveme.ext')); + } + + /** + * @return void + */ + protected function testDirListSuccessful(): void + { + $bucket = $this->getBucket(); + + static::assertTrue($bucket->dirAvailable('')); + $res = $bucket->dirList(''); + + // We expect at least one file + // 'subdir' might be unavailable if a test failed + static::assertGreaterThanOrEqual(1, count($res)); + + foreach ($res as $r) { + if ($r == 'subdir') { + static::assertFalse($bucket->isFile($r)); + } elseif ($r == 'testfile.ext') { + static::assertTrue($bucket->isFile($r)); + } + } + } + + /** + * @return void + */ + protected function testIsFile(): void + { + $bucket = $this->getBucket(); + $bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'test-is-file/file.ext'); + static::assertFalse($bucket->isFile('test-is-file')); + static::assertTrue($bucket->isFile('test-is-file/file.ext')); + $bucket->fileDelete('test-is-file/file.ext'); + } + + /** + * @return void + */ + protected function testDirListNestedSuccessful(): void + { + $bucket = $this->getBucket(); + + // first, place a file in the subdir + static::assertTrue($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'subdir/testDirListNestedSuccessful')); + + static::assertTrue($bucket->dirAvailable('subdir')); + $res = $bucket->dirList('subdir'); + + static::assertCount(1, $res); + static::assertEquals('subdir/testDirListNestedSuccessful', $res[0]); + + // delete the test file afterward + static::assertTrue($bucket->fileDelete('subdir/testDirListNestedSuccessful')); + } + + /** + * @return void + */ + protected function testDirAvailableOnFile(): void + { + $bucket = $this->getBucket(); + static::assertFalse($bucket->dirAvailable('testfile.ext')); + } + + /** + * @return void + */ + protected function testDirListNonexisting(): void + { + $bucket = $this->getBucket(); + static::assertFalse($bucket->dirAvailable('nonexisting')); + $res = $bucket->dirList('nonexisting'); + static::assertEmpty($res); + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws ErrorException + * @throws Throwable + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws exception + * @throws vfsStreamException + */ + protected function setUp(): void + { + $app = static::createApp(); + $app::getAppstack(); + + + static::setEnvironmentConfig([ + 'test' => [ + 'filesystem' => [ + 'local' => [ + 'driver' => 'local', + ], + ], + 'log' => [ + 'errormessage' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + 'debug' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + ], + ], + ]); + + // init test files + $bucket = $this->getBucket(); + if (!$bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'testfile.ext')) { + static::fail('Initial test setup failed'); + } + + // create a VFS for testing various things + // e.g., erroneous local storage location + // or (if applicable) broken 'remote' storage (local bucket) + $this->vfsRoot = vfsStream::setup('vfs-test'); + vfsStream::setQuota(Quota::UNLIMITED); + } + + /** + * {@inheritDoc} + */ + protected function tearDown(): void + { + $this->getBucket()->fileDelete('testfile.ext'); + } } diff --git a/tests/bucket/ftpTest.php b/tests/bucket/ftpTest.php index 5fdf3fd..ad641e5 100644 --- a/tests/bucket/ftpTest.php +++ b/tests/bucket/ftpTest.php @@ -1,91 +1,319 @@ $config ]); - if($config === null) { - // - // Default test bucket - // - $config = [ - // Default config - 'basedir' => '/', - 'ftpserver' => [ - 'host' => 'unittest-ftp', - 'port' => 21, - 'user' => 'unittest-ftp-user', - 'pass' => 'unittest-ftp-pass', - // 'passive_mode' => true, - // 'ignore_passive_address' => true, - ] - // 'public' => false, - ]; - } - - return new \codename\core\bucket\ftp($config); - } - - /** - * @inheritDoc - */ - public static function setUpBeforeClass(): void - { - parent::setUpBeforeClass(); - - // Preliminary check, if DNS is not available - // we simply assume there's no host for testing, skip. - if(!gethostbynamel('unittest-ftp')) { - static::markTestSkipped('FTP server unavailable, skipping.'); - return; - } - - // wait for ftp server to come up - if(!\codename\core\tests\helper::waitForIt('unittest-ftp', 21, 3, 3, 5)) { - throw new \Exception('Failed to connect to ftp server'); - } - } - - /** - * [testInvalidCredentials description] - */ - public function testInvalidCredentials(): void { - $this->expectExceptionMessage('EXCEPTION_BUCKET_FTP_LOGIN_FAILED'); - $this->getBucket([ - 'basedir' => '/', - 'ftpserver' => [ - 'host' => 'unittest-ftp', - 'port' => 21, - 'user' => 'invalid', - 'pass' => 'invalid', - ] - ]); - } - - /** - * Tests connecting to a nonexisting host - * @large - */ - public function testConnectionFail(): void { - $this->expectExceptionMessage('EXCEPTION_BUCKET_FTP_CONNECTION_FAILED'); - $bucket = $this->getBucket([ - 'basedir' => '/', - 'timeout' => 1, // smallest timeout possible - 'ftpserver' => [ - // try to connect to localhost - shouldn't give us an FTP server. - // or you have one running locally... - 'host' => 'localhost', - 'port' => 21, - 'user' => 'random-user', - 'pass' => 'random-pass', - ] - ]); - } +use codename\core\bucket; +use codename\core\bucket\ftp; +use codename\core\tests\helper; +use Exception; +use ReflectionException; + +class ftpTest extends abstractBucketTest +{ + /** + * {@inheritDoc} + * @throws Exception + */ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + // Preliminary check, if DNS is not available, + // we simply assume there's no host for testing, skip. + if (!gethostbynamel('unittest-ftp')) { + static::markTestSkipped('FTP server unavailable, skipping.'); + } + + // wait for ftp server to come up + if (!helper::waitForIt('unittest-ftp', 21, 3, 3, 5)) { + static::fail('Failed to connect to ftp server'); + } + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testInvalidCredentials(): void + { + $this->expectExceptionMessage('EXCEPTION_BUCKET_FTP_LOGIN_FAILED'); + $this->getBucket([ + 'basedir' => '/', + 'ftpserver' => [ + 'host' => 'unittest-ftp', + 'port' => 21, + 'user' => 'invalid', + 'pass' => 'invalid', + ], + ]); + } + + /** + * {@inheritDoc} + * @param array|null $config + * @return bucket + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function getBucket(?array $config = null): bucket + { + if ($config === null) { + // + // Default test bucket + // + $config = [ + // Default config + 'basedir' => '/', + 'ftpserver' => [ + 'host' => 'unittest-ftp', + 'port' => 21, + 'user' => 'unittest-ftp-user', + 'pass' => 'unittest-ftp-pass', + ], + ]; + } + + return new ftp($config); + } + + /** + * {@inheritDoc} + */ + public function testInvalidEmptyConfiguration(): void + { + parent::testInvalidEmptyConfiguration(); + } + + /** + * {@inheritDoc} + */ + public function testFileAvailableFalse(): void + { + parent::testFileAvailableFalse(); + } + + /** + * {@inheritDoc} + */ + public function testFileAvailableTrue(): void + { + parent::testFileAvailableTrue(); + } + + /** + * {@inheritDoc} + */ + public function testVfsLocalDirNotWritableFilePull(): void + { + parent::testVfsLocalDirNotWritableFilePull(); + } + + /** + * {@inheritDoc} + */ + public function testVfsLocalDirQuotaLimitedFilePull(): void + { + parent::testVfsLocalDirQuotaLimitedFilePull(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushSuccessful(): void + { + parent::testFilePushSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushNestedSuccessful(): void + { + parent::testFilePushNestedSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMaliciousDirUpFails(): void + { + parent::testFilePushMaliciousDirUpFails(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMaliciousDirTraversalFails(): void + { + parent::testFilePushMaliciousDirTraversalFails(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMaliciousMultipleDirTraversalFails(): void + { + parent::testFilePushMaliciousMultipleDirTraversalFails(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushAlreadyExists(): void + { + parent::testFilePushAlreadyExists(); + } + + /** + * {@inheritDoc} + */ + public function testFilePullSuccessful(): void + { + parent::testFilePullSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testFilePullAlreadyExists(): void + { + parent::testFilePullAlreadyExists(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMissingLocalFile(): void + { + parent::testFilePullNonexisting(); + } + + /** + * {@inheritDoc} + */ + public function testFilePullNonexisting(): void + { + parent::testFilePullNonexisting(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveSuccess(): void + { + parent::testFileMoveSuccess(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveNestedSuccess(): void + { + parent::testFileMoveNestedSuccess(); + } + + /** + * {@inheritDoc} + */ + public function testFileAvailableOnDir(): void + { + parent::testFileAvailableOnDir(); + } + + /** + * {@inheritDoc} + */ + public function testFileDeleteNonexistent(): void + { + parent::testFileDeleteNonexistent(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveNonexistentFailed(): void + { + parent::testFileMoveNonexistentFailed(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveAlreadyExistsFailed(): void + { + parent::testFileMoveAlreadyExistsFailed(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveAlreadyExistsNestedFailed(): void + { + parent::testFileMoveAlreadyExistsNestedFailed(); + } + + /** + * {@inheritDoc} + */ + public function testDirListSuccessful(): void + { + parent::testDirListSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testIsFile(): void + { + parent::testIsFile(); + } + + /** + * {@inheritDoc} + */ + public function testDirListNestedSuccessful(): void + { + parent::testDirListNestedSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testDirAvailableOnFile(): void + { + parent::testDirAvailableOnFile(); + } + + /** + * {@inheritDoc} + */ + public function testDirListNonexisting(): void + { + parent::testDirListNonexisting(); + } + + /** + * Tests connecting to a nonexisting host + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + * @large + */ + public function testConnectionFail(): void + { + $this->expectExceptionMessage('EXCEPTION_BUCKET_FTP_CONNECTION_FAILED'); + $this->getBucket([ + 'basedir' => '/', + 'timeout' => 1, // smallest timeout possible + 'ftpserver' => [ + // try to connect to localhost - shouldn't give us an FTP server. + // or you have one running locally... + 'host' => 'localhost', + 'port' => 21, + 'user' => 'random-user', + 'pass' => 'random-pass', + ], + ]); + } } diff --git a/tests/bucket/localTest.php b/tests/bucket/localTest.php index d20fe7c..4dc8093 100644 --- a/tests/bucket/localTest.php +++ b/tests/bucket/localTest.php @@ -1,107 +1,295 @@ isDir()) rmdir($file->getPathname()); - } - rmdir(static::$localTmpDir); - } - } - - /** - * @inheritDoc - */ - public function getBucket(?array $config = null): \codename\core\bucket - { - // print_r([ 'getBucket' => $config ]); - if($config === null) { - // - // Default test bucket - // - $config = [ - // Default config - 'basedir' => static::$localTmpDir, - 'public' => false, - ]; - - // create the local temp folder, if it doesn't exist yet. - if(!is_dir($config['basedir'])) { - mkdir($config['basedir'], 0777, true); - } - } - - return new \codename\core\bucket\local($config); - } - - /** - * tests pushing to a local bucket - * while having not enough disk space to do so. - */ - public function testRemoteQuotaLimited(): void { - $bucketDir = $this->vfsRoot->url() . '/quota-limited/'; - mkdir($bucketDir, 0777, true); - $bucket = $this->getBucket([ - 'basedir' => $bucketDir, - 'public' => false - ]); - \org\bovigo\vfs\vfsStream::setQuota(1); - - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage(\codename\core\bucket\local::EXCEPTION_FILEPUSH_FILEWRITABLE_UNKNOWN_ERROR); - $this->assertFalse($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'pushed_file.ext')); - } - - /** - * Emulates a not-writable remote target directory - */ - public function testRemoteNotWritable(): void { - $bucketDir = $this->vfsRoot->url() . '/not-writable/'; - mkdir($bucketDir, 0600, true); - $this->vfsRoot->getChild('not-writable')->chown('other-user'); - $bucket = $this->getBucket([ - 'basedir' => $bucketDir, - 'public' => false - ]); - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage(\codename\core\bucket\local::EXCEPTION_FILEPUSH_FILENOTWRITABLE); - $this->assertFalse($bucket->filePush(__DIR__.'/testdata/testfile.ext', 'pushed_file.ext')); - } +use codename\core\bucket; +use codename\core\bucket\local; +use codename\core\exception; +use FilesystemIterator; +use org\bovigo\vfs\vfsStream; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use ReflectionException; + +class localTest extends abstractBucketTest +{ + /** + * Suffix to be used for local test bucket + * @var null|string + */ + protected static ?string $localTmpDir = null; + + /** + * {@inheritDoc} + */ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + static::$localTmpDir = sys_get_temp_dir() . '/bucket-local-test-' . microtime(true) . '/'; + } + + /** + * {@inheritDoc} + */ + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + + // + // local tmp dir might be removed + // if tests are executed in parallel or process-isolated. + // + if (is_dir(static::$localTmpDir)) { + // + // at this point, there _SHOULD_ be no file left; only (sub)dirs + // try to remove them... + // + $it = new RecursiveDirectoryIterator(static::$localTmpDir, FilesystemIterator::SKIP_DOTS); + $it = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); + foreach ($it as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } + } + rmdir(static::$localTmpDir); + } + } + + /** + * tests pushing to a local bucket + * while having not enough disk space to do so. + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testRemoteQuotaLimited(): void + { + $bucketDir = $this->vfsRoot->url() . '/quota-limited/'; + mkdir($bucketDir, 0777, true); + $bucket = $this->getBucket([ + 'basedir' => $bucketDir, + 'public' => false, + ]); + vfsStream::setQuota(1); + $this->expectException(exception::class); + $this->expectExceptionMessage(local::EXCEPTION_FILEPUSH_FILEWRITABLE_UNKNOWN_ERROR); + static::assertFalse($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'pushed_file.ext')); + } + + /** + * {@inheritDoc} + * @param array|null $config + * @return bucket + * @throws ReflectionException + * @throws exception + */ + public function getBucket(?array $config = null): bucket + { + if ($config === null) { + // + // Default test bucket + // + $config = [ + // Default config + 'basedir' => static::$localTmpDir, + 'public' => false, + ]; + + // create the local temp folder if it doesn't exist yet. + if (!is_dir($config['basedir'])) { + mkdir($config['basedir'], 0777, true); + } + } + + return new local($config); + } + + /** + * {@inheritDoc} + */ + public function testInvalidEmptyConfiguration(): void + { + parent::testInvalidEmptyConfiguration(); + } + + /** + * {@inheritDoc} + */ + public function testFileAvailableFalse(): void + { + parent::testFileAvailableFalse(); + } + + /** + * {@inheritDoc} + */ + public function testVfsLocalDirQuotaLimitedFilePull(): void + { + parent::testVfsLocalDirQuotaLimitedFilePull(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushSuccessful(): void + { + parent::testFilePushSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushNestedSuccessful(): void + { + parent::testFilePushNestedSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMaliciousDirUpFails(): void + { + parent::testFilePushMaliciousDirUpFails(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMaliciousDirTraversalFails(): void + { + parent::testFilePushMaliciousDirTraversalFails(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMaliciousMultipleDirTraversalFails(): void + { + parent::testFilePushMaliciousMultipleDirTraversalFails(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushAlreadyExists(): void + { + parent::testFilePushAlreadyExists(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMissingLocalFile(): void + { + parent::testFilePullNonexisting(); + } + + /** + * {@inheritDoc} + */ + public function testFilePullNonexisting(): void + { + parent::testFilePullNonexisting(); + } + + /** + * {@inheritDoc} + */ + public function testFileAvailableOnDir(): void + { + parent::testFileAvailableOnDir(); + } + + /** + * {@inheritDoc} + */ + public function testFileDeleteNonexistent(): void + { + parent::testFileDeleteNonexistent(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveNonexistentFailed(): void + { + parent::testFileMoveNonexistentFailed(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveAlreadyExistsFailed(): void + { + parent::testFileMoveAlreadyExistsFailed(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveAlreadyExistsNestedFailed(): void + { + parent::testFileMoveAlreadyExistsNestedFailed(); + } + + /** + * {@inheritDoc} + */ + public function testDirListSuccessful(): void + { + parent::testDirListSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testIsFile(): void + { + parent::testIsFile(); + } + + /** + * {@inheritDoc} + */ + public function testDirListNestedSuccessful(): void + { + parent::testDirListNestedSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testDirAvailableOnFile(): void + { + parent::testDirAvailableOnFile(); + } + + /** + * {@inheritDoc} + */ + public function testDirListNonexisting(): void + { + parent::testDirListNonexisting(); + } + + /** + * Emulates a not-writable remote target directory + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testRemoteNotWritable(): void + { + $bucketDir = $this->vfsRoot->url() . '/not-writable/'; + mkdir($bucketDir, 0600, true); + $this->vfsRoot->getChild('not-writable')->chown('other-user'); + $bucket = $this->getBucket([ + 'basedir' => $bucketDir, + 'public' => false, + ]); + $this->expectException(exception::class); + $this->expectExceptionMessage(local::EXCEPTION_FILEPUSH_FILENOTWRITABLE); + static::assertFalse($bucket->filePush(__DIR__ . '/testdata/testfile.ext', 'pushed_file.ext')); + } } diff --git a/tests/bucket/s3Test.php b/tests/bucket/s3Test.php index d865dc0..8b9b80f 100644 --- a/tests/bucket/s3Test.php +++ b/tests/bucket/s3Test.php @@ -1,66 +1,289 @@ $config ]); - if($config === null) { - // - // Default test bucket - // - $config = [ - // Default config - // - 'bucket' => 'fakes3', - - // NOTE: if bucket_endpoint is used, we NEED to hardcode stuff: - // (bucket inside endpoint) - // 'bucket_endpoint' => true, - // 'endpoint' => 'http://fakes3.unittest-s3:4569', - - // Alternative, but requires hostnames to be available - // (e.g. bucketname.unittest-s3) - 'bucket_endpoint' => false, - 'endpoint' => 'http://unittest-s3:4569', - - - 'credentials' => [ - 'key' => 'dummy', - 'secret' => 'dummy', - ], - 'prefix' => null, - 'region' => null - // 'public' => false, - ]; - } - - return new \codename\core\bucket\s3($config); - } - - /** - * @inheritDoc - */ - public static function setUpBeforeClass(): void - { - parent::setUpBeforeClass(); - - // Preliminary check, if DNS is not available - // we simply assume there's no host for testing, skip. - if(!gethostbynamel('unittest-s3')) { - static::markTestSkipped('S3 server unavailable, skipping.'); - return; - } - - // wait for S3 to come up - if(!\codename\core\tests\helper::waitForIt('unittest-s3', 4569, 3, 3, 5)) { - throw new \Exception('Failed to connect to S3 server'); - } - } +use codename\core\bucket; +use codename\core\bucket\s3; +use codename\core\tests\helper; +use Exception; +use ReflectionException; + +class s3Test extends abstractBucketTest +{ + /** + * {@inheritDoc} + * @throws Exception + */ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + // Preliminary check, if DNS is not available, + // we simply assume there's no host for testing, skip. + if (!gethostbynamel('unittest-s3')) { + static::markTestSkipped('S3 server unavailable, skipping.'); + } + + // wait for S3 to come up + if (!helper::waitForIt('unittest-s3', 4569, 3, 3, 5)) { + static::fail('Failed to connect to S3 server'); + } + } + + /** + * {@inheritDoc} + * @param array|null $config + * @return bucket + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function getBucket(?array $config = null): bucket + { + if ($config === null) { + // + // Default test bucket + // + $config = [ + // Default config + // + 'bucket' => 'fakes3', + + // NOTE: if bucket_endpoint is used, we NEED to hardcode stuff: + // (bucket inside endpoint) + // 'bucket_endpoint' => true, + // 'endpoint' => 'http://fakes3.unittest-s3:4569', + + // Alternative, but requires hostnames to be available + // (e.g., bucketname.unittest-s3) + 'bucket_endpoint' => false, + 'endpoint' => 'http://unittest-s3:4569', + + + 'credentials' => [ + 'key' => 'dummy', + 'secret' => 'dummy', + ], + 'prefix' => null, + 'region' => null, + ]; + } + + return new s3($config); + } + + /** + * {@inheritDoc} + */ + public function testInvalidEmptyConfiguration(): void + { + parent::testInvalidEmptyConfiguration(); + } + + /** + * {@inheritDoc} + */ + public function testFileAvailableFalse(): void + { + parent::testFileAvailableFalse(); + } + + /** + * {@inheritDoc} + */ + public function testFileAvailableTrue(): void + { + parent::testFileAvailableTrue(); + } + + /** + * {@inheritDoc} + */ + public function testVfsLocalDirNotWritableFilePull(): void + { + parent::testVfsLocalDirNotWritableFilePull(); + } + + /** + * {@inheritDoc} + */ + public function testVfsLocalDirQuotaLimitedFilePull(): void + { + parent::testVfsLocalDirQuotaLimitedFilePull(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushSuccessful(): void + { + parent::testFilePushSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushNestedSuccessful(): void + { + parent::testFilePushNestedSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMaliciousDirUpFails(): void + { + parent::testFilePushMaliciousDirUpFails(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMaliciousDirTraversalFails(): void + { + parent::testFilePushMaliciousDirTraversalFails(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMaliciousMultipleDirTraversalFails(): void + { + parent::testFilePushMaliciousMultipleDirTraversalFails(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushAlreadyExists(): void + { + parent::testFilePushAlreadyExists(); + } + + /** + * {@inheritDoc} + */ + public function testFilePullSuccessful(): void + { + parent::testFilePullSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testFilePullAlreadyExists(): void + { + parent::testFilePullAlreadyExists(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMissingLocalFile(): void + { + parent::testFilePullNonexisting(); + } + + /** + * {@inheritDoc} + */ + public function testFilePullNonexisting(): void + { + parent::testFilePullNonexisting(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveSuccess(): void + { + parent::testFileMoveSuccess(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveNestedSuccess(): void + { + parent::testFileMoveNestedSuccess(); + } + + /** + * {@inheritDoc} + */ + public function testFileAvailableOnDir(): void + { + parent::testFileAvailableOnDir(); + } + + /** + * {@inheritDoc} + */ + public function testFileDeleteNonexistent(): void + { + parent::testFileDeleteNonexistent(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveNonexistentFailed(): void + { + parent::testFileMoveNonexistentFailed(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveAlreadyExistsFailed(): void + { + parent::testFileMoveAlreadyExistsFailed(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveAlreadyExistsNestedFailed(): void + { + parent::testFileMoveAlreadyExistsNestedFailed(); + } + + /** + * {@inheritDoc} + */ + public function testDirListSuccessful(): void + { + parent::testDirListSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testIsFile(): void + { + parent::testIsFile(); + } + + /** + * {@inheritDoc} + */ + public function testDirListNestedSuccessful(): void + { + parent::testDirListNestedSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testDirAvailableOnFile(): void + { + parent::testDirAvailableOnFile(); + } + + /** + * {@inheritDoc} + */ + public function testDirListNonexisting(): void + { + parent::testDirListNonexisting(); + } } diff --git a/tests/bucket/sftpTest.php b/tests/bucket/sftpTest.php index 92d8627..3f81373 100644 --- a/tests/bucket/sftpTest.php +++ b/tests/bucket/sftpTest.php @@ -1,106 +1,317 @@ $config ]); - if($config === null) { - // - // Default test bucket - // - $config = [ - // Default config - 'basedir' => '/share/', - // 'sftp_method' => \codename\core\bucket\sftp::METHOD_SFTP, - 'sftpserver' => [ - 'host' => 'unittest-sftp', - 'port' => 22, - 'auth_type' => 'password', - 'user' => 'unittest-sftp-user-auth-pw', - 'pass' => 'unittest-sftp-user-pass' - ] - // 'public' => false, - ]; - } - - $hash = md5(serialize($config)); - - if(!(static::$instances[$hash] ?? false)) { - static::$instances[$hash] = new \codename\core\bucket\sftp($config); - } - return static::$instances[$hash]; - - } - - protected static $instances = []; - - /** - * @inheritDoc - */ - public static function setUpBeforeClass(): void - { - parent::setUpBeforeClass(); - - // Preliminary check, if DNS is not available - // we simply assume there's no host for testing, skip. - if(!gethostbynamel('unittest-sftp')) { - static::markTestSkipped('SFTP server unavailable, skipping.'); - return; - } - - // wait for rmysql to come up - if(!\codename\core\tests\helper::waitForIt('unittest-sftp', 22, 3, 3, 5)) { - throw new \Exception('Failed to connect to sftp server'); - } - } - - /** - * [testUnreachableHost description] - */ - public function testUnreachableHost(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessageMatches('/EXCEPTION_BUCKET_SFTP_SSH_CONNECTION_FAILED|ssh2_connect\(\)\: php_network_getaddresses: getaddrinfo failed: (?:Name or service not known|Temporary failure in name resolution)/'); - - $this->getBucket( [ - // Default config - 'basedir' => '/share/', - 'sftpserver' => [ - 'host' => 'nonexisting-sftp', - 'port' => 22, - 'auth_type' => 'password', - 'user' => 'unittest-sftp-user-auth-pw', - 'pass' => 'unittest-sftp-user-pass' - ] - ]); - } - - /** - * @inheritDoc - */ - public function testVfsLocalDirNotWritableFilePull(): void - { - // SFTP bucket throws a custom exception in this case, in contrast to other buckets - $this->expectExceptionMessage('Unable to open local file for writing: vfs://vfs-test/not-writable-dir/file2.txt'); - parent::testVfsLocalDirNotWritableFilePull(); - } - - /** - * @inheritDoc - */ - public function testVfsLocalDirQuotaLimitedFilePull(): void - { - // SFTP bucket throws a custom exception in this case, in contrast to other buckets - $this->expectExceptionMessage('Unable to write to local file: vfs://vfs-test/file.txt'); - parent::testVfsLocalDirQuotaLimitedFilePull(); - } +use codename\core\bucket; +use codename\core\bucket\sftp; +use codename\core\exception; +use codename\core\exception\sensitiveException; +use codename\core\tests\helper; +use ReflectionException; + +class sftpTest extends abstractBucketTest +{ + protected static array $instances = []; + + /** + * {@inheritDoc} + * @throws \Exception + */ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + // Preliminary check, if DNS is not available, + // we simply assume there's no host for testing, skip. + if (!gethostbynamel('unittest-sftp')) { + static::markTestSkipped('SFTP server unavailable, skipping.'); + } + + // wait for rmysql to come up + if (!helper::waitForIt('unittest-sftp', 22, 3, 3, 5)) { + static::fail('Failed to connect to sftp server'); + } + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + * @throws sensitiveException + */ + public function testUnreachableHost(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessageMatches('/EXCEPTION_BUCKET_SFTP_SSH_CONNECTION_FAILED|ssh2_connect\(\)\: php_network_getaddresses: getaddrinfo for nonexisting-sftp failed: (?:Name or service not known|Temporary failure in name resolution)/'); + + $this->getBucket([ + // Default config + 'basedir' => '/share/', + 'sftpserver' => [ + 'host' => 'nonexisting-sftp', + 'port' => 22, + 'auth_type' => 'password', + 'user' => 'unittest-sftp-user-auth-pw', + 'pass' => 'unittest-sftp-user-pass', + ], + ]); + } + + /** + * {@inheritDoc} + * @param array|null $config + * @return bucket + * @throws ReflectionException + * @throws exception + * @throws sensitiveException + */ + public function getBucket(?array $config = null): bucket + { + // print_r([ 'getBucket' => $config ]); + if ($config === null) { + // + // Default test bucket + // + $config = [ + // Default config + 'basedir' => '/share/', + // 'sftp_method' => \codename\core\bucket\sftp::METHOD_SFTP, + 'sftpserver' => [ + 'host' => 'unittest-sftp', + 'port' => 22, + 'auth_type' => 'password', + 'user' => 'unittest-sftp-user-auth-pw', + 'pass' => 'unittest-sftp-user-pass', + ], + // 'public' => false, + ]; + } + + $hash = md5(serialize($config)); + + if (!(static::$instances[$hash] ?? false)) { + static::$instances[$hash] = new sftp($config); + } + return static::$instances[$hash]; + } + + /** + * {@inheritDoc} + */ + public function testInvalidEmptyConfiguration(): void + { + parent::testInvalidEmptyConfiguration(); + } + + /** + * {@inheritDoc} + */ + public function testFileAvailableFalse(): void + { + parent::testFileAvailableFalse(); + } + + /** + * {@inheritDoc} + */ + public function testFileAvailableTrue(): void + { + parent::testFileAvailableTrue(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushSuccessful(): void + { + parent::testFilePushSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushNestedSuccessful(): void + { + parent::testFilePushNestedSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMaliciousDirUpFails(): void + { + parent::testFilePushMaliciousDirUpFails(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMaliciousDirTraversalFails(): void + { + parent::testFilePushMaliciousDirTraversalFails(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMaliciousMultipleDirTraversalFails(): void + { + parent::testFilePushMaliciousMultipleDirTraversalFails(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushAlreadyExists(): void + { + parent::testFilePushAlreadyExists(); + } + + /** + * {@inheritDoc} + */ + public function testFilePullSuccessful(): void + { + parent::testFilePullSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testFilePullAlreadyExists(): void + { + parent::testFilePullAlreadyExists(); + } + + /** + * {@inheritDoc} + */ + public function testFilePushMissingLocalFile(): void + { + parent::testFilePullNonexisting(); + } + + /** + * {@inheritDoc} + */ + public function testFilePullNonexisting(): void + { + parent::testFilePullNonexisting(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveSuccess(): void + { + parent::testFileMoveSuccess(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveNestedSuccess(): void + { + parent::testFileMoveNestedSuccess(); + } + + /** + * {@inheritDoc} + */ + public function testFileAvailableOnDir(): void + { + parent::testFileAvailableOnDir(); + } + + /** + * {@inheritDoc} + */ + public function testFileDeleteNonexistent(): void + { + parent::testFileDeleteNonexistent(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveNonexistentFailed(): void + { + parent::testFileMoveNonexistentFailed(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveAlreadyExistsFailed(): void + { + parent::testFileMoveAlreadyExistsFailed(); + } + + /** + * {@inheritDoc} + */ + public function testFileMoveAlreadyExistsNestedFailed(): void + { + parent::testFileMoveAlreadyExistsNestedFailed(); + } + + /** + * {@inheritDoc} + */ + public function testDirListSuccessful(): void + { + parent::testDirListSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testIsFile(): void + { + parent::testIsFile(); + } + + /** + * {@inheritDoc} + */ + public function testDirListNestedSuccessful(): void + { + parent::testDirListNestedSuccessful(); + } + + /** + * {@inheritDoc} + */ + public function testDirAvailableOnFile(): void + { + parent::testDirAvailableOnFile(); + } + /** + * {@inheritDoc} + */ + public function testDirListNonexisting(): void + { + parent::testDirListNonexisting(); + } + /** + * {@inheritDoc} + */ + public function testVfsLocalDirNotWritableFilePull(): void + { + // SFTP bucket throws a custom exception in this case, in contrast to other buckets + $this->expectExceptionMessage('Unable to open local file for writing: vfs://vfs-test/not-writable-dir/file2.txt'); + parent::testVfsLocalDirNotWritableFilePull(); + } + /** + * {@inheritDoc} + */ + public function testVfsLocalDirQuotaLimitedFilePull(): void + { + // SFTP bucket throws a custom exception in this case, in contrast to other buckets + $this->expectExceptionMessage('Unable to write to local file: vfs://vfs-test/file.txt'); + parent::testVfsLocalDirQuotaLimitedFilePull(); + } } diff --git a/tests/cache/abstractCacheTest.php b/tests/cache/abstractCacheTest.php index 6fe32c3..209ed40 100644 --- a/tests/cache/abstractCacheTest.php +++ b/tests/cache/abstractCacheTest.php @@ -1,120 +1,123 @@ [ - // 'filesystem' =>[ - // 'local' => [ - // 'driver' => 'local', - // ] - // ], - 'log' => [ - 'debug' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ] - ], - ] - ]); - } - - /** - * [getCache description] - * @param array|null $config - * @return \codename\core\cache [description] - */ - public abstract function getCache(?array $config = null): \codename\core\cache; +abstract class abstractCacheTest extends base +{ + /** + * @return void + */ + protected function testInvalidEmptyConfiguration(): void + { + $this->expectException(exception::class); + // Pass an empty configuration array + $this->getCache([]); + } - /** - * [testInvalidEmptyConfiguration description] - */ - public function testInvalidEmptyConfiguration(): void { - $this->expectException(\codename\core\exception::class); - // Simply pass an empty configuration array - $cache = $this->getCache([]); - } + /** + * @param array|null $config + * @return cache + */ + abstract public function getCache(?array $config = null): cache; - /** - * [testSetCacheSimple description] - */ - public function testSetCacheSimple(): void { - $cache = $this->getCache(); - $cache->set('test', 'simple', 'example'); - $this->assertEquals('example', $cache->get('test', 'simple')); - } + /** + * @return void + */ + protected function testSetCacheSimple(): void + { + $cache = $this->getCache(); + $cache->set('test', 'simple', 'example'); + static::assertEquals('example', $cache->get('test', 'simple')); + } - /** - * [testSetCacheStructure description] - */ - public function testSetCacheStructure(): void { - $cache = $this->getCache(); - $cache->set('test', 'structure', [ 'some_key' => 'some_value' ]); - $this->assertEquals([ 'some_key' => 'some_value' ], $cache->get('test', 'structure')); - } + /** + * @return void + */ + protected function testSetCacheStructure(): void + { + $cache = $this->getCache(); + $cache->set('test', 'structure', ['some_key' => 'some_value']); + static::assertEquals(['some_key' => 'some_value'], $cache->get('test', 'structure')); + } - /** - * [testSetCacheOverwrite description] - */ - public function testSetCacheOverwrite(): void { - $cache = $this->getCache(); - $cache->set('test', 'overwritten', 'first_value'); - $cache->set('test', 'overwritten', 'second_value'); - $this->assertEquals('second_value', $cache->get('test', 'overwritten')); - } + /** + * @return void + */ + protected function testSetCacheOverwrite(): void + { + $cache = $this->getCache(); + $cache->set('test', 'overwritten', 'first_value'); + $cache->set('test', 'overwritten', 'second_value'); + static::assertEquals('second_value', $cache->get('test', 'overwritten')); + } - /** - * [testClearKey description] - */ - public function testClearKey(): void { - $cache = $this->getCache(); - $cache->set('test', 'clear_me', 'some_value'); - $cache->clearKey('test', 'clear_me'); - $this->assertFalse($cache->isDefined('test', 'clear_me')); - $this->assertNull($cache->get('test', 'clear_me')); - } + /** + * @return void + */ + protected function testClearKey(): void + { + $cache = $this->getCache(); + $cache->set('test', 'clear_me', 'some_value'); + $cache->clearKey('test', 'clear_me'); + static::assertFalse($cache->isDefined('test', 'clear_me')); + static::assertNull($cache->get('test', 'clear_me')); + } - /** - * [testClearGroup description] - */ - public function testClearGroup(): void { - $cache = $this->getCache(); - $cache->set('some_group', 'key1', 'some_value'); - $cache->set('some_group', 'key2', 'another_value'); - // make sure they exist, first - $this->assertTrue($cache->isDefined('some_group', 'key1')); - $this->assertTrue($cache->isDefined('some_group', 'key2')); - $cache->clearGroup('some_group'); - $this->assertFalse($cache->isDefined('some_group', 'key1')); - $this->assertFalse($cache->isDefined('some_group', 'key2')); - $this->assertNull($cache->get('some_group', 'key1')); - $this->assertNull($cache->get('some_group', 'key1')); - } + /** + * @return void + */ + protected function testClearGroup(): void + { + $cache = $this->getCache(); + $cache->set('some_group', 'key1', 'some_value'); + $cache->set('some_group', 'key2', 'another_value'); + // make sure they exist, first + static::assertTrue($cache->isDefined('some_group', 'key1')); + static::assertTrue($cache->isDefined('some_group', 'key2')); + $cache->clearGroup('some_group'); + static::assertFalse($cache->isDefined('some_group', 'key1')); + static::assertFalse($cache->isDefined('some_group', 'key2')); + static::assertNull($cache->get('some_group', 'key1')); + static::assertNull($cache->get('some_group', 'key1')); + } - /** - * [testFlush description] - */ - public function testFlush(): void { - $cache = $this->getCache(); - $cache->set('flushgroup1', 'key1', 'some_value'); - $cache->set('flushgroup2', 'key2', 'another_value'); - $this->assertTrue($cache->isDefined('flushgroup1', 'key1')); - $this->assertTrue($cache->isDefined('flushgroup2', 'key2')); + /** + * @return void + */ + protected function testFlush(): void + { + $cache = $this->getCache(); + $cache->set('flushgroup1', 'key1', 'some_value'); + $cache->set('flushgroup2', 'key2', 'another_value'); + static::assertTrue($cache->isDefined('flushgroup1', 'key1')); + static::assertTrue($cache->isDefined('flushgroup2', 'key2')); - // Flush all entries - $cache->flush(); - $this->assertFalse($cache->isDefined('flushgroup1', 'key1')); - $this->assertFalse($cache->isDefined('flushgroup2', 'key2')); - } + // Flush all entries + $cache->flush(); + static::assertFalse($cache->isDefined('flushgroup1', 'key1')); + static::assertFalse($cache->isDefined('flushgroup2', 'key2')); + } + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + static::setEnvironmentConfig([ + 'test' => [ + 'log' => [ + 'debug' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + ], + ], + ]); + } } diff --git a/tests/cache/memcachedTest.php b/tests/cache/memcachedTest.php index b7107a2..365bfe1 100644 --- a/tests/cache/memcachedTest.php +++ b/tests/cache/memcachedTest.php @@ -1,50 +1,106 @@ 'memcached', - 'host' => getenv('unittest_core_cache_memcached_host'), - 'port' => getenv('unittest_core_cache_memcached_port'), - ]; - } - - return new \codename\core\cache\memcached($config); - } - - /** - * @inheritDoc - */ - public static function setUpBeforeClass(): void - { - parent::setUpBeforeClass(); - - // wait for host to come up - if(getenv('unittest_core_cache_memcached_host')) { - if(!\codename\core\tests\helper::waitForIt(getenv('unittest_core_cache_memcached_host'), (int)getenv('unittest_core_cache_memcached_port'), 3, 3, 5)) { - throw new \Exception('Failed to connect to memcached server'); - } - } else { - static::markTestSkipped('memcached host unavailable'); - } - } - - /** - * @inheritDoc - */ - public function testClearGroup(): void - { - $this->markTestSkipped('memcached::clearGroup is based on Memcached::getAllKeys() which is not fully supported'); - parent::testClearGroup(); - } +use codename\core\cache; +use codename\core\cache\memcached; +use codename\core\tests\helper; +use Exception; + +class memcachedTest extends abstractCacheTest +{ + /** + * {@inheritDoc} + * @throws Exception + */ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + // wait for the host to come up + if (getenv('unittest_core_cache_memcached_host')) { + if (!helper::waitForIt(getenv('unittest_core_cache_memcached_host'), (int)getenv('unittest_core_cache_memcached_port'), 3, 3, 5)) { + throw new Exception('Failed to connect to memcached server'); + } + } else { + static::markTestSkipped('memcached host unavailable'); + } + } + + /** + * @return void + */ + public function testInvalidEmptyConfiguration(): void + { + parent::testInvalidEmptyConfiguration(); + } + + /** + * @return void + */ + public function testSetCacheSimple(): void + { + parent::testSetCacheSimple(); + } + + /** + * @return void + */ + public function testSetCacheStructure(): void + { + parent::testSetCacheStructure(); + } + + /** + * @return void + */ + public function testSetCacheOverwrite(): void + { + parent::testSetCacheOverwrite(); + } + + /** + * @return void + */ + public function testClearKey(): void + { + parent::testClearKey(); + } + + /** + * @return void + */ + public function testFlush(): void + { + parent::testFlush(); + } + + /** + * {@inheritDoc} + * @param array|null $config + * @return cache + * @throws \codename\core\exception + */ + public function getCache(?array $config = null): cache + { + if ($config === null) { + $config = [ + // default config? + 'driver' => 'memcached', + 'host' => getenv('unittest_core_cache_memcached_host'), + 'port' => getenv('unittest_core_cache_memcached_port'), + ]; + } + + return new memcached($config); + } + + /** + * {@inheritDoc} + */ + public function testClearGroup(): void + { + static::markTestSkipped('memcached::clearGroup is based on Memcached::getAllKeys() which is not fully supported'); +// parent::testClearGroup(); + } } diff --git a/tests/cache/memoryTest.php b/tests/cache/memoryTest.php index a34aced..e1d2f54 100644 --- a/tests/cache/memoryTest.php +++ b/tests/cache/memoryTest.php @@ -1,31 +1,81 @@ 'memory', - ]; - } - - return new \codename\core\cache\memory($config); - } - - /** - * @inheritDoc - */ - public function testInvalidEmptyConfiguration(): void - { - $this->markTestSkipped('Empty configuration is allowed for bare-memory cache'); - parent::testInvalidEmptyConfiguration(); - } +use codename\core\cache; +use codename\core\cache\memory; + +class memoryTest extends abstractCacheTest +{ + /** + * @return void + */ + public function testSetCacheSimple(): void + { + parent::testSetCacheSimple(); + } + + /** + * @return void + */ + public function testSetCacheStructure(): void + { + parent::testSetCacheStructure(); + } + + /** + * @return void + */ + public function testSetCacheOverwrite(): void + { + parent::testSetCacheOverwrite(); + } + + /** + * @return void + */ + public function testClearKey(): void + { + parent::testClearKey(); + } + + /** + * @return void + */ + public function testClearGroup(): void + { + parent::testClearGroup(); + } + + /** + * @return void + */ + public function testFlush(): void + { + parent::testFlush(); + } + + /** + * {@inheritDoc} + */ + public function getCache(?array $config = null): cache + { + if ($config === null) { + $config = [ + // default config? + 'driver' => 'memory', + ]; + } + + return new memory($config); + } + + /** + * {@inheritDoc} + */ + public function testInvalidEmptyConfiguration(): void + { + static::markTestSkipped('Empty configuration is allowed for bare-memory cache'); +// parent::testInvalidEmptyConfiguration(); + } } diff --git a/tests/config/example.extends.json b/tests/config/example.extends.json index 97085a9..7166047 100644 --- a/tests/config/example.extends.json +++ b/tests/config/example.extends.json @@ -1,16 +1,18 @@ { - "extends" : [ + "extends": [ "tests/config/example.json" ], - "mixins" : [ + "mixins": [ "tests/config/example.mixmein.json" ], "some-key": "some-overridden-value", - "some-array": [ "some-item-2?" ], + "some-array": [ + "some-item-2?" + ], "some-object": { "key2": "value-changed", "key4": "value-added", "key5": "value5" }, - "some-key2" : "some-value2" + "some-key2": "some-value2" } diff --git a/tests/config/example.json b/tests/config/example.json index 5ddbad6..3d710e1 100644 --- a/tests/config/example.json +++ b/tests/config/example.json @@ -1,6 +1,8 @@ { "some-key": "some-value", - "some-array": [ "some-item" ], + "some-array": [ + "some-item" + ], "some-object": { "key1": "value1", "key2": "value2", diff --git a/tests/config/example.mixmein.json b/tests/config/example.mixmein.json index 209ae91..1119ffb 100644 --- a/tests/config/example.mixmein.json +++ b/tests/config/example.mixmein.json @@ -1,10 +1,10 @@ { "mixed-in-key": "mixed-in-value", - "some-object" : { + "some-object": { "key5": "added-value" }, - "some-array" : [ + "some-array": [ "new-item" ], - "some-key2" : "other-value" + "some-key2": "other-value" } diff --git a/tests/config/jsonTest.php b/tests/config/jsonTest.php index 8358cde..c234048 100644 --- a/tests/config/jsonTest.php +++ b/tests/config/jsonTest.php @@ -1,300 +1,343 @@ get()); + } + + /** + * @return void + */ + public function testEmptyJsonFileWillThrow(): void + { + $this->expectExceptionMessage(json::EXCEPTION_DECODEFILE_FILEISEMPTY); + new json('tests/config/empty.json'); + } + + /** + * @return void + */ + public function testInvalidJsonFileWillThrow(): void + { + $this->expectExceptionMessage(json::EXCEPTION_DECODEFILE_FILEISINVALID); + new json('tests/config/invalid.json'); + } + + /** + * @return void + */ + public function testExtendedJsonConfig(): void + { + $config = new extendable('tests/config/example.extends.json'); + static::assertEquals('some-overridden-value', $config->get('some-key')); + static::assertEquals('value1', $config->get('some-object>key1')); + static::assertEquals('value-changed', $config->get('some-object>key2')); + static::assertEquals('value3', $config->get('some-object>key3')); + static::assertEquals('value-added', $config->get('some-object>key4')); + + // Mixin, root key added + static::assertEquals('mixed-in-value', $config->get('mixed-in-key')); + + // Mixin, root key merged + static::assertEquals(['some-value2', 'other-value'], $config->get('some-key2')); + + // Mixin adds a value to this key, making it an array + static::assertEquals(['value5', 'added-value'], $config->get('some-object>key5')); + + // Root is being overridden by extends, mixin is merged + static::assertEquals(['some-item-2?', 'new-item'], $config->get('some-array')); + } + + /** + * Tests whether loading a config with appstack && !inherit crashes + * @return void + */ + public function testExtendedJsonConfigInvalidInitParams(): void + { + $this->expectExceptionMessage(json::EXCEPTION_CONSTRUCT_INVALIDBEHAVIOR); + + // + // We can't use inheritance without the appstack + // + new extendable('sample.json', false, true); + } + + /** + * Tests whether loading a config with appstack && !inherit crashes + * @return void + */ + public function testJsonConfigInvalidInitParams(): void + { + $this->expectExceptionMessage(json::EXCEPTION_CONSTRUCT_INVALIDBEHAVIOR); + + // + // We can't use inheritance without the appstack + // + new json('sample.json', false, true); + } -class jsonTest extends base { - - /** - * @inheritDoc - */ - protected function setUp(): void - { - $app = static::createApp(); - $app->getAppstack(); - - static::setEnvironmentConfig([ - 'test' => [ - 'filesystem' =>[ - 'local' => [ - 'driver' => 'local', - ] - ], - // 'log' => [ - // 'errormessage' => [ - // 'driver' => 'system', - // 'data' => [ - // 'name' => 'dummy' - // ] - // ], - // 'debug' => [ - // 'driver' => 'system', - // 'data' => [ - // 'name' => 'dummy' - // ] - // ] - // ], - ] - ]); - } - - /** - * [testSimpleJsonConfig description] - */ - public function testSimpleJsonConfig(): void { - $config = new \codename\core\config\json('tests/config/example.json'); - $original = json_decode(file_get_contents(__DIR__.'/example.json'),true); - $this->assertEquals($original, $config->get()); - } - - /** - * [testEmptyJsonFileWillThrow description] - */ - public function testEmptyJsonFileWillThrow(): void { - $this->expectExceptionMessage(\codename\core\config\json::EXCEPTION_DECODEFILE_FILEISEMPTY); - $config = new \codename\core\config\json('tests/config/empty.json'); - } - - /** - * [testInvalidJsonFileWillThrow description] - */ - public function testInvalidJsonFileWillThrow(): void { - $this->expectExceptionMessage(\codename\core\config\json::EXCEPTION_DECODEFILE_FILEISINVALID); - $config = new \codename\core\config\json('tests/config/invalid.json'); - } - - /** - * [testExtendedJsonConfig description] - */ - public function testExtendedJsonConfig(): void { - $config = new \codename\core\config\json\extendable('tests/config/example.extends.json'); - $this->assertEquals('some-overridden-value', $config->get('some-key')); - $this->assertEquals('value1', $config->get('some-object>key1')); - $this->assertEquals('value-changed', $config->get('some-object>key2')); - $this->assertEquals('value3', $config->get('some-object>key3')); - $this->assertEquals('value-added', $config->get('some-object>key4')); - - // Mixin, root key added - $this->assertEquals('mixed-in-value', $config->get('mixed-in-key')); - - // Mixin, root key merged - $this->assertEquals(['some-value2', 'other-value'], $config->get('some-key2')); - - // Mixin adds a value to this key, making it an array - $this->assertEquals(['value5', 'added-value'], $config->get('some-object>key5')); - - // Root is being overridden by extends, mixin is merged - $this->assertEquals(['some-item-2?', 'new-item'], $config->get('some-array')); - - // TODO: arrays? - - // print_r($config->get()); - } - - /** - * Tests whether loading a config with appstack && !inherit crashes - */ - public function testExtendedJsonConfigInvalidInitParams(): void { - $this->expectErrorMessage(\codename\core\config\json::EXCEPTION_CONSTRUCT_INVALIDBEHAVIOR); - - // - // We can't use inheritance without the appstack - // - $config = new \codename\core\config\json\extendable('sample.json', false, true); - } - - /** - * Tests whether loading a config with appstack && !inherit crashes - */ - public function testJsonConfigInvalidInitParams(): void { - $this->expectErrorMessage(\codename\core\config\json::EXCEPTION_CONSTRUCT_INVALIDBEHAVIOR); - - // - // We can't use inheritance without the appstack - // - $config = new \codename\core\config\json('sample.json', false, true); - } - - /** - * [testAbsolutePathWithAppstack description] - */ - public function testExtendedJsonAbsolutePathWithAppstack(): void { - $config = new \codename\core\config\json\extendable(__DIR__.'/app1/sample.json', true, true); - $this->assertEquals('value2', $config->get('key2')); - } - - /** - * [testAbsolutePathWithAppstack description] - */ - public function testJsonAbsolutePathWithAppstack(): void { - // NOTE: due to internal usage of realpath - // we simply workaround platform test differences - // by inserting the platform-dependent directory separators right here - $config = new \codename\core\config\json(__DIR__.DIRECTORY_SEPARATOR.'app1'.DIRECTORY_SEPARATOR.'sample.json', true, true); - $this->assertEquals('value2', $config->get('key2')); - } - - /** - * config\json: First-match config loading - */ - public function testJsonAppstackNoInheritance(): void { - overrideableApp::reset(); - overrideableApp::__setApp('app1'); - overrideableApp::__setVendor('irrelevant'); - overrideableApp::__setHomedir(__DIR__.'/app1'); - - overrideableApp::__injectApp([ - 'vendor' => 'irrelevant', - 'app' => 'app_injected', - 'homedir' => __DIR__.'/app_injected', - 'namespace' => '--irrelevant--', - ]); - - // - // We traverse the appstack, - // but we load the first matching config only. - // - $config = new \codename\core\config\json('otherSample.json', true, false); - $this->assertEquals(true, $config->get('otherSample')); - } - - /** - * config\json\extendable: First-match config loading - */ - public function testExtendedJsonAppstackNoInheritance(): void { - overrideableApp::reset(); - overrideableApp::__setApp('app1'); - overrideableApp::__setVendor('irrelevant'); - overrideableApp::__setHomedir(__DIR__.'/app1'); - - overrideableApp::__injectApp([ - 'vendor' => 'irrelevant', - 'app' => 'app_injected', - 'homedir' => __DIR__.'/app_injected', - 'namespace' => '--irrelevant--', - ]); - - // - // We traverse the appstack, - // but we load the first matching config only. - // - $config = new \codename\core\config\json\extendable('otherSample.json', true, false); - $this->assertEquals(true, $config->get('otherSample')); - } - - /** - * config\json\extendable: Traverse appstack, but no file exists anywhere. - */ - public function testExtendedJsonAppstackInheritanceNonexistingFile(): void { - overrideableApp::reset(); - overrideableApp::__setApp('app1'); - overrideableApp::__setVendor('irrelevant'); - overrideableApp::__setHomedir(__DIR__.'/app1'); - - overrideableApp::__injectApp([ - 'vendor' => 'irrelevant', - 'app' => 'app_injected', - 'homedir' => __DIR__.'/app_injected', - 'namespace' => '--irrelevant--', - ]); - - $this->expectExceptionMessage(\codename\core\config\json::EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND); - - // - // We traverse the appstack, - // but we load the first matching config only. - // - $config = new \codename\core\config\json\extendable('nonexisting.json', true, true); - } - - /** - * config\json: Traverse appstack, but no file exists anywhere. - */ - public function testJsonAppstackInheritanceNonexistingFile(): void { - overrideableApp::reset(); - overrideableApp::__setApp('app1'); - overrideableApp::__setVendor('irrelevant'); - overrideableApp::__setHomedir(__DIR__.'/app1'); - - overrideableApp::__injectApp([ - 'vendor' => 'irrelevant', - 'app' => 'app_injected', - 'homedir' => __DIR__.'/app_injected', - 'namespace' => '--irrelevant--', - ]); - - $this->expectExceptionMessage(\codename\core\config\json::EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND); - - // - // We traverse the appstack, - // but we load the first matching config only. - // - $config = new \codename\core\config\json('nonexisting.json', true, true); - } - - /** - * Tests a more complex case with inheritance, appstack, mixins and extends - * and keys that override each other - */ - public function testExtendedJsonConfigAppstackInheritance(): void { - overrideableApp::reset(); - overrideableApp::__setApp('app1'); - overrideableApp::__setVendor('irrelevant'); - overrideableApp::__setHomedir(__DIR__.'/app1'); - - overrideableApp::__injectApp([ - 'vendor' => 'irrelevant', - 'app' => 'app_injected', - 'homedir' => __DIR__.'/app_injected', - 'namespace' => '--irrelevant--', - ]); - - overrideableApp::__injectApp([ - 'vendor' => 'irrelevant', - 'app' => 'app_injected_extends', - 'homedir' => __DIR__.'/app_injected_extends', - 'namespace' => '--irrelevant--', - ]); - overrideableApp::__injectApp([ - 'vendor' => 'irrelevant', - 'app' => 'app_injected_mixin', - 'homedir' => __DIR__.'/app_injected_mixin', - 'namespace' => '--irrelevant--', - ]); - overrideableApp::__injectApp([ - 'vendor' => 'irrelevant', - 'app' => 'app_injected_overrides', - 'homedir' => __DIR__.'/app_injected_overrides', - 'namespace' => '--irrelevant--', - ]); - - $config = new \codename\core\config\json\extendable('sample.json', true, true); - - $this->assertEquals('value1', $config->get('key1')); - $this->assertEquals('value2', $config->get('key2')); - $this->assertEquals('overridden', $config->get('overrideMe')); - $this->assertEquals(true, $config->get('extend1')); - $this->assertEquals(true, $config->get('mixin1')); - $this->assertEquals(true, $config->get('extend1-extends')); - $this->assertEquals(true, $config->get('mixin1-mixin')); - - $expectedInheritanceRegexes = [ - '/app_injected_mixin\/sample.json/', - '/app_injected_extends\/sample.json/', - '/app_injected\/sample.json/', - '/app1\/sample.json/', - ]; - - $inheritance = $config->getInheritance(); - - // make sure we have no unexpected inherited elements - $this->assertCount(count($expectedInheritanceRegexes), $inheritance); - - foreach($expectedInheritanceRegexes as $index => $regex) { - $this->assertMatchesRegularExpression($regex, $inheritance[$index]); + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testExtendedJsonAbsolutePathWithAppstack(): void + { + $config = new extendable(__DIR__ . '/app1/sample.json', true, true); + static::assertEquals('value2', $config->get('key2')); } - } + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testJsonAbsolutePathWithAppstack(): void + { + // NOTE: due to internal usage of realpath + // we simply work around platform test differences + // by inserting the platform-dependent directory separators right here + $config = new json(__DIR__ . DIRECTORY_SEPARATOR . 'app1' . DIRECTORY_SEPARATOR . 'sample.json', true, true); + static::assertEquals('value2', $config->get('key2')); + } + + /** + * config\json: First-match config loading + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testJsonAppstackNoInheritance(): void + { + overrideableApp::reset(); + overrideableApp::__setApp('app1'); + overrideableApp::__setVendor('irrelevant'); + overrideableApp::__setHomedir(__DIR__ . '/app1'); + + overrideableApp::__injectApp([ + 'vendor' => 'irrelevant', + 'app' => 'app_injected', + 'homedir' => __DIR__ . '/app_injected', + 'namespace' => '--irrelevant--', + ]); + + // + // We traverse the appstack, + // but we load the first matching config only. + // + $config = new json('otherSample.json', true, false); + static::assertTrue($config->get('otherSample')); + } + + /** + * config\json\extendable: First-match config loading + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testExtendedJsonAppstackNoInheritance(): void + { + overrideableApp::reset(); + overrideableApp::__setApp('app1'); + overrideableApp::__setVendor('irrelevant'); + overrideableApp::__setHomedir(__DIR__ . '/app1'); + + overrideableApp::__injectApp([ + 'vendor' => 'irrelevant', + 'app' => 'app_injected', + 'homedir' => __DIR__ . '/app_injected', + 'namespace' => '--irrelevant--', + ]); + + // + // We traverse the appstack, + // but we load the first matching config only. + // + $config = new extendable('otherSample.json', true, false); + static::assertTrue($config->get('otherSample')); + } + + /** + * config\json\extendable: Traverse appstack, but no file exists anywhere. + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testExtendedJsonAppstackInheritanceNonexistingFile(): void + { + overrideableApp::reset(); + overrideableApp::__setApp('app1'); + overrideableApp::__setVendor('irrelevant'); + overrideableApp::__setHomedir(__DIR__ . '/app1'); + + overrideableApp::__injectApp([ + 'vendor' => 'irrelevant', + 'app' => 'app_injected', + 'homedir' => __DIR__ . '/app_injected', + 'namespace' => '--irrelevant--', + ]); + + $this->expectExceptionMessage(json::EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND); + + // + // We traverse the appstack, + // but we load the first matching config only. + // + new extendable('nonexisting.json', true, true); + } + + /** + * config\json: Traverse appstack, but no file exists anywhere. + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testJsonAppstackInheritanceNonexistingFile(): void + { + overrideableApp::reset(); + overrideableApp::__setApp('app1'); + overrideableApp::__setVendor('irrelevant'); + overrideableApp::__setHomedir(__DIR__ . '/app1'); + + overrideableApp::__injectApp([ + 'vendor' => 'irrelevant', + 'app' => 'app_injected', + 'homedir' => __DIR__ . '/app_injected', + 'namespace' => '--irrelevant--', + ]); + + $this->expectExceptionMessage(json::EXCEPTION_CONFIG_JSON_CONSTRUCT_HIERARCHY_NOT_FOUND); + + // + // We traverse the appstack, + // but we load the first matching config only. + // + new json('nonexisting.json', true, true); + } + + /** + * Tests a more complex case with inheritance, appstack, mixins and extends + * and keys that override each other + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testExtendedJsonConfigAppstackInheritance(): void + { + overrideableApp::reset(); + overrideableApp::__setApp('app1'); + overrideableApp::__setVendor('irrelevant'); + overrideableApp::__setHomedir(__DIR__ . '/app1'); + + overrideableApp::__injectApp([ + 'vendor' => 'irrelevant', + 'app' => 'app_injected', + 'homedir' => __DIR__ . '/app_injected', + 'namespace' => '--irrelevant--', + ]); + + overrideableApp::__injectApp([ + 'vendor' => 'irrelevant', + 'app' => 'app_injected_extends', + 'homedir' => __DIR__ . '/app_injected_extends', + 'namespace' => '--irrelevant--', + ]); + overrideableApp::__injectApp([ + 'vendor' => 'irrelevant', + 'app' => 'app_injected_mixin', + 'homedir' => __DIR__ . '/app_injected_mixin', + 'namespace' => '--irrelevant--', + ]); + overrideableApp::__injectApp([ + 'vendor' => 'irrelevant', + 'app' => 'app_injected_overrides', + 'homedir' => __DIR__ . '/app_injected_overrides', + 'namespace' => '--irrelevant--', + ]); + + $config = new extendable('sample.json', true, true); + + static::assertEquals('value1', $config->get('key1')); + static::assertEquals('value2', $config->get('key2')); + static::assertEquals('overridden', $config->get('overrideMe')); + static::assertTrue($config->get('extend1')); + static::assertTrue($config->get('mixin1')); + static::assertTrue($config->get('extend1-extends')); + static::assertTrue($config->get('mixin1-mixin')); + + $expectedInheritanceRegexes = [ + '/app_injected_mixin\/sample.json/', + '/app_injected_extends\/sample.json/', + '/app_injected\/sample.json/', + '/app1\/sample.json/', + ]; + + $inheritance = $config->getInheritance(); + + // make sure we have no unexpected inherited elements + static::assertCount(count($expectedInheritanceRegexes), $inheritance); + + foreach ($expectedInheritanceRegexes as $index => $regex) { + static::assertMatchesRegularExpression($regex, $inheritance[$index]); + } + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws ErrorException + * @throws Throwable + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws exception + */ + protected function setUp(): void + { + $app = static::createApp(); + $app::getAppstack(); + + static::setEnvironmentConfig([ + 'test' => [ + 'filesystem' => [ + 'local' => [ + 'driver' => 'local', + ], + ], + ], + ]); + } } diff --git a/tests/database/databaseTest.php b/tests/database/databaseTest.php index 05e70ea..2fe5c80 100644 --- a/tests/database/databaseTest.php +++ b/tests/database/databaseTest.php @@ -1,84 +1,102 @@ expectExceptionMessage(database::EXCEPTION_CONSTRUCT_CONNECTIONERROR); - $db = new database([]); - } - - /** - * [testDatabaseMissingHost description] - */ - public function testDatabaseMissingHost(): void { - $this->expectExceptionMessage(database::EXCEPTION_CONSTRUCT_CONNECTIONERROR); - $db = new database([ - 'pass' => 'test' - ]); - } - - /** - * [testDatabaseMissingHost description] - */ - public function testDatabaseMissingHostEnvPassGiven(): void { - $this->expectExceptionMessage(database::EXCEPTION_CONSTRUCT_CONNECTIONERROR); - $db = new database([ - 'env_pass' => 'some_key' - ]); - } +class databaseTest extends base +{ + /** + * @return void + * @throws exception + * @throws sensitiveException + */ + public function testDatabaseMissingPassword(): void + { + $this->expectExceptionMessage(database::EXCEPTION_CONSTRUCT_CONNECTIONERROR); + new database([]); + } - /** - * [testDatabaseMissingUser description] - */ - public function testDatabaseMissingUser(): void { - $this->expectExceptionMessage(database::EXCEPTION_CONSTRUCT_CONNECTIONERROR); - $db = new database([ - 'pass' => 'test', - 'host' => 'test' - ]); - } + /** + * @return void + * @throws exception + * @throws sensitiveException + */ + public function testDatabaseMissingHost(): void + { + $this->expectExceptionMessage(database::EXCEPTION_CONSTRUCT_CONNECTIONERROR); + new database([ + 'pass' => 'test', + ]); + } - /** - * [testDatabaseMissingUserEnvGiven description] - */ - public function testDatabaseMissingUserEnvGiven(): void { - $this->expectExceptionMessage(database::EXCEPTION_CONSTRUCT_CONNECTIONERROR); - $db = new database([ - 'env_pass' => 'test', - 'env_host' => 'test' - ]); - } + /** + * @return void + * @throws exception + * @throws sensitiveException + */ + public function testDatabaseMissingHostEnvPassGiven(): void + { + $this->expectExceptionMessage(database::EXCEPTION_CONSTRUCT_CONNECTIONERROR); + new database([ + 'env_pass' => 'some_key', + ]); + } - /** - * [testEnvBasedConfig description] - */ - public function testEnvBasedConfig(): void { + /** + * @return void + * @throws exception + * @throws sensitiveException + */ + public function testDatabaseMissingUser(): void + { + $this->expectExceptionMessage(database::EXCEPTION_CONSTRUCT_CONNECTIONERROR); + new database([ + 'pass' => 'test', + 'host' => 'test', + ]); + } - // Actually, this is a PDO Exception message - // as we try to trick the database driver into passing the generic config checks - // and run into the problem that the base driver does NOT define a PDO driver to use. - $this->expectExceptionMessage('could not find driver'); - putenv('databaseTest_env_pass_key=pass_value'); - putenv('databaseTest_env_host_key=user_value'); - putenv('databaseTest_env_user_key=host_value'); + /** + * @return void + * @throws exception + * @throws sensitiveException + */ + public function testDatabaseMissingUserEnvGiven(): void + { + $this->expectExceptionMessage(database::EXCEPTION_CONSTRUCT_CONNECTIONERROR); + new database([ + 'env_pass' => 'test', + 'env_host' => 'test', + ]); + } - $db = new database([ - 'env_pass' => 'databaseTest_env_pass_key', - 'env_host' => 'databaseTest_env_host_key', - 'env_user' => 'databaseTest_env_user_key', - 'database' => 'xyz' - ]); - } + /** + * @return void + * @throws exception + * @throws sensitiveException + */ + public function testEnvBasedConfig(): void + { + // Actually, this is a PDO Exception message + // as we try to trick the database driver into passing the generic config checks + // and run into the problem that the base driver does NOT define a PDO driver to use. + $this->expectExceptionMessage('could not find driver'); + putenv('databaseTest_env_pass_key=pass_value'); + putenv('databaseTest_env_host_key=user_value'); + putenv('databaseTest_env_user_key=host_value'); + new database([ + 'env_pass' => 'databaseTest_env_pass_key', + 'env_host' => 'databaseTest_env_host_key', + 'env_user' => 'databaseTest_env_user_key', + 'database' => 'xyz', + ]); + } } diff --git a/tests/datacontainerTest.php b/tests/datacontainerTest.php index 7105d8c..8fc7d04 100644 --- a/tests/datacontainerTest.php +++ b/tests/datacontainerTest.php @@ -1,78 +1,80 @@ 'abc', - 'integer' => 123, - 'array' => [ 'abc', 123 ], - 'nested' => [ - 'string' => 'def', - 'integer' => 123 - ] - ]); - - $this->assertEquals('abc', $datacontainer->getData('string')); - $this->assertEquals(123, $datacontainer->getData('integer')); +class datacontainerTest extends base +{ + /** + * @return void + */ + public function testDatacontainer(): void + { + $datacontainer = new datacontainer([ + 'string' => 'abc', + 'integer' => 123, + 'array' => ['abc', 123], + 'nested' => [ + 'string' => 'def', + 'integer' => 123, + ], + ]); - $this->assertEquals([ 'abc', 123 ], $datacontainer->getData('array')); - $this->assertEquals([ 'string' => 'def', 'integer' => 123 ], $datacontainer->getData('nested')); - $this->assertEquals('def', $datacontainer->getData('nested>string')); - $this->assertEquals(123, $datacontainer->getData('nested>integer')); + static::assertEquals('abc', $datacontainer->getData('string')); + static::assertEquals(123, $datacontainer->getData('integer')); - // modify, nested - $datacontainer->setData('nested>string', 'xyz'); - $this->assertEquals([ 'string' => 'xyz', 'integer' => 123 ], $datacontainer->getData('nested')); + static::assertEquals(['abc', 123], $datacontainer->getData('array')); + static::assertEquals(['string' => 'def', 'integer' => 123], $datacontainer->getData('nested')); + static::assertEquals('def', $datacontainer->getData('nested>string')); + static::assertEquals(123, $datacontainer->getData('nested>integer')); - // add nested - $datacontainer->setData('nested2>string', 'vwu'); - $this->assertEquals([ 'string' => 'vwu' ], $datacontainer->getData('nested2')); + // modify, nested + $datacontainer->setData('nested>string', 'xyz'); + static::assertEquals(['string' => 'xyz', 'integer' => 123], $datacontainer->getData('nested')); - $datacontainer->addData([ - 'integer' => 456, - 'nested' => [ - 'changed' => true - ] - ]); + // add nested + $datacontainer->setData('nested2>string', 'vwu'); + static::assertEquals(['string' => 'vwu'], $datacontainer->getData('nested2')); - $this->assertEquals(456, $datacontainer->getData('integer')); - $this->assertEquals([ 'changed' => true ], $datacontainer->getData('nested')); + $datacontainer->addData([ + 'integer' => 456, + 'nested' => [ + 'changed' => true, + ], + ]); - $datacontainer->setData('string', 'ghi'); - $datacontainer->unsetData('nested2'); - $datacontainer->unsetData(''); - $datacontainer->unsetData('fake'); + static::assertEquals(456, $datacontainer->getData('integer')); + static::assertEquals(['changed' => true], $datacontainer->getData('nested')); - $this->assertEquals([ - 'string' => 'ghi', - 'integer' => 456, - 'array' => [ 'abc', 123 ], - 'nested' => [ - 'changed' => true - ] - ], $datacontainer->getData()); + $datacontainer->setData('string', 'ghi'); + $datacontainer->unsetData('nested2'); + $datacontainer->unsetData(''); + $datacontainer->unsetData('fake'); - $this->assertTrue($datacontainer->isDefined('string')); - $this->assertTrue($datacontainer->isDefined('nested>changed')); + static::assertEquals([ + 'string' => 'ghi', + 'integer' => 456, + 'array' => ['abc', 123], + 'nested' => [ + 'changed' => true, + ], + ], $datacontainer->getData()); - $this->assertFalse($datacontainer->isDefined('nested2')); - $this->assertFalse($datacontainer->isDefined('nonexisting')); - $this->assertFalse($datacontainer->isDefined('nonexisting>nonexisting_subkey')); + static::assertTrue($datacontainer->isDefined('string')); + static::assertTrue($datacontainer->isDefined('nested>changed')); - $datacontainer->setData('null_value', null); - $this->assertTrue($datacontainer->isDefined('null_value')); + static::assertFalse($datacontainer->isDefined('nested2')); + static::assertFalse($datacontainer->isDefined('nonexisting')); + static::assertFalse($datacontainer->isDefined('nonexisting>nonexisting_subkey')); - $this->assertNull($datacontainer->getData('nonexisting')); - $this->assertNull($datacontainer->getData('nonexisting>nonexisting_subkey')); + $datacontainer->setData('null_value', null); + static::assertTrue($datacontainer->isDefined('null_value')); - $this->assertEmpty($datacontainer->setData('', 'test')); - } + static::assertNull($datacontainer->getData('nonexisting')); + static::assertNull($datacontainer->getData('nonexisting>nonexisting_subkey')); + } } diff --git a/tests/errorstackTest.php b/tests/errorstackTest.php index 1671548..065ab4c 100644 --- a/tests/errorstackTest.php +++ b/tests/errorstackTest.php @@ -1,48 +1,50 @@ assertEquals([], $errorstack->jsonSerialize()); - $this->assertTrue($errorstack->isSuccess()); - - $errorstack->addError('example', 'test', 'lalelu'); - $errorstack->addErrors($errorstack->getErrors()); - - $this->assertEquals([ - [ - '__IDENTIFIER' => 'example', - '__CODE' => 'EXAMPLE.test', - '__TYPE' => 'EXAMPLE', - '__DETAILS' => 'lalelu', - ], - [ - '__IDENTIFIER' => 'example', - '__CODE' => 'EXAMPLE.test', - '__TYPE' => 'EXAMPLE', - '__DETAILS' => 'lalelu', - ] - ], $errorstack->getErrors()); - - $this->assertFalse($errorstack->isSuccess()); - - $errorstack->reset(); - - $this->assertEmpty($errorstack->getErrors()); - - $errorstack->addErrorstack((new \codename\core\errorstack('example'))); - - $this->assertEmpty($errorstack->getErrors()); - - } - +class errorstackTest extends base +{ + /** + * @return void + */ + public function testErrorstack(): void + { + $errorstack = new errorstack('example'); + + static::assertEquals([], $errorstack->jsonSerialize()); + static::assertTrue($errorstack->isSuccess()); + + $errorstack->addError('example', 'test', 'lalelu'); + $errorstack->addErrors($errorstack->getErrors()); + + static::assertEquals([ + [ + '__IDENTIFIER' => 'example', + '__CODE' => 'EXAMPLE.test', + '__TYPE' => 'EXAMPLE', + '__DETAILS' => 'lalelu', + ], + [ + '__IDENTIFIER' => 'example', + '__CODE' => 'EXAMPLE.test', + '__TYPE' => 'EXAMPLE', + '__DETAILS' => 'lalelu', + ], + ], $errorstack->getErrors()); + + static::assertFalse($errorstack->isSuccess()); + + $errorstack->reset(); + + static::assertEmpty($errorstack->getErrors()); + + $errorstack->addErrorstack((new errorstack('example'))); + + static::assertEmpty($errorstack->getErrors()); + } } diff --git a/tests/eventTest.php b/tests/eventTest.php index 841d451..cf29e40 100644 --- a/tests/eventTest.php +++ b/tests/eventTest.php @@ -1,64 +1,73 @@ assertEquals('testevent', $event->getName()); - - $event->addEventHandler(new eventHandler(function($eventArgs) { - $this->assertEquals('test', $eventArgs); - })); - - $res = $event->invoke($this, 'test'); - $this->assertEmpty($res); - } +class eventTest extends TestCase +{ + /** + * @return void + */ + public function testEventInvoke(): void + { + $event = new event('testevent'); + static::assertEquals('testevent', $event->getName()); - /** - * [testEventInvokeWithResult description] - */ - public function testEventInvokeWithResult(): void { - $event = new event('testevent'); - $this->assertEquals('testevent', $event->getName()); + $event->addEventHandler( + new eventHandler(function ($eventArgs) { + static::assertEquals('test', $eventArgs); + }) + ); + } - $event->addEventHandler(new eventHandler(function($eventArgs) { - $this->assertEquals('test', $eventArgs); - return 'success'; - })); + /** + * @return void + */ + public function testEventInvokeWithResult(): void + { + $event = new event('testevent'); + static::assertEquals('testevent', $event->getName()); - $res = $event->invokeWithResult($this, 'test'); - $this->assertEquals('success', $res); - } + $event->addEventHandler( + new eventHandler(function ($eventArgs) { + static::assertEquals('test', $eventArgs); + return 'success'; + }) + ); - /** - * [testEventInvokeWithAllResults description] - */ - public function testEventInvokeWithAllResults(): void { - $event = new event('testevent'); - $this->assertEquals('testevent', $event->getName()); + $res = $event->invokeWithResult($this, 'test'); + static::assertEquals('success', $res); + } - $event->addEventHandler(new eventHandler(function($eventArgs) { - $this->assertEquals('test', $eventArgs); - return 'success1'; - })); + /** + * @return void + */ + public function testEventInvokeWithAllResults(): void + { + $event = new event('testevent'); + static::assertEquals('testevent', $event->getName()); - $event->addEventHandler(new eventHandler(function($eventArgs) { - $this->assertEquals('test', $eventArgs); - return 'success2'; - })); + $event->addEventHandler( + new eventHandler(function ($eventArgs) { + static::assertEquals('test', $eventArgs); + return 'success1'; + }) + ); - $res = $event->invokeWithAllResults($this, 'test'); - $this->assertEquals(['success1', 'success2'], $res); - } + $event->addEventHandler( + new eventHandler(function ($eventArgs) { + static::assertEquals('test', $eventArgs); + return 'success2'; + }) + ); + $res = $event->invokeWithAllResults($this, 'test'); + static::assertEquals(['success1', 'success2'], $res); + } } diff --git a/tests/extension/exampleapp/config/app.json b/tests/extension/exampleapp/config/app.json index 553c997..4a0523d 100644 --- a/tests/extension/exampleapp/config/app.json +++ b/tests/extension/exampleapp/config/app.json @@ -1,5 +1,5 @@ { - "extensions" : [ + "extensions": [ "codename_core_tests_extension_exampleextension" ], "defaultcontext": "testcontext", diff --git a/tests/extension/exampleextension/database/exttest.php b/tests/extension/exampleextension/database/exttest.php index 5433ba7..bcf89ba 100644 --- a/tests/extension/exampleextension/database/exttest.php +++ b/tests/extension/exampleextension/database/exttest.php @@ -1,14 +1,16 @@ appInstance->__setInstance('request', null); - // $this->appInstance->__setInstance('response', null); - // parent::tearDown(); - // $this->appInstance->reset(); - // } - - /** - * @inheritDoc - */ - protected function setUp(): void - { - // overrideableApp::__overrideJsonConfigPath('tests/lifecycle/contextTest.app.json'); - - $this->appInstance = $this->createApp(); - $this->appInstance->__setApp('exampleapp'); - $this->appInstance->__setVendor('codename'); - $this->appInstance->__setNamespace('\\codename\\core\\tests\\extension\\exampleapp'); - $this->appInstance->__setHomedir(__DIR__.'/exampleapp'); - - $this->appInstance->getAppstack(); - - static::setEnvironmentConfig([ - 'test' => [ - // 'database' => [ - // 'default' => [ - // 'driver' => 'sqlite', - // 'database_file' => ':memory:', - // ] - // ], - 'templateengine' => [ - 'default' => [ - "driver" => "dummy" - ] - ], - 'session' => [ - 'default' => [ - 'driver' => 'dummy' - ] - ], - 'cache' => [ - 'default' => [ - 'driver' => 'memory' - ] - ], - 'filesystem' =>[ - 'local' => [ - 'driver' => 'local', - ] - ], - 'log' => [ - 'default' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ], - 'debug' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] +class extensionTest extends base +{ + /** + * [protected description] + * @var \codename\core\test\overrideableApp|overrideableApp|null + */ + protected \codename\core\test\overrideableApp|null|overrideableApp $appInstance = null; + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testExtensionLoaded(): void + { + $appstack = app::getAppstack(); + static::assertEquals('exampleextension', $appstack[1]['app']); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testExtensionClientAvailable(): void + { + $class = app::getInheritedClass('database_exttest'); + $instance = new $class([]); + static::assertInstanceOf(exttest::class, $instance); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testExtensionNotLoaded(): void + { + if (!($this->appInstance instanceof \codename\core\test\overrideableApp)) { + static::fail('setup fail'); + } + // Reset app to make a sure extension is not injected + $this->appInstance::reset(); + $class = app::getInheritedClass('database_exttest'); + static::assertFalse(class_exists($class)); + } + + /** + * @return void + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws ErrorException + * @throws parseException + * @throws recoverableErrorException + * @throws ReflectionException + * @throws strictException + * @throws Throwable + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws exception + */ + public function testExtensionCouldNotBeLoaded(): void + { + if (!($this->appInstance instanceof \codename\core\test\overrideableApp)) { + static::fail('setup fail'); + } + // Reset the app to make sure we have a clean starting point + $this->appInstance::reset(); + + $this->appInstance = static::createApp(); + $this->appInstance::__setApp('nonexistingext'); + $this->appInstance::__setVendor('codename'); + $this->appInstance::__setNamespace('\\codename\\core\\tests\\extension\\nonexistingext'); + $this->appInstance::__setHomedir(__DIR__ . '/nonexistingext'); + + $this->expectExceptionMessage('CORE_APP_EXTENSION_COULD_NOT_BE_LOADED'); + $this->appInstance::getAppstack(); + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws ErrorException + * @throws Throwable + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws exception + */ + protected function setUp(): void + { + $this->appInstance = static::createApp(); + $this->appInstance::__setApp('exampleapp'); + $this->appInstance::__setVendor('codename'); + $this->appInstance::__setNamespace('\\codename\\core\\tests\\extension\\exampleapp'); + $this->appInstance::__setHomedir(__DIR__ . '/exampleapp'); + + $this->appInstance::getAppstack(); + + static::setEnvironmentConfig([ + 'test' => [ + 'templateengine' => [ + 'default' => [ + "driver" => "dummy", + ], + ], + 'session' => [ + 'default' => [ + 'driver' => 'dummy', + ], + ], + 'cache' => [ + 'default' => [ + 'driver' => 'memory', + ], + ], + 'filesystem' => [ + 'local' => [ + 'driver' => 'local', + ], + ], + 'log' => [ + 'default' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + 'debug' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + 'access' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + ], ], - 'access' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ] - ], - ] - ]); - } - - /** - * [testExtensionLoaded description] - */ - public function testExtensionLoaded(): void { - $appstack = app::getAppstack(); - $this->assertEquals('exampleextension', $appstack[1]['app']); - } - - /** - * [testExtensionClientAvailable description] - */ - public function testExtensionClientAvailable(): void { - $class = app::getInheritedClass('database_exttest'); - $instance = new $class([]); - $this->assertInstanceOf(\codename\core\tests\extension\exampleextension\database\exttest::class, $instance); - } - - /** - * [testExtensionNotLoaded description] - */ - public function testExtensionNotLoaded(): void { - // Reset app to make sure extension is not injected - $this->appInstance->reset(); - $class = app::getInheritedClass('database_exttest'); - $this->assertFalse(class_exists($class)); - } - - /** - * [testExtensionCouldNotBeLoaded description] - */ - public function testExtensionCouldNotBeLoaded(): void { - // Reset app to make sure we have a clean starting point - $this->appInstance->reset(); - - $this->appInstance = $this->createApp(); - $this->appInstance->__setApp('nonexistingext'); - $this->appInstance->__setVendor('codename'); - $this->appInstance->__setNamespace('\\codename\\core\\tests\\extension\\nonexistingext'); - $this->appInstance->__setHomedir(__DIR__.'/nonexistingext'); - - $this->expectExceptionMessage('CORE_APP_EXTENSION_COULD_NOT_BE_LOADED'); - $this->appInstance->getAppstack(); - } + ]); + } } diff --git a/tests/extension/nonexistingext/config/app.json b/tests/extension/nonexistingext/config/app.json index 2b74922..b415c29 100644 --- a/tests/extension/nonexistingext/config/app.json +++ b/tests/extension/nonexistingext/config/app.json @@ -1,5 +1,5 @@ { - "extensions" : [ + "extensions": [ "nonexisting" ], "defaultcontext": "testcontext", diff --git a/tests/helper.php b/tests/helper.php index ebbd8df..4f0bcdf 100644 --- a/tests/helper.php +++ b/tests/helper.php @@ -1,37 +1,41 @@ 0)] - * @return bool [whether waiting was successful] - */ - public static function waitForIt(string $host, int $port, int $connectionTimeout, int $waitBetweenRetries, int $tryCount): bool { - if($tryCount < 1) { - throw new \Exception('Invalid tryCount'); - } - for ($i=0; $i < $tryCount; $i++) { - try { - $ret = (@fsockopen ($host, $port, $error_code, $error_message, $connectionTimeout) !== false); - if($ret) { - return true; +class helper +{ + /** + * synchronously waits for a host to be available + * + * @param string $host [host to connect to] + * @param int $port [port to connect on] + * @param int $connectionTimeout [the connection timeout per try] + * @param int $waitBetweenRetries [wait time in seconds after a try failed] + * @param int $tryCount [overall count of tries to connect (>0)] + * @return bool [whether waiting was successful] + * @throws Exception + */ + public static function waitForIt(string $host, int $port, int $connectionTimeout, int $waitBetweenRetries, int $tryCount): bool + { + if ($tryCount < 1) { + throw new Exception('Invalid tryCount'); + } + for ($i = 0; $i < $tryCount; $i++) { + try { + $ret = (@fsockopen($host, $port, $error_code, $error_message, $connectionTimeout) !== false); + if ($ret) { + return true; + } + } catch (Exception) { + // NOTE: simply swallow exception + } + sleep($waitBetweenRetries); } - } catch (\Exception $e) { - // NOTE: simply swallow exception - } - sleep($waitBetweenRetries); + return false; } - return false; - } - } diff --git a/tests/helper/deepaccessTest.php b/tests/helper/deepaccessTest.php new file mode 100644 index 0000000..cfc1ed9 --- /dev/null +++ b/tests/helper/deepaccessTest.php @@ -0,0 +1,36 @@ + [ + 'example2' => 'example', + ], + ], $example); + + // get example data + $result = deepaccess::get($example, ['example1', 'example2']); + + static::assertEquals('example', $result); + + // get not exists key + $result = deepaccess::get($example, ['error1', 'error2']); + + static::assertNull($result); + } +} diff --git a/tests/helper/testDeepaccess.php b/tests/helper/testDeepaccess.php deleted file mode 100644 index 3c749db..0000000 --- a/tests/helper/testDeepaccess.php +++ /dev/null @@ -1,46 +0,0 @@ -expectException(\Error::class); - new deepaccess(); - } - - /** - * [testDeepaccessGet description] - * @return [type] [description] - */ - public function testDeepaccess () { - $example = []; - - // set example data - $example = deepaccess::set($example, [ 'example1', 'example2' ], 'example'); - - $this->assertEquals([ - 'example1' => [ - 'example2' => 'example' - ], - ], $example); - - // get example data - $result = deepaccess::get($example, [ 'example1', 'example2' ]); - - $this->assertEquals('example', $result); - - // get not exists key - $result = deepaccess::get($example, [ 'error1', 'error2' ]); - - $this->assertNull($result); - - } - -} diff --git a/tests/jsonModel.php b/tests/jsonModel.php index cd93b12..ec84c59 100644 --- a/tests/jsonModel.php +++ b/tests/jsonModel.php @@ -1,31 +1,37 @@ useCache(); - $modeldata['appstack'] = \codename\core\app::getAppstack(); - $value = parent::__CONSTRUCT($modeldata); - $this->config = new \codename\core\config($config); - $this->setConfig($file, $prefix, $name); - return $value; - } +class jsonModel extends json +{ + /** + * {@inheritDoc} + */ + public function __construct(string $file, string $prefix, string $name, array $config) + { + $this->useCache(); + $modeldata['appstack'] = app::getAppstack(); + $value = parent::__construct($modeldata); + $this->config = new config($config); + $this->setConfig($file, $prefix, $name); + return $value; + } - /** - * @inheritDoc - */ - protected function loadConfig(): \codename\core\config - { - // has to be pre-set above - return $this->config; - } + /** + * {@inheritDoc} + */ + protected function loadConfig(): config + { + // has to be pre-set above + return $this->config; + } } diff --git a/tests/lifecycle/appGetModelTest.php b/tests/lifecycle/appGetModelTest.php index 05a3c2b..a1e02ea 100644 --- a/tests/lifecycle/appGetModelTest.php +++ b/tests/lifecycle/appGetModelTest.php @@ -1,125 +1,156 @@ __setApp('lifecycletest'); - $app->__setVendor('codename'); - $app->__setNamespace('\\codename\\core\\tests\\lifecycle'); - - $app->getAppstack(); - - // avoid re-init - if(static::$initialized) { - return; + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testAppGetModel(): void + { + $sampleModel = app::getModel('sample'); + static::assertEquals([ + 'sample_id', + 'sample_created', + 'sample_modified', + 'sample_text', + ], $sampleModel->getFields()); } - static::$initialized = true; + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testAppGetModelAgain(): void + { + $sampleModel = app::getModel('sample'); + static::assertEquals([ + 'sample_id', + 'sample_created', + 'sample_modified', + 'sample_text', + ], $sampleModel->getFields()); + } - static::setEnvironmentConfig([ - 'test' => [ - 'database' => [ - // NOTE: by default, we do these tests using - // pure in-memory sqlite. - 'default' => [ - 'driver' => 'sqlite', - 'database_file' => ':memory:', + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws ErrorException + * @throws Throwable + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws exception + */ + protected function setUp(): void + { + $app = static::createApp(); + + // Additional overrides to get a more complete app lifecycle + // and allow static global app::getModel() to work correctly + $app::__setApp('lifecycletest'); + $app::__setVendor('codename'); + $app::__setNamespace('\\codename\\core\\tests\\lifecycle'); + + $app::getAppstack(); + + // avoid re-init + if (static::$initialized) { + return; + } + + static::$initialized = true; + + static::setEnvironmentConfig([ + 'test' => [ + 'database' => [ + // NOTE: by default, we do these tests using + // pure in-memory sqlite. + 'default' => [ + 'driver' => 'sqlite', + 'database_file' => ':memory:', + ], + ], + 'cache' => [ + 'default' => [ + 'driver' => 'memory', + ], + ], + 'filesystem' => [ + 'local' => [ + 'driver' => 'local', + ], + ], + 'log' => [ + 'default' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + ], ], - ], - 'cache' => [ - 'default' => [ - 'driver' => 'memory' - ] - ], - 'filesystem' =>[ - 'local' => [ - 'driver' => 'local', - ] - ], - 'log' => [ - 'default' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ] - ], - ] - ]); - - static::createModel('lifecycle', 'sample', [ - 'field' => [ - 'sample_id', - 'sample_created', - 'sample_modified', - 'sample_text', - ], - 'primary' => [ - 'sample_id' - ], - 'datatype' => [ - 'sample_id' => 'number_natural', - 'sample_created' => 'text_timestamp', - 'sample_modified' => 'text_timestamp', - 'sample_text' => 'text', - ], - 'connection' => 'default' - ]); - - static::architect('lifecycletest', 'codename', 'test'); - - } - - /** - * [testAppGetModel description] - */ - public function testAppGetModel(): void { - $sampleModel = \codename\core\app::getModel('sample'); - $this->assertEquals([ - 'sample_id', - 'sample_created', - 'sample_modified', - 'sample_text', - ], $sampleModel->getFields()); - } - - /** - * [testAppGetModelAgain description] - */ - public function testAppGetModelAgain(): void { - $sampleModel = \codename\core\app::getModel('sample'); - $this->assertEquals([ - 'sample_id', - 'sample_created', - 'sample_modified', - 'sample_text', - ], $sampleModel->getFields()); - } + ]); + + static::createModel('lifecycle', 'sample', [ + 'field' => [ + 'sample_id', + 'sample_created', + 'sample_modified', + 'sample_text', + ], + 'primary' => [ + 'sample_id', + ], + 'datatype' => [ + 'sample_id' => 'number_natural', + 'sample_created' => 'text_timestamp', + 'sample_modified' => 'text_timestamp', + 'sample_text' => 'text', + ], + 'connection' => 'default', + ]); + static::architect('lifecycletest', 'codename', 'test'); + } } diff --git a/tests/lifecycle/contextTest.app.json b/tests/lifecycle/contextTest.app.json index 7738a2c..b6e5cc6 100644 --- a/tests/lifecycle/contextTest.app.json +++ b/tests/lifecycle/contextTest.app.json @@ -1,14 +1,18 @@ { - "extensions" : [ ], + "extensions": [], "defaultcontext": "testcontext", - "x-defaulttemplateengine" : "twig_default", + "x-defaulttemplateengine": "twig_default", "defaulttemplate": "blank", "context": { "testcontext": { "defaultview": "default", "view": { - "default": { "public": true }, - "nonexisting_function": { "public": true } + "default": { + "public": true + }, + "nonexisting_function": { + "public": true + } } }, "disallowedcontext": { diff --git a/tests/lifecycle/contextTest.php b/tests/lifecycle/contextTest.php index ed9f7f0..7a67d10 100644 --- a/tests/lifecycle/contextTest.php +++ b/tests/lifecycle/contextTest.php @@ -1,282 +1,371 @@ appInstance->__setInstance('request', null); - $this->appInstance->__setInstance('response', null); - parent::tearDown(); - $this->appInstance->reset(); - } - - /** - * @inheritDoc - */ - protected function setUp(): void - { - overrideableApp::__overrideJsonConfigPath('tests/lifecycle/contextTest.app.json'); - - $this->appInstance = $this->createApp(); - - // $this->appInstance->__setApp(''); - $this->appInstance->getAppstack(); - - static::setEnvironmentConfig([ - 'test' => [ - // 'database' => [ - // 'default' => [ - // 'driver' => 'sqlite', - // 'database_file' => ':memory:', - // ] - // ], - 'templateengine' => [ - 'default' => [ - "driver" => "dummy" - ] - ], - 'session' => [ - 'default' => [ - 'driver' => 'dummy' - ] - ], - 'cache' => [ - 'default' => [ - 'driver' => 'memory' - ] - ], - 'filesystem' =>[ - 'local' => [ - 'driver' => 'local', - ] - ], - 'log' => [ - 'default' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ], - 'debug' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] +class contextTest extends base +{ + /** + * [protected description] + * @var \codename\core\test\overrideableApp|overrideableApp|null + */ + protected \codename\core\test\overrideableApp|null|overrideableApp $appInstance = null; + + /** + * Tests a simple runtime cycle - from init/start to end. + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testRuntimeCycle(): void + { + if (!($this->appInstance instanceof \codename\core\test\overrideableApp)) { + static::fail('setup fail'); + } + + $this->appInstance::getRequest()->setData('template', ''); + $this->appInstance::getRequest(); + $this->appInstance::getResponse()->getData(); + + // Response instance is not stored in $_REQUEST, but instead bootstrap::$instances + // $_REQUEST['response'] = new cliExitPreventResponse(); + $this->appInstance::__setInstance('response', new cliExitPreventResponse()); + + $contextInstance = new testcontext(); + $this->appInstance::__injectContextInstance('testcontext', $contextInstance); + $this->appInstance::__injectClientInstance('templateengine', 'default', new dummyTemplateengine()); + $this->appInstance::getRequest()->setData('context', 'testcontext'); + + // Make sure we have our custom response instance + // to prevent some side effects (e.g., exiting prematurely) + static::assertInstanceOf(cliExitPreventResponse::class, $this->appInstance::getResponse()); + + // Add a callback into app run end hook/event + $appRunEnd = false; + $this->appInstance::getHook()->add(hook::EVENT_APP_RUN_END, function () use (&$appRunEnd) { + $appRunEnd = true; + }); + + $this->appInstance->run(); + + // Check if callback (see above) had been called successfully + static::assertTrue($appRunEnd); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testContextIsAllowedReturnsFalseAndAppRunForbidden(): void + { + if (!($this->appInstance instanceof \codename\core\test\overrideableApp)) { + static::fail('setup fail'); + } + + $this->appInstance::getRequest()->setData('template', ''); + $this->appInstance::getRequest()->setData('context', 'disallowedcontext'); + $this->appInstance::getResponse()->getData(); + + // Response instance is not stored in $_REQUEST, but instead bootstrap::$instances + // $_REQUEST['response'] = new cliExitPreventResponse($data); + $this->appInstance::__setInstance('response', new cliExitPreventResponse()); + + $contextInstance = new disallowedcontext(); + $this->appInstance::__injectContextInstance('disallowedcontext', $contextInstance); + $this->appInstance::__injectClientInstance('templateengine', 'default', new dummyTemplateengine()); + + // Add a callback into app run end hook/event + $appRunForbidden = null; + $this->appInstance::getHook()->add(hook::EVENT_APP_RUN_FORBIDDEN, function () use (&$appRunForbidden) { + $appRunForbidden = true; + }); + + $this->appInstance->run(); + + // Check if callback (see above) had been called successfully + static::assertTrue($appRunForbidden); + } + + /** + * @return void + * @throws Exception + */ + public function testAppNonexistingContext(): void + { + if (!($this->appInstance instanceof \codename\core\test\overrideableApp)) { + static::fail('setup fail'); + } + $this->expectExceptionMessage(app::EXCEPTION_MAKEREQUEST_CONTEXT_CONFIGURATION_MISSING); + + $this->appInstance::getRequest()->setData('template', ''); + $this->appInstance::getRequest()->setData('context', 'nonexisting'); + + $this->appInstance::__injectClientInstance('templateengine', 'default', new dummyTemplateengine()); + $this->appInstance::__setInstance('response', new cliThrowExceptionResponse()); + $this->appInstance->run(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAppNonexistingView(): void + { + if (!($this->appInstance instanceof \codename\core\test\overrideableApp)) { + static::fail('setup fail'); + } + $this->expectExceptionMessage(app::EXCEPTION_MAKEREQUEST_REQUESTEDVIEWNOTINCONTEXT); + + $this->appInstance::getRequest()->setData('template', ''); + $this->appInstance::getRequest()->setData('context', 'testcontext'); + $this->appInstance::getRequest()->setData('view', 'nonexisting'); // Nonexisting view + + $this->appInstance::__injectClientInstance('templateengine', 'default', new dummyTemplateengine()); + $this->appInstance::__injectContextInstance('testcontext', new testcontext()); + $this->appInstance::__setInstance('response', new cliThrowExceptionResponse()); + $this->appInstance->run(); + } + + /** + * Tests case when the view function is defined in app.json + * but unavailable in class + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAppNonexistingViewFunction(): void + { + if (!($this->appInstance instanceof \codename\core\test\overrideableApp)) { + static::fail('setup fail'); + } + $this->expectExceptionMessage(app::EXCEPTION_DOVIEW_VIEWFUNCTIONNOTFOUNDINCONTEXT); + + $this->appInstance::getRequest()->setData('template', ''); + $this->appInstance::getRequest()->setData('context', 'testcontext'); + $this->appInstance::getRequest()->setData('view', 'nonexisting_function'); // Nonexisting view function + + $this->appInstance::__injectClientInstance('templateengine', 'default', new dummyTemplateengine()); + $this->appInstance::__injectContextInstance('testcontext', new testcontext()); + $this->appInstance::__setInstance('response', new cliThrowExceptionResponse()); + + $this->appInstance->run(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testViewLevelTemplate(): void + { + if (!($this->appInstance instanceof \codename\core\test\overrideableApp)) { + static::fail('setup fail'); + } + $this->appInstance::getRequest()->setData('context', 'templatelevel'); + $this->appInstance::getRequest()->setData('view', 'viewlevel_template'); + $this->appInstance::__injectContextInstance('templatelevel', new testcontext()); + $this->appInstance::__setInstance('response', new cliThrowExceptionResponse()); + + $this->appInstance->run(); + static::assertEquals('viewlevel', $this->appInstance::getRequest()->getData('template')); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testContextLevelTemplate(): void + { + if (!($this->appInstance instanceof \codename\core\test\overrideableApp)) { + static::fail('setup fail'); + } + $this->appInstance::getRequest()->setData('context', 'templatelevel'); + $this->appInstance::getRequest()->setData('view', 'contextlevel_template'); + $this->appInstance::__injectContextInstance('templatelevel', new testcontext()); + $this->appInstance::__setInstance('response', new cliThrowExceptionResponse()); + + $this->appInstance->run(); + static::assertEquals('contextlevel', $this->appInstance::getRequest()->getData('template')); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAppLevelTemplate(): void + { + if (!($this->appInstance instanceof \codename\core\test\overrideableApp)) { + static::fail('setup fail'); + } + $this->appInstance::getRequest()->setData('context', 'templatefallback'); + $this->appInstance::getRequest()->setData('view', 'default'); + $this->appInstance::__injectContextInstance('templatefallback', new testcontext()); + $this->appInstance::__setInstance('response', new cliThrowExceptionResponse()); + + $this->appInstance->run(); + static::assertEquals('blank', $this->appInstance::getRequest()->getData('template')); + } + + /** + * {@inheritDoc} + */ + protected function tearDown(): void + { + if (!($this->appInstance instanceof \codename\core\test\overrideableApp)) { + static::fail('setup fail'); + } + $this->appInstance::__setInstance('request', null); + $this->appInstance::__setInstance('response', null); + parent::tearDown(); + $this->appInstance::reset(); + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws ErrorException + * @throws Throwable + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws \codename\core\exception + */ + protected function setUp(): void + { + overrideableApp::__overrideJsonConfigPath('tests/lifecycle/contextTest.app.json'); + + $this->appInstance = static::createApp(); + + // $this->appInstance->__setApp(''); + $this->appInstance::getAppstack(); + + static::setEnvironmentConfig([ + 'test' => [ + 'templateengine' => [ + 'default' => [ + "driver" => "dummy", + ], + ], + 'session' => [ + 'default' => [ + 'driver' => 'dummy', + ], + ], + 'cache' => [ + 'default' => [ + 'driver' => 'memory', + ], + ], + 'filesystem' => [ + 'local' => [ + 'driver' => 'local', + ], + ], + 'log' => [ + 'default' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + 'debug' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + 'access' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + ], ], - 'access' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ] - ], - ] - ]); - } - - /** - * Tests a simple runtime cycle - from init/start to end. - * @return void - */ - public function testRuntimeCycle(): void { - $this->appInstance->getRequest()->setData('template', ''); - $this->appInstance->getRequest(); - $data = $this->appInstance->getResponse()->getData(); - - // Response instance is not stored in $_REQUEST, but instead bootstrap::$instances - // $_REQUEST['response'] = new cliExitPreventResponse($data); - $this->appInstance->__setInstance('response', new cliExitPreventResponse($data)); - - $contextInstance = new testcontext(); - $this->appInstance->__injectContextInstance('testcontext', $contextInstance); - $this->appInstance->__injectClientInstance('templateengine', 'default', new dummyTemplateengine); - $this->appInstance->getRequest()->setData('context', 'testcontext'); - - // Make sure we have our custom response instance - // to prevent some side-effects (e.g. exiting prematurely) - $this->assertInstanceOf(cliExitPreventResponse::class, $this->appInstance->getResponse()); - - // Add a callback into app run end hook/event - $appRunEnd = false; - $this->appInstance->getHook()->add(\codename\core\hook::EVENT_APP_RUN_END, function() use (&$appRunEnd) { - $appRunEnd = true; - }); - - $this->appInstance->run(); - - // Check if callback (see above) had been called successfully - $this->assertTrue($appRunEnd); - } - - /** - * [testContextIsAllowedReturnsFalseAndAppRunForbidden description] - */ - public function testContextIsAllowedReturnsFalseAndAppRunForbidden(): void { - $this->appInstance->getRequest()->setData('template', ''); - $this->appInstance->getRequest()->setData('context', 'disallowedcontext'); - $data = $this->appInstance->getResponse()->getData(); - - // Response instance is not stored in $_REQUEST, but instead bootstrap::$instances - // $_REQUEST['response'] = new cliExitPreventResponse($data); - $this->appInstance->__setInstance('response', new cliExitPreventResponse($data)); - - $contextInstance = new disallowedcontext(); - $this->appInstance->__injectContextInstance('disallowedcontext', $contextInstance); - $this->appInstance->__injectClientInstance('templateengine', 'default', new dummyTemplateengine); - - // Add a callback into app run end hook/event - $appRunForbidden = null; - $this->appInstance->getHook()->add(\codename\core\hook::EVENT_APP_RUN_FORBIDDEN, function() use (&$appRunForbidden) { - $appRunForbidden = true; - }); - - $this->appInstance->run(); - - // Check if callback (see above) had been called successfully - $this->assertTrue($appRunForbidden); - } - - /** - * Tests accessing an undefined context - */ - public function testAppNonexistingContext(): void { - $this->expectExceptionMessage(\codename\core\app::EXCEPTION_MAKEREQUEST_CONTEXT_CONFIGURATION_MISSING); - - $this->appInstance->getRequest()->setData('template', ''); - $this->appInstance->getRequest()->setData('context', 'nonexisting'); - - $this->appInstance->__injectClientInstance('templateengine', 'default', new dummyTemplateengine); - $this->appInstance->__setInstance('response', new cliThrowExceptionResponse); - $this->appInstance->run(); - } - - /** - * Tests accessing an undefined view - */ - public function testAppNonexistingView(): void { - $this->expectExceptionMessage(\codename\core\app::EXCEPTION_MAKEREQUEST_REQUESTEDVIEWNOTINCONTEXT); - - $this->appInstance->getRequest()->setData('template', ''); - $this->appInstance->getRequest()->setData('context', 'testcontext'); - $this->appInstance->getRequest()->setData('view', 'nonexisting'); // Nonexisting view - - $this->appInstance->__injectClientInstance('templateengine', 'default', new dummyTemplateengine); - $this->appInstance->__injectContextInstance('testcontext', new testcontext); - $this->appInstance->__setInstance('response', new cliThrowExceptionResponse); - $this->appInstance->run(); - } - - /** - * Tests case when the view function is defined in app.json - * but unavailable in class - */ - public function testAppNonexistingViewFunction(): void { - $this->expectExceptionMessage(\codename\core\app::EXCEPTION_DOVIEW_VIEWFUNCTIONNOTFOUNDINCONTEXT); - - $this->appInstance->getRequest()->setData('template', ''); - $this->appInstance->getRequest()->setData('context', 'testcontext'); - $this->appInstance->getRequest()->setData('view', 'nonexisting_function'); // Nonexisting view function - - $this->appInstance->__injectClientInstance('templateengine', 'default', new dummyTemplateengine); - $this->appInstance->__injectContextInstance('testcontext', new testcontext); - $this->appInstance->__setInstance('response', new cliThrowExceptionResponse); - - $this->appInstance->run(); - } - - /** - * [testViewLevelTemplate description] - */ - public function testViewLevelTemplate(): void { - $this->appInstance->getRequest()->setData('context', 'templatelevel'); - $this->appInstance->getRequest()->setData('view', 'viewlevel_template'); - $this->appInstance->__injectContextInstance('templatelevel', new testcontext); - $this->appInstance->__setInstance('response', new cliThrowExceptionResponse); - - $this->appInstance->run(); - $this->assertEquals('viewlevel', $this->appInstance->getRequest()->getData('template')); - } - - /** - * [testContextLevelTemplate description] - */ - public function testContextLevelTemplate(): void { - $this->appInstance->getRequest()->setData('context', 'templatelevel'); - $this->appInstance->getRequest()->setData('view', 'contextlevel_template'); - $this->appInstance->__injectContextInstance('templatelevel', new testcontext); - $this->appInstance->__setInstance('response', new cliThrowExceptionResponse); - - $this->appInstance->run(); - $this->assertEquals('contextlevel', $this->appInstance->getRequest()->getData('template')); - } - - /** - * [testAppLevelTemplate description] - */ - public function testAppLevelTemplate(): void { - $this->appInstance->getRequest()->setData('context', 'templatefallback'); - $this->appInstance->getRequest()->setData('view', 'default'); - $this->appInstance->__injectContextInstance('templatefallback', new testcontext); - $this->appInstance->__setInstance('response', new cliThrowExceptionResponse); - - $this->appInstance->run(); - $this->assertEquals('blank', $this->appInstance->getRequest()->getData('template')); - } + ]); + } } /** * helper class for really throw the exception * that is usually displayed */ -class cliThrowExceptionResponse extends cliExitPreventResponse { - /** - * @inheritDoc - */ - public function displayException(\Exception $e) - { - throw $e; - } +class cliThrowExceptionResponse extends cliExitPreventResponse +{ + /** + * {@inheritDoc} + * @param Exception $e + * @throws Exception + */ + public function displayException(Exception $e): void + { + throw $e; + } } /** * dummy context, bare minimum */ -class testcontext extends \codename\core\context { - public function view_default() {} +class testcontext extends context +{ + /** + * @return void + */ + public function view_default(): void + { + } } /** * a context class that simply should not be accessible */ -class disallowedcontext extends \codename\core\context { - /** - * @inheritDoc - */ - public function isAllowed(): bool - { - return false; - } - - public function view_default() {} +class disallowedcontext extends context +{ + /** + * {@inheritDoc} + */ + public function isAllowed(): bool + { + return false; + } + + /** + * @return void + */ + public function view_default(): void + { + } } diff --git a/tests/lifecycle/model/sample.php b/tests/lifecycle/model/sample.php index 7fe7ac8..72c5e61 100644 --- a/tests/lifecycle/model/sample.php +++ b/tests/lifecycle/model/sample.php @@ -1,33 +1,42 @@ [ - 'sample_id', - 'sample_created', - 'sample_modified', - 'sample_text', - ], - 'primary' => [ - 'sample_id' - ], - 'datatype' => [ - 'sample_id' => 'number_natural', - 'sample_created' => 'text_timestamp', - 'sample_modified' => 'text_timestamp', - 'sample_text' => 'text', - ], - 'connection' => 'default' - ]); - } +class sample extends sqlModel +{ + /** + * {@inheritDoc} + * @param array $modeldata + * @throws ReflectionException + * @throws exception + */ + public function __construct(array $modeldata = []) + { + parent::__construct('lifecycle', 'sample', [ + 'field' => [ + 'sample_id', + 'sample_created', + 'sample_modified', + 'sample_text', + ], + 'primary' => [ + 'sample_id', + ], + 'datatype' => [ + 'sample_id' => 'number_natural', + 'sample_created' => 'text_timestamp', + 'sample_modified' => 'text_timestamp', + 'sample_text' => 'text', + ], + 'connection' => 'default', + ]); + } } diff --git a/tests/mail/PHPMailerTest.php b/tests/mail/PHPMailerTest.php index 23a38d5..3db9dab 100644 --- a/tests/mail/PHPMailerTest.php +++ b/tests/mail/PHPMailerTest.php @@ -1,256 +1,252 @@ 'unittest-smtp', - 'port' => 1025, - 'user' => 'someuser', - 'pass' => 'somepass', - 'secure' => null, - 'auth' => true, // ?? - ]; + // wait for smtp to come up + if (!helper::waitForIt('unittest-smtp', 1025, 3, 3, 5)) { + throw new Exception('Failed to connect to smtp server'); + } } - return new \codename\core\mail\PHPMailer($config); - } - - /** - * [getMailhogClient description] - * @param array|null $config [description] - * @return \GuzzleHttp\Client [description] - */ - protected function getMailhogClient(?array $config = null): \GuzzleHttp\Client { - $client = new \GuzzleHttp\Client([ - 'base_uri' => 'http://unittest-smtp:8025/api/v1/', - ]); - return $client; - } - - /** - * [getMailhogV2Client description] - * @param array|null $config [description] - * @return \GuzzleHttp\Client [description] - */ - protected function getMailhogV2Client(?array $config = null): \GuzzleHttp\Client { - $client = new \GuzzleHttp\Client([ - 'base_uri' => 'http://unittest-smtp:8025/api/v2/', - ]); - return $client; - } - - /** - * @inheritDoc - */ - protected function setChaosMonkey(bool $state, array $options = []) - { - $client = $this->getMailhogV2Client(); - $currentState = null; - - // get current state - try { - $response = $client->get('jim'); - $currentState = true; - // echo("Jim state:"); - // print_r(json_decode($response->getBody()->getContents(), true)); - } catch (\GuzzleHttp\Exception\ClientException $e) { - if($e->getResponse()->getStatusCode() === 404) { - // jim not enabled (yet) - $currentState = false; - } + /** + * @return void + */ + public function testSendMail(): void + { + parent::testSendMail(); } - if($state) { - - // - // TODO: random == full randomization? or just some defaults? - // - $params = [ - 'AcceptChance' => in_array(static::CHAOSMONKEY_REJECT_CONNECTION, $options) ? 0.0 : 1.0, - 'DisconnectChance' => in_array(static::CHAOSMONKEY_DISCONNECT, $options) ? 1.0 : 0.0, - 'RejectSenderChance' => in_array(static::CHAOSMONKEY_REJECT_MAIL_FROM, $options) ? 1.0 : 0.0, - 'RejectRecipientChance' => in_array(static::CHAOSMONKEY_REJECT_RCPT_TO, $options) ? 1.0 : 0.0, - 'RejectAuthChance' => in_array(static::CHAOSMONKEY_REJECT_AUTH, $options) ? 1.0 : 0.0, - ]; - - // echo("new params: " ); - // print_r($params); - // $params = array_filter($params); - - if(!$currentState) { - // enable jim - $res = $client->post('jim', [ - \GuzzleHttp\RequestOptions::JSON => $params, - ]); - // echo("Jim created:"); - // print_r($res->getBody()->getContents()); - } else { - // enable jim - $res = $client->put('jim', [ - \GuzzleHttp\RequestOptions::JSON => $params, - ]); - // echo("Jim updated:"); - // print_r($res->getBody()->getContents()); - } - - $response = $client->get('jim'); - $currentState = true; - // echo("Jim NEW state:"); - // print_r(json_decode($response->getBody()->getContents(), true)); - - - } else { - // disable jim - if($currentState) { - $res = $client->delete('jim'); - // echo("jim removed."); - } - // try { - // } catch (\GuzzleHttp\Exception\ClientException $e) { - // if($e->getResponse()->getStatusCode() === 404) { - // // successful deletion - // } - // } - - try { - $response = $client->get('jim'); - // $currentState = true; - // echo("jim should be inactive but is not"); - // print_r(json_decode($response->getBody()->getContents(), true)); - } catch (\Exception $e) { + /** + * {@inheritDoc} + */ + public function getMail(?array $config = null): mail + { + if ($config === null) { + // + // default config + // + $config = [ + 'host' => 'unittest-smtp', + 'port' => 1025, + 'user' => 'someuser', + 'pass' => 'somepass', + 'secure' => null, + 'auth' => true, // ?? + ]; + } - } - return; + return new PHPMailer($config); } - } - /** - * [setUpBeforeClass description] - */ - public static function setUpBeforeClass(): void - { - parent::setUpBeforeClass(); + /** + * {@inheritDoc} + */ + public function testRejectConnection(): void + { + $this->expectException(\PHPMailer\PHPMailer\Exception::class); + $this->expectExceptionMessageMatches('/SMTP Error: Could not connect to SMTP hos.|SMTP connect\(\) failed\./'); + parent::testRejectConnection(); + } - // Preliminary check, if DNS is not available - // we simply assume there's no host for testing, skip. - if(!gethostbynamel('unittest-smtp')) { - static::markTestSkipped('SMTP server unavailable, skipping.'); - return; + /** + * {@inheritDoc} + */ + public function testRejectAuth(): void + { + $this->expectException(\PHPMailer\PHPMailer\Exception::class); + $this->expectExceptionMessageMatches('/Could not authenticate./'); + parent::testRejectAuth(); } - // wait for smtp to come up - if(!\codename\core\tests\helper::waitForIt('unittest-smtp', 1025, 3, 3, 5)) { - throw new \Exception('Failed to connect to smtp server'); + /** + * {@inheritDoc} + */ + public function testRejectSender(): void + { + $this->expectException(\PHPMailer\PHPMailer\Exception::class); + $this->expectExceptionMessageMatches('/MAIL FROM command failed/'); + parent::testRejectSender(); } - } - /** - * @inheritDoc - */ - protected function deleteMail(?array $params = null): bool - { - if(!$params) { - // delete all mails - $this->getMailhogClient()->delete('messages'); - } else { - // TODO: delete only specific? + /** + * {@inheritDoc} + */ + public function testRejectRecipient(): void + { + $this->expectException(\PHPMailer\PHPMailer\Exception::class); + $this->expectExceptionMessageMatches('/Invalid recipient/'); + parent::testRejectRecipient(); } - return true; - } - /** - * @inheritDoc - */ - protected function tryFetchMail(?array $params = null): ?array - { - $client = $this->getMailhogClient(); - $response = $client->get('messages'); + /** + * {@inheritDoc} + */ + public function testDisconnect(): void + { + $this->expectException(\PHPMailer\PHPMailer\Exception::class); + // We can't know when this is really killing it. ? + // $this->expectExceptionMessageMatches('/SMTP connect\(\) failed\./'); // ? + parent::testDisconnect(); + } - $result = json_decode($response->getBody()->getContents(), true); + /** + * {@inheritDoc} + * @param bool $state + * @param array $options + * @throws GuzzleException + */ + protected function setChaosMonkey(bool $state, array $options = []): void + { + $client = $this->getMailhogV2Client(); + $currentState = null; + + // get current state + try { + $client->get('jim'); + $currentState = true; + } catch (ClientException $e) { + if ($e->getResponse()->getStatusCode() === 404) { + // jim aren't enabled (yet) + $currentState = false; + } + } - // comment-in for DEBUG: - // print_r($result); + if ($state) { + // + // TODO: random == full randomization? or just some defaults? + // + $params = [ + 'AcceptChance' => in_array(static::CHAOSMONKEY_REJECT_CONNECTION, $options) ? 0.0 : 1.0, + 'DisconnectChance' => in_array(static::CHAOSMONKEY_DISCONNECT, $options) ? 1.0 : 0.0, + 'RejectSenderChance' => in_array(static::CHAOSMONKEY_REJECT_MAIL_FROM, $options) ? 1.0 : 0.0, + 'RejectRecipientChance' => in_array(static::CHAOSMONKEY_REJECT_RCPT_TO, $options) ? 1.0 : 0.0, + 'RejectAuthChance' => in_array(static::CHAOSMONKEY_REJECT_AUTH, $options) ? 1.0 : 0.0, + ]; + + if (!$currentState) { + // enable jim + $client->post('jim', [ + RequestOptions::JSON => $params, + ]); + } else { + // enable jim + $client->put('jim', [ + RequestOptions::JSON => $params, + ]); + } + + $client->get('jim'); + } else { + // disable jim + if ($currentState) { + $client->delete('jim'); + } + + try { + $client->get('jim'); + } catch (Exception) { + } + } + } - if($params) { - // TODO: search for a specific mail? - $result = array_values(array_filter($result, function($entry) use ($params) { + /** + * [getMailhogV2Client description] + * @param array|null $config [description] + * @return Client [description] + */ + protected function getMailhogV2Client(?array $config = null): Client + { + return new Client([ + 'base_uri' => 'http://unittest-smtp:8025/api/v2/', + ]); + } - if($v = $params['Subject']) { - if($entry['Content']['Headers']['Subject'][0] != $params['Subject']) { - return false; - } + /** + * {@inheritDoc} + * @param array|null $params + * @return bool + * @throws GuzzleException + */ + protected function deleteMail(?array $params = null): bool + { + if (!$params) { + // delete all mails + $this->getMailhogClient()->delete('messages'); + } else { + // TODO: delete only specific? } - return true; - })); } - return array_map(function($entry) { - return $entry['Content']; - }, $result); - } - - /** - * @inheritDoc - */ - public function testRejectConnection(): void - { - $this->expectException(\PHPMailer\PHPMailer\Exception::class); - $this->expectExceptionMessageMatches('/SMTP connect\(\) failed\./'); - parent::testRejectConnection(); - } - - /** - * @inheritDoc - */ - public function testRejectAuth(): void - { - $this->expectException(\PHPMailer\PHPMailer\Exception::class); - $this->expectExceptionMessageMatches('/Could not authenticate./'); - parent::testRejectAuth(); - } - - /** - * @inheritDoc - */ - public function testRejectSender(): void - { - $this->expectException(\PHPMailer\PHPMailer\Exception::class); - $this->expectExceptionMessageMatches('/MAIL FROM command failed/'); - parent::testRejectSender(); - } - - /** - * @inheritDoc - */ - public function testRejectRecipient(): void - { - $this->expectException(\PHPMailer\PHPMailer\Exception::class); - $this->expectExceptionMessageMatches('/Invalid recipient/'); - parent::testRejectRecipient(); - } + /** + * @param array|null $config + * @return Client + */ + protected function getMailhogClient(?array $config = null): Client + { + return new Client([ + 'base_uri' => 'http://unittest-smtp:8025/api/v1/', + ]); + } - /** - * @inheritDoc - */ - public function testDisconnect(): void - { - $this->expectException(\PHPMailer\PHPMailer\Exception::class); - // We can't know when this is really killing it. ? - // $this->expectExceptionMessageMatches('/SMTP connect\(\) failed\./'); // ? - parent::testDisconnect(); - } + /** + * {@inheritDoc} + * @param array|null $params + * @return array|null + * @throws GuzzleException + */ + protected function tryFetchMail(?array $params = null): ?array + { + $client = $this->getMailhogClient(); + $response = $client->get('messages'); + + $result = json_decode($response->getBody()->getContents(), true); + + // comment-in for DEBUG: + // print_r($result); + + if ($params) { + // TODO: search for a specific mail? + $result = array_values( + array_filter($result, function ($entry) use ($params) { + if ($params['Subject'] ?? false) { + if ($entry['Content']['Headers']['Subject'][0] != $params['Subject']) { + return false; + } + } + + return true; + }) + ); + } + return array_map(function ($entry) { + return $entry['Content']; + }, $result); + } } diff --git a/tests/mail/abstractMailTest.php b/tests/mail/abstractMailTest.php index ad69bfe..60634e3 100644 --- a/tests/mail/abstractMailTest.php +++ b/tests/mail/abstractMailTest.php @@ -1,216 +1,236 @@ getAppstack(); - - static::setEnvironmentConfig([ - 'test' => [ - 'filesystem' =>[ - 'local' => [ - 'driver' => 'local', - ] - ], - 'log' => [ - 'errormessage' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] +use ErrorException; +use ReflectionException; +use Throwable; + +abstract class abstractMailTest extends base +{ + /** + * random failure possible + * @var string + */ + public const string CHAOSMONKEY_RANDOM = 'random'; + /** + * you'll receive a disconnect, for sure. + * @var string + */ + public const string CHAOSMONKEY_DISCONNECT = 'disconnect'; + /** + * you'll be rejected at any times, even connecting + * @var string + */ + public const string CHAOSMONKEY_REJECT_CONNECTION = 'reject_connection'; + /** + * reject MAIL FROM + * @var string + */ + public const string CHAOSMONKEY_REJECT_MAIL_FROM = 'reject_mail_from'; + /** + * reject RCPT TO + * @var string + */ + public const string CHAOSMONKEY_REJECT_RCPT_TO = 'reject_rcpt_to'; + /** + * reject AUTH + * @var string + */ + public const string CHAOSMONKEY_REJECT_AUTH = 'reject_auth'; + + /** + * @return void + */ + protected function testSendMail(): void + { + // Make sure mailsink is empty before running this test + static::assertEmpty($this->tryFetchMail()); + + $mail = $this->getMail(); + $success = $mail + ->setFrom('me@mail.com', 'Sender Name') + ->addTo('recipient@mail.com', 'Recipient Name') + ->setSubject('Test email 1') + ->setBody('Hello!') + ->send(); + static::assertTrue($success); + + static::assertNotEmpty($this->tryFetchMail(['Subject' => 'Test email 1'])); + } + + /** + * @param array|null $params + * @return array|null + */ + abstract protected function tryFetchMail(?array $params = null): ?array; + + /** + * @param array|null $config + * @return mail + */ + abstract public function getMail(?array $config = null): mail; + + /** + * @return void + */ + protected function testRejectConnection(): void + { + $this->setChaosMonkey(true, [static::CHAOSMONKEY_REJECT_CONNECTION]); + + $mail = $this->getMail(); + $mail + ->setFrom('me@mail.com', 'Sender Name') + ->addTo('recipient@mail.com', 'Recipient Name') + ->setSubject('Test email connection rejected') + ->setBody('Hello!') + ->send(); + } + + /** + * @param bool $state + * @param array $options + * @return void + */ + abstract protected function setChaosMonkey(bool $state, array $options = []): void; + + /** + * @return void + */ + protected function testDisconnect(): void + { + $this->setChaosMonkey(true, [static::CHAOSMONKEY_DISCONNECT]); + + $mail = $this->getMail(); + $mail + ->setFrom('me@mail.com', 'Sender Name') + ->addTo('recipient@mail.com', 'Recipient Name') + ->setSubject('Test email disconnect') + ->setBody('Hello!') + ->send(); + } + + /** + * @return void + */ + protected function testRejectSender(): void + { + $this->setChaosMonkey(true, [static::CHAOSMONKEY_REJECT_MAIL_FROM]); + + $mail = $this->getMail(); + $mail + ->setFrom('me@mail.com', 'Sender Name') + ->addTo('recipient@mail.com', 'Recipient Name') + ->setSubject('Test email reject sender') + ->setBody('Hello!') + ->send(); + } + + /** + * @return void + */ + protected function testRejectRecipient(): void + { + $this->setChaosMonkey(true, [static::CHAOSMONKEY_REJECT_RCPT_TO]); + + $mail = $this->getMail(); + $mail + ->setFrom('me@mail.com', 'Sender Name') + ->addTo('recipient@mail.com', 'Recipient Name') + ->setSubject('Test email reject recipient') + ->setBody('Hello!') + ->send(); + } + + /** + * @return void + */ + protected function testRejectAuth(): void + { + $this->setChaosMonkey(true, [static::CHAOSMONKEY_REJECT_AUTH]); + + $mail = $this->getMail(); + $mail + ->setFrom('me@mail.com', 'Sender Name') + ->addTo('recipient@mail.com', 'Recipient Name') + ->setSubject('Test email reject auth') + ->setBody('Hello!') + ->send(); + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws ErrorException + * @throws Throwable + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws exception + */ + protected function setUp(): void + { + $app = static::createApp(); + $app::getAppstack(); + + static::setEnvironmentConfig([ + 'test' => [ + 'filesystem' => [ + 'local' => [ + 'driver' => 'local', + ], + ], + 'log' => [ + 'errormessage' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + 'debug' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + ], ], - 'debug' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ] - ], - ] - ]); - - $this->setChaosMonkey(false); - } - - /** - * [getMail description] - * @param array|null $config - * @return \codename\core\mail [description] - */ - public abstract function getMail(?array $config = null): \codename\core\mail; - - /** - * @inheritDoc - */ - protected function tearDown(): void - { - // delete all mails created in the test - $this->deleteMail(); - parent::tearDown(); - } - - /** - * [testSendMail description] - */ - public function testSendMail(): void { - // Make sure mailsink is empty before running this test - $this->assertEmpty($this->tryFetchMail()); - - $mail = $this->getMail(); - $success = $mail - ->setFrom('me@mail.com', 'Sender Name') - ->addTo('recipient@mail.com', 'Recipient Name') - ->setSubject('Test email 1') - ->setBody('Hello!') - ->send(); - $this->assertTrue($success); - - $this->assertNotEmpty($this->tryFetchMail(['Subject' => 'Test email 1'])); - } - - /** - * [testRejectConnection description] - */ - public function testRejectConnection(): void { - $this->setChaosMonkey(true, [ static::CHAOSMONKEY_REJECT_CONNECTION ]); - - $mail = $this->getMail(); - $success = $mail - ->setFrom('me@mail.com', 'Sender Name') - ->addTo('recipient@mail.com', 'Recipient Name') - ->setSubject('Test email connection rejected') - ->setBody('Hello!') - ->send(); - } - - /** - * [testDisconnect description] - */ - public function testDisconnect(): void { - $this->setChaosMonkey(true, [ static::CHAOSMONKEY_DISCONNECT ]); - - $mail = $this->getMail(); - $success = $mail - ->setFrom('me@mail.com', 'Sender Name') - ->addTo('recipient@mail.com', 'Recipient Name') - ->setSubject('Test email disconnect') - ->setBody('Hello!') - ->send(); - } - - /** - * [testRejectSender description] - */ - public function testRejectSender(): void { - $this->setChaosMonkey(true, [ static::CHAOSMONKEY_REJECT_MAIL_FROM ]); - - $mail = $this->getMail(); - $success = $mail - ->setFrom('me@mail.com', 'Sender Name') - ->addTo('recipient@mail.com', 'Recipient Name') - ->setSubject('Test email reject sender') - ->setBody('Hello!') - ->send(); - } - - /** - * [testRejectRecipient description] - */ - public function testRejectRecipient(): void { - $this->setChaosMonkey(true, [ static::CHAOSMONKEY_REJECT_RCPT_TO ]); - - $mail = $this->getMail(); - $success = $mail - ->setFrom('me@mail.com', 'Sender Name') - ->addTo('recipient@mail.com', 'Recipient Name') - ->setSubject('Test email reject recipient') - ->setBody('Hello!') - ->send(); - } - - /** - * [testRejectAuth description] - */ - public function testRejectAuth(): void { - $this->setChaosMonkey(true, [ static::CHAOSMONKEY_REJECT_AUTH ]); - - $mail = $this->getMail(); - $success = $mail - ->setFrom('me@mail.com', 'Sender Name') - ->addTo('recipient@mail.com', 'Recipient Name') - ->setSubject('Test email reject auth') - ->setBody('Hello!') - ->send(); - } - - /** - * [tryFetchMail description] - * @param array|null $params [description] - * @return array|null - */ - protected abstract function tryFetchMail(?array $params = null): ?array; - - /** - * [deleteMail description] - * @param array|null $params [description] - * @return bool [description] - */ - protected abstract function deleteMail(?array $params = null): bool; - - /** - * [setChaosMonkey description] - * @param bool $state [description] - * @param array $options [description] - */ - protected abstract function setChaosMonkey(bool $state, array $options = []); - - /** - * random failure possible - * @var string - */ - const CHAOSMONKEY_RANDOM = 'random'; - - /** - * you'll receive a disconnect, for sure. - * @var string - */ - const CHAOSMONKEY_DISCONNECT = 'disconnect'; - - /** - * you'll be rejected at any times, even connecting - * @var string - */ - const CHAOSMONKEY_REJECT_CONNECTION = 'reject_connection'; - - /** - * rate limiting? - * @var string - */ - // const CHAOSMONKEY_RATELIMIT = 'ratelimit'; - - /** - * reject MAIL FROM - * @var string - */ - const CHAOSMONKEY_REJECT_MAIL_FROM = 'reject_mail_from'; - - /** - * reject RCPT TO - * @var string - */ - const CHAOSMONKEY_REJECT_RCPT_TO = 'reject_rcpt_to'; - - /** - * reject AUTH - * @var string - */ - const CHAOSMONKEY_REJECT_AUTH = 'reject_auth'; + ]); + + $this->setChaosMonkey(false); + } + + /** + * {@inheritDoc} + */ + protected function tearDown(): void + { + // delete all mails created in the test + $this->deleteMail(); + parent::tearDown(); + } + + /** + * @param array|null $params + * @return bool + */ + abstract protected function deleteMail(?array $params = null): bool; } diff --git a/tests/model/abstractDynamicValueModelTest.php b/tests/model/abstractDynamicValueModelTest.php index 36cb9aa..1bde424 100644 --- a/tests/model/abstractDynamicValueModelTest.php +++ b/tests/model/abstractDynamicValueModelTest.php @@ -1,449 +1,515 @@ getModel('advm_data') - ->addFilter('advm_data_id', 0, '>') - ->delete(); - - parent::tearDown(); - } - - /** - * @inheritDoc - */ - protected function setUp(): void - { - $app = static::createApp(); - - // Additional overrides to get a more complete app lifecycle - // and allow static global app::getModel() to work correctly - $app->__setApp('advmtest'); - $app->__setVendor('codename'); - $app->__setNamespace('\\codename\\core\\tests\\model'); - - $app->getAppstack(); - - // avoid re-init - if(static::$initialized) { - return; +class abstractDynamicValueModelTest extends base +{ + /** + * @var bool + */ + protected static bool $initialized = false; + + /** + * {@inheritDoc} + */ + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + static::$initialized = false; + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function testBasicADVMEmpty(): void + { + $model = new advmTestModel($this->getModel('advm_data')); + static::assertEquals([ + [ + 'advm_test' => null, + 'some_integer' => null, + 'some_text' => null, + 'some_boolean' => null, + 'some_structure' => null, + 'some_timestamp' => null, + ], + ], $model->search()->getResult()); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function testBasicADVMSetValueTwice(): void + { + $model = new advmTestModel($this->getModel('advm_data')); + $model->save([ + 'some_integer' => 1, + ]); + $model->save([ + 'some_integer' => 2, + ]); + static::assertEquals(2, $model->load('advm_test')['some_integer']); } - static::$initialized = true; + /** + * @testWith [ "some_integer", 3 ] + * [ "some_integer", null ] + * [ "some_text", "test1" ] + * [ "some_boolean", true ] + * [ "some_boolean", false ] + * [ "some_structure", { "a": "b" } ] + * [ "some_structure", null ] + * [ "some_timestamp", "2021-11-11" ] + * [ "some_timestamp", "2021-11-11 12:34:56" ] + * @param string $field [description] + * @param mixed $value [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function testBasicADVMSetValue(string $field, mixed $value): void + { + $model = new advmTestModel($this->getModel('advm_data')); + $dataset = [ + $field => $value, + ]; + static::assertTrue($model->isValid($dataset)); + $model->save($dataset); + static::assertEquals($value, $model->load('advm_test')[$field]); + } + + /** + * @testWith [ "some_integer", "abc" ] + * [ "some_integer", false ] + * [ "some_text", false ] + * [ "some_boolean", "abc" ] + * [ "some_boolean", 123 ] + * [ "some_timestamp", "abc" ] + * [ "some_timestamp", 123 ] + * [ "some_timestamp", true ] + * @param string $field [description] + * @param mixed $value [description] + * @throws ReflectionException + * @throws exception + */ + public function testBasicADVMInvalidValue(string $field, mixed $value): void + { + $model = new advmTestModel($this->getModel('advm_data')); + $dataset = [ + $field => $value, + ]; + static::assertFalse($model->isValid($dataset)); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function testBasicADVMFieldlist(): void + { + $model = new advmTestModel($this->getModel('advm_data')); + $model->hideAllFields(); + $model->addField('some_integer'); + static::assertEquals([ + [ + 'some_integer' => null, + ], + ], $model->search()->getResult()); + } - static::setEnvironmentConfig([ - 'test' => [ - 'database' => [ - // NOTE: by default, we do these tests using - // pure in-memory sqlite. - 'default' => [ - 'driver' => 'sqlite', - 'database_file' => ':memory:', + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function testAdvancedADVMEmpty(): void + { + $model = new advmDomainTestModel($this->getModel('advm_domain_data')); + static::assertEquals([ + [ + 'advm_domain_test' => null, + 'some_integer' => null, + 'some_text' => null, + 'some_boolean' => null, + 'some_structure' => null, + 'some_timestamp' => null, + ], + ], $model->search()->getResult()); + // + $model->addFilter('advm_domain_test', 1); + + static::assertEquals([ + [ + 'advm_domain_test' => 1, + 'some_integer' => null, + 'some_text' => null, + 'some_boolean' => null, + 'some_structure' => null, + 'some_timestamp' => null, + ], + ], $model->search()->getResult()); + } + + /** + * @return void + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception + */ + public function testAdvancedADVMSave(): void + { + $model = new advmDomainTestModel($this->getModel('advm_domain_data')); + $model->save([ + 'advm_domain_test' => 1, + 'some_integer' => 123, + ]); + $model->addFilter('advm_domain_test', 1); + static::assertEquals([ + [ + 'advm_domain_test' => 1, + 'some_integer' => 123, + 'some_text' => null, + 'some_boolean' => null, + 'some_structure' => null, + 'some_timestamp' => null, + ], + ], $model->search()->getResult()); + + $model->addFilter('advm_domain_test', 2); + static::assertEquals([ + [ + 'advm_domain_test' => 2, + 'some_integer' => null, + 'some_text' => null, + 'some_boolean' => null, + 'some_structure' => null, + 'some_timestamp' => null, + ], + ], $model->search()->getResult()); + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws exception + */ + protected function tearDown(): void + { + // clean up the data model + $this->getModel('advm_data') + ->addFilter('advm_data_id', 0, '>') + ->delete(); + + parent::tearDown(); + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws ErrorException + * @throws Throwable + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws exception + */ + protected function setUp(): void + { + $app = static::createApp(); + + // Additional overrides to get a more complete app lifecycle + // and allow static global app::getModel() to work correctly + $app::__setApp('advmtest'); + $app::__setVendor('codename'); + $app::__setNamespace('\\codename\\core\\tests\\model'); + + $app::getAppstack(); + + // avoid re-init + if (static::$initialized) { + return; + } + + static::$initialized = true; + + static::setEnvironmentConfig([ + 'test' => [ + 'database' => [ + // NOTE: by default, we do these tests using + // pure in-memory sqlite. + 'default' => [ + 'driver' => 'sqlite', + 'database_file' => ':memory:', + ], + ], + 'cache' => [ + 'default' => [ + 'driver' => 'memory', + ], + ], + 'filesystem' => [ + 'local' => [ + 'driver' => 'local', + ], + ], + 'log' => [ + 'default' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + ], + ], + ]); + + // + // Base/data model for an abstractDynamicValueModel + // + static::createModel('testschema', 'advm_data', [ + 'field' => [ + 'advm_data_id', + 'advm_data_created', + 'advm_data_modified', + 'advm_data_name', + 'advm_data_datatype', + 'advm_data_value', ], - ], - 'cache' => [ - 'default' => [ - 'driver' => 'memory' - ] - ], - 'filesystem' =>[ - 'local' => [ - 'driver' => 'local', - ] - ], - 'log' => [ - 'default' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ] - ], - ] - ]); - - // - // Base/data model for an abstractDynamicValueModel - // - static::createModel('testschema', 'advm_data', [ - 'field' => [ - 'advm_data_id', - 'advm_data_created', - 'advm_data_modified', - 'advm_data_name', - 'advm_data_datatype', - 'advm_data_value', - ], - 'primary' => [ - 'advm_data_id' - ], - 'unique' => [ - 'advm_data_name' - ], - 'required' => [ - 'advm_data_name', - 'advm_data_datatype' - ], - 'options' => [ - 'advm_data_name' => [ - 'length' => 128 - ], - ], - 'datatype' => [ - 'advm_data_id' => 'number_natural', - 'advm_data_created' => 'text_timestamp', - 'advm_data_modified' => 'text_timestamp', - 'advm_data_name' => 'text', - 'advm_data_datatype' => 'text', - 'advm_data_value' => 'text', - ], - 'connection' => 'default' - ]); - - // - // Base/data model for an abstractDynamicValueModel - // - static::createModel('testschema', 'advm_domain_data', [ - 'field' => [ - 'advm_domain_data_id', - 'advm_domain_data_created', - 'advm_domain_data_modified', - 'advm_domain_data_domain_id', - 'advm_domain_data_name', - 'advm_domain_data_datatype', - 'advm_domain_data_value', - ], - 'primary' => [ - 'advm_domain_data_id' - ], - 'unique' => [ - [ 'advm_domain_data_domain_id', 'advm_domain_data_name'] - ], - 'required' => [ - 'advm_domain_data_name', - 'advm_domain_data_datatype' - ], - 'options' => [ - 'advm_domain_data_name' => [ - 'length' => 128 - ], - ], - 'datatype' => [ - 'advm_domain_data_id' => 'number_natural', - 'advm_domain_data_created' => 'text_timestamp', - 'advm_domain_data_modified' => 'text_timestamp', - 'advm_domain_data_domain_id' => 'number_natural', - 'advm_domain_data_name' => 'text', - 'advm_domain_data_datatype' => 'text', - 'advm_domain_data_value' => 'text', - ], - 'connection' => 'default' - ]); - - static::architect('advmtest', 'codename', 'test'); - } - - /** - * [testBasicADVMEmpty description] - */ - public function testBasicADVMEmpty(): void { - $model = new advmTestModel($this->getModel('advm_data')); - $this->assertEquals([ - [ - 'advm_test' => null, - 'some_integer' => null, - 'some_text' => null, - 'some_boolean' => null, - 'some_structure' => null, - 'some_timestamp' => null, - ] - ], $model->search()->getResult()); - } - - /** - * [testBasicADVMSetValueTwice description] - */ - public function testBasicADVMSetValueTwice(): void { - $model = new advmTestModel($this->getModel('advm_data')); - $model->save([ - 'some_integer' => 1 - ]); - $model->save([ - 'some_integer' => 2 - ]); - $this->assertEquals(2, $model->load('advm_test')['some_integer']); - } - - /** - * @testWith [ "some_integer", 3 ] - * [ "some_integer", null ] - * [ "some_text", "test1" ] - * [ "some_boolean", true ] - * [ "some_boolean", false ] - * [ "some_structure", { "a": "b" } ] - * [ "some_structure", null ] - * [ "some_timestamp", "2021-11-11" ] - * [ "some_timestamp", "2021-11-11 12:34:56" ] - * @param string $field [description] - * @param mixed $value [description] - */ - public function testBasicADVMSetValue(string $field, $value): void { - $model = new advmTestModel($this->getModel('advm_data')); - $dataset = [ - $field => $value - ]; - $this->assertTrue($model->isValid($dataset)); - $model->save($dataset); - $this->assertEquals($value, $model->load('advm_test')[$field]); - } - - /** - * @testWith [ "some_integer", "abc" ] - * [ "some_integer", false ] - * [ "some_text", false ] - * [ "some_boolean", "abc" ] - * [ "some_boolean", 123 ] - * [ "some_timestamp", "abc" ] - * [ "some_timestamp", 123 ] - * [ "some_timestamp", true ] - * @param string $field [description] - * @param mixed $value [description] - */ - public function testBasicADVMInvalidValue(string $field, $value): void { - $model = new advmTestModel($this->getModel('advm_data')); - $dataset = [ - $field => $value - ]; - $this->assertFalse($model->isValid($dataset)); - } - - /** - * Tests hideAllFields/addField functionality on an ADVM - */ - public function testBasicADVMFieldlist(): void { - $model = new advmTestModel($this->getModel('advm_data')); - $model->hideAllFields(); - $model->addField('some_integer'); - $this->assertEquals([ - [ - 'some_integer' => null, - ] - ], $model->search()->getResult()); - } - - /** - * [testAdvancedADVMEmpty description] - */ - public function testAdvancedADVMEmpty(): void { - $model = new advmDomainTestModel($this->getModel('advm_domain_data')); - $this->assertEquals([ - [ - 'advm_domain_test'=> null, - 'some_integer' => null, - 'some_text' => null, - 'some_boolean' => null, - 'some_structure' => null, - 'some_timestamp' => null, - ] - ], $model->search()->getResult()); - // - $model->addFilter('advm_domain_test', 1); - - $this->assertEquals([ - [ - 'advm_domain_test'=> 1, - 'some_integer' => null, - 'some_text' => null, - 'some_boolean' => null, - 'some_structure' => null, - 'some_timestamp' => null, - ] - ], $model->search()->getResult()); - } - - /** - * [testAdvancedADVMSave description] - */ - public function testAdvancedADVMSave(): void { - $model = new advmDomainTestModel($this->getModel('advm_domain_data')); - $model->save([ - 'advm_domain_test' => 1, - 'some_integer' => 123 - ]); - $model->addFilter('advm_domain_test', 1); - $this->assertEquals([ - [ - 'advm_domain_test'=> 1, - 'some_integer' => 123, - 'some_text' => null, - 'some_boolean' => null, - 'some_structure' => null, - 'some_timestamp' => null, - ] - ], $model->search()->getResult()); - - $model->addFilter('advm_domain_test', 2); - $this->assertEquals([ - [ - 'advm_domain_test'=> 2, - 'some_integer' => null, - 'some_text' => null, - 'some_boolean' => null, - 'some_structure' => null, - 'some_timestamp' => null, - ] - ], $model->search()->getResult()); - } + 'primary' => [ + 'advm_data_id', + ], + 'unique' => [ + 'advm_data_name', + ], + 'required' => [ + 'advm_data_name', + 'advm_data_datatype', + ], + 'options' => [ + 'advm_data_name' => [ + 'length' => 128, + ], + ], + 'datatype' => [ + 'advm_data_id' => 'number_natural', + 'advm_data_created' => 'text_timestamp', + 'advm_data_modified' => 'text_timestamp', + 'advm_data_name' => 'text', + 'advm_data_datatype' => 'text', + 'advm_data_value' => 'text', + ], + 'connection' => 'default', + ]); + + // + // Base/data model for an abstractDynamicValueModel + // + static::createModel('testschema', 'advm_domain_data', [ + 'field' => [ + 'advm_domain_data_id', + 'advm_domain_data_created', + 'advm_domain_data_modified', + 'advm_domain_data_domain_id', + 'advm_domain_data_name', + 'advm_domain_data_datatype', + 'advm_domain_data_value', + ], + 'primary' => [ + 'advm_domain_data_id', + ], + 'unique' => [ + ['advm_domain_data_domain_id', 'advm_domain_data_name'], + ], + 'required' => [ + 'advm_domain_data_name', + 'advm_domain_data_datatype', + ], + 'options' => [ + 'advm_domain_data_name' => [ + 'length' => 128, + ], + ], + 'datatype' => [ + 'advm_domain_data_id' => 'number_natural', + 'advm_domain_data_created' => 'text_timestamp', + 'advm_domain_data_modified' => 'text_timestamp', + 'advm_domain_data_domain_id' => 'number_natural', + 'advm_domain_data_name' => 'text', + 'advm_domain_data_datatype' => 'text', + 'advm_domain_data_value' => 'text', + ], + 'connection' => 'default', + ]); + + static::architect('advmtest', 'codename', 'test'); + } } /** * Basic implementation of an ADVM */ -class advmTestModel extends \codename\core\model\abstractDynamicValueModel { - - /** - * [__construct description] - * @param \codename\core\model $dataModel [description] - */ - public function __construct(\codename\core\model $dataModel) - { - parent::__construct([]); - - $this->dataModel = $dataModel; - - // - // only provide an unspecific pkey (no association with data model) - // - $this->setPrimaryKey('advm_test'); - - $this->setDataModelConfig( - 'advm_data_name', // some unique key (or unique component) for identifying the variable - 'advm_data_datatype', // the datatype field - 'advm_data_value' // the value field - ); - $this->setDynamicConfig(null, new \codename\core\config([ - 'some_integer' => [ - "datatype" => "number_natural", - ], - 'some_text' => [ - "datatype" => "text", - ], - 'some_boolean' => [ - "datatype" => "boolean", - ], - 'some_structure' => [ - "datatype" => "structure", - ], - 'some_timestamp' => [ - "datatype" => "text_timestamp", - ], - ])); - $this->loadConfig(); - } - - /** - * @inheritDoc - */ - protected function initializeDataModel() - { - return; // Done in .ctor - } - - /** - * @inheritDoc - */ - public function getIdentifier() : string - { - return 'advm_test'; - } +class advmTestModel extends abstractDynamicValueModel +{ + /** + * [__construct description] + * @param model $dataModel [description] + * @throws ReflectionException + * @throws exception + */ + public function __construct(model $dataModel) + { + parent::__construct([]); + + $this->dataModel = $dataModel; + + // + // only provide an unspecific pkey (no association with data model) + // + $this->setPrimaryKey('advm_test'); + + $this->setDataModelConfig( + 'advm_data_name', // some unique key (or unique component) for identifying the variable + 'advm_data_datatype', // the datatype field + 'advm_data_value' // the value field + ); + $this->setDynamicConfig( + null, + new config([ + 'some_integer' => [ + "datatype" => "number_natural", + ], + 'some_text' => [ + "datatype" => "text", + ], + 'some_boolean' => [ + "datatype" => "boolean", + ], + 'some_structure' => [ + "datatype" => "structure", + ], + 'some_timestamp' => [ + "datatype" => "text_timestamp", + ], + ]) + ); + $this->loadConfig(); + } + + /** + * {@inheritDoc} + */ + public function getIdentifier(): string + { + return 'advm_test'; + } + + /** + * {@inheritDoc} + */ + protected function initializeDataModel(): void + { + } } /** * More advanced implementation of an ADVM */ -class advmDomainTestModel extends \codename\core\model\abstractDynamicValueModel { - - /** - * [__construct description] - * @param \codename\core\model $dataModel [description] - */ - public function __construct(\codename\core\model $dataModel) - { - parent::__construct([]); - - $this->dataModel = $dataModel; - - // - // only provide an unspecific pkey (no association with data model) - // - $this->setPrimaryKey('advm_domain_test', 'advm_domain_data_domain_id'); - - $this->setDataModelConfig( - 'advm_domain_data_name', // some unique key (or unique component) for identifying the variable - 'advm_domain_data_datatype', // the datatype field - 'advm_domain_data_value' // the value field - ); - $this->setDynamicConfig(null, new \codename\core\config([ - 'some_integer' => [ - "datatype" => "number_natural", - ], - 'some_text' => [ - "datatype" => "text", - ], - 'some_boolean' => [ - "datatype" => "boolean", - ], - 'some_structure' => [ - "datatype" => "structure", - ], - 'some_timestamp' => [ - "datatype" => "text_timestamp", - ], - ])); - $this->loadConfig(); - } - - /** - * @inheritDoc - */ - protected function initializeDataModel() - { - return; // Done in .ctor - } - - /** - * @inheritDoc - */ - public function getIdentifier() : string - { - return 'advm_domain_test'; - } +class advmDomainTestModel extends abstractDynamicValueModel +{ + /** + * @param model $dataModel [description] + * @throws ReflectionException + * @throws exception + */ + public function __construct(model $dataModel) + { + parent::__construct([]); + + $this->dataModel = $dataModel; + + // + // only provide an unspecific pkey (no association with data model) + // + $this->setPrimaryKey('advm_domain_test', 'advm_domain_data_domain_id'); + + $this->setDataModelConfig( + 'advm_domain_data_name', // some unique key (or unique component) for identifying the variable + 'advm_domain_data_datatype', // the datatype field + 'advm_domain_data_value' // the value field + ); + $this->setDynamicConfig( + null, + new config([ + 'some_integer' => [ + "datatype" => "number_natural", + ], + 'some_text' => [ + "datatype" => "text", + ], + 'some_boolean' => [ + "datatype" => "boolean", + ], + 'some_structure' => [ + "datatype" => "structure", + ], + 'some_timestamp' => [ + "datatype" => "text_timestamp", + ], + ]) + ); + $this->loadConfig(); + } + + /** + * {@inheritDoc} + */ + public function getIdentifier(): string + { + return 'advm_domain_test'; + } + + /** + * {@inheritDoc} + */ + protected function initializeDataModel(): void + { + } } diff --git a/tests/model/abstractModelTest.php b/tests/model/abstractModelTest.php index 412e97c..313c1ad 100644 --- a/tests/model/abstractModelTest.php +++ b/tests/model/abstractModelTest.php @@ -1,5468 +1,6321 @@ modelDataEmpty($model)) { - $this->fail('Model data not empty: '.$model); - } - } - // rollback any existing transactions - // to allow transaction testing - try { - $db = \codename\core\app::getDb('default'); - $db->rollback(); - } catch (\Exception $e) { - } - - // perform teardown steps - parent::tearDown(); - } - - /** - * @inheritDoc - */ - protected function setUp(): void - { - $app = static::createApp(); - - $app->__setApp('modeltest'); - $app->__setVendor('codename'); - $app->__setNamespace('\\codename\\core\\tests\\model'); - $app->__setHomedir(__DIR__); - - $app->getAppstack(); - - // avoid re-init - if(static::$initialized) { - return; - } - - static::$initialized = true; - - static::setEnvironmentConfig([ - 'test' => [ - 'database' => [ - 'default' => $this->getDefaultDatabaseConfig(), - ], - 'cache' => [ - 'default' => [ - 'driver' => 'memory' - ] - ], - 'filesystem' =>[ - 'local' => [ - 'driver' => 'local', - ] - ], - 'log' => [ - 'default' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ] - ], - ] - ]); - - static::createModel('testschema', 'testdata', [ - 'validators' => [ - 'model_testdata', - ], - 'field' => [ - 'testdata_id', - 'testdata_created', - 'testdata_modified', - 'testdata_datetime', - 'testdata_text', - 'testdata_date', - 'testdata_number', - 'testdata_integer', - 'testdata_boolean', - 'testdata_structure', - 'testdata_details_id', - 'testdata_moredetails_id', - 'testdata_flag', - ], - 'primary' => [ - 'testdata_id' - ], - 'flag' => [ - 'foo' => 1, - 'bar' => 2, - 'baz' => 4, - 'qux' => 8, - ], - 'foreign' => [ - 'testdata_details_id' => [ - 'schema' => 'testschema', - 'model' => 'details', - 'key' => 'details_id' - ], - 'testdata_moredetails_id' => [ - 'schema' => 'testschema', - 'model' => 'moredetails', - 'key' => 'moredetails_id' - ], - ], - 'options' => [ - 'testdata_number' => [ - 'length' => 16, - 'precision' => 8 - ] - ], - 'datatype' => [ - 'testdata_id' => 'number_natural', - 'testdata_created' => 'text_timestamp', - 'testdata_modified' => 'text_timestamp', - 'testdata_datetime' => 'text_timestamp', - 'testdata_details_id' => 'number_natural', - 'testdata_moredetails_id' => 'number_natural', - 'testdata_text' => 'text', - 'testdata_date' => 'text_date', - 'testdata_number' => 'number', - 'testdata_integer' => 'number_natural', - 'testdata_boolean' => 'boolean', - 'testdata_structure'=> 'structure', - 'testdata_flag' => 'number_natural', - ], - 'connection' => 'default' - ]); - - static::createModel('testschema', 'details', [ - 'field' => [ - 'details_id', - 'details_created', - 'details_modified', - 'details_data', - 'details_virtual', - ], - 'primary' => [ - 'details_id' - ], - 'datatype' => [ - 'details_id' => 'number_natural', - 'details_created' => 'text_timestamp', - 'details_modified' => 'text_timestamp', - 'details_data' => 'structure', - 'details_virtual' => 'virtual', - ], - 'connection' => 'default' - ]); - - static::createModel('testschema', 'moredetails', [ - 'field' => [ - 'moredetails_id', - 'moredetails_created', - 'moredetails_modified', - 'moredetails_data', - ], - 'primary' => [ - 'moredetails_id' - ], - 'datatype' => [ - 'moredetails_id' => 'number_natural', - 'moredetails_created' => 'text_timestamp', - 'moredetails_modified' => 'text_timestamp', - 'moredetails_data' => 'structure', - ], - 'connection' => 'default' - ]); - - static::createModel('multi_fkey', 'table1', [ - 'field' => [ - 'table1_id', - 'table1_created', - 'table1_modified', - 'table1_key1', - 'table1_key2', - 'table1_value', - ], - 'primary' => [ - 'table1_id' - ], - 'foreign' => [ - 'multi_component_fkey' => [ - 'schema' => 'multi_fkey', - 'model' => 'table2', - 'key' => [ - 'table1_key1' => 'table2_key1', - 'table1_key2' => 'table2_key2', - ], - 'optional' => true - ] - ], - 'options' => [ - 'table1_key1' => [ - 'length' => 16, - ] - ], - 'datatype' => [ - 'table1_id' => 'number_natural', - 'table1_created' => 'text_timestamp', - 'table1_modified' => 'text_timestamp', - 'table1_modified' => 'text_timestamp', - 'table1_key1' => 'text', - 'table1_key2' => 'number_natural', - 'table1_value' => 'text', - ], - 'connection' => 'default' - ]); - static::createModel('multi_fkey', 'table2', [ - 'field' => [ - 'table2_id', - 'table2_created', - 'table2_modified', - 'table2_key1', - 'table2_key2', - 'table2_value', - ], - 'primary' => [ - 'table2_id' - ], - 'options' => [ - 'table2_key1' => [ - 'length' => 16, - ] - ], - 'datatype' => [ - 'table2_id' => 'number_natural', - 'table2_created' => 'text_timestamp', - 'table2_modified' => 'text_timestamp', - 'table2_modified' => 'text_timestamp', - 'table2_key1' => 'text', - 'table2_key2' => 'number_natural', - 'table2_value' => 'text', - ], - 'connection' => 'default' - ]); - - static::createModel('vfields', 'customer', [ - 'field' => [ - 'customer_id', - 'customer_created', - 'customer_modified', - 'customer_no', - 'customer_person_id', - 'customer_person', - 'customer_contactentries', - 'customer_notes', - ], - 'primary' => [ - 'customer_id' - ], - 'unique' => [ - 'customer_no', - ], - 'required' => [ - 'customer_no' - ], - 'children' => [ - 'customer_person' => [ - 'type' => 'foreign', - 'field' => 'customer_person_id' - ], - 'customer_contactentries' => [ - 'type' => 'collection', - ] - ], - 'collection' => [ - 'customer_contactentries' => [ - 'schema' => 'vfields', - 'model' => 'contactentry', - 'key' => 'contactentry_customer_id' - ] - ], - 'foreign' => [ - 'customer_person_id' => [ - 'schema' => 'vfields', - 'model' => 'person', - 'key' => 'person_id' - ] - ], - 'options' => [ - 'customer_no' => [ - 'length' => 16 - ] - ], - 'datatype' => [ - 'customer_id' => 'number_natural', - 'customer_created' => 'text_timestamp', - 'customer_modified' => 'text_timestamp', - 'customer_no' => 'text', - 'customer_person_id' => 'number_natural', - 'customer_person' => 'virtual', - 'customer_contactentries' => 'virtual', - 'customer_notes' => 'text', - ], - 'connection' => 'default' - ]); - - static::createModel('vfields', 'contactentry', [ - 'field' => [ - 'contactentry_id', - 'contactentry_created', - 'contactentry_modified', - 'contactentry_name', - 'contactentry_telephone', - 'contactentry_customer_id', - ], - 'primary' => [ - 'contactentry_id' - ], - 'required' => [ - 'contactentry_name' - ], - 'foreign' => [ - 'contactentry_customer_id' => [ - 'schema' => 'vfields', - 'model' => 'customer', - 'key' => 'customer_id' - ] - ], - 'datatype' => [ - 'contactentry_id' => 'number_natural', - 'contactentry_created' => 'text_timestamp', - 'contactentry_modified' => 'text_timestamp', - 'contactentry_name' => 'text', - 'contactentry_telephone' => 'text_telephone', - 'contactentry_customer_id'=> 'number_natural', - ], - 'connection' => 'default' - ]); - - static::createModel('vfields', 'person', [ - 'field' => [ - 'person_id', - 'person_created', - 'person_modified', - 'person_firstname', - 'person_lastname', - 'person_birthdate', - 'person_country', - 'person_parent_id', - 'person_parent', - ], - 'primary' => [ - 'person_id' - ], - 'children' => [ - 'person_parent' => [ - 'type' => 'foreign', - 'field' => 'person_parent_id' - ], - ], - 'foreign' => [ - 'person_parent_id' => [ - 'schema' => 'vfields', - 'model' => 'person', - 'key' => 'person_id' - ], - 'person_country' => [ - 'schema' => 'json', - 'model' => 'country', - 'key' => 'country_code' - ] - ], - 'options' => [ - 'person_country' => [ - 'length' => 2 - ] - ], - 'datatype' => [ - 'person_id' => 'number_natural', - 'person_created' => 'text_timestamp', - 'person_modified' => 'text_timestamp', - 'person_firstname' => 'text', - 'person_lastname' => 'text', - 'person_birthdate' => 'text_date', - 'person_country' => 'text', - 'person_parent_id' => 'number_natural', - 'person_parent' => 'virtual' - ], - 'connection' => 'default' - ]); - - static::createModel('json', 'country', [ - 'field' => [ - 'country_code', - 'country_name', - ], - 'primary' => [ - 'country_code' - ], - 'datatype' => [ - 'country_code' => 'text', - 'country_name' => 'text', - ], - // No connection, JSON datamodel - ], function($schema, $model, $config) { - return new \codename\core\tests\jsonModel( - 'tests/model/data/json_country.json', - $schema, - $model, - $config - ); - }); - - static::createModel('timemachine', 'timemachine', [ - 'field' => [ - 'timemachine_id', - 'timemachine_created', - 'timemachine_modified', - 'timemachine_model', - 'timemachine_ref', - 'timemachine_data', - 'timemachine_source', - 'timemachine_user_id', - ], - 'primary' => [ - 'timemachine_id' - ], - 'required' => [ - 'timemachine_model', - 'timemachine_ref', - 'timemachine_data', - ], - 'index' => [ - [ 'timemachine_model', 'timemachine_ref' ], - ], - 'options' => [ - 'timemachine_model' => [ - 'length' => 64, - ], - 'timemachine_ref' => [ - 'db_column_type' => 'bigint', - ], - 'timemachine_data' => [ - 'db_column_type' => 'longtext', - ], - ], - 'datatype' => [ - 'timemachine_id' => 'number_natural', - 'timemachine_created' => 'text_timestamp', - 'timemachine_modified' => 'text_timestamp', - 'timemachine_model' => 'text', - 'timemachine_ref' => 'number_natural', - 'timemachine_data' => 'structure', - 'timemachine_source' => 'text', - 'timemachine_user_id' => 'number_natural' - ], - 'connection' => 'default' - ]); - - - static::architect('modeltest', 'codename', 'test'); - - static::createTestData(); - } - - /** - * [modelDataEmpty description] - * @param string $model [description] - * @return bool [description] - */ - public function modelDataEmpty(string $model): bool { - return $this->getModel($model)->getCount() === 0; - } - - /** - * Deletes data that is created during createTestData() - */ - public static function deleteTestData(): void { - $cleanupModels = [ - 'testdata', - 'details', - 'moredetails', - 'timemachine', - 'table1', - 'table2', - ]; - foreach($cleanupModels as $modelName) { - $model = static::getModelStatic($modelName); - $model->addFilter($model->getPrimarykey(), 0, '>') - ->delete()->reset(); - - // NOTE: we should not assert this in a static way - // as it interferes with parallel or isolated test execution - // and tests, that target doesNotPerformAssertions - // static::assertEquals(0, $model->getCount()); - } - } - - /** - * [createTestData description] - */ - protected static function createTestData(): void { - - // Just to make sure... initial cleanup - // If there has been a shutdown failure after the last test - // if this executed using a still running DB. - static::deleteTestData(); - - $testdataModel = static::getModelStatic('testdata'); - - $entries = [ - [ - 'testdata_text' => 'foo', - 'testdata_datetime' => '2021-03-22 12:34:56', - 'testdata_date' => '2021-03-22', - 'testdata_number' => 3.14, - 'testdata_integer' => 3, - 'testdata_structure'=> [ 'foo' => 'bar' ], - 'testdata_boolean' => true, - ], - [ - 'testdata_text' => 'bar', - 'testdata_datetime' => '2021-03-22 12:34:56', - 'testdata_date' => '2021-03-22', - 'testdata_number' => 4.25, - 'testdata_integer' => 2, - 'testdata_structure'=> [ 'foo' => 'baz' ], - 'testdata_boolean' => true, - ], - [ - 'testdata_text' => 'foo', - 'testdata_datetime' => '2021-03-23 23:34:56', - 'testdata_date' => '2021-03-23', - 'testdata_number' => 5.36, - 'testdata_integer' => 1, - 'testdata_structure'=> [ 'boo' => 'far' ], - 'testdata_boolean' => false, - ], - [ - 'testdata_text' => 'bar', - 'testdata_datetime' => '2019-01-01 00:00:01', - 'testdata_date' => '2019-01-01', - 'testdata_number' => 0.99, - 'testdata_integer' => 42, - 'testdata_structure'=> [ 'bar' => 'foo' ], - 'testdata_boolean' => false, - ], - ]; - - foreach($entries as $dataset) { - $testdataModel->save($dataset); - } - } - - /** - * [getDatabaseInstance description] - * @param array $config [description] - * @return \codename\core\database [description] - */ - protected abstract function getDatabaseInstance(array $config): \codename\core\database; - - /** - * [testSetConfigExplicitConnectionValid description] - */ - public function testSetConfigExplicitConnectionValid(): void { - $model = $this->getModel('testdata'); - $model->setConfig('default', 'testschema', 'testdata'); - - $dataset = $model->setLimit(1)->search()->getResult()[0]; - $this->assertGreaterThanOrEqual(1, $dataset['testdata_id']); - } - - /** - * [testSetConfigExplicitConnectionInvalid description] - */ - public function testSetConfigExplicitConnectionInvalid(): void { - $this->expectException(\codename\core\exception::class); - // TODO: right now we expect EXCEPTION_GETDATA_REQUESTEDKEYINTYPENOTFOUND message - // but this might change soon - $model = $this->getModel('testdata'); - $model->setConfig('nonexisting_connection', 'testschema', 'testdata'); - } - - /** - * [testSetConfigInvalidValues description] - */ - public function testSetConfigInvalidValues(): void { - $this->expectException(\codename\core\exception::class); - // TODO: specify the exception message - $model = $this->getModel('testdata'); - $model->setConfig('default', 'nonexisting_schema', 'nonexisting_model'); - } - - /** - * [testModelconfigInvalidWithoutCreatedAndModifiedField description] - */ - public function testModelconfigInvalidWithoutCreatedAndModifiedField(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage(\codename\core\model\schematic\sql::EXCEPTION_MODEL_CONFIG_MISSING_FIELD); - new \codename\core\tests\sqlModel('nonexisting', 'without_created_and_modified', [ - 'field' => [ - 'without_created_and_modified_id', - ], - 'primary' => [ - 'without_created_and_modified_id' - ], - 'datatype' => [ - 'without_created_and_modified_id' => 'number_natural', - ] - ]); - } - - /** - * [testModelconfigInvalidWithoutModifiedField description] - */ - public function testModelconfigInvalidWithoutModifiedField(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage(\codename\core\model\schematic\sql::EXCEPTION_MODEL_CONFIG_MISSING_FIELD); - new \codename\core\tests\sqlModel('nonexisting', 'without_modified', [ - 'field' => [ - 'without_modified_id', - 'without_modified_created', - ], - 'primary' => [ - 'without_modified_id' - ], - 'datatype' => [ - 'without_modified_id' => 'number_natural', - 'without_modified_created' => 'text_timestamp', - ] - ]); - } - - /** - * [testDeleteWithoutArgsWillFail description] - */ - public function testDeleteWithoutArgsWillFail(): void { - // - // ::delete() without given PKEY, nor filters, MUST FAIL. - // - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage('EXCEPTION_MODEL_SCHEMATIC_SQL_DELETE_NO_FILTERS_DEFINED'); - $model = $this->getModel('testdata'); - $model->delete(); - } - - /** - * [testUpdateWithoutArgsWillFail description] - */ - public function testUpdateWithoutArgsWillFail(): void { - // - // ::update() without filters MUST FAIL. - // - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage('EXCEPTION_MODEL_SCHEMATIC_SQL_UPDATE_NO_FILTERS_DEFINED'); - $model = $this->getModel('testdata'); - $model->update([ - 'testdata_integer' => 0 - ]); - } - - /** - * [testAddCalculatedFieldExistsWillFail description] - */ - public function testAddCalculatedFieldExistsWillFail(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ADDCALCULATEDFIELD_FIELDALREADYEXISTS); - $this->getModel('testdata') - ->addCalculatedField('testdata_integer', '(1+1)'); - } - - /** - * [testHideFieldSingle description] - */ - public function testHideFieldSingle(): void { - $model = $this->getModel('testdata'); - $fields = $model->getFields(); - - $visibleFields = array_filter($fields, function($f) { - return ($f != 'testdata_integer'); - }); - - $model->hideField('testdata_integer'); - $res = $model->search()->getResult(); - - $this->assertCount(4, $res); - foreach($res as $r) { - // - // Make sure we don't get testdata_integer - // but every other field - // - foreach($visibleFields as $f) { - $this->assertArrayHasKey($f, $r); - } - $this->assertArrayNotHasKey('testdata_integer', $r); - } - } - - /** - * [testHideFieldMultipleCommaTrim description] - */ - public function testHideFieldMultipleCommaTrim(): void { - $model = $this->getModel('testdata'); - $fields = $model->getFields(); - - $visibleFields = array_filter($fields, function($f) { - return ($f != 'testdata_integer') && ($f != 'testdata_text'); - }); - - // Testing auto-split/explode and trim - $model->hideField('testdata_integer, testdata_text'); - $res = $model->search()->getResult(); - - $this->assertCount(4, $res); - foreach($res as $r) { - // - // Make sure we don't get testdata_integer and testdata_text - // but every other field - // - foreach($visibleFields as $f) { - $this->assertArrayHasKey($f, $r); - } - $this->assertArrayNotHasKey('testdata_integer', $r); - $this->assertArrayNotHasKey('testdata_text', $r); - } - } - - /** - * [testHideAllFieldsAddOne description] - */ - public function testHideAllFieldsAddOne(): void { - $model = $this->getModel('testdata'); - $res = $model - ->hideAllFields() - ->addField('testdata_integer') - ->search()->getResult(); - $this->assertCount(4, $res); - foreach($res as $r) { - // Make sure 'testdata_integer' is the one and only field in the result datasets - $this->assertArrayHasKey('testdata_integer', $r); - $this->assertEquals([ 'testdata_integer' ], array_keys($r)); - } - } - - /** - * Tests whether ::addField() works with comma-separated field names (string) - */ - public function testHideAllFieldsAddMultiple(): void { - $model = $this->getModel('testdata'); - $res = $model - ->hideAllFields() - ->addField('testdata_integer,testdata_text, testdata_number ') // internal trimming - ->search()->getResult(); - $this->assertCount(4, $res); - foreach($res as $r) { - // Make sure 'testdata_integer' is the one and only field in the result datasets - $this->assertArrayHasKey('testdata_integer', $r); - $this->assertArrayHasKey('testdata_text', $r); - $this->assertArrayHasKey('testdata_number', $r); - $this->assertEquals([ 'testdata_integer', 'testdata_text', 'testdata_number' ], array_keys($r)); - } - } - - /** - * WARNING: this tests a special edge case - which is almost not the desired state - * but there's not better defined solution right now - * so we test for stability/behaviour - */ - public function testAddFieldComplexEdgeCaseNoVfr(): void { - $model = $this->getModel('testdata') - ->addModel($detailsModel = $this->getModel('details')) - ->addModel($moreDetailsModel = $this->getModel('moredetails')); - - $model->hideAllFields(); - $detailsModel->hideAllFields(); - $moreDetailsModel->hideAllFields(); - - $model->addVirtualField('test', function($dataset) { - return 'value'; - }); - - $res = $model->search()->getResult(); - - $dataset = $res[0]; - - // we expect all pkeys to exist - $this->assertArrayHasKey($model->getPrimaryKey(), $dataset); - $this->assertArrayHasKey($detailsModel->getPrimaryKey(), $dataset); - $this->assertArrayHasKey($moreDetailsModel->getPrimaryKey(), $dataset); - - // virtual field should not be there - // as we have no VFR set - $this->assertArrayNotHasKey('test', $dataset); - } - - /** - * WARNING: this tests a special edge case - which is almost not the desired state - * but there's not better defined solution right now - * so we test for stability/behaviour - */ - public function testAddFieldComplexEdgeCasePartialVfr(): void { - $model = $this->getModel('testdata')->setVirtualFieldResult(true) - ->addModel($detailsModel = $this->getModel('details')) - ->addModel($moreDetailsModel = $this->getModel('moredetails')); - - $model->hideAllFields(); - $detailsModel->hideAllFields(); - $moreDetailsModel->hideAllFields(); - - $model->addVirtualField('test', function($dataset) { - return 'value'; - }); - - $res = $model->search()->getResult(); - - $dataset = $res[0]; - - // WARNING: edge case - ->hideAllFields on all models have been called - // but only a virtual field on root model has been added - // this gives us a strange situation/result - root model will 'disappear' - // but the joins are kept, fully. - $this->assertArrayNotHasKey($model->getPrimaryKey(), $dataset); - - // those will be available - $this->assertArrayHasKey($detailsModel->getPrimaryKey(), $dataset); - $this->assertArrayHasKey($moreDetailsModel->getPrimaryKey(), $dataset); - - // this virtual field on root model should persist - $this->assertArrayHasKey('test', $dataset); - } - - /** - * WARNING: this tests a special edge case - which is almost not the desired state - * but there's not better defined solution right now - * so we test for stability/behaviour - */ - public function testAddFieldComplexEdgeCaseFullVfr(): void { - $model = $this->getModel('testdata')->setVirtualFieldResult(true) - ->addModel($detailsModel = $this->getModel('details')->setVirtualFieldResult(true)) - ->addModel($moreDetailsModel = $this->getModel('moredetails')->setVirtualFieldResult(true)); - - $model->hideAllFields(); - $detailsModel->hideAllFields(); - $moreDetailsModel->hideAllFields(); - - $model->addVirtualField('test', function($dataset) { - return 'value'; - }); - - $res = $model->search()->getResult(); - - $dataset = $res[0]; - - // WARNING: edge case - ->hideAllFields on all models have been called - // but only a virtual field on root model has been added - // this gives us a strange situation/result - root model will 'disappear' - // but the joins are kept, fully. - $this->assertArrayNotHasKey($model->getPrimaryKey(), $dataset); - - // those will be available - $this->assertArrayHasKey($detailsModel->getPrimaryKey(), $dataset); - $this->assertArrayHasKey($moreDetailsModel->getPrimaryKey(), $dataset); - - // this virtual field on root model should persist - $this->assertArrayHasKey('test', $dataset); - } - - /** - * WARNING: this tests a special edge case - which is almost not the desired state - * but there's not better defined solution right now - * so we test for stability/behaviour - */ - public function testAddFieldComplexEdgeCaseRegularFieldFullVfr(): void { - $model = $this->getModel('testdata')->setVirtualFieldResult(true) - ->addModel($detailsModel = $this->getModel('details')->setVirtualFieldResult(true)) - ->addModel($moreDetailsModel = $this->getModel('moredetails')->setVirtualFieldResult(true)); - - $model->hideAllFields(); - $detailsModel->hideAllFields(); - $moreDetailsModel->hideAllFields(); - - // only add one field, in this case: the PKEY of the root model - $model->addField($model->getPrimaryKey()); - - $res = $model->search()->getResult(); - - $dataset = $res[0]; - - // WARNING: edge case - ->hideAllFields on all models have been called - // and we only add a (regular) field on the root model - // all fields, except the root model's field will disappear - $this->assertArrayHasKey($model->getPrimaryKey(), $dataset); - - // those will be available - $this->assertArrayNotHasKey($detailsModel->getPrimaryKey(), $dataset); - $this->assertArrayNotHasKey($moreDetailsModel->getPrimaryKey(), $dataset); - } - - /** - * WARNING: this tests a special edge case - which is almost not the desired state - * but there's not better defined solution right now - * so we test for stability/behaviour - */ - public function testAddFieldComplexEdgeCaseNestedRegularFieldFullVfr(): void { - $model = $this->getModel('testdata')->setVirtualFieldResult(true) - ->addModel($detailsModel = $this->getModel('details')->setVirtualFieldResult(true)) - ->addModel($moreDetailsModel = $this->getModel('moredetails')->setVirtualFieldResult(true)); - - $model->hideAllFields(); - $detailsModel->hideAllFields(); - $moreDetailsModel->hideAllFields(); - - // only add one field, in this case: the PKEY of the root model - $detailsModel->addField($detailsModel->getPrimaryKey()); - - $res = $model->search()->getResult(); - - $dataset = $res[0]; - - // WARNING: edge case - ->hideAllFields on all models have been called - // and we only add a (regular) field on a *NESTED* model - // all fields, except the joined model's field will disappear - $this->assertArrayHasKey($detailsModel->getPrimaryKey(), $dataset); - - // those will be available - $this->assertArrayNotHasKey($model->getPrimaryKey(), $dataset); - $this->assertArrayNotHasKey($moreDetailsModel->getPrimaryKey(), $dataset); - } - - /** - * [testAddFieldFailsWithNonexistingField description] - */ - public function testAddFieldFailsWithNonexistingField(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ADDFIELD_FIELDNOTFOUND); - $model = $this->getModel('testdata'); - $model->addField('testdata_nonexisting'); // We expect an early failure - } - - /** - * [testAddFieldFailsWithMultipleFieldsAndAliasProvided description] - */ - public function testAddFieldFailsWithMultipleFieldsAndAliasProvided(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage('EXCEPTION_ADDFIELD_ALIAS_ON_MULTIPLE_FIELDS'); - $model = $this->getModel('testdata'); - $model->addField('testdata_integer,testdata_text', 'some_alias'); // Obviously, this is a no-go. - } - - /** - * [testHideAllFieldsAddAliasedField description] - */ - public function testHideAllFieldsAddAliasedField(): void { - $model = $this->getModel('testdata'); - $res = $model - ->hideAllFields() - ->addField('testdata_integer', 'aliased_field') - ->search()->getResult(); - $this->assertCount(4, $res); - foreach($res as $r) { - // Make sure 'aliased_field' is the one and only field in the result datasets - $this->assertArrayHasKey('aliased_field', $r); - $this->assertEquals([ 'aliased_field' ], array_keys($r)); - } - } - - /** - * [testSimpleModelJoin description] - */ - public function testSimpleModelJoin(): void { - $model = $this->getModel('testdata') - ->addModel($detailsModel = $this->getModel('details')); - - $originalDataset = [ - 'testdata_number' => 3.3, - 'testdata_text' => 'some_dataset', - ]; - - $detailsModel->save([ - 'details_data' => $originalDataset, - ]); - $detailsId = $detailsModel->lastInsertId(); - - $model->save(array_merge( - $originalDataset, [ 'testdata_details_id' => $detailsId ] - )); - $id = $model->lastInsertId(); - - $dataset = $model->load($id); - $this->assertEquals($originalDataset, $dataset['details_data']); - - foreach($detailsModel->getFields() as $field) { - if($detailsModel->getConfig()->get('datatype>'.$field) == 'virtual') { - // In this case, no vfields/handler, expect it to NOT appear. - $this->assertArrayNotHasKey($field, $dataset); - } else { - $this->assertArrayHasKey($field, $dataset); - } - } - foreach($model->getFields() as $field) { - $this->assertArrayHasKey($field, $dataset); - } - - $model->delete($id); - $detailsModel->delete($detailsId); - } - - /** - * [testSimpleModelJoinWithVirtualFields description] - */ - public function testSimpleModelJoinWithVirtualFields(): void { - $model = $this->getModel('testdata')->setVirtualFieldResult(true) - ->addModel($detailsModel = $this->getModel('details')); - - $originalDataset = [ - 'testdata_number' => 3.3, - 'testdata_text' => 'some_dataset', - ]; - - $detailsModel->save([ - 'details_data' => $originalDataset, - ]); - $detailsId = $detailsModel->lastInsertId(); - - $model->save(array_merge( - $originalDataset, [ 'testdata_details_id' => $detailsId ] - )); - $id = $model->lastInsertId(); - - $dataset = $model->load($id); - - $this->assertEquals($originalDataset, $dataset['details_data']); - - foreach($detailsModel->getFields() as $field) { - if($detailsModel->getConfig()->get('datatype>'.$field) == 'virtual') { - // In this case, no vfields/handler, expect it to NOT appear. - $this->assertArrayNotHasKey($field, $dataset); - } else { - $this->assertArrayHasKey($field, $dataset); - } - } - foreach($model->getFields() as $field) { - $this->assertArrayHasKey($field, $dataset); - } - - // modify some model details - $model->hideField('testdata_id'); - $detailsModel->hideField('details_created'); - $model->addField('testdata_id', 'root_level_alias'); - $detailsModel->addField('details_id', 'nested_alias'); - - $dataset = $model->load($id); - - $this->assertArrayNotHasKey('testdata_id', $dataset); - $this->assertArrayNotHasKey('details_created', $dataset); - $this->assertArrayHasKey('root_level_alias', $dataset); - $this->assertArrayHasKey('nested_alias', $dataset); - - $this->assertEquals($id, $dataset['root_level_alias']); - $this->assertEquals($detailsId, $dataset['nested_alias']); - - $model->delete($id); - $detailsModel->delete($detailsId); - } - - /** - * [testConditionalJoin description] - * @return void - */ - public function testConditionalJoin(): void { - $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) - ->addModel( - $personModel = $this->getModel('person')->setVirtualFieldResult(true) - ); - - $customerIds = []; - $personIds = []; - - $datasets = [ - [ - 'customer_no' => 'A1000', - 'customer_person' => [ - 'person_country' => 'AT', - 'person_firstname' => 'Alex', - 'person_lastname' => 'Anderson', - 'person_birthdate' => '1978-02-03', - ], - ], - [ - 'customer_no' => 'A1001', - 'customer_person' => [ - 'person_country' => 'AT', - 'person_firstname' => 'Bridget', - 'person_lastname' => 'Balmer', - 'person_birthdate' => '1981-11-15', - ], - ], - [ - 'customer_no' => 'A1002', - 'customer_person' => [ - 'person_country' => 'DE', - 'person_firstname' => 'Christian', - 'person_lastname' => 'Crossback', - 'person_birthdate' => '1990-04-19', - ], - ], - [ - 'customer_no' => 'A1003', - 'customer_person' => [ - 'person_country' => 'DE', - 'person_firstname' => 'Dodgy', - 'person_lastname' => 'Data', - 'person_birthdate' => null, - ], - ] - ]; - - foreach($datasets as $d) { - $customerModel->saveWithChildren($d); - $customerIds[] = $customerModel->lastInsertId(); - $personIds[] = $personModel->lastInsertId(); - } - - // w/o model_name + double conditions - $model = $this->getModel('customer') - ->addCustomJoin( - $this->getModel('person'), - \codename\core\model\plugin\join::TYPE_LEFT, - 'customer_person_id', - 'person_id', - [ - // will default to the higher-level model - [ 'field' => 'customer_no', 'operator' => '>=', 'value' => '\'A1001\'' ], - [ 'field' => 'customer_no', 'operator' => '<=', 'value' => '\'A1002\'' ], - ] - ); - $model->addOrder('customer_no', 'ASC'); // make sure to have the right order, see below - $model->saveLastQuery = true; - $res = $model->search()->getResult(); - $this->assertCount(4, $res); - $this->assertEquals([null, 'AT', 'DE', null], array_column($res, 'person_country')); - - // using model_name - $model = $this->getModel('customer') - ->addCustomJoin( - $this->getModel('person'), - \codename\core\model\plugin\join::TYPE_LEFT, - 'customer_person_id', - 'person_id', - [ - [ 'model_name' => 'person', 'field' => 'person_country', 'operator' => '=', 'value' => '\'DE\'' ], - ] - ); - $model->addOrder('customer_no', 'ASC'); // make sure to have the right order, see below - $model->saveLastQuery = true; - $res = $model->search()->getResult(); - $this->assertCount(4, $res); - $this->assertEquals([null, null, 'DE','DE'], array_column($res, 'person_country')); - - // null value condition - $model = $this->getModel('customer') - ->addCustomJoin( - $this->getModel('person'), - \codename\core\model\plugin\join::TYPE_LEFT, - 'customer_person_id', - 'person_id', - [ - [ 'model_name' => 'person', 'field' => 'person_birthdate', 'operator' => '=', 'value' => null ], - ] - ); - $model->addOrder('customer_no', 'ASC'); // make sure to have the right order, see below - $model->saveLastQuery = true; - $res = $model->search()->getResult(); - $this->assertCount(4, $res); - $this->assertEquals([null, null, null,'DE'], array_column($res, 'person_country')); - - - foreach($customerIds as $id) { - $customerModel->delete($id); - } - foreach($personIds as $id) { - $personModel->delete($id); - } - } - - /** - * [testConditionalJoinFail description] - */ - public function testConditionalJoinFail(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage('INVALID_JOIN_CONDITION_MODEL_NAME'); - $model = $this->getModel('customer') - ->addCustomJoin( - $this->getModel('person'), - \codename\core\model\plugin\join::TYPE_LEFT, - 'customer_person_id', - 'person_id', - [ - // non-associated model... - [ 'model_name' => 'testdata', 'field' => 'testdata_number', 'operator' => '!=', 'value' => null ], - ] - ); - $model->addOrder('customer_no', 'ASC'); // make sure to have the right order, see below - $model->saveLastQuery = true; - $res = $model->search()->getResult(); - } - - /** - * [testReverseJoin description] - */ - public function testReverseJoinEquality(): void { - $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) - ->addModel( - $personModel = $this->getModel('person')->setVirtualFieldResult(true) - ); - - $customerIds = []; - $personIds = []; - - $datasets = [ - [ - 'customer_no' => 'A1000', - 'customer_person' => [ - 'person_country' => 'AT', - 'person_firstname' => 'Alex', - 'person_lastname' => 'Anderson', - 'person_birthdate' => '1978-02-03', - ], - ], - [ - 'customer_no' => 'A1001', - 'customer_person' => [ - 'person_country' => 'AT', - 'person_firstname' => 'Bridget', - 'person_lastname' => 'Balmer', - 'person_birthdate' => '1981-11-15', - ], - ], - [ - 'customer_no' => 'A1002', - 'customer_person' => [ - 'person_country' => 'DE', - 'person_firstname' => 'Christian', - 'person_lastname' => 'Crossback', - 'person_birthdate' => '1990-04-19', - ], - ], - [ - 'customer_no' => 'A1003', - 'customer_person' => [ - 'person_country' => 'DE', - 'person_firstname' => 'Dodgy', - 'person_lastname' => 'Data', - 'person_birthdate' => null, - ], - ] - ]; - - foreach($datasets as $d) { - $customerModel->saveWithChildren($d); - $customerIds[] = $customerModel->lastInsertId(); - $personIds[] = $personModel->lastInsertId(); - } - - // - // Create two models: - // one customer->person - // and one person->customer - // - as long as we don't have much more data in it - // this must match. - // TODO: test multijoin aliases - // - $forwardJoinModel = $this->getModel('customer') - ->addModel($this->getModel('person')); - $resForward = $forwardJoinModel->search()->getResult(); - - $reverseJoinModel = $this->getModel('person') - ->addModel($this->getModel('customer')); - $resReverse = $reverseJoinModel->search()->getResult(); - - $this->assertCount(4, $resForward); - $this->assertEquals($resForward, $resReverse); - - foreach($customerIds as $id) { - $customerModel->delete($id); - } - foreach($personIds as $id) { - $personModel->delete($id); - } - } - - /** - * Tests replace() method of model (UPSERT) - */ - public function testReplace(): void { - $ids = []; - $model = $this->getModel('customer'); - - if(!($this instanceof \codename\core\tests\model\schematic\mysqlTest)) { - $this->markTestIncomplete('Upsert is working differently on this platform - not implemented yet!'); - } - - $model->save([ - 'customer_no' => 'R1000', - 'customer_notes' => 'Replace me' - ]); - $ids[] = $firstId = $model->lastInsertId(); - - $model->replace([ - 'customer_no' => 'R1000', - 'customer_notes' => 'Replaced' - ]); - - $dataset = $model->load($firstId); - $this->assertEquals('Replaced', $dataset['customer_notes']); - - foreach($ids as $id) { - $model->delete($id); - } - } - - /** - * [testMultiComponentForeignKeyJoin description] - */ - public function testMultiComponentForeignKeyJoin(): void { - $table1 = $this->getModel('table1'); - $table2 = $this->getModel('table2'); - - $table1->save([ - 'table1_key1' => 'first', - 'table1_key2' => 1, - 'table1_value' => 'table1' - ]); - $table2->save([ - 'table2_key1' => 'first', - 'table2_key2' => 1, - 'table2_value' => 'table2' - ]); - $table1->save([ - 'table1_key1' => 'arbitrary', - 'table1_key2' => 2, - 'table1_value' => 'not in table2' - ]); - $table2->save([ - 'table2_key1' => 'arbitrary', - 'table2_key2' => 3, - 'table2_value' => 'not in table1' - ]); - - $table1->addModel($table2); - $res = $table1->search()->getResult(); - - $this->assertCount(2, $res); - $this->assertEquals('table1', $res[0]['table1_value']); - $this->assertEquals('table2', $res[0]['table2_value']); - - $this->assertEquals('not in table2', $res[1]['table1_value']); - $this->assertEquals(null, $res[1]['table2_value']); - } - - public function testTimemachineHistory(): void { - $model = $this->getTimemachineEnabledModel('testdata'); - $model->save([ - 'testdata_text' => 'tm_history', - 'testdata_integer' => 5555, - ]); - $tsAtCreation = \codename\core\helper\date::getCurrentTimestamp(); - $id = $model->lastInsertId(); - $this->assertNotEmpty($model->load($id)); - - $timemachine = new \codename\core\timemachine($model); - - $this->assertEmpty($timemachine->getDeltaData($id, 0), 'Timemachine deltas should be empty at this point'); - $this->assertEmpty($timemachine->getHistory($id), 'Timemachine history should be empty at this point'); - - // Emulate next second. - sleep(1); - - $model->save([ - 'testdata_id' => $id, - 'testdata_integer' => 5556, - ]); - $tsAtUpdate = \codename\core\helper\date::getCurrentTimestamp(); - - // Emulate next second. - sleep(1); - $tsAfterUpdate = \codename\core\helper\date::getCurrentTimestamp(); - - $this->assertEmpty($timemachine->getDeltaData($id, $tsAfterUpdate), 'Timemachine deltas should be empty due to late reference TS'); - $this->assertNotEmpty($timemachine->getDeltaData($id, $tsAtCreation), 'Timemachine deltas should include the history'); - - $this->assertEquals(5555, $timemachine->getHistoricData($id, $tsAtCreation)['testdata_integer']); - - $tsBeforeDelete = \codename\core\helper\date::getCurrentTimestamp(); - - $model->delete($id); - $this->assertEmpty($model->load($id)); - - // Ensure we have an entry for a deleted record - $this->assertEquals(5556, $timemachine->getHistoricData($id, $tsBeforeDelete)['testdata_integer']); - } - - /** - * [testDeleteSinglePkeyTimemachineEnabled description] - */ - public function testDeleteSinglePkeyTimemachineEnabled(): void { - $model = $this->getTimemachineEnabledModel('testdata'); - $model->save([ - 'testdata_text' => 'single_pkey_delete', - 'testdata_integer' => 1234, - ]); - $id = $model->lastInsertId(); - $this->assertNotEmpty($model->load($id)); - $model->delete($id); - $this->assertEmpty($model->load($id)); - } - - /** - * [testBulkUpdateAndDelete description] - */ - public function testBulkUpdateAndDelete(): void { - $model = $this->getModel('testdata'); - $this->testBulkUpdateAndDeleteUsingModel($model); - } - - /** - * [testBulkUpdateAndDeleteTimemachineEnabled description] - */ - public function testBulkUpdateAndDeleteTimemachineEnabled(): void { - $model = $this->getTimemachineEnabledModel('testdata'); - $this->testBulkUpdateAndDeleteUsingModel($model); - } - - /** - * [testBulkUpdateAndDeleteUsingModel description] - * @param \codename\core\model $model [description] - */ - protected function testBulkUpdateAndDeleteUsingModel(\codename\core\model $model): void { - // $model = $this->getModel('testdata'); - - // create example dataset - $ids = []; - for ($i=1; $i <= 10; $i++) { - $model->save([ - 'testdata_text' => 'bulkdata_test', - 'testdata_integer' => $i, - 'testdata_structure'=> [ - 'some_key' => 'some_value', - ] - ]); - $ids[] = $model->lastInsertId(); - } - - // update those entries (not by PKEY) - $model - ->addFilter('testdata_text', 'bulkdata_test') - ->update([ - 'testdata_integer' => 333, - 'testdata_number' => 12.34, // additional update data in this field not used before - 'testdata_structure'=> [ - 'some_key' => 'some_value', - 'some_new_key' => 'some_new_value', - ] - ]); - - // compare data - foreach($ids as $id) { - $dataset = $model->load($id); - $this->assertEquals('bulkdata_test', $dataset['testdata_text']); - $this->assertEquals(333, $dataset['testdata_integer']); - } - - // delete them - $model - ->addFilter($model->getPrimaryKey(), $ids) - ->delete(); - - // make sure they don't exist anymore - $res = $model->addFilter($model->getPrimaryKey(), $ids)->search()->getResult(); - $this->assertEmpty($res); - } - - /** - * [testRecursiveModelJoin description] - */ - public function testRecursiveModelJoin(): void { - $personModel = $this->getModel('person'); - - $datasets = [ - [ - // Top, no parent - 'person_firstname' => 'Ella', - 'person_lastname' => 'Campbell', - ], - [ - // 1st level down - 'person_firstname' => 'Harry', - 'person_lastname' => 'Sanders', - ], - [ - // 2nd level down - 'person_firstname' => 'Stephen', - 'person_lastname' => 'Perkins', - ], - [ - // 3rd level down, no more childs - 'person_firstname' => 'Michael', - 'person_lastname' => 'Vaughn', - ], - ]; - - $ids = []; - - $parentId = null; - foreach($datasets as $dataset) { - $dataset['person_parent_id'] = $parentId; - $personModel->save($dataset); - $parentId = $personModel->lastInsertId(); - $ids[] = $personModel->lastInsertId(); - } - - $queryModel = $this->getModel('person') - ->addRecursiveModel( - $recursiveModel = $this->getModel('person') - ->hideAllFields(), - 'person_parent_id', - 'person_id', - [ - [ 'field' => 'person_lastname', 'operator' => '=', 'value' => 'Vaughn' ] - ], - \codename\core\model\plugin\join::TYPE_INNER, - 'person_id', - 'person_parent_id' - ); - $recursiveModel->addFilter('person_lastname', 'Sanders'); - $res = $queryModel->search()->getResult(); - $this->assertCount(1, $res); - $this->assertEquals('Vaughn', $res[0]['person_lastname']); - - // - // Joined traverse-up - // - $traverseUpModel = $this->getModel('person') - ->hideAllFields() - ->addRecursiveModel( - $this->getModel('person'), - 'person_parent_id', - 'person_id', - [ - // No anchor conditions - // [ 'field' => 'person_lastname', 'operator' => '=', 'value' => 'Vaughn' ] - ], - \codename\core\model\plugin\join::TYPE_INNER, - 'person_id', - 'person_parent_id' - ); - $traverseUpModel->addFilter('person_lastname', 'Perkins'); - $res = $traverseUpModel->search()->getResult(); - - $this->assertCount(3, $res); - // NOTE: order is not guaranteed, therefore: just compare item presence - $this->assertEqualsCanonicalizing([ - 'Stephen', - 'Harry', - 'Ella', - ], array_column($res, 'person_firstname')); - - // - // Joined traverse-down - // - $traverseDownModel = $this->getModel('person') - ->hideAllFields() - ->addRecursiveModel( - $this->getModel('person'), - 'person_id', - 'person_parent_id', - [ - // No anchor conditions - // e.g. [ 'field' => 'person_lastname', 'operator' => '=', 'value' => 'Vaughn' ] - ], - \codename\core\model\plugin\join::TYPE_INNER, - 'person_id', - 'person_parent_id' - ); - $traverseDownModel->addFilter('person_lastname', 'Perkins'); - $res = $traverseDownModel->search()->getResult(); - $this->assertCount(2, $res); - // NOTE: order is not guaranteed, therefore: just compare item presence - $this->assertEqualsCanonicalizing(['Stephen', 'Michael'], array_column($res, 'person_firstname')); - - // - // Root-level traverse up - // - $rootTraverseUpModel = $this->getModel('person') - ->setRecursive( - 'person_parent_id', - 'person_id', - [ - // Single anchor condition - [ 'field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders' ] - ] - ); - $res = $rootTraverseUpModel->search()->getResult(); - $this->assertCount(2, $res); - // NOTE: order is not guaranteed, therefore: just compare item presence - $this->assertEqualsCanonicalizing([ 'Harry', 'Ella' ], array_column($res, 'person_firstname')); - - // - // Root-level traverse down - // - $rootTraverseDownModel = $this->getModel('person') - ->setRecursive( - 'person_id', - 'person_parent_id', - [ - // Single anchor condition - [ 'field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders' ] - ] - ); - $res = $rootTraverseDownModel->search()->getResult(); - $this->assertCount(3, $res); - // NOTE: order is not guaranteed, therefore: just compare item presence - $this->assertEqualsCanonicalizing([ 'Harry', 'Stephen', 'Michael' ], array_column($res, 'person_firstname')); - - // - // Root-level traverse down using filter instance - // - $rootTraverseDownUsingFilterInstanceModel = $this->getModel('person') - ->setRecursive( - 'person_id', - 'person_parent_id', - [ - // Single anchor condition, as filter plugin instance. - // In this case, we use dynamic, just so we get a better compatibility - // across differing drivers - new \codename\core\model\plugin\filter\dynamic(\codename\core\value\text\modelfield::getInstance('person_lastname'), 'Sanders', '=') - ] - ); - $res = $rootTraverseDownUsingFilterInstanceModel->search()->getResult(); - $this->assertCount(3, $res); - // NOTE: order is not guaranteed, therefore: just compare item presence - $this->assertEqualsCanonicalizing([ 'Harry', 'Stephen', 'Michael' ], array_column($res, 'person_firstname')); - - // - // Test joining a model that is used recursively - // - $joinedRecursiveModel = $this->getModel('person') - ->hideAllFields() - ->addField('person_id', 'main_id') - ->addField('person_parent_id', 'main_parent') - ->addField('person_firstname', 'main_firstname') - ->addField('person_lastname', 'main_lastname') - ->addModel( - $this->getModel('person') - // ->hideField('__anchor') - ->setRecursive('person_parent_id', 'person_id', [ - // No filters in this case, we're just using an 'entry point' (Vaughn) below - // [ 'field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders' ] - ]) - , \codename\core\model\plugin\join::TYPE_INNER - // , 'person_id' - // , 'person_id' - ); - - $joinedRecursiveModel->addFilter('person_lastname', 'Vaughn' ); - $res = $joinedRecursiveModel->search()->getResult(); - $this->assertCount(3, $res); - $this->assertEquals([ 'Vaughn' ], array_unique(array_column($res, 'main_lastname'))); - - // NOTE: databases might behave differently regarding order - // - // e.g. SQLite: see https://www.sqlite.org/lang_with.html: - // "If there is no ORDER BY clause, then the order in which rows are extracted is undefined." - // SQLite is mostly doing FIFO. - // - $this->assertEqualsCanonicalizing([ 'Ella', 'Harry', 'Stephen' ], array_column($res, 'person_firstname')); - - foreach(array_reverse($ids) as $id) { - $personModel->delete($id); - } - } - - /** - * Tests whether calling setRecursive a second time will throw an exception - */ - public function testSetRecursiveTwiceWillThrow(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage('EXCEPTION_MODEL_SETRECURSIVE_ALREADY_ENABLED'); - - $model = $this->getModel('person'); - for ($i=1; $i <= 2; $i++) { - $model->setRecursive( - 'person_parent_id', - 'person_id', - [ - // Single anchor condition - [ 'field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders' ] - ] - ); - } - } - - /** - * Tests whether setRecursive will throw an exception - * if an undefined relation is used as recursion parameter - */ - public function testSetRecursiveInvalidConfigWillThrow(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage('INVALID_RECURSIVE_MODEL_CONFIG'); - - $model = $this->getModel('person'); - $model->setRecursive( - 'person_firstname', - 'person_id', - [ - // Single anchor condition - [ 'field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders' ] - ] - ); - } - - /** - * Tests whether setRecursive throws an exception - * if a nonexisting field is provided in the configuration - */ - public function testSetRecursiveNonexistingFieldWillThrow(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage('INVALID_RECURSIVE_MODEL_CONFIG'); - - $model = $this->getModel('person'); - $model->setRecursive( - 'person_nonexisting', - 'person_id', - [ - // Single anchor condition - [ 'field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders' ] - ] - ); - } - - /** - * Tests whether addRecursiveModel throws an exception - * if an invalid/nonexisting field is provided in the configuration - */ - public function testAddRecursiveModelNonexistingFieldWillThrow(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage('INVALID_RECURSIVE_MODEL_JOIN'); - - $model = $this->getModel('person'); - $model->addRecursiveModel( - $this->getModel('person'), - 'person_nonexisting', - 'person_id', - [ - // Single anchor condition - [ 'field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders' ] - ] - ); - } - - /** - * [testFiltercollectionValueArray description] - */ - public function testFiltercollectionValueArray(): void { - - // Filtercollection with an array as filter value - // (e.g. IN-query) - $model = $this->getModel('testdata'); - - $model->addFiltercollection([ - [ 'field' => 'testdata_text', 'operator' => '=', 'value' => [ 'foo' ] ], - ], 'OR'); - $res = $model->search()->getResult(); - $this->assertCount(2, $res); - $this->assertEquals([3.14, 5.36], array_column($res, 'testdata_number')); - - $model->addFiltercollection([ - [ 'field' => 'testdata_text', 'operator' => '!=', 'value' => [ 'foo' ] ], - ], 'OR'); - $res = $model->search()->getResult(); - $this->assertCount(2, $res); - $this->assertEquals([4.25, 0.99], array_column($res, 'testdata_number')); - } - - /** - * [testDefaultFiltercollectionValueArray description] - */ - public function testDefaultFiltercollectionValueArray(): void { - // Filtercollection with an array as filter value - // (e.g. IN-query) - $model = $this->getModel('testdata'); - - $model->addDefaultFilterCollection([ - [ 'field' => 'testdata_text', 'operator' => '=', 'value' => [ 'foo' ] ], - ], 'OR'); - $res = $model->search()->getResult(); - $this->assertCount(2, $res); - $this->assertEquals([3.14, 5.36], array_column($res, 'testdata_number')); - - // as we've added a default FC (and nothing else) - // searching second time should yield the same resultset - $this->assertEquals($res, $model->search()->getResult()); - } - - /** - * Tests performing a regular left join - * using forced virtual joining with no dataset available/set - * to return a nulled/empty child dataset - */ - public function testLeftJoinForcedVirtualNoReferenceDataset(): void { - $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) - ->addModel( - $personModel = $this->getModel('person')->setVirtualFieldResult(true) - ->setForceVirtualJoin(true), - ); +abstract class abstractModelTest extends base +{ + /** + * @var bool + */ + protected static bool $initialized = false; + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws exception + */ + public static function tearDownAfterClass(): void + { + static::deleteTestData(); + parent::tearDownAfterClass(); + static::$initialized = false; + overrideableApp::reset(); + } + + /** + * Deletes data that is created during createTestData() + * @return void + * @throws ReflectionException + * @throws exception + */ + public static function deleteTestData(): void + { + $cleanupModels = [ + 'testdata', + 'details', + 'moredetails', + 'timemachine', + 'table1', + 'table2', + ]; + foreach ($cleanupModels as $modelName) { + $model = static::getModelStatic($modelName); + $model->addFilter($model->getPrimaryKey(), 0, '>') + ->delete()->reset(); + + // NOTE: we should not assert this in a static way + // as it interferes with parallel or isolated test execution + // and tests, that target doesNotPerformAssertions + // static::assertEquals(0, $model->getCount()); + } + } - $customerModel->saveWithChildren([ - 'customer_no' => 'join_fv_nochild', - // No customer_person provided - ]); + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testSetConfigExplicitConnectionValid(): void + { + $model = $this->getModel('testdata'); + $model->setConfig('default', 'testschema', 'testdata'); + + $dataset = $model->setLimit(1)->search()->getResult()[0]; + static::assertGreaterThanOrEqual(1, $dataset['testdata_id']); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testSetConfigExplicitConnectionInvalid(): void + { + $this->expectException(exception::class); + // TODO: right now we expect EXCEPTION_GETDATA_REQUESTEDKEYINTYPENOTFOUND message + // but this might change soon + $model = $this->getModel('testdata'); + $model->setConfig('nonexisting_connection', 'testschema', 'testdata'); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testSetConfigInvalidValues(): void + { + $this->expectException(exception::class); + // TODO: specify the exception message + $model = $this->getModel('testdata'); + $model->setConfig('default', 'nonexisting_schema', 'nonexisting_model'); + } - $customerId = $customerModel->lastInsertId(); + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testModelconfigInvalidWithoutCreatedAndModifiedField(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage(sql::EXCEPTION_MODEL_CONFIG_MISSING_FIELD); + new sqlModel('nonexisting', 'without_created_and_modified', [ + 'field' => [ + 'without_created_and_modified_id', + ], + 'primary' => [ + 'without_created_and_modified_id', + ], + 'datatype' => [ + 'without_created_and_modified_id' => 'number_natural', + ], + ]); + } - // make sure to only find one result - // (one entry that has both datasets) - $dataset = $customerModel->load($customerId); + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testModelconfigInvalidWithoutModifiedField(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage(sql::EXCEPTION_MODEL_CONFIG_MISSING_FIELD); + new sqlModel('nonexisting', 'without_modified', [ + 'field' => [ + 'without_modified_id', + 'without_modified_created', + ], + 'primary' => [ + 'without_modified_id', + ], + 'datatype' => [ + 'without_modified_id' => 'number_natural', + 'without_modified_created' => 'text_timestamp', + ], + ]); + } - $this->assertEquals('join_fv_nochild', $dataset['customer_no']); - $this->assertNotEmpty($dataset['customer_person']); - foreach($personModel->getFields() as $field) { - if($personModel->getConfig()->get('datatype>'.$field) == 'virtual') { + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testDeleteWithoutArgsWillFail(): void + { // - // NOTE: we have no child models added - // and we expect the result to NOT have those (virtual) fields at all + // ::delete() without given PKEY, nor filters, MUST FAIL. // - $this->assertArrayNotHasKey($field, $dataset['customer_person']); - } else { - // Expect the key(s) to exist, but be null. - $this->assertArrayHasKey($field, $dataset['customer_person']); - $this->assertNull($dataset['customer_person'][$field]); - } + $this->expectException(exception::class); + $this->expectExceptionMessage('EXCEPTION_MODEL_SCHEMATIC_SQL_DELETE_NO_FILTERS_DEFINED'); + $model = $this->getModel('testdata'); + $model->delete(); } + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testUpdateWithoutArgsWillFail(): void + { + // + // ::update() without filters MUST FAIL. + // + $this->expectException(exception::class); + $this->expectExceptionMessage('EXCEPTION_MODEL_SCHEMATIC_SQL_UPDATE_NO_FILTERS_DEFINED'); + $model = $this->getModel('testdata'); + if (!($model instanceof sql)) { + static::fail('setup fail'); + } + $model->update([ + 'testdata_integer' => 0, + ]); + } + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddCalculatedFieldExistsWillFail(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage(model::EXCEPTION_ADDCALCULATEDFIELD_FIELDALREADYEXISTS); + $this->getModel('testdata') + ->addCalculatedField('testdata_integer', '(1+1)'); + } - // - // Test again using no VFR and varying FVJ states - // - $forceVirtualJoinStates = [ true, false ]; + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testHideFieldSingle(): void + { + $model = $this->getModel('testdata'); + $fields = $model->getFields(); + + $visibleFields = array_filter($fields, function ($f) { + return ($f != 'testdata_integer'); + }); + + $model->hideField('testdata_integer'); + $res = $model->search()->getResult(); + + static::assertCount(4, $res); + foreach ($res as $r) { + // + // Make sure we don't get testdata_integer + // but every other field + // + foreach ($visibleFields as $f) { + static::assertArrayHasKey($f, $r); + } + static::assertArrayNotHasKey('testdata_integer', $r); + } + } - foreach($forceVirtualJoinStates as $fvjState) { + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testHideFieldMultipleCommaTrim(): void + { + $model = $this->getModel('testdata'); + $fields = $model->getFields(); + + $visibleFields = array_filter($fields, function ($f) { + return ($f != 'testdata_integer') && ($f != 'testdata_text'); + }); + + // Testing auto-split/explode and trim + $model->hideField('testdata_integer, testdata_text'); + $res = $model->search()->getResult(); + + static::assertCount(4, $res); + foreach ($res as $r) { + // + // Make sure we don't get testdata_integer and testdata_text + // but every other field + // + foreach ($visibleFields as $f) { + static::assertArrayHasKey($f, $r); + } + static::assertArrayNotHasKey('testdata_integer', $r); + static::assertArrayNotHasKey('testdata_text', $r); + } + } - $noVfrCustomerModel = $this->getModel('customer')->setVirtualFieldResult(false) - ->addModel( - $noVfrPersonModel = $this->getModel('person')->setVirtualFieldResult(false) - ->setForceVirtualJoin($fvjState), - ); + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testHideAllFieldsAddOne(): void + { + $model = $this->getModel('testdata'); + $res = $model + ->hideAllFields() + ->addField('testdata_integer') + ->search()->getResult(); + static::assertCount(4, $res); + foreach ($res as $r) { + // Make sure 'testdata_integer' is the one and only field in the result datasets + static::assertArrayHasKey('testdata_integer', $r); + static::assertEquals(['testdata_integer'], array_keys($r)); + } + } - $datasetNoVfr = $noVfrCustomerModel->load($customerId); - - $this->assertEquals('join_fv_nochild', $datasetNoVfr['customer_no']); - $this->assertArrayNotHasKey('customer_person', $datasetNoVfr); - foreach($noVfrPersonModel->getFields() as $field) { - if($noVfrPersonModel->getConfig()->get('datatype>'.$field) == 'virtual') { - // - // NOTE: we have no child models added - // and we expect the result to NOT have those (virtual) fields at all - // - $this->assertArrayNotHasKey($field, $datasetNoVfr); - } else { - // Expect the key(s) to exist, but be null. - $this->assertArrayHasKey($field, $datasetNoVfr); - $this->assertNull($datasetNoVfr[$field]); + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testHideAllFieldsAddMultiple(): void + { + $model = $this->getModel('testdata'); + $res = $model + ->hideAllFields() + ->addField('testdata_integer,testdata_text, testdata_number ') // internal trimming + ->search()->getResult(); + static::assertCount(4, $res); + foreach ($res as $r) { + // Make sure 'testdata_integer' is the one and only field in the result datasets + static::assertArrayHasKey('testdata_integer', $r); + static::assertArrayHasKey('testdata_text', $r); + static::assertArrayHasKey('testdata_number', $r); + static::assertEquals(['testdata_integer', 'testdata_text', 'testdata_number'], array_keys($r)); } - } - } - - - $customerModel->delete($customerId); - } - - /** - * [testInnerJoinRegular description] - */ - public function testInnerJoinRegular(): void { - $this->testInnerJoin(false); - } - - /** - * [testInnerJoinForcedVirtualJoin description] - */ - public function testInnerJoinForcedVirtualJoin(): void { - $this->testInnerJoin(true); - } - - /** - * [testInnerJoin description] - * @param bool $forceVirtualJoin [description] - */ - protected function testInnerJoin(bool $forceVirtualJoin): void { - $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) - ->addModel( - $personModel = $this->getModel('person')->setVirtualFieldResult(true) - ); - - $customerIds = []; - $personIds = []; - - $customerModel->saveWithChildren([ - 'customer_no' => 'join1', - 'customer_person' => [ - 'person_firstname' => 'Some', - 'person_lastname' => 'Join', - ] - ]); - - $customerIds[] = $customerModel->lastInsertId(); - $personIds[] = $personModel->lastInsertId(); - - $customerModel->saveWithChildren([ - 'customer_no' => 'join2', - 'customer_person' => null - ]); - - $customerIds[] = $customerModel->lastInsertId(); - $personIds[] = $personModel->lastInsertId(); - - $personModel->save([ - 'person_firstname' => 'extra', - 'person_lastname' => 'person', - ]); - $personIds[] = $personModel->lastInsertId(); - - $innerJoinModel = $this->getModel('customer')->setVirtualFieldResult(true) - ->addModel( - $this->getModel('person') - ->setVirtualFieldResult(true) - ->setForceVirtualJoin($forceVirtualJoin), - \codename\core\model\plugin\join::TYPE_INNER - ); - - // make sure to only find one result - // (one entry that has both datasets) - $innerJoinRes = $innerJoinModel->search()->getResult(); - $this->assertCount(1, $innerJoinRes); - - // compare to regular result (left join) - $res = $customerModel->search()->getResult(); - $this->assertCount(2, $res); - - foreach($customerIds as $id) { - $customerModel->delete($id); - } - foreach($personIds as $id) { - $personModel->delete($id); - } - } - - /** - * Tests a special situation: - * - * customer (model, vfr enabled) - * customer_person (vfield) displays: - * -> person (model, joined) - * person_country (field) is join base for: - * -> country (model, bare join) - * - * if you hideAllFields in customer, - * customer_person does not exist and neither does person_country - * but the join is tried anyways. - * We're throwing an exception this case, - * as it is an indicator for incomplete code, missing definition - * or even legacy code. - */ - public function testJoinVirtualFieldResultEnabledMissingVKey(): void { - $customerModel = $this->getModel('customer') - ->setVirtualFieldResult(true) - ->hideAllFields() - ->addField('customer_no') - ->addModel( - $personModel = $this->getModel('person') - ->addModel($this->getModel('country')) - ); - - $personModel->save([ - 'person_firstname' => 'john', - 'person_lastname' => 'doe', - 'person_country' => 'DE', - ]); - $personId = $personModel->lastInsertId(); - $customerModel->save([ - 'customer_no' => 'missing_vkey', - 'customer_person_id' => $personId, - ]); - $customerId = $customerModel->lastInsertId(); - - $dataset = $customerModel->load($customerId); - $this->assertArrayHasKey('customer_person', $dataset); - $this->assertEquals('john', $dataset['customer_person']['person_firstname']); - $this->assertEquals('Germany', $dataset['customer_person']['country_name']); - - // - // NOTE: this is still pending clearance. For now, this emulates the old behaviour. - // VFR keys are added implicitly - // - // try { - // $dataset = $customerModel->load($customerId); - // $this->fail('Dataset loaded without exception to be fired - should crash.'); - // } catch (\codename\core\exception $e) { - // // NOTE: we only catch this specific exception! - // $this->assertEquals('EXCEPTION_MODEL_PERFORMBAREJOIN_MISSING_VKEY', $e->getMessage()); - // } - - $customerModel->delete($customerId); - $personModel->delete($personId); - } - - /** - * [testJoinVirtualFieldResultEnabledCustomVKey description] - */ - public function testJoinVirtualFieldResultEnabledCustomVKey(): void { - $customerModel = $this->getModel('customer') - ->setVirtualFieldResult(true) - ->addModel( - $personModel = $this->getModel('person') - ->addModel($this->getModel('country')) - ); - - $personModel->save([ - 'person_firstname' => 'john', - 'person_lastname' => 'doe', - 'person_country' => 'DE', - ]); - $personId = $personModel->lastInsertId(); - $customerModel->save([ - 'customer_no' => 'missing_vkey', - 'customer_person_id' => $personId, - ]); - $customerId = $customerModel->lastInsertId(); - - $customVKeyModel = $this->getModel('customer') - ->setVirtualFieldResult(true) - ->addModel( - $personModel = $this->getModel('person') - ->addModel($this->getModel('country')) - ); - - // change the virtual field name of the join - $join = $customVKeyModel->getNestedJoins('person')[0]; - $join->virtualField = 'custom_vfield'; - - $dataset = $customVKeyModel->load($customerId); - $this->assertArrayNotHasKey('customer_person', $dataset); - $this->assertArrayHasKey('custom_vfield', $dataset); - $this->assertEquals('john', $dataset['custom_vfield']['person_firstname']); - $this->assertEquals('Germany', $dataset['custom_vfield']['country_name']); - - // NOTE: see testJoinVirtualFieldResultEnabledMissingVKey - - $customerModel->delete($customerId); - $personModel->delete($personId); - } - - /** - * Tests a special case of model renormalization - * no virtual field results enabled, two models on same nesting level (root) - * with one or more hidden fields (each?) - */ - public function testJoinHiddenFieldsNoVirtualFieldResult(): void { - $customerModel = $this->getModel('customer') - ->hideField('customer_no') - ->addModel( - $personModel = $this->getModel('person') - ->hideField('person_firstname') - ); - - $personModel->save([ - 'person_firstname' => 'john', - 'person_lastname' => 'doe', - ]); - $personId = $personModel->lastInsertId(); - $customerModel->save([ - 'customer_no' => 'no_vfr', - 'customer_person_id' => $personId, - ]); - $customerId = $customerModel->lastInsertId(); - - $dataset = $customerModel->load($customerId); - $this->assertEquals('doe', $dataset['person_lastname']); - $this->assertEquals($personId, $dataset['customer_person_id']); - $this->assertArrayNotHasKey('person_firstname', $dataset); - $this->assertArrayNotHasKey('customer_no', $dataset); - - $customerModel->delete($customerId); - $personModel->delete($personId); - } - - /** - * Tests equally named fields in a joined model - * to be re-normalized correctly - * NOTE: this is SQL syntax and might be erroneous on non-sql models - */ - public function testSameNamedCalculatedFieldsInVirtualFieldResults(): void { - $personModel = $this->getModel('person')->setVirtualFieldResult(true) - ->addCalculatedField('calcfield', '(1+1)') - ->addModel( - $parentPersonModel = $this->getModel('person')->setVirtualFieldResult(true) - ->addCalculatedField('calcfield', '(2+2)') - ); - - $personModel->saveWithChildren([ - 'person_firstname' => 'theFirstname', - 'person_lastname' => 'theLastName', - 'person_parent' => [ - 'person_firstname' => 'parentFirstname', - 'person_lastname' => 'parentLastName', - ] - ]); - - $personId = $personModel->lastInsertId(); - $parentPersonId = $parentPersonModel->lastInsertId(); - - $dataset = $personModel->load($personId); - $this->assertEquals(2, $dataset['calcfield']); - $this->assertEquals(4, $dataset['person_parent']['calcfield']); - - $personModel->delete($personId); - $parentPersonModel->delete($parentPersonId); - } - - /** - * [testMixedModeVirtualFields description] - */ - public function testRecursiveModelVirtualFieldDisabledWithAliasedFields(): void { - $personModel = $this->getModel('person')->setVirtualFieldResult(true) - ->hideAllFields() - ->addField('person_firstname') - ->addField('person_lastname') - ->addModel( - // Parent optionally as forced virtual - $parentPersonModel = $this->getModel('person') + } + + /** + * WARNING: this tests a special edge case - which is almost not the desired state, + * but there's no better defined solution right now, + * so we test for stability/behavior + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddFieldComplexEdgeCaseNoVfr(): void + { + $model = $this->getModel('testdata') + ->addModel($detailsModel = $this->getModel('details')) + ->addModel($moreDetailsModel = $this->getModel('moredetails')); + + $model->hideAllFields(); + $detailsModel->hideAllFields(); + $moreDetailsModel->hideAllFields(); + + $model->addVirtualField('test', function ($dataset) { + return 'value'; + }); + + $res = $model->search()->getResult(); + + $dataset = $res[0]; + + // we expect all pkeys to exist + static::assertArrayHasKey($model->getPrimaryKey(), $dataset); + static::assertArrayHasKey($detailsModel->getPrimaryKey(), $dataset); + static::assertArrayHasKey($moreDetailsModel->getPrimaryKey(), $dataset); + + // virtual field should not be there + // as we have no VFR set + static::assertArrayNotHasKey('test', $dataset); + } + + /** + * WARNING: this tests a special edge case - which is almost not the desired state, + * but there's no better defined solution right now, + * so we test for stability/behavior + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddFieldComplexEdgeCasePartialVfr(): void + { + $model = $this->getModel('testdata')->setVirtualFieldResult(true) + ->addModel($detailsModel = $this->getModel('details')) + ->addModel($moreDetailsModel = $this->getModel('moredetails')); + + $model->hideAllFields(); + $detailsModel->hideAllFields(); + $moreDetailsModel->hideAllFields(); + + $model->addVirtualField('test', function ($dataset) { + return 'value'; + }); + + $res = $model->search()->getResult(); + + $dataset = $res[0]; + + // WARNING: edge case - ->hideAllFields on all models have been called, + // but only a virtual field on a root model has been added + // this gives us a strange situation/result - root model will 'disappear' + // but the joins are kept, fully. + static::assertArrayNotHasKey($model->getPrimaryKey(), $dataset); + + // those will be available + static::assertArrayHasKey($detailsModel->getPrimaryKey(), $dataset); + static::assertArrayHasKey($moreDetailsModel->getPrimaryKey(), $dataset); + + // this virtual field on a root model should persist + static::assertArrayHasKey('test', $dataset); + } + + /** + * WARNING: this tests a special edge case - which is almost not the desired state, + * but there's no better defined solution right now, + * so we test for stability/behavior + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddFieldComplexEdgeCaseFullVfr(): void + { + $model = $this->getModel('testdata')->setVirtualFieldResult(true) + ->addModel($detailsModel = $this->getModel('details')->setVirtualFieldResult(true)) + ->addModel($moreDetailsModel = $this->getModel('moredetails')->setVirtualFieldResult(true)); + + $model->hideAllFields(); + $detailsModel->hideAllFields(); + $moreDetailsModel->hideAllFields(); + + $model->addVirtualField('test', function ($dataset) { + return 'value'; + }); + + $res = $model->search()->getResult(); + + $dataset = $res[0]; + + // WARNING: edge case - ->hideAllFields on all models have been called, + // but only a virtual field on a root model has been added + // this gives us a strange situation/result - root model will 'disappear' + // but the joins are kept, fully. + static::assertArrayNotHasKey($model->getPrimaryKey(), $dataset); + + // those will be available + static::assertArrayHasKey($detailsModel->getPrimaryKey(), $dataset); + static::assertArrayHasKey($moreDetailsModel->getPrimaryKey(), $dataset); + + // this virtual field on a root model should persist + static::assertArrayHasKey('test', $dataset); + } + + /** + * WARNING: this tests a special edge case - which is almost not the desired state, + * but there's no better defined solution right now, + * so we test for stability/behavior + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddFieldComplexEdgeCaseRegularFieldFullVfr(): void + { + $model = $this->getModel('testdata')->setVirtualFieldResult(true) + ->addModel($detailsModel = $this->getModel('details')->setVirtualFieldResult(true)) + ->addModel($moreDetailsModel = $this->getModel('moredetails')->setVirtualFieldResult(true)); + + $model->hideAllFields(); + $detailsModel->hideAllFields(); + $moreDetailsModel->hideAllFields(); + + // only add one field, in this case: the PKEY of the root model + $model->addField($model->getPrimaryKey()); + + $res = $model->search()->getResult(); + + $dataset = $res[0]; + + // WARNING: edge case - ->hideAllFields on all models have been called, + // and we only add a (regular) field on the root model + // all fields, except the root model's field will disappear + static::assertArrayHasKey($model->getPrimaryKey(), $dataset); + + // those will be available + static::assertArrayNotHasKey($detailsModel->getPrimaryKey(), $dataset); + static::assertArrayNotHasKey($moreDetailsModel->getPrimaryKey(), $dataset); + } + + /** + * WARNING: this tests a special edge case - which is almost not the desired state, + * but there's no better defined solution right now, + * so we test for stability/behavior + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddFieldComplexEdgeCaseNestedRegularFieldFullVfr(): void + { + $model = $this->getModel('testdata')->setVirtualFieldResult(true) + ->addModel($detailsModel = $this->getModel('details')->setVirtualFieldResult(true)) + ->addModel($moreDetailsModel = $this->getModel('moredetails')->setVirtualFieldResult(true)); + + $model->hideAllFields(); + $detailsModel->hideAllFields(); + $moreDetailsModel->hideAllFields(); + + // only add one field, in this case: the PKEY of the root model + $detailsModel->addField($detailsModel->getPrimaryKey()); + + $res = $model->search()->getResult(); + + $dataset = $res[0]; + + // WARNING: edge case - ->hideAllFields on all models have been called, + // and we only add a (regular) field on a *NESTED* model + // all fields, except the joined model's field will disappear + static::assertArrayHasKey($detailsModel->getPrimaryKey(), $dataset); + + // those will be available + static::assertArrayNotHasKey($model->getPrimaryKey(), $dataset); + static::assertArrayNotHasKey($moreDetailsModel->getPrimaryKey(), $dataset); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddFieldFailsWithNonexistingField(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage(model::EXCEPTION_ADDFIELD_FIELDNOTFOUND); + $model = $this->getModel('testdata'); + $model->addField('testdata_nonexisting'); // We expect an early failure + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddFieldFailsWithMultipleFieldsAndAliasProvided(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage('EXCEPTION_ADDFIELD_ALIAS_ON_MULTIPLE_FIELDS'); + $model = $this->getModel('testdata'); + $model->addField('testdata_integer,testdata_text', 'some_alias'); // This is a no-go. + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testHideAllFieldsAddAliasedField(): void + { + $model = $this->getModel('testdata'); + $res = $model ->hideAllFields() - ->addField('person_firstname', 'parent_firstname') - ->addField('person_lastname', 'parent_lastname') - ); - - $personModel->saveWithChildren([ - 'person_firstname' => 'theFirstname', - 'person_lastname' => 'theLastName', - 'person_parent' => [ - 'person_firstname' => 'parentFirstname', - 'person_lastname' => 'parentLastName', - ] - ]); - - // NOTE: Important, disable for the following step. - // (disabling vfields) - $personModel->setVirtualFieldResult(false); - - $personId = $personModel->lastInsertId(); - $parentPersonId = $parentPersonModel->lastInsertId(); - - $dataset = $personModel->load($personId); - $this->assertEquals([ - 'person_firstname' => 'theFirstname', - 'person_lastname' => 'theLastName', - 'parent_firstname' => 'parentFirstname', - 'parent_lastname' => 'parentLastName', - ], $dataset); - - $personModel->delete($personId); - $parentPersonModel->delete($parentPersonId); - } - - - /** - * Tests whether all identifiers and references behave as designed - * E.g. a child PKEY is authoritative over a given FKEY reference - * in the parent model's dataset - */ - public function testSaveWithChildrenAuthoritativeDatasetsAndIdentifiers(): void { - $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) - ->addModel($personModel = $this->getModel('person')); - - $customerModel->saveWithChildren([ - 'customer_no' => 'X000', - 'customer_person' => [ - 'person_firstname' => 'XYZ', - 'person_lastname' => 'ASD', - ] - ]); - - $customerId = $customerModel->lastInsertId(); - $personId = $personModel->lastInsertId(); - - $customerModel->saveWithChildren([ - 'customer_id' => $customerId, - 'customer_person' => [ - 'person_id' => $personId, - 'person_firstname' => 'VVV', - ] - ]); - - $dataset = $customerModel->load($customerId); - $this->assertEquals($personId, $dataset['customer_person_id']); - - // create a secondary, unassociated person - - $personModel->save([ - 'person_firstname' => 'otherX', - 'person_lastname' => 'otherY', - ]); - $otherPersonId = $personModel->lastInsertId(); - $customerModel->saveWithChildren([ - 'customer_id' => $customerId, - 'customer_person' => [ - 'person_id' => $otherPersonId, - 'person_firstname' => 'changed', - ] - ]); - - $dataset = $customerModel->load($customerId); - $this->assertEquals($otherPersonId, $dataset['customer_person_id']); - - $customerModel->saveWithChildren([ - 'customer_id' => $customerId, - 'customer_person' => [ - 'person_firstname' => 'another', - ] - ]); - $anotherPersonId = $personModel->lastInsertId(); - $dataset = $customerModel->load($customerId); - - // - // Make sure we have created another person (child dataset) implicitly - // - $this->assertNotEquals($otherPersonId, $dataset['customer_person_id']); - - // make sure child PKEY (if given) - // overrides the parent's FKEY value - $customerModel->saveWithChildren([ - 'customer_id' => $customerId, - 'customer_person_id' => $personId, - 'customer_person' => [ - 'person_id' => $otherPersonId, - ] - ]); - - $dataset = $customerModel->load($customerId); - $this->assertEquals($otherPersonId, $dataset['customer_person_id']); - - // Cleanup - $customerModel->delete($customerId); - $personModel->delete($personId); - $personModel->delete($otherPersonId); - $personModel->delete($anotherPersonId); - } - - /** - * Tests a complex case of joining and model renormalization - * (e.g. recursive models joined, but different fieldlists!) - * In this case, a forced virtual join comes in-between. - */ - public function testComplexVirtualRenormalizeForcedVirtualJoin(): void { - $this->testComplexVirtualRenormalize(true); - } - - /** - * Tests a complex case of joining and model renormalization - * (e.g. recursive models joined, but different fieldlists!) - */ - public function testComplexVirtualRenormalizeRegular(): void { - $this->testComplexVirtualRenormalize(false); - } - - /** - * [testComplexVirtualRenormalize description] - * @param bool $forceVirtualJoin [description] - */ - protected function testComplexVirtualRenormalize(bool $forceVirtualJoin): void { - $personModel = $this->getModel('person')->setVirtualFieldResult(true) - ->hideField('person_lastname') - ->addModel( - // Parent optionally as forced virtual - $parentPersonModel = $this->getModel('person')->setVirtualFieldResult(true) - ->hideField('person_firstname') - ->setForceVirtualJoin($forceVirtualJoin) - ); - - $personModel->saveWithChildren([ - 'person_firstname' => 'theFirstname', - 'person_lastname' => 'theLastName', - 'person_parent' => [ - 'person_firstname' => 'parentFirstname', - 'person_lastname' => 'parentLastName', - ] - ]); - - $personId = $personModel->lastInsertId(); - $parentPersonId = $parentPersonModel->lastInsertId(); - - $dataset = $personModel->load($personId); - - $this->assertArrayNotHasKey('person_lastname', $dataset); - $this->assertArrayNotHasKey('person_firstname', $dataset['person_parent']); - - // re-add the hidden fields aliased - $personModel->addField('person_lastname', 'aliased_lastname'); - $parentPersonModel->addField('person_firstname', 'aliased_firstname'); - $dataset = $personModel->load($personId); - $this->assertEquals('theLastName', $dataset['aliased_lastname']); - $this->assertEquals('parentFirstname', $dataset['person_parent']['aliased_firstname']); - - // add the alias fields to the respective other models - // (aliased vfield renormalization) - $parentPersonModel->addField('person_lastname', 'aliased_lastname'); - $personModel->addField('person_firstname', 'aliased_firstname'); - $dataset = $personModel->load($personId); - $this->assertEquals('theFirstname', $dataset['aliased_firstname']); - $this->assertEquals('theLastName', $dataset['aliased_lastname']); - $this->assertEquals('parentFirstname', $dataset['person_parent']['aliased_firstname']); - $this->assertEquals('parentLastName', $dataset['person_parent']['aliased_lastname']); - - $personModel->delete($personId); - $parentPersonModel->delete($parentPersonId); - } - - /** - * [testComplexJoin description] - */ - public function testComplexJoin(): void { - $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) - ->addModel( - $personModel = $this->getModel('person')->setVirtualFieldResult(true) - ->addVirtualField('person_fullname1', function($dataset) { - return $dataset['person_firstname'].' '.$dataset['person_lastname']; - }) - ->addModel($this->getModel('country')) - ->addModel( - // Parent as forced virtual - $parentPersonModel = $this->getModel('person')->setVirtualFieldResult(true) - ->addVirtualField('person_fullname2', function($dataset) { - return $dataset['person_firstname'].' '.$dataset['person_lastname']; - }) - ->setForceVirtualJoin(true) - ->addModel($this->getModel('country')) + ->addField('testdata_integer', 'aliased_field') + ->search()->getResult(); + static::assertCount(4, $res); + foreach ($res as $r) { + // Make sure 'aliased_field' is the one and only field in the result datasets + static::assertArrayHasKey('aliased_field', $r); + static::assertEquals(['aliased_field'], array_keys($r)); + } + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testSimpleModelJoin(): void + { + $model = $this->getModel('testdata') + ->addModel($detailsModel = $this->getModel('details')); + + $originalDataset = [ + 'testdata_number' => 3.3, + 'testdata_text' => 'some_dataset', + ]; + + $detailsModel->save([ + 'details_data' => $originalDataset, + ]); + $detailsId = $detailsModel->lastInsertId(); + + $model->save( + array_merge( + $originalDataset, + ['testdata_details_id' => $detailsId] ) - ) - ; - - $customerModel->saveWithChildren([ - 'customer_no' => 'COMPLEX1', - 'customer_person' => [ - 'person_firstname' => 'Johnny', - 'person_lastname' => 'Doenny', - 'person_birthdate' => '1950-04-01', - 'person_country' => 'AT', - 'person_parent' => [ - 'person_firstname' => 'Johnnys', - 'person_lastname' => 'Father', - 'person_birthdate' => '1930-12-10', - 'person_country' => 'DE', - ] - ] - ]); - - $customerId = $customerModel->lastInsertId(); - $personId = $personModel->lastInsertId(); - $parentPersonId = $parentPersonModel->lastInsertId(); - - $dataset = $customerModel->load($customerId); - - $this->assertEquals('COMPLEX1', $dataset['customer_no']); - $this->assertEquals('Doenny', $dataset['customer_person']['person_lastname']); - $this->assertEquals('Austria', $dataset['customer_person']['country_name']); - $this->assertEquals('Father', $dataset['customer_person']['person_parent']['person_lastname']); - $this->assertEquals('Germany', $dataset['customer_person']['person_parent']['country_name']); - - $this->assertEquals('Johnny Doenny', $dataset['customer_person']['person_fullname1']); - $this->assertEquals('Johnnys Father', $dataset['customer_person']['person_parent']['person_fullname2']); - - // make sure there are no other fields on the root level - $intersect = array_intersect(array_keys($dataset), $customerModel->getFields()); - $this->assertEmpty(array_diff(array_keys($dataset), $intersect)); - - $customerModel->delete($customerId); - $personModel->delete($personId); - $parentPersonModel->delete($parentPersonId); - } - - /** - * Joins a model (itself) recursively (as far as possible) - * @param string $modelName [model used for joining recursively] - * @param int $limit [amount of joins performed] - * @param bool $virtualFieldResult [whether to switch on vFieldResults by default] - * @return \codename\core\model - */ - protected function joinRecursively(string $modelName, int $limit, bool $virtualFieldResult = false): \codename\core\model { - $model = $this->getModel($modelName)->setVirtualFieldResult($virtualFieldResult); - $currentModel = $model; - for ($i=0; $i < $limit; $i++) { - $recurseModel = $this->getModel($modelName)->setVirtualFieldResult($virtualFieldResult); - $currentModel->addModel($recurseModel); - $currentModel = $recurseModel; - } - return $model; - } - - /** - * [testJoinNestingLimitExceededWillFail description] - */ - public function testJoinNestingLimitExceededWillFail(): void { - $this->expectException(\PDOException::class); - // exhaust the join nesting limit - $model = $this->joinRecursively('person', $this->getJoinNestingLimit()); - $model->search()->getResult(); - } - - /** - * [testJoinNestingLimitMaxxedOut description] - */ - public function testJoinNestingLimitMaxxedOut(): void { - $this->expectNotToPerformAssertions(); - // Try to max-out the join nesting limit (limit - 1) - $model = $this->joinRecursively('person', $this->getJoinNestingLimit() - 1); - $model->search()->getResult(); - } - - /** - * [testJoinNestingLimitMaxxedOutSaving description] - */ - public function testJoinNestingLimitMaxxedOutSaving(): void { - $this->testJoinNestingLimit(); - } - - /** - * [testJoinNestingBypassLimitation1 description] - */ - public function testJoinNestingBypassLimitation1(): void { - $this->testJoinNestingLimit(1); - } - - /** - * [testJoinNestingBypassLimitation2 description] - */ - public function testJoinNestingBypassLimitation2(): void { - $this->testJoinNestingLimit(2); - } - - /** - * [testJoinNestingBypassLimitation3 description] - */ - public function testJoinNestingBypassLimitation3(): void { - $this->testJoinNestingLimit(3); - } - - - /** - * [testJoinNestingLimit description] - * @param int|null $exceedLimit [description] - */ - protected function testJoinNestingLimit(?int $exceedLimit = null): void { - - $limit = $this->getJoinNestingLimit() - 1; - - $model = $this->joinRecursively('person', $limit, true); - - $deeperModel = null; - if($exceedLimit) { - $currentJoin = $model->getNestedJoins('person')[0] ?? null; - $deeplyNestedJoin = $currentJoin; - while($currentJoin !== null) { - $currentJoin = $currentJoin->model->getNestedJoins('person')[0] ?? null; - if($currentJoin) { - $deeplyNestedJoin = $currentJoin; + ); + $id = $model->lastInsertId(); + + $dataset = $model->load($id); + static::assertEquals($originalDataset, $dataset['details_data']); + + foreach ($detailsModel->getFields() as $field) { + if ($detailsModel->getConfig()->get('datatype>' . $field) == 'virtual') { + // In this case, no vfields/handler, expect it to NOT appear. + static::assertArrayNotHasKey($field, $dataset); + } else { + static::assertArrayHasKey($field, $dataset); + } + } + foreach ($model->getFields() as $field) { + static::assertArrayHasKey($field, $dataset); } - } - $deeperModel = $this->getModel('person') - ->setVirtualFieldResult(true) - ->setForceVirtualJoin(true); - - $deeplyNestedJoin->model - ->addModel( - $deeperModel + + $model->delete($id); + $detailsModel->delete($detailsId); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testSimpleModelJoinWithVirtualFields(): void + { + $model = $this->getModel('testdata')->setVirtualFieldResult(true) + ->addModel($detailsModel = $this->getModel('details')); + + $originalDataset = [ + 'testdata_number' => 3.3, + 'testdata_text' => 'some_dataset', + ]; + + $detailsModel->save([ + 'details_data' => $originalDataset, + ]); + $detailsId = $detailsModel->lastInsertId(); + + $model->save( + array_merge( + $originalDataset, + ['testdata_details_id' => $detailsId] + ) ); + $id = $model->lastInsertId(); - if($exceedLimit > 1) { - // NOTE: joinRecursively returns at least 1 model instance - // as we already have one above, we now have to reduce by 2 (!) - $evenDeeperModel = $this->joinRecursively('person', $exceedLimit-2, true); - $deeperModel->addModel($evenDeeperModel); - } - $limit += $exceedLimit; + $dataset = $model->load($id); + + static::assertEquals($originalDataset, $dataset['details_data']); + + foreach ($detailsModel->getFields() as $field) { + if ($detailsModel->getConfig()->get('datatype>' . $field) == 'virtual') { + // In this case, no vfields/handler, expect it to NOT appear. + static::assertArrayNotHasKey($field, $dataset); + } else { + static::assertArrayHasKey($field, $dataset); + } + } + foreach ($model->getFields() as $field) { + static::assertArrayHasKey($field, $dataset); + } + + // modify some model details + $model->hideField('testdata_id'); + $detailsModel->hideField('details_created'); + $model->addField('testdata_id', 'root_level_alias'); + $detailsModel->addField('details_id', 'nested_alias'); + + $dataset = $model->load($id); + + static::assertArrayNotHasKey('testdata_id', $dataset); + static::assertArrayNotHasKey('details_created', $dataset); + static::assertArrayHasKey('root_level_alias', $dataset); + static::assertArrayHasKey('nested_alias', $dataset); + + static::assertEquals($id, $dataset['root_level_alias']); + static::assertEquals($detailsId, $dataset['nested_alias']); + + $model->delete($id); + $detailsModel->delete($detailsId); } + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testConditionalJoin(): void + { + $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) + ->addModel( + $personModel = $this->getModel('person')->setVirtualFieldResult(true) + ); + + $customerIds = []; + $personIds = []; + + $datasets = [ + [ + 'customer_no' => 'A1000', + 'customer_person' => [ + 'person_country' => 'AT', + 'person_firstname' => 'Alex', + 'person_lastname' => 'Anderson', + 'person_birthdate' => '1978-02-03', + ], + ], + [ + 'customer_no' => 'A1001', + 'customer_person' => [ + 'person_country' => 'AT', + 'person_firstname' => 'Bridget', + 'person_lastname' => 'Balmer', + 'person_birthdate' => '1981-11-15', + ], + ], + [ + 'customer_no' => 'A1002', + 'customer_person' => [ + 'person_country' => 'DE', + 'person_firstname' => 'Christian', + 'person_lastname' => 'Crossback', + 'person_birthdate' => '1990-04-19', + ], + ], + [ + 'customer_no' => 'A1003', + 'customer_person' => [ + 'person_country' => 'DE', + 'person_firstname' => 'Dodgy', + 'person_lastname' => 'Data', + 'person_birthdate' => null, + ], + ], + ]; + + if (!($customerModel instanceof sql)) { + static::fail('setup fail'); + } + + foreach ($datasets as $d) { + $customerModel->saveWithChildren($d); + $customerIds[] = $customerModel->lastInsertId(); + $personIds[] = $personModel->lastInsertId(); + } + + // w/o model_name + double conditions + $model = $this->getModel('customer') + ->addCustomJoin( + $this->getModel('person'), + join::TYPE_LEFT, + 'customer_person_id', + 'person_id', + [ + // will default to the higher-level model + ['field' => 'customer_no', 'operator' => '>=', 'value' => '\'A1001\''], + ['field' => 'customer_no', 'operator' => '<=', 'value' => '\'A1002\''], + ] + ); + $model->addOrder('customer_no'); // make sure to have the right order, see below + $model->saveLastQuery = true; + $res = $model->search()->getResult(); + static::assertCount(4, $res); + static::assertEquals([null, 'AT', 'DE', null], array_column($res, 'person_country')); + + // using model_name + $model = $this->getModel('customer') + ->addCustomJoin( + $this->getModel('person'), + join::TYPE_LEFT, + 'customer_person_id', + 'person_id', + [ + ['model_name' => 'person', 'field' => 'person_country', 'operator' => '=', 'value' => '\'DE\''], + ] + ); + $model->addOrder('customer_no'); // make sure to have the right order, see below + $model->saveLastQuery = true; + $res = $model->search()->getResult(); + static::assertCount(4, $res); + static::assertEquals([null, null, 'DE', 'DE'], array_column($res, 'person_country')); + + // null value condition + $model = $this->getModel('customer') + ->addCustomJoin( + $this->getModel('person'), + join::TYPE_LEFT, + 'customer_person_id', + 'person_id', + [ + ['model_name' => 'person', 'field' => 'person_birthdate', 'operator' => '=', 'value' => null], + ] + ); + $model->addOrder('customer_no'); // make sure to have the right order, see below + $model->saveLastQuery = true; + $res = $model->search()->getResult(); + static::assertCount(4, $res); + static::assertEquals([null, null, null, 'DE'], array_column($res, 'person_country')); + - $dataset = null; - $savedExceeded = 0; + foreach ($customerIds as $id) { + $customerModel->delete($id); + } + foreach ($personIds as $id) { + $personModel->delete($id); + } + } - // $maxI = $limit + 1; - foreach(range($limit + 1, 1) as $i) { - $dataset = [ - 'person_firstname' => 'firstname'.$i, - 'person_lastname' => 'testJoinNestingLimitMaxxedOutSaving', - 'person_parent' => $dataset, - ]; - if($exceedLimit && ($i > ($limit-$exceedLimit+1))) { - $dataset['person_country'] = 'DE'; - $savedExceeded++; - } + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testConditionalJoinFail(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage('INVALID_JOIN_CONDITION_MODEL_NAME'); + $model = $this->getModel('customer') + ->addCustomJoin( + $this->getModel('person'), + join::TYPE_LEFT, + 'customer_person_id', + 'person_id', + [ + // non-associated model... + ['model_name' => 'testdata', 'field' => 'testdata_number', 'operator' => '!=', 'value' => null], + ] + ); + $model->addOrder('customer_no'); // make sure to have the right order, see below + $model->saveLastQuery = true; + $model->search()->getResult(); } - $model->saveWithChildren($dataset); + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testReverseJoinEquality(): void + { + $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) + ->addModel( + $personModel = $this->getModel('person')->setVirtualFieldResult(true) + ); + + $customerIds = []; + $personIds = []; + + $datasets = [ + [ + 'customer_no' => 'A1000', + 'customer_person' => [ + 'person_country' => 'AT', + 'person_firstname' => 'Alex', + 'person_lastname' => 'Anderson', + 'person_birthdate' => '1978-02-03', + ], + ], + [ + 'customer_no' => 'A1001', + 'customer_person' => [ + 'person_country' => 'AT', + 'person_firstname' => 'Bridget', + 'person_lastname' => 'Balmer', + 'person_birthdate' => '1981-11-15', + ], + ], + [ + 'customer_no' => 'A1002', + 'customer_person' => [ + 'person_country' => 'DE', + 'person_firstname' => 'Christian', + 'person_lastname' => 'Crossback', + 'person_birthdate' => '1990-04-19', + ], + ], + [ + 'customer_no' => 'A1003', + 'customer_person' => [ + 'person_country' => 'DE', + 'person_firstname' => 'Dodgy', + 'person_lastname' => 'Data', + 'person_birthdate' => null, + ], + ], + ]; + + if (!($customerModel instanceof sql)) { + static::fail('setup fail'); + } + + foreach ($datasets as $d) { + $customerModel->saveWithChildren($d); + $customerIds[] = $customerModel->lastInsertId(); + $personIds[] = $personModel->lastInsertId(); + } + + // + // Create two models: + // one customer->person + // and one person->customer + // - as long as we don't have much more data in it + // this must match. + // TODO: test multijoin aliases + // + $forwardJoinModel = $this->getModel('customer') + ->addModel($this->getModel('person')); + $resForward = $forwardJoinModel->search()->getResult(); - $id = $model->lastInsertId(); + $reverseJoinModel = $this->getModel('person') + ->addModel($this->getModel('customer')); + $resReverse = $reverseJoinModel->search()->getResult(); - $loadedDataset = $model->load($id); + static::assertCount(4, $resForward); + static::assertEquals($resForward, $resReverse); - // if we have a deeper model joined - // (see above) we verify we have those tiny modifications - // successfully saved - if($deeperModel) { - $deeperId = $deeperModel->lastInsertId(); - $deeperDataset = $deeperModel->load($deeperId); - - // print_r($deeperDataset); - $this->assertEquals($exceedLimit, $savedExceeded); - - $diveDataset = $deeperDataset; - for ($i=0; $i < $savedExceeded; $i++) { - $this->assertEquals('DE', $diveDataset['person_country']); - $diveDataset = $diveDataset['person_parent'] ?? null; - } - } - - $this->assertEquals('firstname1', $loadedDataset['person_firstname']); - - foreach(range(0, $limit) as $l) { - $path = array_fill(0, $l,'person_parent'); - $childDataset = \codename\core\helper\deepaccess::get($dataset, $path); - $this->assertEquals('firstname'.($l + 1), $childDataset['person_firstname']); - } - - $cnt = $this->getModel('person') - ->addFilter('person_lastname', 'testJoinNestingLimitMaxxedOutSaving') - ->getCount(); - $this->assertEquals($limit + 1, $cnt); - - $this->getModel('person') - ->addDefaultfilter('person_lastname','testJoinNestingLimitMaxxedOutSaving') - ->update([ - 'person_parent_id' => null - ]) - ->delete(); - } - - /** - * Maximum (expected) join limit - * @return int [description] - */ - protected abstract function getJoinNestingLimit(): int; - - /** - * [testGetCount description] - */ - public function testGetCount(): void { - $model = $this->getModel('testdata'); - - $this->assertEquals(4, $model->getCount()); - - $model->addFilter('testdata_text', 'bar'); - $this->assertEquals(2, $model->getCount()); - - // Test model getCount() to _NOT_ reset filters - $this->assertEquals(2, $model->getCount()); - - // Explicit reset - $model->reset(); - $this->assertEquals(4, $model->getCount()); - } - - /** - * [testFlags description] - */ - public function testFlags(): void { - - // $this->markTestIncomplete('TODO: test flags'); - - $model = $this->getModel('testdata'); - $model->save([ - 'testdata_text' => 'flagtest' - ]); - $id = $model->lastInsertId(); - - $model->entryLoad($id); - - $flags = $model->getConfig()->get('flag'); - $this->assertCount(4, $flags); - - foreach($flags as $flagName => $flagMask) { - $this->assertEquals($model->getFlag($flagName), $flagMask); - $this->assertFalse($model->isFlag($flagMask, $model->getData())); - } - - // a little bit of fuzzing... - // $combos = [ 0 ]; - // for ($i=0; $i < count($flags); $i++) { - // $mask = pow(2, $i); - // $combos[] = $mask; - // for ($j=0; $j < count($flags); $j++) { - // $mask2 = pow(2, $j); - // if($mask != $mask2) { - // $combos[] = $mask + $mask2; - // } - // } - // } - - // $combos = []; - // $combos[] = 0; - // foreach(range(0,count($flags)) as $v1) { - // $combos[] = pow(2, $v1); - // $iterationV = $v1; - // foreach(range(0,count($flags)) as $v2) { - // if($v1 != $v2) { - // $iterationV += pow(2, $v2); - // $combos[] = $iterationV; - // } - // } - // } - // // print_r(array_unique($combos)); - // // foreach($combos as $c) { - // // $model->flagfieldValue(); - // // } - - $model->save($model->normalizeData([ - $model->getPrimaryKey() => $id, - 'testdata_flag' => [ - 'foo' => true, - 'baz' => true, - ] - ])); - - // - // we should only have one. - // see above. - // - $res = $model->withFlag($model->getFlag('foo'))->search()->getResult(); - $this->assertCount(1, $res); - - // We assume the base testdata entries have a null value - // and therefore, are not to be included in the results at all. - $res = $model->withoutFlag($model->getFlag('baz'))->search()->getResult(); - $this->assertCount(0, $res); - - // combined flags filters - $res = $model - ->withFlag($model->getFlag('foo')) - ->withoutFlag($model->getFlag('qux')) - ->search()->getResult(); - $this->assertCount(1, $res); - - $withDefaultFlagModel = $this->getModel('testdata')->withDefaultFlag($model->getFlag('foo')); - $res1 = $withDefaultFlagModel->search()->getResult(); - $res2 = $withDefaultFlagModel->search()->getResult(); // default filters are re-applied. - $this->assertCount(1, $res1); - $this->assertEquals($res1, $res2); - - $withoutDefaultFlagModel = $this->getModel('testdata')->withoutDefaultFlag($model->getFlag('baz')); - $res1 = $withoutDefaultFlagModel->search()->getResult(); - $res2 = $withoutDefaultFlagModel->search()->getResult(); // default filters are re-applied. - $this->assertCount(0, $res1); - $this->assertEquals($res1, $res2); - - $model->delete($id); - } - - /** - * [testFlagfieldValueNoFlagsInModel description] - */ - public function testFlagfieldValueNoFlagsInModel(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_MODEL_FUNCTION_FLAGFIELDVALUE_NOFLAGSINMODEL); - $this->getModel('person')->flagfieldValue(1, []); - } - - /** - * [testFlagfieldValu description] - */ - public function testFlagfieldValu(): void { - $model = $this->getModel('testdata'); - - $this->assertEquals(0, $model->flagfieldValue(0, []), 'No flags provided'); - $this->assertEquals(1, $model->flagfieldValue(1, []), 'Do not change anything'); - $this->assertEquals(128, $model->flagfieldValue(128, []), 'Do not change anything, nonexisting given'); - - $this->assertEquals(1 + 8, $model->flagfieldValue( - 1, - [ - 8 => true, - ] - ), 'Change a single flag'); - - $this->assertEquals(1 + 4 + 8, $model->flagfieldValue( - 1 + 2 + 8, - [ - 4 => true, - 2 => false, - ] - ), 'Change flags'); - - $this->assertEquals(1, $model->flagfieldValue( - 1, - [ - 128 => true, - ] - ), 'Setting invalid flag does not change anything'); - - $this->assertEquals(1, $model->flagfieldValue( - 1, - [ - (1 + 2) => true, - ] - ), 'Setting combined flag has no effect'); - - $this->assertEquals(1, $model->flagfieldValue( - 1, - [ - -2 => true, - ] - ), 'Setting invalid/negative flag has no effect'); - } - - /** - * [testGetFlagNonexisting description] - */ - public function testGetFlagNonexisting(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_GETFLAG_FLAGNOTFOUND); - $this->getModel('testdata')->getFlag('nonexisting'); - } - - /** - * [testIsFlagNoFlagField description] - */ - public function testIsFlagNoFlagField(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ISFLAG_NOFLAGFIELD); - $this->getModel('testdata')->isFlag(3, [ 'testdata_text' => 'abc' ]); - } - - /** - * [testFlagNormalization description] - */ - public function testFlagNormalization(): void { - $model = $this->getModel('testdata'); - - // - // no normalization, if value provided - // - $normalized = $model->normalizeData([ - 'testdata_flag' => 1 - ]); - $this->assertEquals(1, $normalized['testdata_flag']); - - // - // retain value, if flag values present - // that are not defined - // - $normalized = $model->normalizeData([ - 'testdata_flag' => 123 - ]); - $this->assertEquals(123, $normalized['testdata_flag']); - - // - // no flag (array-technique) - // - $normalized = $model->normalizeData([ - 'testdata_flag' => [] - ]); - $this->assertEquals(0, $normalized['testdata_flag']); - - // - // single flag - // - $normalized = $model->normalizeData([ - 'testdata_flag' => [ - 'foo' => true - ] - ]); - $this->assertEquals(1, $normalized['testdata_flag']); - - // - // multiple flags - // - $normalized = $model->normalizeData([ - 'testdata_flag' => [ - 'foo' => true, - 'baz' => true, - 'qux' => false, - ] - ]); - $this->assertEquals(5, $normalized['testdata_flag']); - - // - // nonexisting flag - // - $normalized = $model->normalizeData([ - 'testdata_flag' => [ - 'nonexisting' => true, - 'foo' => true, - 'baz' => true, - 'qux' => false, - ] - ]); - $this->assertEquals(5, $normalized['testdata_flag']); - } - - /** - * [testAddModelExplicitModelfieldValid description] - */ - public function testAddModelExplicitModelfieldValid(): void { - $saveCustomerModel = $this->getModel('customer')->setVirtualFieldResult(true) - ->addModel($savePersonModel = $this->getModel('person')); - $saveCustomerModel->saveWithChildren([ - 'customer_no' => 'ammv', - 'customer_person' => [ - 'person_firstname' => 'ammv1', - 'person_firstname' => 'ammv2', - ] - ]); - $customerId = $saveCustomerModel->lastInsertId(); - $personId = $savePersonModel->lastInsertId(); - - - $model = $this->getModel('customer') - ->addModel( - $this->getModel('person'), - \codename\core\model\plugin\join::TYPE_LEFT, - 'customer_person_id' - ); - - $res = $model->search()->getResult(); - $this->assertCount(1, $res); - - // TODO: detail data tests? - - $saveCustomerModel->delete($customerId); - $savePersonModel->delete($personId); - $this->assertEmpty($savePersonModel->load($personId)); - $this->assertEmpty($saveCustomerModel->load($customerId)); - } - - /** - * [testAddModelExplicitModelfieldInvalid description] - */ - public function testAddModelExplicitModelfieldInvalid(): void { - // - // Try to join on a field that's not designed for it - // - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage('EXCEPTION_MODEL_ADDMODEL_INVALID_OPERATION'); - - $model = $this->getModel('customer') - ->addModel( - $this->getModel('person'), - \codename\core\model\plugin\join::TYPE_LEFT, - 'customer_no' // invalid field for this model - ); - } - - /** - * [testAddModelInvalidNoRelation description] - */ - public function testAddModelInvalidNoRelation(): void { - // - // Try to join a model that has no relation to it - // - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage('EXCEPTION_MODEL_ADDMODEL_INVALID_OPERATION'); - - $model = $this->getModel('testdata') - ->addModel( - $this->getModel('person') - ); - } - - /** - * [testVirtualFieldSaving description] - */ - public function testVirtualFieldResultSaving(): void { - - $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) - ->addModel( - $personModel = $this->getModel('person')->setVirtualFieldResult(true) - ->addModel($parentPersonModel = $this->getModel('person')) - ) - ->addCollectionModel($contactentryModel = $this->getModel('contactentry')); - - $dataset = [ - 'customer_no' => 'K1000', - 'customer_person' => [ - 'person_firstname' => 'John', - 'person_lastname' => 'Doe', - 'person_birthdate' => '1970-01-01', - 'person_parent' => [ - 'person_firstname' => 'Maria', - 'person_lastname' => 'Ada', - 'person_birthdate' => null, - ] - ], - 'customer_contactentries' => [ - [ 'contactentry_name' => 'Phone', 'contactentry_telephone' => '+49123123123' ] - ] - ]; - - $this->assertTrue($customerModel->isValid($dataset)); - - $customerModel->saveWithChildren($dataset); - - $customerId = $customerModel->lastInsertId(); - $personId = $personModel->lastInsertId(); - $parentPersonId = $parentPersonModel->lastInsertId(); - - $dataset = $customerModel->load($customerId); - - $this->assertEquals('K1000', $dataset['customer_no']); - $this->assertEquals('John', $dataset['customer_person']['person_firstname']); - $this->assertEquals('Doe', $dataset['customer_person']['person_lastname']); - $this->assertEquals('Phone', $dataset['customer_contactentries'][0]['contactentry_name']); - $this->assertEquals('+49123123123', $dataset['customer_contactentries'][0]['contactentry_telephone']); - - $this->assertEquals('Maria', $dataset['customer_person']['person_parent']['person_firstname']); - $this->assertEquals('Ada', $dataset['customer_person']['person_parent']['person_lastname']); - $this->assertEquals(null, $dataset['customer_person']['person_parent']['person_birthdate']); - - $this->assertNotNull($dataset['customer_id']); - $this->assertNotNull($dataset['customer_person']['person_id']); - $this->assertNotNull($dataset['customer_contactentries'][0]['contactentry_id']); - - $this->assertEquals($dataset['customer_person_id'], $dataset['customer_person']['person_id']); - $this->assertEquals($dataset['customer_contactentries'][0]['contactentry_customer_id'], $dataset['customer_id']); - - // - // Cleanup - // - $customerModel->saveWithChildren([ - $customerModel->getPrimarykey() => $customerId, - // Implicitly remove contactentries by saving an empty collection (Not null!) - 'customer_contactentries' => [] - ]); - $customerModel->delete($customerId); - $personModel->delete($personId); - $parentPersonModel->delete($parentPersonId); - } - - /** - * [testVirtualFieldResultCollectionHandling description] - */ - public function testVirtualFieldResultCollectionHandling(): void { - $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) - ->addCollectionModel($contactentryModel = $this->getModel('contactentry')); - - $dataset = [ - 'customer_no' => 'K1002', - 'customer_contactentries' => [ - [ 'contactentry_name' => 'Entry1', 'contactentry_telephone' => '+49123123123' ], - [ 'contactentry_name' => 'Entry2', 'contactentry_telephone' => '+49234234234' ], - [ 'contactentry_name' => 'Entry3', 'contactentry_telephone' => '+49345345345' ], - ] - ]; - - $customerModel->saveWithChildren($dataset); - $id = $customerModel->lastInsertId(); - - $customer = $customerModel->load($id); - $this->assertCount(3, $customer['customer_contactentries']); - - // delete the middle contactentry - unset($customer['customer_contactentries'][1]); - - // store PKEYs of other entries - $contactentryIds = array_column($customer['customer_contactentries'], 'contactentry_id'); - $customerModel->saveWithChildren($customer); - - $customerModified = $customerModel->load($id); - $this->assertCount(2, $customerModified['customer_contactentries']); - - $contactentryIdsVerify = array_column($customerModified['customer_contactentries'], 'contactentry_id'); - - // assert the IDs haven't changed - $this->assertEquals($contactentryIds, $contactentryIdsVerify); - - // assert nothing happens if a null value is provided or being unset - $customerUnsetCollection = $customerModified; - unset($customerUnsetCollection['customer_contactentries']); - $customerModel->saveWithChildren($customerUnsetCollection); - $this->assertEquals($customerModified['customer_contactentries'], $customerModel->load($id)['customer_contactentries']); - - $customerNullCollection = $customerModified; - $customerNullCollection['customer_contactentries'] = null; - $customerModel->saveWithChildren($customerNullCollection); - $this->assertEquals($customerModified['customer_contactentries'], $customerModel->load($id)['customer_contactentries']); - - // - // Cleanup - // - $customerModel->saveWithChildren([ - $customerModel->getPrimarykey() => $id, - // Implicitly remove contactentries by saving an empty collection (Not null!) - 'customer_contactentries' => [] - ]); - $customerModel->delete($id); - } - - - /** - * Tests trying ::addCollectionModel w/o having the respective config. - */ - public function testAddCollectionModelMissingCollectionConfig(): void { - // Testdata model does not have a collection config - // (or, at least, it shouldn't have) - $model = $this->getModel('testdata'); - $this->assertFalse($model->getConfig()->exists('collection')); - - $this->expectExceptionMessage('EXCEPTION_NO_COLLECTION_KEY'); - $model->addCollectionModel($this->getModel('details')); - } - - /** - * Tests trying to ::addCollectionModel with an unsupported/unspecified model - */ - public function testAddCollectionModelIncompatible(): void { - $model = $this->getModel('customer'); - $this->expectExceptionMessage('EXCEPTION_UNKNOWN_COLLECTION_MODEL'); - $model->addCollectionModel($this->getModel('person')); - } - - /** - * Tests trying to ::addCollectionModel with a valid collection model - * but simply a wrong or nonexisting field - */ - public function testAddCollectionModelInvalidModelField(): void { - $model = $this->getModel('customer'); - $this->expectExceptionMessage('EXCEPTION_NO_COLLECTION_CONFIG'); - $model->addCollectionModel( - $this->getModel('contactentry'), // Compatible - 'nonexisting_collection_field' // different field - or incompatible - ); - } - - /** - * Tests trying to ::addCollectionModel with an incompatible model - * but a valid/existing collection field key - */ - public function testAddCollectionModelValidModelFieldIncompatibleModel(): void { - $model = $this->getModel('customer'); - $this->expectExceptionMessage('EXCEPTION_MODEL_ADDCOLLECTIONMODEL_INCOMPATIBLE'); - $model->addCollectionModel( - $this->getModel('person'), // Incompatible - 'customer_contactentries' // Existing/valid field, but irrelevant for the model to be joined - ); - } - - /** - * Tests various cases of collection retrieval - */ - public function testGetNestedCollections(): void { - // Model w/o any collection config - $this->assertEmpty($this->getModel('testdata')->getNestedCollections()); - - // Model with available, but unused collection - $this->assertEmpty( - $this->getModel('customer') - ->getNestedCollections() - ); - - // Model with available and _used_ collection - $collections = $this->getModel('customer') - ->addCollectionModel($this->getModel('contactentry')) - ->getNestedCollections(); - - $this->assertNotEmpty($collections); - $this->assertCount(1, $collections); - - $collectionPlugin = $collections['customer_contactentries']; - $this->assertInstanceOf(\codename\core\model\plugin\collection::class, $collectionPlugin); - - $this->assertEquals('customer', $collectionPlugin->baseModel->getIdentifier()); - $this->assertEquals('customer_id', $collectionPlugin->getBaseField()); - $this->assertEquals('customer_contactentries', $collectionPlugin->field->get()); - $this->assertEquals('contactentry', $collectionPlugin->collectionModel->getIdentifier()); - $this->assertEquals('contactentry_customer_id', $collectionPlugin->getCollectionModelBaseRefField()); - } - - /** - * test saving (expect a crash) when having two models joined ambiguously - * in virtual field result mode - */ - public function testVirtualFieldResultSavingFailedAmbiguousJoins(): void { - $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) - ->addModel($personModel = $this->getModel('person')) - ->addModel($personModel = $this->getModel('person')) // double joined - ->addCollectionModel($contactentryModel = $this->getModel('contactentry')); - - $dataset = [ - 'customer_no' => 'K1001', - 'customer_person' => [ - 'person_firstname' => 'John', - 'person_lastname' => 'Doe', - 'person_birthdate' => '1970-01-01', - ], - 'customer_contactentries' => [ - [ 'contactentry_name' => 'Phone', 'contactentry_telephone' => '+49123123123' ] - ] - ]; - - $this->assertTrue($customerModel->isValid($dataset)); - - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage('EXCEPTION_MODEL_SCHEMATIC_SQL_CHILDREN_AMBIGUOUS_JOINS'); - - $customerModel->saveWithChildren($dataset); - - // No need to cleanup, as it must fail beforehand - } - - /** - * tests a runtime-based virtual field - */ - public function testVirtualFieldQuery(): void { - $model = $this->getModel('testdata')->setVirtualFieldResult(true); - $model->addVirtualField('virtual_field', function($dataset) { - return $dataset['testdata_id']; - }); - $res = $model->search()->getResult(); - - $this->assertCount(4, $res); - foreach($res as $r) { - $this->assertEquals($r['testdata_id'], $r['virtual_field']); - } - } - - /** - * [testForcedVirtualJoinWithVirtualFieldResult description] - */ - public function testForcedVirtualJoinWithVirtualFieldResult(): void { - $this->testForcedVirtualJoin(true); - } - - /** - * [testForcedVirtualJoinWithoutVirtualFieldResult description] - */ - public function testForcedVirtualJoinWithoutVirtualFieldResult(): void { - $this->testForcedVirtualJoin(false); - } - - /** - * [testForcedVirtualJoin description] - * @param bool $virtualFieldResult [description] - */ - protected function testForcedVirtualJoin(bool $virtualFieldResult): void { - // - // Store test data - // - $saveCustomerModel = $this->getModel('customer')->setVirtualFieldResult(true) - ->addModel($savePersonModel = $this->getModel('person')->setVirtualFieldResult(true)); - $saveCustomerModel->saveWithChildren([ - 'customer_no' => 'fvj', - 'customer_person' => [ - 'person_firstname' => 'forced', - 'person_lastname' => 'virtualjoin', - ] - ]); - $customerId = $saveCustomerModel->lastInsertId(); - $personId = $savePersonModel->lastInsertId(); - - $referenceCustomerModel = $this->getModel('customer')->setVirtualFieldResult($virtualFieldResult) - ->addModel($referencePersonModel = $this->getModel('person')->setVirtualFieldResult($virtualFieldResult)); - - $referenceDataset = $referenceCustomerModel->load($customerId); - - // - // new model that is forced to do a virtual join - // - - // NOTE/IMPORTANT: force virtual join state has to be set *BEFORE* joining - $personModel = $this->getModel('person'); - $personModel->setForceVirtualJoin(true); - - $customerModel = $this->getModel('customer')->setVirtualFieldResult($virtualFieldResult) - ->addModel($personModel->setVirtualFieldResult($virtualFieldResult)); - - $customerModel->saveLastQuery = true; - $personModel->saveLastQuery = true; - - $compareDataset = $customerModel->load($customerId); - - $customerLastQuery = $customerModel->getLastQuery(); - $personLastQuery = $personModel->getLastQuery(); - - // assert that *BOTH* queries have been executed (not empty) - $this->assertNotNull($customerLastQuery); - $this->assertNotNull($personLastQuery); - $this->assertNotEquals($customerLastQuery, $personLastQuery); - - // echo(chr(10)."---REFERENCE---".chr(10)); - // print_r($referenceDataset); - // echo(chr(10)."---COMPARE---".chr(10)); - // print_r($compareDataset); - - foreach($referenceDataset as $key => $value) { - if(is_array($value)) { - foreach($value as $k => $v) { - if($v !== null) { - $this->assertEquals($v, $compareDataset[$key][$k]); - } + foreach ($customerIds as $id) { + $customerModel->delete($id); } - } else { - if($value !== null) { - $this->assertEquals($value, $compareDataset[$key]); + foreach ($personIds as $id) { + $personModel->delete($id); } - } - } - - // Assert both datasets are equal - // $this->assertEquals($referenceDataset, $compareDataset); - // NOTE: doesn't work right now, because: - // $this->addWarning('Some bug when doing forced virtual joins and unjoined vfields exist'); - // NOTE/CHANGED 2021-04-13: fixed. - - // make sure to clean up - $saveCustomerModel->delete($customerId); - $savePersonModel->delete($personId); - $this->assertEmpty($saveCustomerModel->load($customerId)); - $this->assertEmpty($savePersonModel->load($personId)); - } - - /** - * [testModelJoinWithJson description] - */ - public function testModelJoinWithJson(): void { - // inject some base data, first - $model = $this->getModel('person') - ->addModel($this->getModel('country')); - - $model->save([ - 'person_firstname' => 'German', - 'person_lastname' => 'Resident', - 'person_country' => 'DE', - ]); - $id = $model->lastInsertId(); - - $res = $model->load($id); - $this->assertEquals('DE', $res['person_country']); - $this->assertEquals('DE', $res['country_code']); - $this->assertEquals('Germany', $res['country_name']); - - $model->delete($id); - $this->assertEmpty($model->load($id)); - - // - // save another one, but without FKEY value for country - // - $model->save([ - 'person_firstname' => 'Resident', - 'person_lastname' => 'Without Country', - 'person_country' => null, - ]); - $id = $model->lastInsertId(); - - $res = $model->load($id); - $this->assertEquals(null, $res['person_country']); - $this->assertEquals(null, $res['country_code']); - $this->assertEquals(null, $res['country_name']); - - $model->delete($id); - $this->assertEmpty($model->load($id)); - } - - /** - * [testInvalidFilterOperator description] - */ - public function testInvalidFilterOperator(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage('EXCEPTION_INVALID_OPERATOR'); - $model = $this->getModel('testdata'); - $model->addFilter('testdata_integer', 42, '%&/'); - } - - /** - * [testLikeFilters description] - */ - public function testLikeFilters(): void { - $model = $this->getModel('testdata'); - - // NOTE: this is case sensitive on PG - $res = $model - ->addFilter('testdata_text', 'F%', 'LIKE') - ->search()->getResult(); - $this->assertCount(2, $res); - - // NOTE: this is case sensitive on PG - $res = $model - ->addFilter('testdata_text', 'f%', 'ILIKE') - ->search()->getResult(); - $this->assertCount(2, $res); - } - - /** - * [testSuccessfulCreateAndDeleteTransaction description] - */ - public function testSuccessfulCreateAndDeleteTransaction(): void { - $testTransactionModel = $this->getModel('testdata'); - - $transaction = new \codename\core\transaction('test', [ $testTransactionModel ]); - $transaction->start(); - - // insert a new entry - $testTransactionModel->save([ - 'testdata_integer' => 999, - ]); - $id = $testTransactionModel->lastInsertId(); - - // load the new dataset in the transaction - $newDataset = $testTransactionModel->load($id); - $this->assertEquals(999, $newDataset['testdata_integer']); - - // delete it - $testTransactionModel->delete($id); - - // end transaction, as if nothing happened - $transaction->end(); - - // Make sure it hasn't changed - $this->assertEquals(4, $testTransactionModel->getCount()); - } - - /** - * [testTransactionUntrackedRunning description] - */ - public function testTransactionUntrackedRunning(): void { - $model = $this->getModel('testdata'); - if($model instanceof \codename\core\model\schematic\sql) { - // Make sure there's no open transaction - // and start an untracked, new one. - $this->assertFalse($model->getConnection()->getConnection()->inTransaction()); - $this->assertTrue($model->getConnection()->getConnection()->beginTransaction()); - - $this->expectExceptionMessage('EXCEPTION_DATABASE_VIRTUALTRANSACTION_UNTRACKED_TRANSACTION_RUNNING'); - - $transaction = new \codename\core\transaction('untracked_transaction_test', [ $model ]); - $transaction->start(); - } else { - $this->markTestSkipped('Not applicable.'); - } - } - - /** - * [testTransactionRolledBackPrematurely description] - */ - public function testTransactionRolledBackPrematurely(): void { - $model = $this->getModel('testdata'); - if($model instanceof \codename\core\model\schematic\sql) { - // Make sure there's no open transaction - $this->assertFalse($model->getConnection()->getConnection()->inTransaction()); - - $this->expectExceptionMessage('EXCEPTION_DATABASE_VIRTUALTRANSACTION_UNTRACKED_TRANSACTION_RUNNING'); - - $transaction = new \codename\core\transaction('untracked_transaction_test', [ $model ]); - $transaction->start(); - - // Make sure transaction has begun - $this->assertTrue($model->getConnection()->getConnection()->inTransaction()); - - // End transaction/rollback right away - $this->assertTrue($model->getConnection()->getConnection()->rollBack()); - - // try to end transaction normally. But it was canceled before - $this->expectExceptionMessage('EXCEPTION_DATABASE_VIRTUALTRANSACTION_END_TRANSACTION_INTERRUPTED'); - $transaction->end(); - } else { - $this->markTestSkipped('Not applicable.'); - } - } - - /** - * [testOrderLimitOffset description] - */ - public function testNestedOrder(): void { - // Generic model features - // Offset [& Limit & Order] - $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) - ->addModel($personModel = $this->getModel('person')->setVirtualFieldResult(true)); - - $customerIds = []; - $personIds = []; - - $datasets = [ - [ - 'customer_no' => 'A1000', - 'customer_person' => [ - 'person_firstname' => 'Alex', - 'person_lastname' => 'Anderson', - 'person_birthdate' => '1978-02-03', - ], - ], - [ - 'customer_no' => 'A1001', - 'customer_person' => [ - 'person_firstname' => 'Bridget', - 'person_lastname' => 'Balmer', - 'person_birthdate' => '1981-11-15', - ], - ], - [ - 'customer_no' => 'A1002', - 'customer_person' => [ - 'person_firstname' => 'Christian', - 'person_lastname' => 'Crossback', - 'person_birthdate' => '1990-04-19', - ], - ], - [ - 'customer_no' => 'A1003', - 'customer_person' => [ - 'person_firstname' => 'Dodgy', - 'person_lastname' => 'Data', - 'person_birthdate' => null, - ], - ] - ]; - - foreach($datasets as $d) { - $customerModel->saveWithChildren($d); - $customerIds[] = $customerModel->lastInsertId(); - $personIds[] = $personModel->lastInsertId(); - } - - $customerModel->addOrder('person.person_birthdate', 'DESC'); - $res = $customerModel->search()->getResult(); - - $this->assertEquals([ 'A1002', 'A1001', 'A1000', 'A1003' ], array_column($res, 'customer_no')); - $this->assertEquals([ 'Christian', 'Bridget', 'Alex', 'Dodgy' ], array_map(function($dataset) { - return $dataset['customer_person']['person_firstname']; - }, $res)); - - // cleanup - foreach($customerIds as $id) { - $customerModel->delete($id); - } - foreach($personIds as $id) { - $personModel->delete($id); - } - } - - - /** - * [testOrderLimitOffset description] - */ - public function testOrderLimitOffset(): void { - // Generic model features - // Offset [& Limit & Order] - $testLimitModel = $this->getModel('testdata'); - $testLimitModel->addOrder('testdata_id', 'ASC'); - $testLimitModel->setLimit(1); - $testLimitModel->setOffset(1); - $res = $testLimitModel->search()->getResult(); - $this->assertCount(1, $res); - $this->assertEquals('bar', $res[0]['testdata_text']); - $this->assertEquals(4.25, $res[0]['testdata_number']); - } - - /** - * Tests setting limit & offset twice (reset) - * as only ONE limit and offset is allowed at a time - */ - public function testLimitOffsetReset(): void { - $model = $this->getModel('testdata'); - $model->addOrder('testdata_id', 'ASC'); - $model->setLimit(1); - $model->setOffset(1); - $model->setLimit(0); - $model->setOffset(0); - $res = $model->search()->getResult(); - $this->assertCount(4, $res); - } - - /** - * Tests whether calling model::addOrder() using a nonexisting field - * throws an exception - */ - public function testAddOrderOnNonexistingFieldWillThrow(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ADDORDER_FIELDNOTFOUND); - $model = $this->getModel('testdata'); - $model->addOrder('testdata_nonexisting', 'ASC'); - } - - /** - * Tests updating a structure field (simple) - */ - public function testStructureData(): void { - $model = $this->getModel('testdata'); - $res = $model - ->addFilter('testdata_text', 'foo') - ->addFilter('testdata_date', '2021-03-22') - ->addFilter('testdata_number', 3.14) - ->search()->getResult(); - $this->assertCount(1, $res); - - $testdata = $res[0]; - $id = $testdata[$model->getPrimarykey()]; - - $model->save([ - $model->getPrimarykey() => $testdata[$model->getPrimarykey()], - 'testdata_structure' => [ 'changed' => true ], - ]); - $updated = $model->load($id); - $this->assertEquals([ 'changed' => true ], $updated['testdata_structure']); - $model->save($testdata); - $restored = $model->load($id); - $this->assertEquals($testdata['testdata_structure'], $restored['testdata_structure']); - } - - /** - * tests internal handling during saving (create & update) - * and mass updates that might encode given object/array data - */ - public function testStructureEncoding(): void { - - $model = $this->getModel('testdata'); - - $model->save([ - 'testdata_text' => 'structure-object', - 'testdata_structure' => $testObjectData = [ - 'umlautÄÜÖäüöß' => '"and quotes"', - 'and\\backlashes' => '\\some\\\"backslashes', - 'and some more' => 'with nul bytes'.chr(0), - "with special bytes \u00c2\u00ae" => "\xc3\xa9", - ] - ]); - $objectDataId = $model->lastInsertId(); - - $model->save([ - 'testdata_text' => 'structure-array', - 'testdata_structure' => $testArrayData = [ - 'umlautÄÜÖäüöß', - '"and quotes"', - 'and\\backlashes', - '\\some\\\"backslashes', - 'and some more', - "with special bytes \u00c2\u00ae", - "\xc3\xa9", - 'with nul bytes'.chr(0), - 'more data', - ] - ]); - $arrayDataId = $model->lastInsertId(); - - $storedObjectData = $model->load($objectDataId)['testdata_structure']; - $storedArrayData = $model->load($arrayDataId)['testdata_structure']; - - $this->assertEquals($testObjectData, $storedObjectData); - $this->assertEquals($testArrayData, $storedArrayData); - - $model->save([ - $model->getPrimaryKey() => $objectDataId, - 'testdata_structure' => $updatedObjectData = array_merge( - $storedObjectData, - [ - 'updated' => 1 - ] - ) - ]); - - $model->save([ - $model->getPrimaryKey() => $arrayDataId, - 'testdata_structure' => $updatedArrayData = array_merge( - $storedArrayData, - [ - 'updated' - ] - ) - ]); - - $storedObjectData = $model->load($objectDataId)['testdata_structure']; - $storedArrayData = $model->load($arrayDataId)['testdata_structure']; - - $this->assertEquals($updatedObjectData, $storedObjectData); - $this->assertEquals($updatedArrayData, $storedArrayData); - - $model->addFilter($model->getPrimaryKey(), $objectDataId); - $model->update([ - 'testdata_structure' => $updatedObjectData = array_merge( - $storedObjectData, - [ - 'updated' => 2 - ] - ) - ]); - - $model->addFilter($model->getPrimaryKey(), $arrayDataId); - $model->update([ - 'testdata_structure' => $updatedArrayData = array_merge( - $storedArrayData, - [ - 'updated' - ] - ) - ]); - - $storedObjectData = $model->load($objectDataId)['testdata_structure']; - $storedArrayData = $model->load($arrayDataId)['testdata_structure']; - - $this->assertEquals($updatedObjectData, $storedObjectData); - $this->assertEquals($updatedArrayData, $storedArrayData); - - $model->delete($objectDataId); - $model->delete($arrayDataId); - } - - /** - * tests model::getCount() when having a grouped query - * should return the final count of results - */ - public function testGroupedGetCount(): void { - $model = $this->getModel('testdata'); - $model->addGroup('testdata_text'); - $this->assertEquals(2, $model->getCount()); - } - - /** - * Tests correct aliasing when using the same model twice - * and calling ->getCount() - */ - public function testGetCountAliasing(): void { - $model = $this->getModel('person') - ->addModel($this->getModel('person')); - - $this->assertEquals(0, $model->getCount()); - } - - /** - * Tests grouping on a calculated field - */ - public function testAddGroupOnCalculatedFieldDoesNotCrash(): void { - $model = $this->getModel('testdata'); - // For the sake of simplicity: just do a simple alias here... - $model->addCalculatedField('calc_field', '(testdata_text)'); - $model->addGroup('calc_field'); - - // We do not check for data integrity in this test. - $this->expectNotToPerformAssertions(); - $model->search()->getResult(); - } - - /** - * Tests grouping on a nested model's calculated field - * which in which case the alias of the model MUST NOT propagate - * as it is a unique, temporary field - */ - public function testAddGroupOnNestedCalculatedFieldDoesNotCrash(): void { - $model = $this->getModel('testdata') - ->addModel($detailsModel = $this->getModel('details')); - - // For the sake of simplicity: just do a simple alias here... - $detailsModel->addCalculatedField('nested_calc_field', '(details_data)'); - $detailsModel->addGroup('nested_calc_field'); - - // We do not check for data integrity in this test. - $this->expectNotToPerformAssertions(); - $model->search()->getResult(); - } - - /** - * Tests whether we get an exception when trying to group - * on a nonexisting field - */ - public function testAddGroupNonExistingField(): void { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ADDGROUP_FIELDDOESNOTEXIST); - - $model = $this->getModel('testdata') - ->addModel($detailsModel = $this->getModel('details')); - - $model->addGroup('nonexisting'); - } - - /** - * [testAmbiguousAliasFieldsNormalization description] - */ - public function testAmbiguousAliasFieldsNormalization(): void { - $model = $this->getModel('testdata') - ->addField('testdata_text', 'aliasedField') - ->addModel( - $detailsModel = $this->getModel('details') - ->addField('details_data', 'aliasedField') - ); - - $res = $model->search()->getResult(); - - // Same-level keys mapped to array - $this->assertEquals([ - [ 'foo', null ], - [ 'bar', null ], - [ 'foo', null ], - [ 'bar', null ], - ], array_column($res, 'aliasedField')); - - // Modify model to put details into a virtual field - $model->setVirtualFieldResult(true); - $model->getNestedJoins('details')[0]->virtualField = 'temp_virtual'; - - $res2 = $model->search()->getResult(); - - $this->assertEquals([ 'foo', 'bar', 'foo', 'bar' ], array_column($res2, 'aliasedField')); - $this->assertEquals([ null, null, null, null ], array_column(array_column($res2, 'temp_virtual'), 'aliasedField')); - } - - /** - * [testAggregateCount description] - */ - public function testAggregateCount(): void { - // - // Aggregate: count plugin - // - $testCountModel = $this->getModel('testdata'); - $testCountModel->addAggregateField('entries_count', 'count', 'testdata_id'); - - // count w/o filters - $this->assertEquals(4, $testCountModel->search()->getResult()[0]['entries_count']); - - // w/ simple filter added - $testCountModel->addFilter('testdata_datetime', '2020-01-01', '>='); - $this->assertEquals(3, $testCountModel->search()->getResult()[0]['entries_count']); - } - - /** - * [testAggregateCountDistinct description] - */ - public function testAggregateCountDistinct(): void { - // - // Aggregate: count_distinct plugin - // - $testCountDistinctModel = $this->getModel('testdata'); - $testCountDistinctModel->addAggregateField('entries_count', 'count_distinct', 'testdata_text'); - - // count w/o filters - $this->assertEquals(2, $testCountDistinctModel->search()->getResult()[0]['entries_count']); - - // w/ simple filter added - we only expect a count of 1 - $testCountDistinctModel - ->addFilter('testdata_datetime', '2021-03-23', '>='); - $this->assertEquals(1, $testCountDistinctModel->search()->getResult()[0]['entries_count']); - } - - /** - * [testAddAggregateFieldDuplicateWillThrow description] - */ - public function testAddAggregateFieldDuplicateFixedFieldWillThrow(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ADDAGGREGATEFIELD_FIELDALREADYEXISTS); - $model = $this->getModel('testdata'); - // Try to add the aggregate field as a field that already exists - // as a defined model field - in this case, simply use the PKEY... - $model->addAggregateField('testdata_id', 'count_distinct', 'testdata_text'); - } - - /** - * Tests a rare edge case - * of using an aggregate field with the same name - * as a field of a nested model with enabled VFR - */ - public function testAddAggregateFieldSameNamedWithVirtualFieldResult(): void { - $model = $this->getModel('testdata')->setVirtualFieldResult(true) - ->addModel($this->getModel('details')); - - $model->getNestedJoins('details')[0]->virtualField = 'details'; - // Try to add the aggregate field as a field that already exists - // in a _nested_ mode as a defined model field - in this case, simply use the PKEY... - $model->addAggregateField('details_id', 'count_distinct', 'testdata_text'); - - $res = $model->search()->getResult(); - $this->assertCount(1, $res); - $this->assertEquals(2, $res[0]['details_id']); // this really is the aggregate field... - $this->assertEquals(null, $res[0]['details']['details_id']); - } - - - /** - * [testAggregateSum description] - */ - public function testAggregateSum(): void { - // - // Aggregate: sum plugin - // - $testSumModel = $this->getModel('testdata'); - $testSumModel->addAggregateField('entries_sum', 'sum', 'testdata_integer'); - - // count w/o filters - $this->assertEquals(48, $testSumModel->search()->getResult()[0]['entries_sum']); - - // w/ simple filter added - $testSumModel->addFilter('testdata_datetime', '2020-01-01', '>='); - $this->assertEquals(6, $testSumModel->search()->getResult()[0]['entries_sum']); - - // no entries matching filter - $testSumModel->addFilter('testdata_datetime', '2019-01-01', '<='); - $this->assertEquals(0, $testSumModel->search()->getResult()[0]['entries_sum']); - } - - /** - * [testAggregateAvg description] - */ - public function testAggregateAvg(): void { - // - // Aggregate: avg plugin - // - $testSumModel = $this->getModel('testdata'); - $testSumModel->addAggregateField('entries_avg', 'avg', 'testdata_number'); - - // count w/o filters - $this->assertEquals((3.14 + 4.25 + 5.36 + 0.99)/4, $testSumModel->search()->getResult()[0]['entries_avg']); - - // w/ simple filter added - $testSumModel->addFilter('testdata_datetime', '2020-01-01', '>='); - $this->assertEquals((3.14 + 4.25 + 5.36)/3, $testSumModel->search()->getResult()[0]['entries_avg']); - - // no entries matching filter - $testSumModel->addFilter('testdata_datetime', '2019-01-01', '<='); - $this->assertEquals(0, $testSumModel->search()->getResult()[0]['entries_avg']); - } - - /** - * [testAggregateMax description] - */ - public function testAggregateMax(): void { - // - // Aggregate: max plugin - // - $model = $this->getModel('testdata'); - $model->addAggregateField('entries_max', 'max', 'testdata_number'); - - // count w/o filters - $this->assertEquals(5.36, $model->search()->getResult()[0]['entries_max']); - - // w/ simple filter added - $model->addFilter('testdata_datetime', '2021-03-22', '>='); - $this->assertEquals(5.36, $model->search()->getResult()[0]['entries_max']); - - // w/ simple filter added - $model->addFilter('testdata_datetime', '2021-03-22 23:59:59', '<='); - $this->assertEquals(4.25, $model->search()->getResult()[0]['entries_max']); - - // no entries matching filter - $model->addFilter('testdata_datetime', '2019-01-01', '<='); - $this->assertEquals(0, $model->search()->getResult()[0]['entries_max']); - - // w/ added grouping - $model->addGroup('testdata_date'); - $model->addOrder('testdata_date', 'ASC'); - // max per day - $this->assertEquals([ 0.99, 4.25, 5.36 ], array_column($model->search()->getResult(), 'entries_max')); - } - - /** - * [testAggregateMin description] - */ - public function testAggregateMin(): void { - // - // Aggregate: min plugin - // - $model = $this->getModel('testdata'); - $model->addAggregateField('entries_min', 'min', 'testdata_number'); - - // count w/o filters - $this->assertEquals(0.99, $model->search()->getResult()[0]['entries_min']); - - // w/ simple filter added - $model->addFilter('testdata_datetime', '2021-03-22', '>='); - $this->assertEquals(3.14, $model->search()->getResult()[0]['entries_min']); - - // w/ simple filter added - $model->addFilter('testdata_datetime', '2021-03-22 23:59:59', '<='); - $this->assertEquals(0.99, $model->search()->getResult()[0]['entries_min']); - - // no entries matching filter - $model->addFilter('testdata_datetime', '2019-01-01', '<='); - $this->assertEquals(0, $model->search()->getResult()[0]['entries_min']); - - // w/ added grouping - $model->addGroup('testdata_date'); - $model->addOrder('testdata_date', 'ASC'); - // min per day - $this->assertEquals([ 0.99, 3.14, 5.36 ], array_column($model->search()->getResult(), 'entries_min')); - } - - /** - * [testAggregateDatetimeYear description] - */ - public function testAggregateDatetimeYear(): void { - // - // Aggregate DateTime plugin - // - $testYearModel = $this->getModel('testdata'); - $testYearModel->addAggregateField('entries_year1', 'year', 'testdata_datetime'); - $testYearModel->addAggregateField('entries_year2', 'year', 'testdata_date'); - $testYearModel->addOrder('testdata_id', 'ASC'); - $yearRes = $testYearModel->search()->getResult(); - $this->assertEquals([2021, 2021, 2021, 2019], array_column($yearRes, 'entries_year1')); - $this->assertEquals([2021, 2021, 2021, 2019], array_column($yearRes, 'entries_year2')); - } - - /** - * [testAggregateGroupedSumOrderByAggregateField description] - */ - public function testAggregateGroupedSumOrderByAggregateField(): void { - $testYearModel = $this->getModel('testdata'); - $testYearModel->addAggregateField('entries_year1', 'year', 'testdata_datetime'); - $testYearModel->addAggregateField('entries_year2', 'year', 'testdata_date'); - // add a grouping modifier (WARNING, instance modified) - // introduce additional summing - // and order by calculated/aggregate field - $testYearModel->addGroup('entries_year1'); - $testYearModel->addAggregateField('entries_sum', 'sum', 'testdata_integer'); - $testYearModel->addOrder('entries_year1', 'ASC'); - $yearGroupedRes = $testYearModel->search()->getResult(); - - $this->assertEquals(2019, $yearGroupedRes[0]['entries_year1']); - $this->assertEquals(42, $yearGroupedRes[0]['entries_sum']); - $this->assertEquals(2021, $yearGroupedRes[1]['entries_year1']); - $this->assertEquals(6, $yearGroupedRes[1]['entries_sum']); - } - - /** - * [testAggregateDatetimeInvalid description] - */ - public function testAggregateDatetimeInvalid(): void { - // - // Tests an invalid type config for Aggregate DateTime plugin - // - $this->expectException(\codename\core\exception::class); - $model = $this->getModel('testdata'); - $model->addAggregateField('entries_invalid1', 'invalid', 'testdata_datetime'); - $res = $model->search()->getResult(); - } - - /** - * [testAggregateDatetimeQuarter description] - */ - public function testAggregateDatetimeQuarter(): void { - // - // Aggregate Quarter plugin - // - $testQuarterModel = $this->getModel('testdata'); - $testQuarterModel->addAggregateField('entries_quarter1', 'quarter', 'testdata_datetime'); - $testQuarterModel->addAggregateField('entries_quarter2', 'quarter', 'testdata_date'); - $testQuarterModel->addOrder('testdata_id', 'ASC'); - $res = $testQuarterModel->search()->getResult(); - $this->assertEquals([1, 1, 1, 1], array_column($res, 'entries_quarter1')); - $this->assertEquals([1, 1, 1, 1], array_column($res, 'entries_quarter2')); - } - - /** - * [testAggregateDatetimeMonth description] - */ - public function testAggregateDatetimeMonth(): void { - // - // Aggregate DateTime plugin - // - $testMonthModel = $this->getModel('testdata'); - $testMonthModel->addAggregateField('entries_month1', 'month', 'testdata_datetime'); - $testMonthModel->addAggregateField('entries_month2', 'month', 'testdata_date'); - $testMonthModel->addOrder('testdata_id', 'ASC'); - $res = $testMonthModel->search()->getResult(); - $this->assertEquals([3, 3, 3, 1], array_column($res, 'entries_month1')); - $this->assertEquals([3, 3, 3, 1], array_column($res, 'entries_month2')); - } - - /** - * [testAggregateDatetimeDay description] - */ - public function testAggregateDatetimeDay(): void { - // - // Aggregate DateTime plugin - // - $model = $this->getModel('testdata'); - $model->addAggregateField('entries_day1', 'day', 'testdata_datetime'); - $model->addAggregateField('entries_day2', 'day', 'testdata_date'); - $model->addOrder('testdata_id', 'ASC'); - $res = $model->search()->getResult(); - $this->assertEquals([22, 22, 23, 01], array_column($res, 'entries_day1')); - $this->assertEquals([22, 22, 23, 01], array_column($res, 'entries_day2')); - } - - /** - * [testAggregateFilterSimple description] - */ - public function testAggregateFilterSimple(): void { - // Aggregate Filter - $testAggregateFilterMonthModel = $this->getModel('testdata'); - - $testAggregateFilterMonthModel->addAggregateField('entries_month1', 'month', 'testdata_datetime'); - $testAggregateFilterMonthModel->addAggregateField('entries_month2', 'month', 'testdata_date'); - $testAggregateFilterMonthModel->addAggregateFilter('entries_month1', 3, '>='); - $testAggregateFilterMonthModel->addAggregateFilter('entries_month2', 3, '>='); - - // WARNING: sqlite doesn't support HAVING without GROUP BY - $testAggregateFilterMonthModel->addGroup('testdata_id'); - - $res = $testAggregateFilterMonthModel->search()->getResult(); - $this->assertEquals([3, 3, 3], array_column($res, 'entries_month1')); - $this->assertEquals([3, 3, 3], array_column($res, 'entries_month2')); - } - - /** - * [testAggregateFilterValueArray description] - */ - public function testAggregateFilterValueArray(): void { - // Aggregate Filter - $testAggregateFilterMonthModel = $this->getModel('testdata'); - - $testAggregateFilterMonthModel->addAggregateField('entries_month1', 'month', 'testdata_datetime'); - $testAggregateFilterMonthModel->addAggregateField('entries_month2', 'month', 'testdata_date'); - $testAggregateFilterMonthModel->addAggregateFilter('entries_month1', [1, 3]); - $testAggregateFilterMonthModel->addAggregateFilter('entries_month2', [1, 3]); - - // WARNING: sqlite doesn't support HAVING without GROUP BY - $testAggregateFilterMonthModel->addGroup('testdata_id'); - - $res = $testAggregateFilterMonthModel->search()->getResult(); - $this->assertEquals([3, 3, 3, 1], array_column($res, 'entries_month1')); - $this->assertEquals([3, 3, 3, 1], array_column($res, 'entries_month2')); - } - - /** - * [testDefaultAggregateFilterValueArray description] - */ - public function testDefaultAggregateFilterValueArray(): void { - // Aggregate Filter - $testAggregateFilterMonthModel = $this->getModel('testdata'); - - $testAggregateFilterMonthModel->addAggregateField('entries_month1', 'month', 'testdata_datetime'); - $testAggregateFilterMonthModel->addAggregateField('entries_month2', 'month', 'testdata_date'); - $testAggregateFilterMonthModel->addDefaultAggregateFilter('entries_month1', [1, 3]); - $testAggregateFilterMonthModel->addDefaultAggregateFilter('entries_month2', [1, 3]); - - // WARNING: sqlite doesn't support HAVING without GROUP BY - $testAggregateFilterMonthModel->addGroup('testdata_id'); - - $res = $testAggregateFilterMonthModel->search()->getResult(); - $this->assertEquals([3, 3, 3, 1], array_column($res, 'entries_month1')); - $this->assertEquals([3, 3, 3, 1], array_column($res, 'entries_month2')); - - // make sure the second query returns the same result - $res2 = $testAggregateFilterMonthModel->search()->getResult(); - $this->assertEquals($res, $res2); - } - - /** - * [testAggregateFilterValueArraySimple description] - */ - public function testAggregateFilterValueArraySimple(): void { - // Aggregate Filter - $model = $this->getModel('testdata'); - - // Actually, there's no real aggregate field for this test - // Instead, we just alias existing fields. - $model->addField('testdata_boolean', 'boolean_aliased'); - $model->addField('testdata_integer', 'integer_aliased'); - $model->addField('testdata_number', 'number_aliased'); - - // WARNING: sqlite doesn't support HAVING without GROUP BY - $model->addGroup('testdata_id'); - - $model->saveLastQuery = true; - - $this->assertEquals([3.14, 4.25, 5.36, 0.99], array_column($model->search()->getResult(), 'testdata_number')); - - // - // compacted serial tests - // - $filterTests = [ - // - // Datatype estimation for booleans - // - [ - 'field' => 'boolean_aliased', - 'value' => [ true ], - 'expected' => [ 3.14, 4.25 ] - ], - [ - 'field' => 'boolean_aliased', - 'value' => [ true, false ], - 'expected' => [ 3.14, 4.25, 5.36, 0.99 ] - ], - [ - 'field' => 'boolean_aliased', - 'value' => [ false ], - 'expected' => [ 5.36, 0.99 ] - ], - - // - // Datatype estimation for integers - // - [ - 'field' => 'integer_aliased', - 'value' => [ 1 ], - 'expected' => [ 5.36 ] - ], - [ - 'field' => 'integer_aliased', - 'value' => [ 1, 2, 3, 42 ], - 'expected' => [ 3.14, 4.25, 5.36, 0.99 ] - ], - [ - 'field' => 'integer_aliased', - 'value' => [ 3, 42 ], - 'expected' => [ 3.14, 0.99 ] - ], - - // - // Datatype estimation for numbers (floats, doubles, decimals) - // - [ - 'field' => 'number_aliased', - 'value' => [ 5.36 ], - 'expected' => [ 5.36 ] - ], - [ - 'field' => 'number_aliased', - 'value' => [ 3.14, 4.25, 5.36, 0.99 ], - 'expected' => [ 3.14, 4.25, 5.36, 0.99 ] - ], - [ - 'field' => 'number_aliased', - 'value' => [ 3.14, 0.99 ], - 'expected' => [ 3.14, 0.99 ] - ], - ]; - - foreach($filterTests as $i => $f) { - // use aggregate filter - $model->addAggregateFilter($f['field'], $f['value']); - $this->assertEquals($f['expected'], array_column($model->search()->getResult(), 'testdata_number')); - - // the same, but using FCs - NOTE: does not exist yet (model::aggregateFiltercollection) - // this only works for SQLite due to its nature. - // $model->addFilterCollection([[ 'field' => $f['field'], 'operator' => '=', 'value' => $f['value'] ]]); - // $this->assertEquals($f['expected'], array_column($model->search()->getResult(), 'testdata_number')); - } - } - - /** - * [testFieldAlias description] - */ - public function testFieldAliasWithFilter(): void { - $this->markTestIncomplete('Aliased filter implementation on differing platforms is still unclear'); - $model = $this->getModel('testdata'); - - // - // NOTE/WARNING: - // - on MySQL you can do a HAVING clause without GROUP BY, but not filter for an aliased column in WHERE - // - on SQLite you CANNOT have a HAVING clause without GROUP BY, but you can filter for an aliased column in WHERE - // - $res = $model - ->hideAllFields() - ->addField('testdata_text', 'aliased_text') - ->addFilter('testdata_integer', 3) - // ->addFilter('aliased_text', 'foo') - ->addAggregateFilter('aliased_text', 'foo') - ->search()->getResult(); - - $this->assertCount(1, $res); - $this->assertEquals([ 'aliased_text' => 'foo'], $res[0]); - } - - /** - * Tests the internal datatype fallback - * executed internally when passing an array as filter value - */ - public function testFieldAliasWithFilterArrayFallbackDataTypeSuccessful(): void { - $model = $this->getModel('testdata'); - $res = $model - ->hideAllFields() - ->addField('testdata_text', 'aliased_text') - ->addFilter('testdata_integer', 3) - ->addAggregateFilter('aliased_text', [ 'foo' ]) - ->addGroup('testdata_id') // required due to technical limitations in some RDBMS - ->search()->getResult(); - - $this->assertCount(1, $res); - $this->assertEquals([ 'aliased_text' => 'foo'], $res[0]); - } - - /** - * Try to pass an unsupported value in filter value array - * that is not covered by model::getFallbackDatatype() - */ - public function testFieldAliasWithFilterArrayFallbackDataTypeFailsUnsupportedData(): void { - $this->expectExceptionMessage('INVALID_FALLBACK_PARAMETER_TYPE'); - $model = $this->getModel('testdata'); - $res = $model - ->hideAllFields() - ->addField('testdata_text', 'aliased_text') - ->addFilter('testdata_integer', 3) - ->addAggregateFilter('aliased_text', [ new \stdClass() ]) // this must cause an exception - ->addGroup('testdata_id') // required due to technical limitations in some RDBMS - ->search()->getResult(); - } - - /** - * Tests ->addFilter() with an empty array value as to-be-filtered-for value - * This is an edge case which might change in the future. - * CHANGED 2021-09-13: we now trigger a E_USER_NOTICE when an empty array ([]) is provided as filter value - */ - public function testAddFilterWithEmptyArrayValue(): void { - $model = $this->getModel('testdata'); - - // NOTE: we have to override the error handler for a short period of time - set_error_handler(null, E_USER_NOTICE); - - // - // WARNING: to avoid any issue with error handlers - // we try to keep the amount of calls not covered by the generic handler - // at a minimum - // - try { - @$model->addFilter('testdata_text', []); // this is discarded internally/has no effect - } catch (\Throwable $t) {} - - restore_error_handler(); - - $this->assertEquals(error_get_last()['message'], 'Empty array filter values have no effect on resultset'); - $this->assertEquals(4, $model->getCount()); - } - - /** - * see above - * CHANGED 2021-09-13: we now trigger a E_USER_NOTICE when an empty array ([]) is provided as filter value - */ - public function testAddFiltercollectionWithEmptyArrayValue(): void { - $model = $this->getModel('testdata'); - - // NOTE: we have to override the error handler for a short period of time - set_error_handler(null, E_USER_NOTICE); - - // - // WARNING: to avoid any issue with error handlers - // we try to keep the amount of calls not covered by the generic handler - // at a minimum - // - try { - @$model->addFiltercollection([ - [ 'field' => 'testdata_text', 'operator' => '=', 'value' => [] ] - ]); // this is discarded internally/has no effect - } catch (\Throwable $t) {} - - restore_error_handler(); - - $this->assertEquals(error_get_last()['message'], 'Empty array filter values have no effect on resultset'); - $this->assertEquals(4, $model->getCount()); - } - - /** - * see above - * CHANGED 2021-09-13: we now trigger a E_USER_NOTICE when an empty array ([]) is provided as filter value - */ - public function testAddDefaultfilterWithEmptyArrayValue(): void { - $model = $this->getModel('testdata'); - - // NOTE: we have to override the error handler for a short period of time - set_error_handler(null, E_USER_NOTICE); - - // - // WARNING: to avoid any issue with error handlers - // we try to keep the amount of calls not covered by the generic handler - // at a minimum - // - try { - @$model->addDefaultfilter('testdata_text', []); // this is discarded internally/has no effect - } catch (\Throwable $t) {} - - restore_error_handler(); - - $this->assertEquals(error_get_last()['message'], 'Empty array filter values have no effect on resultset'); - $this->assertEquals(4, $model->getCount()); - } - - /** - * see above - * CHANGED 2021-09-13: we now trigger a E_USER_NOTICE when an empty array ([]) is provided as filter value - */ - public function testAddDefaultFiltercollectionWithEmptyArrayValue(): void { - $model = $this->getModel('testdata'); - - // NOTE: we have to override the error handler for a short period of time - set_error_handler(null, E_USER_NOTICE); - - // - // WARNING: to avoid any issue with error handlers - // we try to keep the amount of calls not covered by the generic handler - // at a minimum - // - try { - @$model->addDefaultFilterCollection([ - [ 'field' => 'testdata_text', 'operator' => '=', 'value' => [] ] - ]); // this is discarded internally/has no effect - } catch (\Throwable $t) {} - - restore_error_handler(); - - $this->assertEquals(error_get_last()['message'], 'Empty array filter values have no effect on resultset'); - $this->assertEquals(4, $model->getCount()); - } - - /** - * Tests ->addAggregateFilter() with an empty array value as to-be-filtered-for value - * This is an edge case which might change in the future. - * CHANGED 2021-09-13: we now trigger a E_USER_NOTICE when an empty array ([]) is provided as filter value - */ - public function testAddAggregateFilterWithEmptyArrayValue(): void { - $model = $this->getModel('testdata'); - - // NOTE: we have to override the error handler for a short period of time - set_error_handler(null, E_USER_NOTICE); - - // - // WARNING: to avoid any issue with error handlers - // we try to keep the amount of calls not covered by the generic handler - // at a minimum - // - try { - @$model->addAggregateFilter('nonexisting', []); // this is discarded internally/has no effect - } catch (\Throwable $t) {} - - restore_error_handler(); - - $this->assertEquals(error_get_last()['message'], 'Empty array filter values have no effect on resultset'); - - // - // NOTE: we just test if the notice has been triggered - // as we're not using a field that's really available - // - } - - /** - * Tests ->addDefaultAggregateFilter() with an empty array value as to-be-filtered-for value - * This is an edge case which might change in the future. - * CHANGED 2021-09-13: we now trigger a E_USER_NOTICE when an empty array ([]) is provided as filter value - */ - public function testAddDefaultAggregateFilterWithEmptyArrayValue(): void { - $model = $this->getModel('testdata'); - - // NOTE: we have to override the error handler for a short period of time - set_error_handler(null, E_USER_NOTICE); - - // - // WARNING: to avoid any issue with error handlers - // we try to keep the amount of calls not covered by the generic handler - // at a minimum - // - try { - @$model->addDefaultAggregateFilter('nonexisting', []); // this is discarded internally/has no effect - } catch (\Throwable $t) {} - - restore_error_handler(); - - $this->assertEquals(error_get_last()['message'], 'Empty array filter values have no effect on resultset'); - - // - // NOTE: we just test if the notice has been triggered - // as we're not using a field that's really available - // - } - - /** - * [testAddDefaultfilterWithArrayValue description] - */ - public function testAddDefaultfilterWithArrayValue(): void { - $model = $this->getModel('testdata'); - $model->addDefaultfilter('testdata_date', [ '2021-03-22', '2021-03-23' ]); - $this->assertCount(3, $model->search()->getResult()); - - // second call, filter should still be active - $this->assertCount(3, $model->search()->getResult()); - - // third call, filter should still be active - // we reset explicitly - $model->reset(); - $this->assertCount(3, $model->search()->getResult()); - } - - /** - * test filter with fully qualified field name - * of _nested_ model's field on root level - */ - public function testAddFilterRootLevelNested(): void { - $model = $this->getModel('testdata') - ->addModel($this->getModel('details')); - $model->addFilter('testschema.details.details_id', null); - $res = $model->search()->getResult(); - $this->assertCount(4, $res); - - $model->addFilter('testschema.details.details_id', 1, '>'); - $res = $model->search()->getResult(); - $this->assertCount(0, $res); - } - - /** - * test filtercollection with fully qualified field name - * of _nested_ model's field on root level - */ - public function testAddFiltercollectionRootLevelNested(): void { - $model = $this->getModel('testdata') - ->addModel($this->getModel('details')); - $model->addFiltercollection([ - [ 'field' => 'testschema.details.details_id', 'operator' => '=', 'value' => null ] - ], 'OR'); - $res = $model->search()->getResult(); - $this->assertCount(4, $res); - - $model->addFiltercollection([ - [ 'field' => 'testschema.details.details_id', 'operator' => '>', 'value' => 1 ] - ], 'OR'); - $res = $model->search()->getResult(); - $this->assertCount(0, $res); - } - - /** - * [testAddFieldFilter description] - */ - public function testAddFieldFilter(): void { - $model = $this->getModel('testdata'); - - $model->addFieldFilter('testdata_integer', 'testdata_number', '<'); - $res = $model->search()->getResult(); - $this->assertCount(3, $res); - $this->assertEquals([ 3, 2, 1 ], array_column($res, 'testdata_integer')); - - // vice-versa - $model->addFieldFilter('testdata_integer', 'testdata_number', '>'); - $res = $model->search()->getResult(); - $this->assertCount(1, $res); - $this->assertEquals([ 42 ], array_column($res, 'testdata_integer')); - } - - /** - * [testAddFieldFilterNested description] - */ - public function testAddFieldFilterNested(): void { - $model = $this->getModel('person')->setVirtualFieldResult(true); - $model->addModel($innerModel = $this->getModel('person')); - - $ids = []; - - $model->saveWithChildren([ - 'person_firstname' => 'A', - 'person_lastname' => 'A', - 'person_parent' => [ - 'person_firstname' => 'C', - 'person_lastname' => 'C', - ] - ]); - - // NOTE: take care of order! - $ids[] = $model->lastInsertId(); - $ids[] = $innerModel->lastInsertId(); - - $model->saveWithChildren([ - 'person_firstname' => 'B', - 'person_lastname' => 'B', - 'person_parent' => [ - 'person_firstname' => 'X', - 'person_lastname' => 'Y', - ] - ]); - - // NOTE: take care of order! - $ids[] = $model->lastInsertId(); - $ids[] = $innerModel->lastInsertId(); - - // should be three: A, B, C - $res = $model->addFieldFilter('person_firstname', 'person_lastname')->search()->getResult(); - $this->assertCount(3, $res); - $this->assertEqualsCanonicalizing(['A', 'B', 'C'], array_column($res, 'person_lastname')); - - // should be one: X/Y - $res = $model->addFieldFilter('person_firstname', 'person_lastname', '!=')->search()->getResult(); - $this->assertCount(1, $res); - $this->assertEqualsCanonicalizing(['Y'], array_column($res, 'person_lastname')); - - // should be one, we only have one parent with same-names (C) - $model->getNestedJoins('person')[0]->model->addFieldFilter('person_firstname', 'person_lastname'); - $res = $model->search()->getResult(); - $this->assertCount(1, $res); - $this->assertEqualsCanonicalizing(['A'], array_column($res, 'person_lastname')); - - // see above, non-same-named parents - $model->getNestedJoins('person')[0]->model->addFieldFilter('person_firstname', 'person_lastname', '!='); - $res = $model->search()->getResult(); - $this->assertCount(1, $res); - $this->assertEqualsCanonicalizing(['B'], array_column($res, 'person_lastname')); - - $personModel = $this->getModel('person'); - foreach($ids as $id) { - $personModel->delete($id); - } - } - - /** - * [testAddFieldFilterWithInvalidOperator description] - */ - public function testAddFieldFilterWithInvalidOperator(): void { - $this->expectExceptionMessage('EXCEPTION_INVALID_OPERATOR'); - $model = $this->getModel('testdata'); - $model->addFieldFilter('testdata_integer', 'testdata_number', 'LIKE'); // like is unsupported - } - - /** - * [testDefaultfilterSimple description] - */ - public function testDefaultfilterSimple(): void { - $model = $this->getModel('testdata'); - - // generic default filter - $model->addDefaultfilter('testdata_number', 3.5, '>'); - - $res1 = $model->search()->getResult(); - $res2 = $model->search()->getResult(); - $this->assertCount(2, $res1); - $this->assertEquals($res1, $res2); - - // add a filter on the fly - and we expect - // an empty resultset - $res = $model - ->addFilter('testdata_text', 'nonexisting') - ->search()->getResult(); - $this->assertCount(0, $res); - - // try to reduce the resultset to 1 - // in conjunction with the above default filter - $res = $model - ->addFilter('testdata_integer', 1, '<=') - ->search()->getResult(); - $this->assertCount(1, $res); - } - - /** - * Tests using a discrete model as root - * and compares equality. - */ - public function testAdhocDiscreteModelAsRoot(): void { - $testdataModel = $this->getModel('testdata'); - $originalRes = $testdataModel->search()->getResult(); - $discreteModelTest = new \codename\core\model\schematic\discreteDynamic('sample1', $testdataModel); - $discreteRes = $discreteModelTest->search()->getResult(); - $this->assertEquals($originalRes, $discreteRes); - - // TODO: add some filters and compare again. - } - - /** - * Fun with discrete models - */ - public function testAdhocDiscreteModelComplex(): void { - $testdataModel = $this->getModel('testdata'); - $testdataModel - ->hideAllFields() - ->addField('testdata_id', 'testdataidaliased') - ->addCalculatedField('calculated', 'testdata_integer * 4') - // ->addDefaultfilter('testdata_id', 2, '>') - ->addGroup('testdata_date') - ->addModel($this->getModel('details')) - ; - $discreteModelTest = new \codename\core\model\schematic\discreteDynamic('sample1', $testdataModel); - $res = $discreteModelTest->search()->getResult(); - - $this->assertCount(3, $res); - - $rootModel = $this->getModel('testdata')->setVirtualFieldResult(true) - ->addCustomJoin( - $discreteModelTest, - \codename\core\model\plugin\join::TYPE_LEFT, - 'testdata_id', - 'testdataidaliased' - ); - $rootModel->getNestedJoins('sample1')[0]->virtualField = 'virtualSample1'; - - $res2 = $rootModel->search()->getResult(); - // print_r($res2); - - $this->assertCount(4, $res2); - $this->assertEquals([ 12, null, 4, 168 ], array_column(array_column($res2, 'virtualSample1'), 'calculated')); - - $secondaryDiscreteModelTest = new \codename\core\model\schematic\discreteDynamic('sample2', $testdataModel); - $secondaryDiscreteModelTest->addCalculatedField('calcCeption', 'sample2.calculated * sample2.calculated'); - $rootModel->addCustomJoin( - $secondaryDiscreteModelTest, - \codename\core\model\plugin\join::TYPE_LEFT, - 'testdata_id', - 'testdataidaliased' - ); - $rootModel->getNestedJoins('sample2')[0]->virtualField = 'virtualSample2'; - - $rootModel->addCalculatedField('calcCeption2', 'sample1.calculated * sample2.calculated'); - - $res3 = $rootModel->search()->getResult(); - // print_r($res3); - - $this->assertEquals([ 144, null, 16, 28224 ], array_column(array_column($res3, 'virtualSample2'), 'calcCeption')); - $this->assertEquals([ 144, null, 16, 28224 ], array_column($res3, 'calcCeption2')); - } - - /** - * [testDiscreteModelLimit description] - */ - public function testDiscreteModelLimitAndOffset(): void { - $testdataModel = $this->getModel('testdata'); - $testdataModel - ->hideAllFields() - ->addField('testdata_id', 'testdataidaliased') - ->addCalculatedField('calculated', 'testdata_integer * 4') - // ->addDefaultfilter('testdata_id', 2, '>') - ->addGroup('testdata_date') - ->addModel($this->getModel('details')) - ; - - // NOTE limit & offset instances get reset after query - $testdataModel->setLimit(2)->setOffset(1); - - $originalRes = $testdataModel->search()->getResult(); - $discreteModelTest = new \codename\core\model\schematic\discreteDynamic('sample1', $testdataModel); - - // NOTE limit & offset instances get reset after query - $testdataModel->setLimit(2)->setOffset(1); - $discreteRes = $discreteModelTest->search()->getResult(); - - $this->assertCount(2, $discreteRes); - $this->assertEquals($originalRes, $discreteRes); - } - - /** - * [testDiscreteModelAddOrder description] - */ - public function testDiscreteModelAddOrder(): void { - // - // NOTE ORDER BY in a subquery is ignored in MySQL for final output - // See https://mariadb.com/kb/en/why-is-order-by-in-a-from-subquery-ignored/ - // But it is essential for LIMIT/OFFSETs used in the subquery! - // - $testdataModel = $this->getModel('testdata'); - - // NOTE order instance gets reset after query - $testdataModel->addOrder('testdata_id', 'DESC'); - $testdataModel->setOffset(2)->setLimit(2); - - $originalRes = $testdataModel->search()->getResult(); - $discreteModelTest = new \codename\core\model\schematic\discreteDynamic('sample1', $testdataModel); - - // NOTE order instance gets reset after query - $testdataModel->addOrder('testdata_id', 'DESC'); - $testdataModel->setOffset(2)->setLimit(2); - $discreteRes = $discreteModelTest->search()->getResult(); - - $this->assertCount(2, $discreteRes); - $this->assertEquals($originalRes, $discreteRes); - - // finally, query the thing with a zero offset - // to make sure we have ORDER+LIMIT+OFFSET really working - // inside the subquery - // though the final order might be different. - $testdataModel->addOrder('testdata_id', 'DESC'); - $testdataModel->setOffset(0)->setLimit(2); - $offset0Res = $testdataModel->search()->getResult(); - - $this->assertNotEquals($offset0Res, $originalRes); - - $this->assertLessThan( - array_sum(array_column($offset0Res, 'testdata_id')), // Offset 0-based results should be topmost => sum of IDs must be greater - array_sum(array_column($originalRes, 'testdata_id')) // ... and this sum must be LESS THAN the above. - ); - } - - /** - * [testDiscreteModelSimpleAggregate description] - */ - public function testDiscreteModelSimpleAggregate(): void { - $testdataModel = $this->getModel('testdata') - ->addAggregateField('id_sum', 'sum', 'testdata_integer') - ->addGroup('testdata_date') - ->addDefaultAggregateFilter('id_sum', 10, '<=') - ; - - $originalRes = $testdataModel->search()->getResult(); - $discreteModelTest = new \codename\core\model\schematic\discreteDynamic('sample1', $testdataModel); - - $discreteRes = $discreteModelTest->search()->getResult(); - - $this->assertCount(2, $discreteRes); - $this->assertEquals($originalRes, $discreteRes); - } - - /** - * [testDiscreteModelSaveWillThrow description] - */ - public function testDiscreteModelSaveWillThrow(): void { - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Not implemented.'); - $discreteModelTest = new \codename\core\model\schematic\discreteDynamic('sample1', $this->getModel('testdata')); - $discreteModelTest->save([ 'value' => 'doesnt matter']); - } - - /** - * [testDiscreteModelUpdateWillThrow description] - */ - public function testDiscreteModelUpdateWillThrow(): void { - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Not implemented.'); - $discreteModelTest = new \codename\core\model\schematic\discreteDynamic('sample1', $this->getModel('testdata')); - $discreteModelTest->update([ 'value' => 'doesnt matter']); - } - - /** - * [testDiscreteModelReplaceWillThrow description] - */ - public function testDiscreteModelReplaceWillThrow(): void { - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Not implemented.'); - $discreteModelTest = new \codename\core\model\schematic\discreteDynamic('sample1', $this->getModel('testdata')); - $discreteModelTest->replace([ 'value' => 'doesnt matter']); - } - - /** - * [testDiscreteModelDeleteWillThrow description] - */ - public function testDiscreteModelDeleteWillThrow(): void { - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Not implemented.'); - $discreteModelTest = new \codename\core\model\schematic\discreteDynamic('sample1', $this->getModel('testdata')); - $discreteModelTest->delete(1); - } - - - /** - * Tests a case where the 'aliased' flag on a group plugin was always active - * (and ignoring schema/table - on root, there's no currentAlias (null)) - * and causes severe errors when executing a query - * (ambiguous column) - */ - public function testGroupAliasBugFixed(): void { - $model = $this->getModel('person')->setVirtualFieldResult(true) - ->addModel($this->getModel('person')) - ->addGroup('person_id'); - $res = $model->search()->getResult(); - $this->expectNotToPerformAssertions(); - } - - /** - * [testNormalizeData description] - * @return [type] [description] - */ - public function testNormalizeData() { - $originalDataset = [ - 'testdata_datetime' => '2021-04-01 11:22:33', - 'testdata_text' => 'normalizeTest', - 'testdata_date' => '2021-01-01', - ]; - - $normalizeMe = $originalDataset; - $normalizeMe['crapkey'] = 'crap'; - - $model = $this->getModel('testdata'); - $normalized = $model->normalizeData($normalizeMe); - $this->assertEquals($originalDataset, $normalized); - } - - /** - * [testNormalizeDataComplex description] - */ - public function testNormalizeDataComplex(): void { - - $fieldComparisons = [ - 'testdata_boolean' => [ - 'nulled boolean' => [ - 'expectedValue' => null, - 'variants' => [ - null, - '' - ] - ], - 'boolean true' => [ - 'expectedValue' => true, - 'variants' => [ - true, - 1, - '1', - 'true' - ] - ], - 'boolean false' => [ - 'expectedValue' => false, - 'variants' => [ - false, - 0, - '0', - 'false' + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testReplace(): void + { + $ids = []; + $model = $this->getModel('customer'); + + if (!($this instanceof mysqlTest)) { + static::markTestIncomplete('Upsert is working differently on this platform - not implemented yet!'); + } + + $model->save([ + 'customer_no' => 'R1000', + 'customer_notes' => 'Replace me', + ]); + $ids[] = $firstId = $model->lastInsertId(); + + if (!($model instanceof sql)) { + static::fail('setup fail'); + } + + $model->replace([ + 'customer_no' => 'R1000', + 'customer_notes' => 'Replaced', + ]); + + $dataset = $model->load($firstId); + static::assertEquals('Replaced', $dataset['customer_notes']); + + foreach ($ids as $id) { + $model->delete($id); + } + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testMultiComponentForeignKeyJoin(): void + { + $table1 = $this->getModel('table1'); + $table2 = $this->getModel('table2'); + + $table1->save([ + 'table1_key1' => 'first', + 'table1_key2' => 1, + 'table1_value' => 'table1', + ]); + $table2->save([ + 'table2_key1' => 'first', + 'table2_key2' => 1, + 'table2_value' => 'table2', + ]); + $table1->save([ + 'table1_key1' => 'arbitrary', + 'table1_key2' => 2, + 'table1_value' => 'not in table2', + ]); + $table2->save([ + 'table2_key1' => 'arbitrary', + 'table2_key2' => 3, + 'table2_value' => 'not in table1', + ]); + + $table1->addModel($table2); + $res = $table1->search()->getResult(); + + static::assertCount(2, $res); + static::assertEquals('table1', $res[0]['table1_value']); + static::assertEquals('table2', $res[0]['table2_value']); + + static::assertEquals('not in table2', $res[1]['table1_value']); + static::assertEquals(null, $res[1]['table2_value']); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testTimemachineHistory(): void + { + $model = $this->getTimemachineEnabledModel('testdata'); + $model->save([ + 'testdata_text' => 'tm_history', + 'testdata_integer' => 5555, + ]); + $tsAtCreation = date::getCurrentTimestamp(); + $id = $model->lastInsertId(); + static::assertNotEmpty($model->load($id)); + + $timemachine = new timemachine($model); + + static::assertEmpty($timemachine->getDeltaData($id, 0), 'Timemachine deltas should be empty at this point'); + static::assertEmpty($timemachine->getHistory($id), 'Timemachine history should be empty at this point'); + + // Emulate next second. + sleep(1); + + $model->save([ + 'testdata_id' => $id, + 'testdata_integer' => 5556, + ]); + date::getCurrentTimestamp(); + + // Emulate next second. + sleep(1); + $tsAfterUpdate = date::getCurrentTimestamp(); + + static::assertEmpty($timemachine->getDeltaData($id, $tsAfterUpdate), 'Timemachine deltas should be empty due to late reference TS'); + static::assertNotEmpty($timemachine->getDeltaData($id, $tsAtCreation), 'Timemachine deltas should include the history'); + + static::assertEquals(5555, $timemachine->getHistoricData($id, $tsAtCreation)['testdata_integer']); + + $tsBeforeDelete = date::getCurrentTimestamp(); + + $model->delete($id); + static::assertEmpty($model->load($id)); + + // Ensure we have an entry for a deleted record + static::assertEquals(5556, $timemachine->getHistoricData($id, $tsBeforeDelete)['testdata_integer']); + } + + /** + * @param string $model [description] + * @return model [description] + * @throws ReflectionException + * @throws exception + */ + protected function getTimemachineEnabledModel(string $model): model + { + return static::getTimemachineEnabledModelStatic($model); + } + + /** + * @param string $model [description] + * @return model + * @throws ReflectionException + * @throws exception + */ + protected static function getTimemachineEnabledModelStatic(string $model): model + { + $modelData = static::$models[$model]; + $instance = new timemachineEnabledSqlModel($modelData['schema'], $modelData['model'], $modelData['config']); + + $tmModelData = static::$models['timemachine']; + $tmModel = new timemachineModel($tmModelData['schema'], $tmModelData['model'], $tmModelData['config']); + $instance->setTimemachineModelInstance($tmModel); + + $tmSecondaryInstance = new timemachineEnabledSqlModel($modelData['schema'], $modelData['model'], $modelData['config']); + $tmSecondaryInstance->setTimemachineModelInstance($tmModel); + overrideableTimemachine::storeInstance($tmSecondaryInstance); + return $instance; + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testDeleteSinglePkeyTimemachineEnabled(): void + { + $model = $this->getTimemachineEnabledModel('testdata'); + $model->save([ + 'testdata_text' => 'single_pkey_delete', + 'testdata_integer' => 1234, + ]); + $id = $model->lastInsertId(); + static::assertNotEmpty($model->load($id)); + $model->delete($id); + static::assertEmpty($model->load($id)); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testBulkUpdateAndDelete(): void + { + $model = $this->getModel('testdata'); + $this->testBulkUpdateAndDeleteUsingModel($model); + } + + /** + * [testBulkUpdateAndDeleteUsingModel description] + * @param model $model [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testBulkUpdateAndDeleteUsingModel(model $model): void + { + // $model = $this->getModel('testdata'); + + // create example dataset + $ids = []; + for ($i = 1; $i <= 10; $i++) { + $model->save([ + 'testdata_text' => 'bulkdata_test', + 'testdata_integer' => $i, + 'testdata_structure' => [ + 'some_key' => 'some_value', + ], + ]); + $ids[] = $model->lastInsertId(); + } + + $model + ->addFilter('testdata_text', 'bulkdata_test'); + + if (!($model instanceof sql)) { + static::fail('setup fail'); + } + + // update those entries (not by PKEY) + $model + ->update([ + 'testdata_integer' => 333, + 'testdata_number' => 12.34, // additional update data in this field aren't used before + 'testdata_structure' => [ + 'some_key' => 'some_value', + 'some_new_key' => 'some_new_value', + ], + ]); + + // compare data + foreach ($ids as $id) { + $dataset = $model->load($id); + static::assertEquals('bulkdata_test', $dataset['testdata_text']); + static::assertEquals(333, $dataset['testdata_integer']); + } + + // delete them + $model + ->addFilter($model->getPrimaryKey(), $ids) + ->delete(); + + // make sure they don't exist anymore + $res = $model->addFilter($model->getPrimaryKey(), $ids)->search()->getResult(); + static::assertEmpty($res); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testBulkUpdateAndDeleteTimemachineEnabled(): void + { + $model = $this->getTimemachineEnabledModel('testdata'); + $this->testBulkUpdateAndDeleteUsingModel($model); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testRecursiveModelJoin(): void + { + $personModel = $this->getModel('person'); + + $datasets = [ + [ + // Top, no parent + 'person_firstname' => 'Ella', + 'person_lastname' => 'Campbell', + ], + [ + // 1st level down + 'person_firstname' => 'Harry', + 'person_lastname' => 'Sanders', + ], + [ + // 2nd level down + 'person_firstname' => 'Stephen', + 'person_lastname' => 'Perkins', + ], + [ + // 3rd level down, no more children + 'person_firstname' => 'Michael', + 'person_lastname' => 'Vaughn', + ], + ]; + + $ids = []; + + $parentId = null; + foreach ($datasets as $dataset) { + $dataset['person_parent_id'] = $parentId; + $personModel->save($dataset); + $parentId = $personModel->lastInsertId(); + $ids[] = $personModel->lastInsertId(); + } + + $queryModel = $this->getModel('person') + ->addRecursiveModel( + $recursiveModel = $this->getModel('person') + ->hideAllFields(), + 'person_id', + 'person_parent_id', + [ + ['field' => 'person_lastname', 'operator' => '=', 'value' => 'Vaughn'], + ], + join::TYPE_INNER, + 'person_id', + 'person_parent_id' + ); + $recursiveModel->addFilter('person_lastname', 'Sanders'); + $res = $queryModel->search()->getResult(); + static::assertCount(1, $res); + static::assertEquals('Sanders', $res[0]['person_lastname']); + + // + // Joined traverse-up + // + $traverseUpModel = $this->getModel('person') + ->hideAllFields() + ->addRecursiveModel( + $this->getModel('person'), + 'person_id', + 'person_parent_id', + [ + ['field' => 'person_lastname', 'operator' => '=', 'value' => 'Perkins'], + ], + join::TYPE_INNER, + 'person_id', + 'person_parent_id' + ); + $res = $traverseUpModel->search()->getResult(); + + static::assertCount(3, $res); + // NOTE: order is not guaranteed, therefore: just compare item presence + static::assertEqualsCanonicalizing([ + 'Stephen', + 'Harry', + 'Ella', + ], array_column($res, 'person_firstname')); + + // + // Joined traverse-down + // + $traverseDownModel = $this->getModel('person') + ->hideAllFields() + ->addRecursiveModel( + $this->getModel('person'), + 'person_parent_id', + 'person_id', + [ + ['field' => 'person_lastname', 'operator' => '=', 'value' => 'Perkins'], + ], + join::TYPE_INNER, + 'person_id', + 'person_parent_id' + ); + $res = $traverseDownModel->search()->getResult(); + static::assertCount(2, $res); + // NOTE: order is not guaranteed, therefore: just compare item presence + static::assertEqualsCanonicalizing(['Stephen', 'Michael'], array_column($res, 'person_firstname')); + + // + // Root-level traverse up + // + $rootTraverseUpModel = $this->getModel('person') + ->setRecursive( + 'person_id', + 'person_parent_id', + [ + // Single anchor condition + ['field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders'], + ] + ); + $res = $rootTraverseUpModel->search()->getResult(); + static::assertCount(2, $res); + // NOTE: order is not guaranteed, therefore: just compare item presence + static::assertEqualsCanonicalizing(['Harry', 'Ella'], array_column($res, 'person_firstname')); + + // + // Root-level traverse down + // + $rootTraverseDownModel = $this->getModel('person') + ->setRecursive( + 'person_parent_id', + 'person_id', + [ + // Single anchor condition + ['field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders'], + ] + ); + $res = $rootTraverseDownModel->search()->getResult(); + static::assertCount(3, $res); + // NOTE: order is not guaranteed, therefore: just compare item presence + static::assertEqualsCanonicalizing(['Harry', 'Stephen', 'Michael'], array_column($res, 'person_firstname')); + + // + // Root level traverse down using filter instance + // + $rootTraverseDownUsingFilterInstanceModel = $this->getModel('person') + ->setRecursive( + 'person_parent_id', + 'person_id', + [ + // Single anchor condition, as filter plugin instance. + // In this case, we use dynamic, just so we get a better compatibility + // across differing drivers + new dynamic(modelfield::getInstance('person_lastname'), 'Sanders', '='), + ] + ); + $res = $rootTraverseDownUsingFilterInstanceModel->search()->getResult(); + static::assertCount(3, $res); + // NOTE: order is not guaranteed, therefore: just compare item presence + static::assertEqualsCanonicalizing(['Harry', 'Stephen', 'Michael'], array_column($res, 'person_firstname')); + + // + // Test joining a model used recursively + // + $joinedRecursiveModel = $this->getModel('person') + ->hideAllFields() + ->addField('person_id', 'main_id') + ->addField('person_parent_id', 'main_parent') + ->addField('person_firstname', 'main_firstname') + ->addField('person_lastname', 'main_lastname') + ->addModel( + $this->getModel('person') + // ->hideField('__anchor') + ->setRecursive('person_id', 'person_parent_id', [ + // No filters in this case, we're just using an 'entry point' (Vaughn) below + // [ 'field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders' ] + ]), + join::TYPE_INNER + ); + $res = $joinedRecursiveModel->search()->getResult(); + static::assertCount(3, $res); + static::assertEquals(['Sanders', 'Perkins', 'Vaughn'], array_unique(array_column($res, 'main_lastname'))); + + // NOTE: databases might behave differently regarding order + // + // e.g., SQLite: see https://www.sqlite.org/lang_with.html: + // "If there is no ORDER BY clause, then the order in which rows are extracted is undefined." + // SQLite is mostly doing FIFO. + // + static::assertEqualsCanonicalizing(['Stephen', 'Harry', 'Ella'], array_column($res, 'person_firstname')); + + foreach (array_reverse($ids) as $id) { + $personModel->delete($id); + } + } + + /** + * Tests whether calling setRecursive a second time will throw an exception + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testSetRecursiveTwiceWillThrow(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage('EXCEPTION_MODEL_SETRECURSIVE_ALREADY_ENABLED'); + + $model = $this->getModel('person'); + for ($i = 1; $i <= 2; $i++) { + $model->setRecursive( + 'person_parent_id', + 'person_id', + [ + // Single anchor condition + ['field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders'], + ] + ); + } + } + + /** + * Tests whether setRecursive will throw an exception + * if an undefined relation is used as recursion parameter + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testSetRecursiveInvalidConfigWillThrow(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage('INVALID_RECURSIVE_MODEL_CONFIG'); + + $model = $this->getModel('person'); + $model->setRecursive( + 'person_firstname', + 'person_id', + [ + // Single anchor condition + ['field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders'], ] - ], - ], - 'testdata_integer' => [ - 'nulled integer' => [ - 'expectedValue' => null, - 'variants' => [ - null, - '' + ); + } + + /** + * Tests whether setRecursive throws an exception + * if a nonexisting field is provided in the configuration + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testSetRecursiveNonexistingFieldWillThrow(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage('INVALID_RECURSIVE_MODEL_CONFIG'); + + $model = $this->getModel('person'); + $model->setRecursive( + 'person_nonexisting', + 'person_id', + [ + // Single anchor condition + ['field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders'], ] - ] - ], - 'testdata_date' => [ - 'nulled date' => [ - 'expectedValue' => null, - 'variants' => [ - null, + ); + } + + /** + * Tests whether addRecursiveModel throws an exception + * if an invalid/nonexisting field is provided in the configuration + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddRecursiveModelNonexistingFieldWillThrow(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage('INVALID_RECURSIVE_MODEL_JOIN'); + + $model = $this->getModel('person'); + $model->addRecursiveModel( + $this->getModel('person'), + 'person_nonexisting', + 'person_id', + [ + // Single anchor condition + ['field' => 'person_lastname', 'operator' => '=', 'value' => 'Sanders'], ] - ], - // throws! - // 'invalid date' => [ - // 'expectedValue' => null, - // 'variants' => [ - // '', - // ] - // ], - ], - ]; - - $model = $this->getModel('testdata'); - - foreach($fieldComparisons as $field => $tests) { - foreach($tests as $testName => $test) { - $expectedValue = $test['expectedValue']; - foreach($test['variants'] as $variant) { - $normalizeMe = [ - $field => $variant - ]; - $expectedDataset = [ - $field => $expectedValue - ]; - $normalized = $model->normalizeData($normalizeMe); - $this->assertEquals($expectedDataset, $normalized, $testName); + ); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testFiltercollectionValueArray(): void + { + // Filtercollection with an array as filter value + // (e.g., IN-query) + $model = $this->getModel('testdata'); + + $model->addFilterCollection([ + ['field' => 'testdata_text', 'operator' => '=', 'value' => ['foo']], + ], 'OR'); + $res = $model->search()->getResult(); + static::assertCount(2, $res); + static::assertEquals([3.14, 5.36], array_column($res, 'testdata_number')); + + $model->addFilterCollection([ + ['field' => 'testdata_text', 'operator' => '!=', 'value' => ['foo']], + ], 'OR'); + $res = $model->search()->getResult(); + static::assertCount(2, $res); + static::assertEquals([4.25, 0.99], array_column($res, 'testdata_number')); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testDefaultFiltercollectionValueArray(): void + { + // Filtercollection with an array as filter value + // (e.g., IN-query) + $model = $this->getModel('testdata'); + + $model->addDefaultFilterCollection([ + ['field' => 'testdata_text', 'operator' => '=', 'value' => ['foo']], + ], 'OR'); + $res = $model->search()->getResult(); + static::assertCount(2, $res); + static::assertEquals([3.14, 5.36], array_column($res, 'testdata_number')); + + // as we've added a default FC (and nothing else), + // searching second time should yield the same resultset + static::assertEquals($res, $model->search()->getResult()); + } + + /** + * Tests performing a regular left join + * using forced virtual joining with no dataset available/set + * to return a nulled/empty child dataset + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testLeftJoinForcedVirtualNoReferenceDataset(): void + { + $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) + ->addModel( + $personModel = $this->getModel('person')->setVirtualFieldResult(true) + ->setForceVirtualJoin(true), + ); + + if (!($customerModel instanceof sql)) { + static::fail('setup fail'); } - } - } - } - - /** - * [testValidate description] - * @return [type] [description] - */ - public function testValidateSimple() { - $dataset = [ - 'testdata_datetime' => '2021-13-01 11:22:33', - 'testdata_text' => [ 'abc' => true ], - 'testdata_date' => '0000-01-01', - ]; - - $model = $this->getModel('testdata'); - $this->assertFalse($model->isValid($dataset)); - - $validationErrors = $model->validate($dataset)->getErrors(); - $this->assertCount(2, $validationErrors); // actually, we should have 3 - } - - /** - * Tests validation fail with a model-validator - */ - public function testModelValidator(): void { - $dataset = [ - 'testdata_text' => 'disallowed_value' - ]; - $model = $this->getModel('testdata'); - $this->assertFalse($model->isValid($dataset)); - $validationErrors = $model->validate($dataset)->getErrors(); - $this->assertCount(1, $validationErrors); - $this->assertEquals('VALIDATION.FIELD_INVALID', $validationErrors[0]['__CODE']); - $this->assertEquals('testdata_text', $validationErrors[0]['__IDENTIFIER']); - } - - /** - * Tests a model-validator that has a non-field-specific validation - * that affects the whole dataset (e.g. field value combinations that are invalid) - */ - public function testModelValidatorSpecial(): void { - $dataset = [ - 'testdata_text' => 'disallowed_condition', - 'testdata_date' => '2021-01-01', - ]; - $model = $this->getModel('testdata'); - $this->assertFalse($model->isValid($dataset)); - $validationErrors = $model->validate($dataset)->getErrors(); - $this->assertCount(1, $validationErrors); - $this->assertEquals('DATA', $validationErrors[0]['__IDENTIFIER']); - $this->assertEquals('VALIDATION.INVALID', $validationErrors[0]['__CODE']); - $this->assertEquals('VALIDATION.DISALLOWED_CONDITION', $validationErrors[0]['__DETAILS'][0]['__CODE']); - } - - /** - * [testValidateSimpleRequiredField description] - */ - public function testValidateSimpleRequiredField(): void { - $model = $this->getModel('customer'); - // - // NOTE: the customer model is explicitly loaded - // w/o the collection model (contactentry) - // to test for skipping those checks (for coverage) - // - $this->assertFalse($model->isValid([ 'customer_notes' => 'missing customer_no' ])); - $this->assertTrue($model->isValid([ 'customer_no' => 'ABC', 'customer_notes' => 'set customer_no' ])); - } - - /** - * [testValidateCollectionNotUsed description] - */ - public function testValidateCollectionNotUsed(): void { - $model = $this->getModel('customer'); - // - // NOTE: the customer model is explicitly loaded - // w/o the collection model (contactentry) - // to test for skipping those checks (for coverage) - // but we use the field in the dataset - // - $this->assertTrue($model->isValid([ - 'customer_no' => 'ABC', - 'customer_contactentries' => [ - [ 'some_value '] - ] - ])); - } - - /** - * [testValidateCollectionData description] - */ - public function testValidateCollectionData(): void { - $dataset = [ - 'customer_no' => 'example', - 'customer_contactentries' => [ - [ - 'contactentry_name' => 'test-valid-phone', - 'contactentry_telephone' => '+4929292929292', - ], - [ - 'contactentry_name' => 'test-invalid-phone', - 'contactentry_telephone' => 'xyz', - ] - ] - ]; - - $model = $this->getModel('customer') - ->addCollectionModel($this->getModel('contactentry')); - - // Just a rough check for invalidity - $this->assertFalse($model->isValid($dataset)); - $validationErrors = $model->validate($dataset)->getErrors(); - $this->assertEquals('customer_contactentries', $validationErrors[0]['__IDENTIFIER']); - - $dataset = [ - 'customer_no' => 'example2', - 'customer_contactentries' => [ - [ - // no name specified - 'contactentry_telephone' => '+4934343455555', - ] - ] - ]; - // Just a rough check for invalidity - $this->assertFalse($model->isValid($dataset)); - - $dataset = [ - 'customer_no' => 'example2', - 'customer_contactentries' => [ - [ - // no name specified - 'contactentry_name' => 'some-name', // is required - 'contactentry_telephone' => '+4934343455555', - ] - ] - ]; - // Just a rough check for invalidity - $this->assertTrue($model->isValid($dataset)); - - } - - /** - * Test model::entry* wrapper functions - * NOTE: they might interfere with regular queries - */ - public function testEntryFunctions(): void { - $entryModel = $this->getModel('testdata'); // model used for testing entry* functions - $model = $this->getModel('testdata'); // model used for querying - - $dataset = [ - 'testdata_datetime' => '2021-04-01 11:22:33', - 'testdata_text' => 'entryMakeTest', - 'testdata_date' => '2021-01-01', - 'testdata_number' => 12345.6789, - 'testdata_integer' => 222, - ]; - - $entryModel->entryMake($dataset); - - $entryModel->entryValidate(); // TODO: do something with the validation result? - - $entryModel->entrySave(); - $id = $entryModel->lastInsertId(); - $entryModel->reset(); - - $model->hideAllFields() - ->addField('testdata_datetime') - ->addField('testdata_text') - ->addField('testdata_date') - ->addField('testdata_number') - ->addField('testdata_integer') - ; - $queriedDataset = $model->load($id); - $this->assertEquals($dataset, $queriedDataset); - - $entryModel->entryLoad($id); - - foreach($dataset as $key => $value) { - $this->assertEquals($value, $entryModel->fieldGet(\codename\core\value\text\modelfield::getInstance($key))); - } - - $entryModel->entryUpdate([ - 'testdata_text' => 'updated', - ]); - $entryModel->entrySave(); - - $modifiedDataset = $model->load($id); - $this->assertEquals('updated', $modifiedDataset['testdata_text']); - - $entryModel->entryLoad($id); - $entryModel->fieldSet(\codename\core\value\text\modelfield::getInstance('testdata_integer'), 333); - $entryModel->entrySave(); - - $this->assertEquals(333, $model->load($id)['testdata_integer']); - - $entryModel->entryDelete(); - $this->assertEmpty($model->load($id)); - } - - /** - * [testEntryFlags description] - */ - public function testEntryFlags(): void { - $verificationModel = $this->getModel('testdata'); - $model = $this->getModel('testdata'); - $model->entryMake([ - 'testdata_text' => 'testEntryFlags' - ]); - $this->assertEmpty($model->entryValidate()); - $model->entrySave(); - - $id = $model->lastInsertId(); - $model->entryLoad($id); - - $model->entrySetflag($model->getFlag('foo')); - $model->entrySave(); - $this->assertEquals(1, $verificationModel->load($id)['testdata_flag']); - - $model->entrySetflag($model->getFlag('qux')); - $model->entrySave(); - $this->assertEquals(1 + 8, $verificationModel->load($id)['testdata_flag']); - - $model->entryUnsetflag($model->getFlag('foo')); - $model->entryUnsetflag($model->getFlag('baz')); // unset not-set - $model->entrySave(); - $this->assertEquals(8, $verificationModel->load($id)['testdata_flag']); - - $model->entryDelete(); - } - - /** - * [testEntrySetFlagNonexisting description] - */ - public function testEntrySetFlagNonexisting(): void { - $verificationModel = $this->getModel('testdata'); - $model = $this->getModel('testdata'); - $model->entryMake([ - 'testdata_text' => 'testEntrySetFlagNonexisting' - ]); - $model->entrySave(); - - $id = $model->lastInsertId(); - $model->entryLoad($id); - - // WARNING/NOTE: you can set nonexisting flags - $model->entrySetflag(64); - $model->entrySave(); - $this->assertEquals(64, $verificationModel->load($id)['testdata_flag']); - - // WARNING/NOTE: you may set combined flag values - $model->entrySetflag(64 + 2 + 8 + 16); - $model->entryUnsetflag(8); - $model->entrySave(); - $this->assertEquals(64 + 2 + 16, $verificationModel->load($id)['testdata_flag']); - - $model->entrySave(); - - $model->entryDelete(); - } - - /** - * [testEntrySetFlagInvalidFlagValueThrows description] - */ - public function testEntrySetFlagInvalidFlagValueThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_INVALID_FLAG_VALUE); - $model = $this->getModel('testdata'); - $model->entryMake([ - 'testdata_text' => 'test' - ]); - $model->entrySetflag(-8); - } - - /** - * [testEntryUnsetFlagInvalidFlagValueThrows description] - */ - public function testEntryUnsetFlagInvalidFlagValueThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_INVALID_FLAG_VALUE); - $model = $this->getModel('testdata'); - $model->entryMake([ - 'testdata_text' => 'test' - ]); - $model->entryUnsetflag(-8); - } - - /** - * [testEntrySetFlagNoDatasetLoadedThrows description] - */ - public function testEntrySetFlagNoDatasetLoadedThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ENTRYSETFLAG_NOOBJECTLOADED); - $model = $this->getModel('testdata'); - $model->entrySetFlag($model->getFlag('foo')); - } - - /** - * [testEntryUnsetFlagNoDatasetLoadedThrows description] - */ - public function testEntryUnsetFlagNoDatasetLoadedThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ENTRYUNSETFLAG_NOOBJECTLOADED); - $model = $this->getModel('testdata'); - $model->entryUnsetflag($model->getFlag('foo')); - } - - /** - * [testEntrySetFlagNoFlagsInModelThrows description] - */ - public function testEntrySetFlagNoFlagsInModelThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ENTRYSETFLAG_NOFLAGSINMODEL); - $model = $this->getModel('person'); - $model->entryMake([ 'person_firstname' => 'test' ]); - $model->entrySetFlag(1); - } - - /** - * [testEntryUnsetFlagNoFlagsInModelThrows description] - */ - public function testEntryUnsetFlagNoFlagsInModelThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ENTRYUNSETFLAG_NOFLAGSINMODEL); - $model = $this->getModel('person'); - $model->entryMake([ 'person_firstname' => 'test' ]); - $model->entryUnsetFlag(1); - } - - /** - * [testEntrySaveNoDataThrows description] - */ - public function testEntrySaveNoDataThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ENTRYSAVE_NOOBJECTLOADED); - $model = $this->getModel('testdata'); - $model->entrySave(); // we have not defined anything (e.g. internal data store is NULL/does not exist) - } - - /** - * [testEntrySaveEmptyDataThrows description] - */ - public function testEntrySaveEmptyDataThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ENTRYSAVE_NOOBJECTLOADED); - $model = $this->getModel('testdata'); - $model->entryMake([]); // define an empty dataset - $model->entrySave(); - } - - /** - * [testEntryUpdateEmptyDataThrows description] - */ - public function testEntryUpdateEmptyDataThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ENTRYUPDATE_UPDATEELEMENTEMPTY); - $model = $this->getModel('testdata'); - $model->entryUpdate([]); // we've not loaded anything, but this should crash first. - } - - /** - * [testEntryUpdateNoDatasetLoaded description] - */ - public function testEntryUpdateNoDatasetLoaded(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ENTRYUPDATE_NOOBJECTLOADED); - $model = $this->getModel('testdata'); - $model->entryUpdate([ 'testdata_integer' => 555 ]); - } - - /** - * [testEntryLoadNonexistingId description] - */ - public function testEntryLoadNonexistingId(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ENTRYLOAD_FAILED); - $model = $this->getModel('testdata'); - $model->entryLoad(-123); - } - - /** - * [testEntryDeleteNoDatasetLoadedThrows description] - */ - public function testEntryDeleteNoDatasetLoadedThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_ENTRYDELETE_NOOBJECTLOADED); - $model = $this->getModel('testdata'); - $model->entryDelete(); - } - - /** - * [testFieldGetNonexistingThrows description] - */ - public function testFieldGetNonexistingThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_FIELDGET_FIELDNOTFOUNDINMODEL); - $model = $this->getModel('testdata'); - $model->fieldGet(\codename\core\value\text\modelfield::getInstance('nonexisting')); - } - - /** - * [testFieldGetNoDatasetLoadedThrows description] - */ - public function testFieldGetNoDatasetLoadedThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_FIELDGET_NOOBJECTLOADED); - $model = $this->getModel('testdata'); - $model->fieldGet(\codename\core\value\text\modelfield::getInstance('testdata_integer')); - } - - /** - * [testFieldSetNonexistingThrows description] - */ - public function testFieldSetNonexistingThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_FIELDSET_FIELDNOTFOUNDINMODEL); - $model = $this->getModel('testdata'); - $model->fieldSet(\codename\core\value\text\modelfield::getInstance('nonexisting'), 'xyz'); - } - - /** - * [testFieldSetNoDatasetLoadedThrows description] - */ - public function testFieldSetNoDatasetLoadedThrows(): void { - $this->expectExceptionMessage(\codename\core\model::EXCEPTION_FIELDSET_NOOBJECTLOADED); - $model = $this->getModel('testdata'); - $model->fieldSet(\codename\core\value\text\modelfield::getInstance('testdata_integer'), 999); - } - - /** - * Basic Timemachine functionality - */ - public function testTimemachineDelta(): void { - $testdataTm = $this->getTimemachineEnabledModel('testdata'); - - $res = $this->getModel('testdata') - ->addFilter('testdata_text', 'foo') - ->addFilter('testdata_date', '2021-03-22') - ->addFilter('testdata_number', 3.14) - ->search()->getResult(); - $this->assertCount(1, $res); - $id = $res[0]['testdata_id']; - - $testdataTm->save([ - 'testdata_id' => $id, - 'testdata_integer' => 888, - ]); - - $timemachine = new \codename\core\timemachine($testdataTm); - $history = $timemachine->getHistory($id); - - $delta = $timemachine->getDeltaData($id, 0); - $this->assertEquals([ 'testdata_integer' => 3], $delta); - - $bigbangState = $timemachine->getHistoricData($id, 0); - $this->assertEquals(3, $bigbangState['testdata_integer']); - - // restore via delta - $testdataTm->save(array_merge([ - 'testdata_id' => $id, - ], $delta)); - } - - /** - * [getModel description] - * @param string $model [description] - * @return \codename\core\model - */ - protected static function getTimemachineEnabledModelStatic(string $model): \codename\core\model { - $modelData = static::$models[$model]; - $instance = new timemachineEnabledSqlModel($modelData['schema'], $modelData['model'], $modelData['config']); - - $tmModelData = static::$models['timemachine']; - $tmModel = new timemachineModel($tmModelData['schema'], $tmModelData['model'], $tmModelData['config']); - $instance->setTimemachineModelInstance($tmModel); - - $tmSecondaryInstance = new timemachineEnabledSqlModel($modelData['schema'], $modelData['model'], $modelData['config']); - $tmSecondaryInstance->setTimemachineModelInstance($tmModel); - overrideableTimemachine::storeInstance($tmSecondaryInstance); - return $instance; - } - - /** - * [getModel description] - * @param string $model [description] - * @return \codename\core\model [description] - */ - protected function getTimemachineEnabledModel(string $model): \codename\core\model { - return static::getTimemachineEnabledModelStatic($model); - } -} -/** - * Overridden timemachine class - * that allows setting an instance directly (and skip app::getModel internally) - * - needed for these 'staged' unit tests - */ -class overrideableTimemachine extends \codename\core\timemachine { - /** - * [storeInstance description] - * @param \codename\core\model $modelInstance [description] - * @param string $app [description] - * @param string $vendor [description] - * @return [type] [description] - */ - public static function storeInstance(\codename\core\model $modelInstance, string $app = '', string $vendor = '') { - $capableModelName = $modelInstance->getIdentifier(); - $identifier = $capableModelName.'-'.$vendor.'-'.$app; - self::$instances[$identifier] = new self($modelInstance); - } + $customerModel->saveWithChildren([ + 'customer_no' => 'join_fv_nochild', + // No customer_person provided + ]); + + $customerId = $customerModel->lastInsertId(); + + // make sure to only find one result + // (one entry that has both datasets) + $dataset = $customerModel->load($customerId); + + static::assertEquals('join_fv_nochild', $dataset['customer_no']); + static::assertNotEmpty($dataset['customer_person']); + foreach ($personModel->getFields() as $field) { + if ($personModel->getConfig()->get('datatype>' . $field) == 'virtual') { + // + // NOTE: we have no child models added, + // and we expect the result to NOT have those (virtual) fields at all + // + static::assertArrayNotHasKey($field, $dataset['customer_person']); + } else { + // Expect the key(s) to exist, but be null. + static::assertArrayHasKey($field, $dataset['customer_person']); + static::assertNull($dataset['customer_person'][$field]); + } + } -} -class timemachineEnabledSqlModel extends \codename\core\tests\sqlModel - implements \codename\core\model\timemachineInterface { - - /** - * @inheritDoc - */ - public function isTimemachineEnabled(): bool - { - return true; - } - - /** - * @inheritDoc - */ - public function getTimemachineModel(): \codename\core\model - { - return $this->timemachineModelInstance; // new timemachine(); - } - - protected $timemachineModelInstance = null; - - public function setTimemachineModelInstance(\codename\core\model\timemachineModelInterface $instance) { - $this->timemachineModelInstance = $instance; - } -} + // + // Test again using no VFR and varying FVJ states + // + $forceVirtualJoinStates = [true, false]; + + foreach ($forceVirtualJoinStates as $fvjState) { + $noVfrCustomerModel = $this->getModel('customer')->setVirtualFieldResult(false) + ->addModel( + $noVfrPersonModel = $this->getModel('person')->setVirtualFieldResult(false) + ->setForceVirtualJoin($fvjState), + ); + + $datasetNoVfr = $noVfrCustomerModel->load($customerId); + + static::assertEquals('join_fv_nochild', $datasetNoVfr['customer_no']); + static::assertArrayNotHasKey('customer_person', $datasetNoVfr); + foreach ($noVfrPersonModel->getFields() as $field) { + if ($noVfrPersonModel->getConfig()->get('datatype>' . $field) == 'virtual') { + // + // NOTE: we have no child models added, + // and we expect the result to NOT have those (virtual) fields at all + // + static::assertArrayNotHasKey($field, $datasetNoVfr); + } else { + // Expect the key(s) to exist, but be null. + static::assertArrayHasKey($field, $datasetNoVfr); + static::assertNull($datasetNoVfr[$field]); + } + } + } + + + $customerModel->delete($customerId); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testInnerJoinRegular(): void + { + $this->testInnerJoin(false); + } + + /** + * @param bool $forceVirtualJoin [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testInnerJoin(bool $forceVirtualJoin): void + { + $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) + ->addModel( + $personModel = $this->getModel('person')->setVirtualFieldResult(true) + ); + + $customerIds = []; + $personIds = []; + + if (!($customerModel instanceof sql)) { + static::fail('setup fail'); + } + + $customerModel->saveWithChildren([ + 'customer_no' => 'join1', + 'customer_person' => [ + 'person_firstname' => 'Some', + 'person_lastname' => 'Join', + ], + ]); + + $customerIds[] = $customerModel->lastInsertId(); + $personIds[] = $personModel->lastInsertId(); + + $customerModel->saveWithChildren([ + 'customer_no' => 'join2', + 'customer_person' => null, + ]); + + $customerIds[] = $customerModel->lastInsertId(); + $personIds[] = $personModel->lastInsertId(); + + $personModel->save([ + 'person_firstname' => 'extra', + 'person_lastname' => 'person', + ]); + $personIds[] = $personModel->lastInsertId(); + + $innerJoinModel = $this->getModel('customer')->setVirtualFieldResult(true) + ->addModel( + $this->getModel('person') + ->setVirtualFieldResult(true) + ->setForceVirtualJoin($forceVirtualJoin), + join::TYPE_INNER + ); + + // make sure to only find one result + // (one entry that has both datasets) + $innerJoinRes = $innerJoinModel->search()->getResult(); + static::assertCount(1, $innerJoinRes); + + // compare to regular result (left join) + $res = $customerModel->search()->getResult(); + static::assertCount(2, $res); + + foreach ($customerIds as $id) { + $customerModel->delete($id); + } + foreach ($personIds as $id) { + $personModel->delete($id); + } + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testInnerJoinForcedVirtualJoin(): void + { + $this->testInnerJoin(true); + } + + /** + * Tests a special situation: + * + * Customer (model, vfr enabled) + * customer_person (vfield) displays: + * -> person (model, joined) + * person_country (field) is join base for: + * -> country (model, bare join) + * + * If you hideAllFields in customer, + * customer_person does not exist and neither doe "person_country" + * but the join is tried anyway. + * We're throwing an exception in this case, + * as it is an indicator for incomplete code, missing definition + * or even legacy code. + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testJoinVirtualFieldResultEnabledMissingVKey(): void + { + $customerModel = $this->getModel('customer') + ->setVirtualFieldResult(true) + ->hideAllFields() + ->addField('customer_no') + ->addModel( + $personModel = $this->getModel('person') + ->addModel($this->getModel('country')) + ); + + $personModel->save([ + 'person_firstname' => 'john', + 'person_lastname' => 'doe', + 'person_country' => 'DE', + ]); + $personId = $personModel->lastInsertId(); + $customerModel->save([ + 'customer_no' => 'missing_vkey', + 'customer_person_id' => $personId, + ]); + $customerId = $customerModel->lastInsertId(); + + $dataset = $customerModel->load($customerId); + static::assertArrayHasKey('customer_person', $dataset); + static::assertEquals('john', $dataset['customer_person']['person_firstname']); + static::assertEquals('Germany', $dataset['customer_person']['country_name']); -class timemachineModel extends \codename\core\tests\sqlModel - implements timemachineModelInterface { - - /** - * @inheritDoc - */ - public function save(array $data) : \codename\core\model - { - if($data[$this->getPrimarykey()] ?? null) { - throw new exception('TIMEMACHINE_UPDATE_DENIED', exception::$ERRORLEVEL_FATAL); - } else { - $data = array_replace($data, $this->getIdentity()); - return parent::save($data); - } - } - - /** - * current identity, null if not retrieved yet - * @var array|null - */ - protected $identity = null; - - /** - * Get identity parameters for injecting - * into the timemachine dataset - * @return array - */ - protected function getIdentity () : array { - if(!$this->identity) { - $this->identity = [ - 'timemachine_source' => 'unittest', - 'timemachine_user_id' => 123, - ]; - } - return $this->identity; - } - - /** - * @inheritDoc - */ - public function getModelField(): string - { - return 'timemachine_model'; - } - - /** - * @inheritDoc - */ - public function getRefField(): string - { - return 'timemachine_ref'; - } - - /** - * @inheritDoc - */ - public function getDataField(): string - { - return 'timemachine_data'; - } + // + // NOTE: this is still pending clearance. + // For now, this emulates the old behavior. + // VFR keys are added implicitly + // + // try { + // $dataset = $customerModel->load($customerId); + // static::fail('Dataset loaded without exception to be fired - should crash.'); + // } catch (\codename\core\exception $e) { + // // NOTE: we only catch this specific exception! + // static::assertEquals('EXCEPTION_MODEL_PERFORMBAREJOIN_MISSING_VKEY', $e->getMessage()); + // } + + $customerModel->delete($customerId); + $personModel->delete($personId); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testJoinVirtualFieldResultEnabledCustomVKey(): void + { + $customerModel = $this->getModel('customer') + ->setVirtualFieldResult(true) + ->addModel( + $personModel = $this->getModel('person') + ->addModel($this->getModel('country')) + ); + + $personModel->save([ + 'person_firstname' => 'john', + 'person_lastname' => 'doe', + 'person_country' => 'DE', + ]); + $personId = $personModel->lastInsertId(); + $customerModel->save([ + 'customer_no' => 'missing_vkey', + 'customer_person_id' => $personId, + ]); + $customerId = $customerModel->lastInsertId(); + + $customVKeyModel = $this->getModel('customer') + ->setVirtualFieldResult(true) + ->addModel( + $personModel = $this->getModel('person') + ->addModel($this->getModel('country')) + ); + + // change the virtual field name of the join + $join = $customVKeyModel->getNestedJoins('person')[0]; + $join->virtualField = 'custom_vfield'; + + $dataset = $customVKeyModel->load($customerId); + static::assertArrayNotHasKey('customer_person', $dataset); + static::assertArrayHasKey('custom_vfield', $dataset); + static::assertEquals('john', $dataset['custom_vfield']['person_firstname']); + static::assertEquals('Germany', $dataset['custom_vfield']['country_name']); + + // NOTE: see testJoinVirtualFieldResultEnabledMissingVKey + + $customerModel->delete($customerId); + $personModel->delete($personId); + } + + /** + * Tests a special case of model renormalization + * no virtual field results enabled, two models on the same nesting level (root) + * with one or more hidden fields (each?) + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testJoinHiddenFieldsNoVirtualFieldResult(): void + { + $customerModel = $this->getModel('customer') + ->hideField('customer_no') + ->addModel( + $personModel = $this->getModel('person') + ->hideField('person_firstname') + ); + + $personModel->save([ + 'person_firstname' => 'john', + 'person_lastname' => 'doe', + ]); + $personId = $personModel->lastInsertId(); + $customerModel->save([ + 'customer_no' => 'no_vfr', + 'customer_person_id' => $personId, + ]); + $customerId = $customerModel->lastInsertId(); + + $dataset = $customerModel->load($customerId); + static::assertEquals('doe', $dataset['person_lastname']); + static::assertEquals($personId, $dataset['customer_person_id']); + static::assertArrayNotHasKey('person_firstname', $dataset); + static::assertArrayNotHasKey('customer_no', $dataset); + + $customerModel->delete($customerId); + $personModel->delete($personId); + } + + /** + * Tests equally named fields in a joined model + * to be re-normalized correctly + * NOTE: this is SQL syntax and might be erroneous on non-sql models + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testSameNamedCalculatedFieldsInVirtualFieldResults(): void + { + $personModel = $this->getModel('person')->setVirtualFieldResult(true) + ->addCalculatedField('calcfield', '(1+1)') + ->addModel( + $parentPersonModel = $this->getModel('person')->setVirtualFieldResult(true) + ->addCalculatedField('calcfield', '(2+2)') + ); + + if (!($personModel instanceof sql)) { + static::fail('setup fail'); + } + + $personModel->saveWithChildren([ + 'person_firstname' => 'theFirstname', + 'person_lastname' => 'theLastName', + 'person_parent' => [ + 'person_firstname' => 'parentFirstname', + 'person_lastname' => 'parentLastName', + ], + ]); + + $personId = $personModel->lastInsertId(); + $parentPersonId = $parentPersonModel->lastInsertId(); + + $dataset = $personModel->load($personId); + static::assertEquals(2, $dataset['calcfield']); + static::assertEquals(4, $dataset['person_parent']['calcfield']); + + $personModel->delete($personId); + $parentPersonModel->delete($parentPersonId); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testRecursiveModelVirtualFieldDisabledWithAliasedFields(): void + { + $personModel = $this->getModel('person')->setVirtualFieldResult(true) + ->hideAllFields() + ->addField('person_firstname') + ->addField('person_lastname') + ->addModel( + // Parent optionally as forced virtual + $parentPersonModel = $this->getModel('person') + ->hideAllFields() + ->addField('person_firstname', 'parent_firstname') + ->addField('person_lastname', 'parent_lastname') + ); + + if (!($personModel instanceof sql)) { + static::fail('setup fail'); + } + + $personModel->saveWithChildren([ + 'person_firstname' => 'theFirstname', + 'person_lastname' => 'theLastName', + 'person_parent' => [ + 'person_firstname' => 'parentFirstname', + 'person_lastname' => 'parentLastName', + ], + ]); + + // NOTE: Important, disable for the following step. + // (disabling vfields) + $personModel->setVirtualFieldResult(false); + + $personId = $personModel->lastInsertId(); + $parentPersonId = $parentPersonModel->lastInsertId(); + + $dataset = $personModel->load($personId); + static::assertEquals([ + 'person_firstname' => 'theFirstname', + 'person_lastname' => 'theLastName', + 'parent_firstname' => 'parentFirstname', + 'parent_lastname' => 'parentLastName', + ], $dataset); + + $personModel->delete($personId); + $parentPersonModel->delete($parentPersonId); + } + + /** + * Tests whether all identifiers and references to behave as designed, + * E.g., a child PKEY are authoritative over a given FKEY reference + * in the parent model's dataset + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testSaveWithChildrenAuthoritativeDatasetsAndIdentifiers(): void + { + $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) + ->addModel($personModel = $this->getModel('person')); + + if (!($customerModel instanceof sql)) { + static::fail('setup fail'); + } + + $customerModel->saveWithChildren([ + 'customer_no' => 'X000', + 'customer_person' => [ + 'person_firstname' => 'XYZ', + 'person_lastname' => 'ASD', + ], + ]); + + $customerId = $customerModel->lastInsertId(); + $personId = $personModel->lastInsertId(); + + $customerModel->saveWithChildren([ + 'customer_id' => $customerId, + 'customer_person' => [ + 'person_id' => $personId, + 'person_firstname' => 'VVV', + ], + ]); + + $dataset = $customerModel->load($customerId); + static::assertEquals($personId, $dataset['customer_person_id']); + + // create a secondary, unassociated person + + $personModel->save([ + 'person_firstname' => 'otherX', + 'person_lastname' => 'otherY', + ]); + $otherPersonId = $personModel->lastInsertId(); + $customerModel->saveWithChildren([ + 'customer_id' => $customerId, + 'customer_person' => [ + 'person_id' => $otherPersonId, + 'person_firstname' => 'changed', + ], + ]); + + $dataset = $customerModel->load($customerId); + static::assertEquals($otherPersonId, $dataset['customer_person_id']); + + $customerModel->saveWithChildren([ + 'customer_id' => $customerId, + 'customer_person' => [ + 'person_firstname' => 'another', + ], + ]); + $anotherPersonId = $personModel->lastInsertId(); + $dataset = $customerModel->load($customerId); + + // + // Make sure we have created another person (child dataset) implicitly + // + static::assertNotEquals($otherPersonId, $dataset['customer_person_id']); + + // make sure child PKEY (if given) + // overrides the parent's FKEY value + $customerModel->saveWithChildren([ + 'customer_id' => $customerId, + 'customer_person_id' => $personId, + 'customer_person' => [ + 'person_id' => $otherPersonId, + ], + ]); + + $dataset = $customerModel->load($customerId); + static::assertEquals($otherPersonId, $dataset['customer_person_id']); + + // Cleanup + $customerModel->delete($customerId); + $personModel->delete($personId); + $personModel->delete($otherPersonId); + $personModel->delete($anotherPersonId); + } + + /** + * Tests a complex case of joining and model renormalization + * (e.g., recursive models joined, but different fieldlists!) + * In this case, a forced virtual join comes in-between. + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testComplexVirtualRenormalizeForcedVirtualJoin(): void + { + $this->testComplexVirtualRenormalize(true); + } + + /** + * @param bool $forceVirtualJoin [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testComplexVirtualRenormalize(bool $forceVirtualJoin): void + { + $personModel = $this->getModel('person')->setVirtualFieldResult(true) + ->hideField('person_lastname') + ->addModel( + // Parent optionally as forced virtual + $parentPersonModel = $this->getModel('person')->setVirtualFieldResult(true) + ->hideField('person_firstname') + ->setForceVirtualJoin($forceVirtualJoin) + ); + + if (!($personModel instanceof sql)) { + static::fail('setup fail'); + } + + $personModel->saveWithChildren([ + 'person_firstname' => 'theFirstname', + 'person_lastname' => 'theLastName', + 'person_parent' => [ + 'person_firstname' => 'parentFirstname', + 'person_lastname' => 'parentLastName', + ], + ]); + + $personId = $personModel->lastInsertId(); + $parentPersonId = $parentPersonModel->lastInsertId(); + + $dataset = $personModel->load($personId); + + static::assertArrayNotHasKey('person_lastname', $dataset); + static::assertArrayNotHasKey('person_firstname', $dataset['person_parent']); + + // re-add the hidden fields aliased + $personModel->addField('person_lastname', 'aliased_lastname'); + $parentPersonModel->addField('person_firstname', 'aliased_firstname'); + $dataset = $personModel->load($personId); + static::assertEquals('theLastName', $dataset['aliased_lastname']); + static::assertEquals('parentFirstname', $dataset['person_parent']['aliased_firstname']); + + // add the alias fields to the respective other models + // (aliased vfield renormalization) + $parentPersonModel->addField('person_lastname', 'aliased_lastname'); + $personModel->addField('person_firstname', 'aliased_firstname'); + $dataset = $personModel->load($personId); + static::assertEquals('theFirstname', $dataset['aliased_firstname']); + static::assertEquals('theLastName', $dataset['aliased_lastname']); + static::assertEquals('parentFirstname', $dataset['person_parent']['aliased_firstname']); + static::assertEquals('parentLastName', $dataset['person_parent']['aliased_lastname']); + + $personModel->delete($personId); + $parentPersonModel->delete($parentPersonId); + } + + /** + * Tests a complex case of joining and model renormalization + * (e.g., recursive models joined, but different fieldlists!) + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testComplexVirtualRenormalizeRegular(): void + { + $this->testComplexVirtualRenormalize(false); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testComplexJoin(): void + { + $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) + ->addModel( + $personModel = $this->getModel('person')->setVirtualFieldResult(true) + ->addVirtualField('person_fullname1', function ($dataset) { + return $dataset['person_firstname'] . ' ' . $dataset['person_lastname']; + }) + ->addModel($this->getModel('country')) + ->addModel( + // Parent as forced virtual + $parentPersonModel = $this->getModel('person')->setVirtualFieldResult(true) + ->addVirtualField('person_fullname2', function ($dataset) { + return $dataset['person_firstname'] . ' ' . $dataset['person_lastname']; + }) + ->setForceVirtualJoin(true) + ->addModel($this->getModel('country')) + ) + ); + + if (!($customerModel instanceof sql)) { + static::fail('setup fail'); + } + $customerModel->saveWithChildren([ + 'customer_no' => 'COMPLEX1', + 'customer_person' => [ + 'person_firstname' => 'Johnny', + 'person_lastname' => 'Doenny', + 'person_birthdate' => '1950-04-01', + 'person_country' => 'AT', + 'person_parent' => [ + 'person_firstname' => 'Johnnys', + 'person_lastname' => 'Father', + 'person_birthdate' => '1930-12-10', + 'person_country' => 'DE', + ], + ], + ]); + + $customerId = $customerModel->lastInsertId(); + $personId = $personModel->lastInsertId(); + $parentPersonId = $parentPersonModel->lastInsertId(); + + $dataset = $customerModel->load($customerId); + + static::assertEquals('COMPLEX1', $dataset['customer_no']); + static::assertEquals('Doenny', $dataset['customer_person']['person_lastname']); + static::assertEquals('Austria', $dataset['customer_person']['country_name']); + static::assertEquals('Father', $dataset['customer_person']['person_parent']['person_lastname']); + static::assertEquals('Germany', $dataset['customer_person']['person_parent']['country_name']); + + static::assertEquals('Johnny Doenny', $dataset['customer_person']['person_fullname1']); + static::assertEquals('Johnnys Father', $dataset['customer_person']['person_parent']['person_fullname2']); + + // make sure there are no other fields on the root level + $intersect = array_intersect(array_keys($dataset), $customerModel->getFields()); + static::assertEmpty(array_diff(array_keys($dataset), $intersect)); + + $customerModel->delete($customerId); + $personModel->delete($personId); + $parentPersonModel->delete($parentPersonId); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testJoinNestingLimitExceededWillFail(): void + { + $this->expectException(PDOException::class); + // exhaust the join nesting limit + $model = $this->joinRecursively('person', $this->getJoinNestingLimit()); + $model->search()->getResult(); + } + + /** + * Joins a model (itself) recursively (as far as possible) + * @param string $modelName [model used for joining recursively] + * @param int $limit [number of joins performed] + * @param bool $virtualFieldResult [whether to switch on vFieldResults by default] + * @return model + * @throws ReflectionException + * @throws exception + */ + protected function joinRecursively(string $modelName, int $limit, bool $virtualFieldResult = false): model + { + $model = $this->getModel($modelName)->setVirtualFieldResult($virtualFieldResult); + $currentModel = $model; + for ($i = 0; $i < $limit; $i++) { + $recurseModel = $this->getModel($modelName)->setVirtualFieldResult($virtualFieldResult); + $currentModel->addModel($recurseModel); + $currentModel = $recurseModel; + } + return $model; + } + + /** + * Maximum (expected) join limit + * @return int [description] + */ + abstract protected function getJoinNestingLimit(): int; + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testJoinNestingLimitMaxxedOut(): void + { + $this->expectNotToPerformAssertions(); + // Try to max-out the join nesting limit (limit - 1) + $model = $this->joinRecursively('person', $this->getJoinNestingLimit() - 1); + $model->search()->getResult(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testJoinNestingLimitMaxxedOutSaving(): void + { + $this->testJoinNestingLimit(); + } + + /** + * @param int|null $exceedLimit [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testJoinNestingLimit(?int $exceedLimit = null): void + { + $limit = $this->getJoinNestingLimit() - 1; + + $model = $this->joinRecursively('person', $limit, true); + + $deeperModel = null; + if ($exceedLimit) { + $currentJoin = $model->getNestedJoins('person')[0] ?? null; + $deeplyNestedJoin = $currentJoin; + while ($currentJoin !== null) { + $currentJoin = $currentJoin->model->getNestedJoins('person')[0] ?? null; + if ($currentJoin) { + $deeplyNestedJoin = $currentJoin; + } + } + $deeperModel = $this->getModel('person') + ->setVirtualFieldResult(true) + ->setForceVirtualJoin(true); + + $deeplyNestedJoin->model + ->addModel( + $deeperModel + ); + + if ($exceedLimit > 1) { + // NOTE: joinRecursively returns at least 1 model instance + // as we already have one above, we now have to reduce by 2 (!) + $evenDeeperModel = $this->joinRecursively('person', $exceedLimit - 2, true); + $deeperModel->addModel($evenDeeperModel); + } + $limit += $exceedLimit; + } + + + $dataset = null; + $savedExceeded = 0; + + // $maxI = $limit + 1; + foreach (range($limit + 1, 1) as $i) { + $dataset = [ + 'person_firstname' => 'firstname' . $i, + 'person_lastname' => 'testJoinNestingLimitMaxxedOutSaving', + 'person_parent' => $dataset, + ]; + if ($exceedLimit && ($i > ($limit - $exceedLimit + 1))) { + $dataset['person_country'] = 'DE'; + $savedExceeded++; + } + } + + if (!($model instanceof sql)) { + static::fail('setup fail'); + } + $model->saveWithChildren($dataset); + + $id = $model->lastInsertId(); + + $loadedDataset = $model->load($id); + + // if we have a deeper model joined + // (see above), we verify we have those tiny modifications + // successfully saved + if ($deeperModel) { + $deeperId = $deeperModel->lastInsertId(); + $deeperDataset = $deeperModel->load($deeperId); + + // print_r($deeperDataset); + static::assertEquals($exceedLimit, $savedExceeded); + + $diveDataset = $deeperDataset; + for ($i = 0; $i < $savedExceeded; $i++) { + static::assertEquals('DE', $diveDataset['person_country']); + $diveDataset = $diveDataset['person_parent'] ?? null; + } + } + + static::assertEquals('firstname1', $loadedDataset['person_firstname']); + + foreach (range(0, $limit) as $l) { + $path = array_fill(0, $l, 'person_parent'); + $childDataset = deepaccess::get($dataset, $path); + static::assertEquals('firstname' . ($l + 1), $childDataset['person_firstname']); + } + + $cnt = $this->getModel('person') + ->addFilter('person_lastname', 'testJoinNestingLimitMaxxedOutSaving') + ->getCount(); + static::assertEquals($limit + 1, $cnt); + + $personModel = $this->getModel('person') + ->addDefaultFilter('person_lastname', 'testJoinNestingLimitMaxxedOutSaving'); + + if (!($personModel instanceof sql)) { + static::fail('setup fail'); + } + + $personModel + ->update([ + 'person_parent_id' => null, + ]) + ->delete(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testJoinNestingBypassLimitation1(): void + { + $this->testJoinNestingLimit(1); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testJoinNestingBypassLimitation2(): void + { + $this->testJoinNestingLimit(2); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testJoinNestingBypassLimitation3(): void + { + $this->testJoinNestingLimit(3); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testGetCount(): void + { + $model = $this->getModel('testdata'); + + static::assertEquals(4, $model->getCount()); + + $model->addFilter('testdata_text', 'bar'); + static::assertEquals(2, $model->getCount()); + + // Test model getCount() to _NOT_ reset filters + static::assertEquals(2, $model->getCount()); + + // Explicit reset + $model->reset(); + static::assertEquals(4, $model->getCount()); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testFlags(): void + { + $model = $this->getModel('testdata'); + $model->save([ + 'testdata_text' => 'flagtest', + ]); + $id = $model->lastInsertId(); + + $model->entryLoad($id); + + $flags = $model->getConfig()->get('flag'); + static::assertCount(4, $flags); + + foreach ($flags as $flagName => $flagMask) { + static::assertEquals($model->getConfig()->get("flag>$flagName"), $flagMask); + static::assertFalse($model->isFlag($flagMask, $model->getData())); + } + + $model->save( + $model->normalizeData([ + $model->getPrimaryKey() => $id, + 'testdata_flag' => [ + 'foo' => true, + 'baz' => true, + ], + ]) + ); + + // + // we should only have one. + // see above. + // + $res = $model->withFlag($model->getConfig()->get('flag>foo'))->search()->getResult(); + static::assertCount(1, $res); + + // We assume the base testdata entries have a null value + // and therefore, are not to be included in the results at all. + $res = $model->withoutFlag($model->getConfig()->get('flag>baz'))->search()->getResult(); + static::assertCount(0, $res); + + // combined flags filters + $res = $model + ->withFlag($model->getConfig()->get('flag>foo')) + ->withoutFlag($model->getConfig()->get('flag>qux')) + ->search()->getResult(); + static::assertCount(1, $res); + + $withDefaultFlagModel = $this->getModel('testdata')->withDefaultFlag($model->getConfig()->get('flag>foo')); + $res1 = $withDefaultFlagModel->search()->getResult(); + $res2 = $withDefaultFlagModel->search()->getResult(); // default filters are re-applied. + static::assertCount(1, $res1); + static::assertEquals($res1, $res2); + + $withoutDefaultFlagModel = $this->getModel('testdata')->withoutDefaultFlag($model->getConfig()->get('flag>baz')); + $res1 = $withoutDefaultFlagModel->search()->getResult(); + $res2 = $withoutDefaultFlagModel->search()->getResult(); // default filters are re-applied. + static::assertCount(0, $res1); + static::assertEquals($res1, $res2); + + $model->delete($id); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testFlagfieldValueNoFlagsInModel(): void + { + $this->expectExceptionMessage(model::EXCEPTION_MODEL_FUNCTION_FLAGFIELDVALUE_NOFLAGSINMODEL); + $this->getModel('person')->flagfieldValue(1, []); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testFlagfieldValue(): void + { + $model = $this->getModel('testdata'); + + static::assertEquals(0, $model->flagfieldValue(0, []), 'No flags provided'); + static::assertEquals(1, $model->flagfieldValue(1, []), 'Do not change anything'); + static::assertEquals(128, $model->flagfieldValue(128, []), 'Do not change anything, nonexisting given'); + + static::assertEquals( + 1 + 8, + $model->flagfieldValue( + 1, + [ + 8 => true, + ] + ), + 'Change a single flag' + ); + + static::assertEquals( + 1 + 4 + 8, + $model->flagfieldValue( + 1 + 2 + 8, + [ + 4 => true, + 2 => false, + ] + ), + 'Change flags' + ); + + static::assertEquals( + 1, + $model->flagfieldValue( + 1, + [ + 128 => true, + ] + ), + 'Setting invalid flag does not change anything' + ); + + static::assertEquals( + 1, + $model->flagfieldValue( + 1, + [ + (1 + 2) => true, + ] + ), + 'Setting combined flag has no effect' + ); + + static::assertEquals( + 1, + $model->flagfieldValue( + 1, + [ + -2 => true, + ] + ), + 'Setting invalid/negative flag has no effect' + ); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testGetFlagNonexisting(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage(model::EXCEPTION_GETFLAG_FLAGNOTFOUND); + $this->getModel('testdata')->getFlag('nonexisting'); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testIsFlagNoFlagField(): void + { + $this->expectExceptionMessage(model::EXCEPTION_ISFLAG_NOFLAGFIELD); + $this->getModel('testdata')->isFlag(3, ['testdata_text' => 'abc']); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testFlagNormalization(): void + { + $model = $this->getModel('testdata'); + + // + // no normalization, if value provided + // + $normalized = $model->normalizeData([ + 'testdata_flag' => 1, + ]); + static::assertEquals(1, $normalized['testdata_flag']); + + // + // retain value, if flag values present + // that are not defined + // + $normalized = $model->normalizeData([ + 'testdata_flag' => 123, + ]); + static::assertEquals(123, $normalized['testdata_flag']); + + // + // no flag (array-technique) + // + $normalized = $model->normalizeData([ + 'testdata_flag' => [], + ]); + static::assertEquals(0, $normalized['testdata_flag']); + + // + // single flag + // + $normalized = $model->normalizeData([ + 'testdata_flag' => [ + 'foo' => true, + ], + ]); + static::assertEquals(1, $normalized['testdata_flag']); + + // + // multiple flags + // + $normalized = $model->normalizeData([ + 'testdata_flag' => [ + 'foo' => true, + 'baz' => true, + 'qux' => false, + ], + ]); + static::assertEquals(5, $normalized['testdata_flag']); + + // + // nonexisting flag + // + $normalized = $model->normalizeData([ + 'testdata_flag' => [ + 'nonexisting' => true, + 'foo' => true, + 'baz' => true, + 'qux' => false, + ], + ]); + static::assertEquals(5, $normalized['testdata_flag']); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddModelExplicitModelfieldValid(): void + { + $saveCustomerModel = $this->getModel('customer')->setVirtualFieldResult(true) + ->addModel($savePersonModel = $this->getModel('person')); + if (!($saveCustomerModel instanceof sql)) { + static::fail('setup fail'); + } + $saveCustomerModel->saveWithChildren([ + 'customer_no' => 'ammv', + 'customer_person' => [ + 'person_firstname' => 'ammv1', + ], + ]); + $customerId = $saveCustomerModel->lastInsertId(); + $personId = $savePersonModel->lastInsertId(); + + + $model = $this->getModel('customer') + ->addModel( + $this->getModel('person'), + join::TYPE_LEFT, + 'customer_person_id' + ); + + $res = $model->search()->getResult(); + static::assertCount(1, $res); + + // TODO: detail data tests? + + $saveCustomerModel->delete($customerId); + $savePersonModel->delete($personId); + static::assertEmpty($savePersonModel->load($personId)); + static::assertEmpty($saveCustomerModel->load($customerId)); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddModelExplicitModelfieldInvalid(): void + { + // + // Try to join on a field that's not designed for it + // + $this->expectException(exception::class); + $this->expectExceptionMessage('EXCEPTION_MODEL_ADDMODEL_INVALID_OPERATION'); + + $this->getModel('customer') + ->addModel( + $this->getModel('person'), + join::TYPE_LEFT, + 'customer_no' // invalid field for this model + ); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddModelInvalidNoRelation(): void + { + // + // Try to join a model that has no relation to it + // + $this->expectException(exception::class); + $this->expectExceptionMessage('EXCEPTION_MODEL_ADDMODEL_INVALID_OPERATION'); + + $this->getModel('testdata') + ->addModel( + $this->getModel('person') + ); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testVirtualFieldResultSaving(): void + { + $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) + ->addModel( + $personModel = $this->getModel('person')->setVirtualFieldResult(true) + ->addModel($parentPersonModel = $this->getModel('person')) + ) + ->addCollectionModel($this->getModel('contactentry')); + + $dataset = [ + 'customer_no' => 'K1000', + 'customer_person' => [ + 'person_firstname' => 'John', + 'person_lastname' => 'Doe', + 'person_birthdate' => '1970-01-01', + 'person_parent' => [ + 'person_firstname' => 'Maria', + 'person_lastname' => 'Ada', + 'person_birthdate' => null, + ], + ], + 'customer_contactentries' => [ + ['contactentry_name' => 'Phone', 'contactentry_telephone' => '+49123123123'], + ], + ]; + + static::assertTrue($customerModel->isValid($dataset)); + + if (!($customerModel instanceof sql)) { + static::fail('setup fail'); + } + $customerModel->saveWithChildren($dataset); + + $customerId = $customerModel->lastInsertId(); + $personId = $personModel->lastInsertId(); + $parentPersonId = $parentPersonModel->lastInsertId(); + + $dataset = $customerModel->load($customerId); + + static::assertEquals('K1000', $dataset['customer_no']); + static::assertEquals('John', $dataset['customer_person']['person_firstname']); + static::assertEquals('Doe', $dataset['customer_person']['person_lastname']); + static::assertEquals('Phone', $dataset['customer_contactentries'][0]['contactentry_name']); + static::assertEquals('+49123123123', $dataset['customer_contactentries'][0]['contactentry_telephone']); + + static::assertEquals('Maria', $dataset['customer_person']['person_parent']['person_firstname']); + static::assertEquals('Ada', $dataset['customer_person']['person_parent']['person_lastname']); + static::assertEquals(null, $dataset['customer_person']['person_parent']['person_birthdate']); + + static::assertNotNull($dataset['customer_id']); + static::assertNotNull($dataset['customer_person']['person_id']); + static::assertNotNull($dataset['customer_contactentries'][0]['contactentry_id']); + + static::assertEquals($dataset['customer_person_id'], $dataset['customer_person']['person_id']); + static::assertEquals($dataset['customer_contactentries'][0]['contactentry_customer_id'], $dataset['customer_id']); + + if (!($customerModel instanceof sql)) { + static::fail('setup fail'); + } + + // + // Cleanup + // + $customerModel->saveWithChildren([ + $customerModel->getPrimaryKey() => $customerId, + // Implicitly remove contactentries by saving an empty collection (Not null!) + 'customer_contactentries' => [], + ]); + $customerModel->delete($customerId); + $personModel->delete($personId); + $parentPersonModel->delete($parentPersonId); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testVirtualFieldResultCollectionHandling(): void + { + $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) + ->addCollectionModel($this->getModel('contactentry')); + + $dataset = [ + 'customer_no' => 'K1002', + 'customer_contactentries' => [ + ['contactentry_name' => 'Entry1', 'contactentry_telephone' => '+49123123123'], + ['contactentry_name' => 'Entry2', 'contactentry_telephone' => '+49234234234'], + ['contactentry_name' => 'Entry3', 'contactentry_telephone' => '+49345345345'], + ], + ]; + + if (!($customerModel instanceof sql)) { + static::fail('setup fail'); + } + $customerModel->saveWithChildren($dataset); + $id = $customerModel->lastInsertId(); + + $customer = $customerModel->load($id); + static::assertCount(3, $customer['customer_contactentries']); + + // delete the middle contactentry + unset($customer['customer_contactentries'][1]); + + // store PKEYs of other entries + $contactentryIds = array_column($customer['customer_contactentries'], 'contactentry_id'); + $customerModel->saveWithChildren($customer); + + $customerModified = $customerModel->load($id); + static::assertCount(2, $customerModified['customer_contactentries']); + + $contactentryIdsVerify = array_column($customerModified['customer_contactentries'], 'contactentry_id'); + + // assert the IDs haven't changed + static::assertEquals($contactentryIds, $contactentryIdsVerify); + + // assert nothing happens if a null value is provided or being unset + $customerUnsetCollection = $customerModified; + unset($customerUnsetCollection['customer_contactentries']); + $customerModel->saveWithChildren($customerUnsetCollection); + static::assertEquals($customerModified['customer_contactentries'], $customerModel->load($id)['customer_contactentries']); + + $customerNullCollection = $customerModified; + $customerNullCollection['customer_contactentries'] = null; + $customerModel->saveWithChildren($customerNullCollection); + static::assertEquals($customerModified['customer_contactentries'], $customerModel->load($id)['customer_contactentries']); + + // + // Cleanup + // + $customerModel->saveWithChildren([ + $customerModel->getPrimaryKey() => $id, + // Implicitly remove contactentries by saving an empty collection (Not null!) + 'customer_contactentries' => [], + ]); + $customerModel->delete($id); + } + + /** + * Tests trying ":: addCollectionModel" w/o having the respective config. + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddCollectionModelMissingCollectionConfig(): void + { + // Testdata model does not have a collection config + // (or, at least, it shouldn't have) + $model = $this->getModel('testdata'); + static::assertFalse($model->getConfig()->exists('collection')); + + $this->expectExceptionMessage('EXCEPTION_NO_COLLECTION_KEY'); + $model->addCollectionModel($this->getModel('details')); + } + + /** + * Tests trying to ":: addCollectionModel" with an unsupported/unspecified model + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddCollectionModelIncompatible(): void + { + $model = $this->getModel('customer'); + $this->expectExceptionMessage('EXCEPTION_UNKNOWN_COLLECTION_MODEL'); + $model->addCollectionModel($this->getModel('person')); + } + + /** + * Tests trying to ":: addCollectionModel" with a valid collection model + * but simply a wrong or nonexisting field + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddCollectionModelInvalidModelField(): void + { + $model = $this->getModel('customer'); + $this->expectExceptionMessage('EXCEPTION_NO_COLLECTION_CONFIG'); + $model->addCollectionModel( + $this->getModel('contactentry'), // Compatible + 'nonexisting_collection_field' // different field - or incompatible + ); + } + + /** + * Tests trying to ":: addCollectionModel" with an incompatible model + * but a valid/existing collection field key + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddCollectionModelValidModelFieldIncompatibleModel(): void + { + $model = $this->getModel('customer'); + $this->expectExceptionMessage('EXCEPTION_MODEL_ADDCOLLECTIONMODEL_INCOMPATIBLE'); + $model->addCollectionModel( + $this->getModel('person'), // Incompatible + 'customer_contactentries' // Existing/valid field, but irrelevant for the model to be joined + ); + } + + /** + * Tests various cases of collection retrieval + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testGetNestedCollections(): void + { + // Model w/o any collection config + static::assertEmpty($this->getModel('testdata')->getNestedCollections()); + + // Model with available, but unused collection + static::assertEmpty( + $this->getModel('customer') + ->getNestedCollections() + ); + + // Model with available and _used_ collection + $collections = $this->getModel('customer') + ->addCollectionModel($this->getModel('contactentry')) + ->getNestedCollections(); + + static::assertNotEmpty($collections); + static::assertCount(1, $collections); + + $collectionPlugin = $collections['customer_contactentries']; + static::assertInstanceOf(collection::class, $collectionPlugin); + + static::assertEquals('customer', $collectionPlugin->baseModel->getIdentifier()); + static::assertEquals('customer_id', $collectionPlugin->getBaseField()); + static::assertEquals('customer_contactentries', $collectionPlugin->field->get()); + static::assertEquals('contactentry', $collectionPlugin->collectionModel->getIdentifier()); + static::assertEquals('contactentry_customer_id', $collectionPlugin->getCollectionModelBaseRefField()); + } + + /** + * test saving (expect a crash) when having two models joined ambiguously + * in virtual field result mode + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testVirtualFieldResultSavingFailedAmbiguousJoins(): void + { + $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) + ->addModel($this->getModel('person')) + ->addModel($this->getModel('person')) // double joined + ->addCollectionModel($this->getModel('contactentry')); + + $dataset = [ + 'customer_no' => 'K1001', + 'customer_person' => [ + 'person_firstname' => 'John', + 'person_lastname' => 'Doe', + 'person_birthdate' => '1970-01-01', + ], + 'customer_contactentries' => [ + ['contactentry_name' => 'Phone', 'contactentry_telephone' => '+49123123123'], + ], + ]; + + static::assertTrue($customerModel->isValid($dataset)); + + $this->expectException(exception::class); + $this->expectExceptionMessage('EXCEPTION_MODEL_SCHEMATIC_SQL_CHILDREN_AMBIGUOUS_JOINS'); + + if (!($customerModel instanceof sql)) { + static::fail('setup fail'); + } + $customerModel->saveWithChildren($dataset); + // No need to clean up, as it must fail beforehand + } + + /** + * tests a runtime-based virtual field + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testVirtualFieldQuery(): void + { + $model = $this->getModel('testdata')->setVirtualFieldResult(true); + $model->addVirtualField('virtual_field', function ($dataset) { + return $dataset['testdata_id']; + }); + $res = $model->search()->getResult(); + + static::assertCount(4, $res); + foreach ($res as $r) { + static::assertEquals($r['testdata_id'], $r['virtual_field']); + } + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testForcedVirtualJoinWithVirtualFieldResult(): void + { + $this->testForcedVirtualJoin(true); + } + + /** + * @param bool $virtualFieldResult [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testForcedVirtualJoin(bool $virtualFieldResult): void + { + // + // Store test data + // + $saveCustomerModel = $this->getModel('customer')->setVirtualFieldResult(true) + ->addModel($savePersonModel = $this->getModel('person')->setVirtualFieldResult(true)); + if (!($saveCustomerModel instanceof sql)) { + static::fail('setup fail'); + } + $saveCustomerModel->saveWithChildren([ + 'customer_no' => 'fvj', + 'customer_person' => [ + 'person_firstname' => 'forced', + 'person_lastname' => 'virtualjoin', + ], + ]); + $customerId = $saveCustomerModel->lastInsertId(); + $personId = $savePersonModel->lastInsertId(); + + $referenceCustomerModel = $this->getModel('customer')->setVirtualFieldResult($virtualFieldResult) + ->addModel($this->getModel('person')->setVirtualFieldResult($virtualFieldResult)); + + $referenceDataset = $referenceCustomerModel->load($customerId); + + // + // new model that is forced to do a virtual join + // + + // NOTE/IMPORTANT: force virtual join state has to be set *BEFORE* joining + $personModel = $this->getModel('person'); + $personModel->setForceVirtualJoin(true); + + $customerModel = $this->getModel('customer')->setVirtualFieldResult($virtualFieldResult) + ->addModel($personModel->setVirtualFieldResult($virtualFieldResult)); + + $customerModel->saveLastQuery = true; + $personModel->saveLastQuery = true; + + $compareDataset = $customerModel->load($customerId); + + $customerLastQuery = $customerModel->getLastQuery(); + $personLastQuery = $personModel->getLastQuery(); + + // assert that *BOTH* queries have been executed (not empty) + static::assertNotNull($customerLastQuery); + static::assertNotNull($personLastQuery); + static::assertNotEquals($customerLastQuery, $personLastQuery); + + foreach ($referenceDataset as $key => $value) { + if (is_array($value)) { + foreach ($value as $k => $v) { + if ($v !== null) { + static::assertEquals($v, $compareDataset[$key][$k]); + } + } + } elseif ($value !== null) { + static::assertEquals($value, $compareDataset[$key]); + } + } + + // Assert both datasets are equal + // static::assertEquals($referenceDataset, $compareDataset); + // NOTE: doesn't work right now, because: + // $this->addWarning('Some bug when doing forced virtual joins and unjoined vfields exist'); + // NOTE/CHANGED 2021-04-13: fixed. + + // make sure to clean up + $saveCustomerModel->delete($customerId); + $savePersonModel->delete($personId); + static::assertEmpty($saveCustomerModel->load($customerId)); + static::assertEmpty($savePersonModel->load($personId)); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testForcedVirtualJoinWithoutVirtualFieldResult(): void + { + $this->testForcedVirtualJoin(false); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testModelJoinWithJson(): void + { + // inject some base data, first + $model = $this->getModel('person') + ->addModel($this->getModel('country')); + + $model->save([ + 'person_firstname' => 'German', + 'person_lastname' => 'Resident', + 'person_country' => 'DE', + ]); + $id = $model->lastInsertId(); + + $res = $model->load($id); + static::assertEquals('DE', $res['person_country']); + static::assertEquals('DE', $res['country_code']); + static::assertEquals('Germany', $res['country_name']); + + $model->delete($id); + static::assertEmpty($model->load($id)); + + // + // save another one, but without FKEY value for country + // + $model->save([ + 'person_firstname' => 'Resident', + 'person_lastname' => 'Without Country', + 'person_country' => null, + ]); + $id = $model->lastInsertId(); + + $res = $model->load($id); + static::assertEquals(null, $res['person_country']); + static::assertEquals(null, $res['country_code']); + static::assertEquals(null, $res['country_name']); + + $model->delete($id); + static::assertEmpty($model->load($id)); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testInvalidFilterOperator(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage('EXCEPTION_INVALID_OPERATOR'); + $model = $this->getModel('testdata'); + $model->addFilter('testdata_integer', 42, '%&/'); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testLikeFilters(): void + { + $model = $this->getModel('testdata'); + + // NOTE: this is case-sensitive on PG + $res = $model + ->addFilter('testdata_text', 'F%', 'LIKE') + ->search()->getResult(); + static::assertCount(2, $res); + + // NOTE: this is case-sensitive on PG + $res = $model + ->addFilter('testdata_text', 'f%', 'LIKE') + ->search()->getResult(); + static::assertCount(2, $res); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testSuccessfulCreateAndDeleteTransaction(): void + { + $testTransactionModel = $this->getModel('testdata'); + + $transaction = new transaction('test', [$testTransactionModel]); + $transaction->start(); + + // insert a new entry + $testTransactionModel->save([ + 'testdata_integer' => 999, + ]); + $id = $testTransactionModel->lastInsertId(); + + // load the new dataset in the transaction + $newDataset = $testTransactionModel->load($id); + static::assertEquals(999, $newDataset['testdata_integer']); + + // delete it + $testTransactionModel->delete($id); + + // end transaction, as if nothing happened + $transaction->end(); + + // Make sure it hasn't changed + static::assertEquals(4, $testTransactionModel->getCount()); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testTransactionUntrackedRunning(): void + { + $model = $this->getModel('testdata'); + if ($model instanceof sql) { + // Make sure there's no open transaction + // and start an untracked, new one. + static::assertFalse($model->getConnection()->getConnection()->inTransaction()); + static::assertTrue($model->getConnection()->getConnection()->beginTransaction()); + + $this->expectExceptionMessage('EXCEPTION_DATABASE_VIRTUALTRANSACTION_UNTRACKED_TRANSACTION_RUNNING'); + + $transaction = new transaction('untracked_transaction_test', [$model]); + $transaction->start(); + } else { + static::markTestSkipped('Not applicable.'); + } + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testTransactionRolledBackPrematurely(): void + { + $model = $this->getModel('testdata'); + if ($model instanceof sql) { + // Make sure there's no open transaction + static::assertFalse($model->getConnection()->getConnection()->inTransaction()); + + $this->expectExceptionMessage('EXCEPTION_DATABASE_VIRTUALTRANSACTION_UNTRACKED_TRANSACTION_RUNNING'); + + $transaction = new transaction('untracked_transaction_test', [$model]); + $transaction->start(); + + // Make sure the transaction has begun + static::assertTrue($model->getConnection()->getConnection()->inTransaction()); + + // End transaction/rollback right away + static::assertTrue($model->getConnection()->getConnection()->rollBack()); + + // Try to end transaction normally. But it was canceled before + $this->expectExceptionMessage('EXCEPTION_DATABASE_VIRTUALTRANSACTION_END_TRANSACTION_INTERRUPTED'); + $transaction->end(); + } else { + static::markTestSkipped('Not applicable.'); + } + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testNestedOrder(): void + { + // Generic model features + // Offset [& Limit & Order] + $customerModel = $this->getModel('customer')->setVirtualFieldResult(true) + ->addModel($personModel = $this->getModel('person')->setVirtualFieldResult(true)); + + $customerIds = []; + $personIds = []; + + $datasets = [ + [ + 'customer_no' => 'A1000', + 'customer_person' => [ + 'person_firstname' => 'Alex', + 'person_lastname' => 'Anderson', + 'person_birthdate' => '1978-02-03', + ], + ], + [ + 'customer_no' => 'A1001', + 'customer_person' => [ + 'person_firstname' => 'Bridget', + 'person_lastname' => 'Balmer', + 'person_birthdate' => '1981-11-15', + ], + ], + [ + 'customer_no' => 'A1002', + 'customer_person' => [ + 'person_firstname' => 'Christian', + 'person_lastname' => 'Crossback', + 'person_birthdate' => '1990-04-19', + ], + ], + [ + 'customer_no' => 'A1003', + 'customer_person' => [ + 'person_firstname' => 'Dodgy', + 'person_lastname' => 'Data', + 'person_birthdate' => null, + ], + ], + ]; + + if (!($customerModel instanceof sql)) { + static::fail('setup fail'); + } + + foreach ($datasets as $d) { + $customerModel->saveWithChildren($d); + $customerIds[] = $customerModel->lastInsertId(); + $personIds[] = $personModel->lastInsertId(); + } + + $customerModel->addOrder('person.person_birthdate', 'DESC'); + $res = $customerModel->search()->getResult(); + + static::assertEquals(['A1002', 'A1001', 'A1000', 'A1003'], array_column($res, 'customer_no')); + static::assertEquals( + ['Christian', 'Bridget', 'Alex', 'Dodgy'], + array_map(function ($dataset) { + return $dataset['customer_person']['person_firstname']; + }, $res) + ); + + // cleanup + foreach ($customerIds as $id) { + $customerModel->delete($id); + } + foreach ($personIds as $id) { + $personModel->delete($id); + } + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testOrderLimitOffset(): void + { + // Generic model features + // Offset [& Limit & Order] + $testLimitModel = $this->getModel('testdata'); + $testLimitModel->addOrder('testdata_id'); + $testLimitModel->setLimit(1); + $testLimitModel->setOffset(1); + $res = $testLimitModel->search()->getResult(); + static::assertCount(1, $res); + static::assertEquals('bar', $res[0]['testdata_text']); + static::assertEquals(4.25, $res[0]['testdata_number']); + } + + /** + * Tests setting limit & offset twice (reset) + * as only ONE limit and offset is allowed at a time + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testLimitOffsetReset(): void + { + $model = $this->getModel('testdata'); + $model->addOrder('testdata_id'); + $model->setLimit(1); + $model->setOffset(1); + $model->setLimit(0); + $model->setOffset(0); + $res = $model->search()->getResult(); + static::assertCount(4, $res); + } + + /** + * Tests whether calling model::addOrder() using a nonexisting field + * throws an exception + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddOrderOnNonexistingFieldWillThrow(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage(model::EXCEPTION_ADDORDER_FIELDNOTFOUND); + $model = $this->getModel('testdata'); + $model->addOrder('testdata_nonexisting'); + } + + /** + * Tests updating a structure field (simple) + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testStructureData(): void + { + $model = $this->getModel('testdata'); + $res = $model + ->addFilter('testdata_text', 'foo') + ->addFilter('testdata_date', '2021-03-22') + ->addFilter('testdata_number', 3.14) + ->search()->getResult(); + static::assertCount(1, $res); + + $testdata = $res[0]; + $id = $testdata[$model->getPrimaryKey()]; + + $model->save([ + $model->getPrimaryKey() => $testdata[$model->getPrimaryKey()], + 'testdata_structure' => ['changed' => true], + ]); + $updated = $model->load($id); + static::assertEquals(['changed' => true], $updated['testdata_structure']); + $model->save($testdata); + $restored = $model->load($id); + static::assertEquals($testdata['testdata_structure'], $restored['testdata_structure']); + } + + /** + * tests internal handling during saving (create and update) + * and mass updates that might encode given object/array data + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testStructureEncoding(): void + { + $model = $this->getModel('testdata'); + + $model->save([ + 'testdata_text' => 'structure-object', + 'testdata_structure' => $testObjectData = [ + 'umlautÄÜÖäüöß' => '"and quotes"', + 'and\\backlashes' => '\\some\\\"backslashes', + 'and some more' => 'with nul bytes' . chr(0), + "with special bytes \u00c2\u00ae" => "\xc3\xa9", + ], + ]); + $objectDataId = $model->lastInsertId(); + + $model->save([ + 'testdata_text' => 'structure-array', + 'testdata_structure' => $testArrayData = [ + 'umlautÄÜÖäüöß', + '"and quotes"', + 'and\\backlashes', + '\\some\\\"backslashes', + 'and some more', + "with special bytes \u00c2\u00ae", + "\xc3\xa9", + 'with nul bytes' . chr(0), + 'more data', + ], + ]); + $arrayDataId = $model->lastInsertId(); + + $storedObjectData = $model->load($objectDataId)['testdata_structure']; + $storedArrayData = $model->load($arrayDataId)['testdata_structure']; + + static::assertEquals($testObjectData, $storedObjectData); + static::assertEquals($testArrayData, $storedArrayData); + + $model->save([ + $model->getPrimaryKey() => $objectDataId, + 'testdata_structure' => $updatedObjectData = array_merge( + $storedObjectData, + [ + 'updated' => 1, + ] + ), + ]); + + $model->save([ + $model->getPrimaryKey() => $arrayDataId, + 'testdata_structure' => $updatedArrayData = array_merge( + $storedArrayData, + [ + 'updated', + ] + ), + ]); + + $storedObjectData = $model->load($objectDataId)['testdata_structure']; + $storedArrayData = $model->load($arrayDataId)['testdata_structure']; + + static::assertEquals($updatedObjectData, $storedObjectData); + static::assertEquals($updatedArrayData, $storedArrayData); + + if (!($model instanceof sql)) { + static::fail('setup fail'); + } + + $model->addFilter($model->getPrimaryKey(), $objectDataId); + $model->update([ + 'testdata_structure' => $updatedObjectData = array_merge( + $storedObjectData, + [ + 'updated' => 2, + ] + ), + ]); + + $model->addFilter($model->getPrimaryKey(), $arrayDataId); + $model->update([ + 'testdata_structure' => $updatedArrayData = array_merge( + $storedArrayData, + [ + 'updated', + ] + ), + ]); + + $storedObjectData = $model->load($objectDataId)['testdata_structure']; + $storedArrayData = $model->load($arrayDataId)['testdata_structure']; + + static::assertEquals($updatedObjectData, $storedObjectData); + static::assertEquals($updatedArrayData, $storedArrayData); + + $model->delete($objectDataId); + $model->delete($arrayDataId); + } + + /** + * tests model::getCount() when having a grouped query + * should return the final count of results + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testGroupedGetCount(): void + { + $model = $this->getModel('testdata'); + $model->addGroup('testdata_text'); + static::assertEquals(2, $model->getCount()); + } + + /** + * Tests correct aliasing when using the same model twice + * and calling ->getCount() + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testGetCountAliasing(): void + { + $model = $this->getModel('person') + ->addModel($this->getModel('person')); + + static::assertEquals(0, $model->getCount()); + } + + /** + * Tests grouping on a calculated field + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddGroupOnCalculatedFieldDoesNotCrash(): void + { + $model = $this->getModel('testdata'); + // For the sake of simplicity: do a simple alias here... + $model->addCalculatedField('calc_field', '(testdata_text)'); + $model->addGroup('calc_field'); + + // We do not check for data integrity in this test. + $this->expectNotToPerformAssertions(); + $model->search()->getResult(); + } + + /** + * Tests grouping on a nested model's calculated field + * which in which case the alias of the model MUST NOT propagate + * as it is a unique, temporary field + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddGroupOnNestedCalculatedFieldDoesNotCrash(): void + { + $model = $this->getModel('testdata') + ->addModel($detailsModel = $this->getModel('details')); + + // For the sake of simplicity: do a simple alias here... + $detailsModel->addCalculatedField('nested_calc_field', '(details_data)'); + $detailsModel->addGroup('nested_calc_field'); + + // We do not check for data integrity in this test. + $this->expectNotToPerformAssertions(); + $model->search()->getResult(); + } + + /** + * Tests whether we get an exception when trying to group + * on a nonexisting field + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddGroupNonExistingField(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage(model::EXCEPTION_ADDGROUP_FIELDDOESNOTEXIST); + + $model = $this->getModel('testdata') + ->addModel($this->getModel('details')); + + $model->addGroup('nonexisting'); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAmbiguousAliasFieldsNormalization(): void + { + $model = $this->getModel('testdata') + ->addField('testdata_text', 'aliasedField') + ->addModel( + $this->getModel('details') + ->addField('details_data', 'aliasedField') + ); + + $res = $model->search()->getResult(); + + // Same-level keys mapped to array + static::assertEquals([ + ['foo', null], + ['bar', null], + ['foo', null], + ['bar', null], + ], array_column($res, 'aliasedField')); + + // Modify model to put details into a virtual field + $model->setVirtualFieldResult(true); + $model->getNestedJoins('details')[0]->virtualField = 'temp_virtual'; + + $res2 = $model->search()->getResult(); + + static::assertEquals(['foo', 'bar', 'foo', 'bar'], array_column($res2, 'aliasedField')); + static::assertEquals([null, null, null, null], array_column(array_column($res2, 'temp_virtual'), 'aliasedField')); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateCount(): void + { + // + // Aggregate: count plugin + // + $testCountModel = $this->getModel('testdata'); + $testCountModel->addAggregateField('entries_count', 'count', 'testdata_id'); + + // count w/o filters + static::assertEquals(4, $testCountModel->search()->getResult()[0]['entries_count']); + + // w/ simple filter added + $testCountModel->addFilter('testdata_datetime', '2020-01-01', '>='); + static::assertEquals(3, $testCountModel->search()->getResult()[0]['entries_count']); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateCountDistinct(): void + { + // + // Aggregate: count_distinct plugin + // + $testCountDistinctModel = $this->getModel('testdata'); + $testCountDistinctModel->addAggregateField('entries_count', 'count_distinct', 'testdata_text'); + + // count w/o filters + static::assertEquals(2, $testCountDistinctModel->search()->getResult()[0]['entries_count']); + + // w/ simple filter added - we only expect a count of 1 + $testCountDistinctModel + ->addFilter('testdata_datetime', '2021-03-23', '>='); + static::assertEquals(1, $testCountDistinctModel->search()->getResult()[0]['entries_count']); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddAggregateFieldDuplicateFixedFieldWillThrow(): void + { + $this->expectExceptionMessage(model::EXCEPTION_ADDAGGREGATEFIELD_FIELDALREADYEXISTS); + $model = $this->getModel('testdata'); + // Try to add the aggregate field as a field that already exists + // as a defined model field - in this case, use the PKEY... + $model->addAggregateField('testdata_id', 'count_distinct', 'testdata_text'); + } + + /** + * Tests a rare edge case + * of using an aggregate field with the same name + * as a field of a nested model with enabled VFR + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddAggregateFieldSameNamedWithVirtualFieldResult(): void + { + $model = $this->getModel('testdata')->setVirtualFieldResult(true) + ->addModel($this->getModel('details')); + + $model->getNestedJoins('details')[0]->virtualField = 'details'; + // Try to add the aggregate field as a field that already exists + // in a _nested_ mode as a defined model field - in this case, use the PKEY... + $model->addAggregateField('details_id', 'count_distinct', 'testdata_text'); + + $res = $model->search()->getResult(); + static::assertCount(1, $res); + static::assertEquals(2, $res[0]['details_id']); // this really is the aggregate field... + static::assertEquals(null, $res[0]['details']['details_id']); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateSum(): void + { + // + // Aggregate: sum plugin + // + $testSumModel = $this->getModel('testdata'); + $testSumModel->addAggregateField('entries_sum', 'sum', 'testdata_integer'); + + // count w/o filters + static::assertEquals(48, $testSumModel->search()->getResult()[0]['entries_sum']); + + // w/ simple filter added + $testSumModel->addFilter('testdata_datetime', '2020-01-01', '>='); + static::assertEquals(6, $testSumModel->search()->getResult()[0]['entries_sum']); + + // no entries matching filter + $testSumModel->addFilter('testdata_datetime', '2019-01-01', '<='); + static::assertEquals(0, $testSumModel->search()->getResult()[0]['entries_sum']); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateAvg(): void + { + // + // Aggregate: avg plugin + // + $testSumModel = $this->getModel('testdata'); + $testSumModel->addAggregateField('entries_avg', 'avg', 'testdata_number'); + + // count w/o filters + static::assertEquals((3.14 + 4.25 + 5.36 + 0.99) / 4, $testSumModel->search()->getResult()[0]['entries_avg']); + + // w/ simple filter added + $testSumModel->addFilter('testdata_datetime', '2020-01-01', '>='); + static::assertEquals((3.14 + 4.25 + 5.36) / 3, $testSumModel->search()->getResult()[0]['entries_avg']); + + // no entries matching filter + $testSumModel->addFilter('testdata_datetime', '2019-01-01', '<='); + static::assertEquals(0, $testSumModel->search()->getResult()[0]['entries_avg']); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateMax(): void + { + // + // Aggregate: max plugin + // + $model = $this->getModel('testdata'); + $model->addAggregateField('entries_max', 'max', 'testdata_number'); + + // count w/o filters + static::assertEquals(5.36, $model->search()->getResult()[0]['entries_max']); + + // w/ simple filter added + $model->addFilter('testdata_datetime', '2021-03-22', '>='); + static::assertEquals(5.36, $model->search()->getResult()[0]['entries_max']); + + // w/ simple filter added + $model->addFilter('testdata_datetime', '2021-03-22 23:59:59', '<='); + static::assertEquals(4.25, $model->search()->getResult()[0]['entries_max']); + + // no entries matching filter + $model->addFilter('testdata_datetime', '2019-01-01', '<='); + static::assertEquals(0, $model->search()->getResult()[0]['entries_max']); + + // w/ added grouping + $model->addGroup('testdata_date'); + $model->addOrder('testdata_date'); + // max per day + static::assertEquals([0.99, 4.25, 5.36], array_column($model->search()->getResult(), 'entries_max')); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateMin(): void + { + // + // Aggregate: min plugin + // + $model = $this->getModel('testdata'); + $model->addAggregateField('entries_min', 'min', 'testdata_number'); + + // count w/o filters + static::assertEquals(0.99, $model->search()->getResult()[0]['entries_min']); + + // w/ simple filter added + $model->addFilter('testdata_datetime', '2021-03-22', '>='); + static::assertEquals(3.14, $model->search()->getResult()[0]['entries_min']); + + // w/ simple filter added + $model->addFilter('testdata_datetime', '2021-03-22 23:59:59', '<='); + static::assertEquals(0.99, $model->search()->getResult()[0]['entries_min']); + + // no entries matching filter + $model->addFilter('testdata_datetime', '2019-01-01', '<='); + static::assertEquals(0, $model->search()->getResult()[0]['entries_min']); + + // w/ added grouping + $model->addGroup('testdata_date'); + $model->addOrder('testdata_date'); + // min per day + static::assertEquals([0.99, 3.14, 5.36], array_column($model->search()->getResult(), 'entries_min')); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateDatetimeYear(): void + { + // + // Aggregate DateTime plugin + // + $testYearModel = $this->getModel('testdata'); + $testYearModel->addAggregateField('entries_year1', 'year', 'testdata_datetime'); + $testYearModel->addAggregateField('entries_year2', 'year', 'testdata_date'); + $testYearModel->addOrder('testdata_id'); + $yearRes = $testYearModel->search()->getResult(); + static::assertEquals([2021, 2021, 2021, 2019], array_column($yearRes, 'entries_year1')); + static::assertEquals([2021, 2021, 2021, 2019], array_column($yearRes, 'entries_year2')); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateGroupedSumOrderByAggregateField(): void + { + $testYearModel = $this->getModel('testdata'); + $testYearModel->addAggregateField('entries_year1', 'year', 'testdata_datetime'); + $testYearModel->addAggregateField('entries_year2', 'year', 'testdata_date'); + // add a grouping modifier (WARNING, instance modified) + // introduce additional summing + // and order by calculated/aggregate field + $testYearModel->addGroup('entries_year1'); + $testYearModel->addAggregateField('entries_sum', 'sum', 'testdata_integer'); + $testYearModel->addOrder('entries_year1'); + $yearGroupedRes = $testYearModel->search()->getResult(); + + static::assertEquals(2019, $yearGroupedRes[0]['entries_year1']); + static::assertEquals(42, $yearGroupedRes[0]['entries_sum']); + static::assertEquals(2021, $yearGroupedRes[1]['entries_year1']); + static::assertEquals(6, $yearGroupedRes[1]['entries_sum']); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateDatetimeInvalid(): void + { + // + // Tests an invalid type config for Aggregate DateTime plugin + // + $this->expectException(exception::class); + $model = $this->getModel('testdata'); + $model->addAggregateField('entries_invalid1', 'invalid', 'testdata_datetime'); + $model->search()->getResult(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateDatetimeQuarter(): void + { + // + // Aggregate Quarter plugin + // + $testQuarterModel = $this->getModel('testdata'); + $testQuarterModel->addAggregateField('entries_quarter1', 'quarter', 'testdata_datetime'); + $testQuarterModel->addAggregateField('entries_quarter2', 'quarter', 'testdata_date'); + $testQuarterModel->addOrder('testdata_id'); + $res = $testQuarterModel->search()->getResult(); + static::assertEquals([1, 1, 1, 1], array_column($res, 'entries_quarter1')); + static::assertEquals([1, 1, 1, 1], array_column($res, 'entries_quarter2')); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateDatetimeMonth(): void + { + // + // Aggregate DateTime plugin + // + $testMonthModel = $this->getModel('testdata'); + $testMonthModel->addAggregateField('entries_month1', 'month', 'testdata_datetime'); + $testMonthModel->addAggregateField('entries_month2', 'month', 'testdata_date'); + $testMonthModel->addOrder('testdata_id'); + $res = $testMonthModel->search()->getResult(); + static::assertEquals([3, 3, 3, 1], array_column($res, 'entries_month1')); + static::assertEquals([3, 3, 3, 1], array_column($res, 'entries_month2')); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateDatetimeDay(): void + { + // + // Aggregate DateTime plugin + // + $model = $this->getModel('testdata'); + $model->addAggregateField('entries_day1', 'day', 'testdata_datetime'); + $model->addAggregateField('entries_day2', 'day', 'testdata_date'); + $model->addOrder('testdata_id'); + $res = $model->search()->getResult(); + static::assertEquals([22, 22, 23, 01], array_column($res, 'entries_day1')); + static::assertEquals([22, 22, 23, 01], array_column($res, 'entries_day2')); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateFilterSimple(): void + { + // Aggregate Filter + $testAggregateFilterMonthModel = $this->getModel('testdata'); + + $testAggregateFilterMonthModel->addAggregateField('entries_month1', 'month', 'testdata_datetime'); + $testAggregateFilterMonthModel->addAggregateField('entries_month2', 'month', 'testdata_date'); + $testAggregateFilterMonthModel->addAggregateFilter('entries_month1', 3, '>='); + $testAggregateFilterMonthModel->addAggregateFilter('entries_month2', 3, '>='); + + // WARNING: sqlite doesn't support HAVING without GROUP BY + $testAggregateFilterMonthModel->addGroup('testdata_id'); + + $res = $testAggregateFilterMonthModel->search()->getResult(); + static::assertEquals([3, 3, 3], array_column($res, 'entries_month1')); + static::assertEquals([3, 3, 3], array_column($res, 'entries_month2')); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateFilterValueArray(): void + { + // Aggregate Filter + $testAggregateFilterMonthModel = $this->getModel('testdata'); + + $testAggregateFilterMonthModel->addAggregateField('entries_month1', 'month', 'testdata_datetime'); + $testAggregateFilterMonthModel->addAggregateField('entries_month2', 'month', 'testdata_date'); + $testAggregateFilterMonthModel->addAggregateFilter('entries_month1', [1, 3]); + $testAggregateFilterMonthModel->addAggregateFilter('entries_month2', [1, 3]); + + // WARNING: sqlite doesn't support HAVING without GROUP BY + $testAggregateFilterMonthModel->addGroup('testdata_id'); + + $res = $testAggregateFilterMonthModel->search()->getResult(); + static::assertEquals([3, 3, 3, 1], array_column($res, 'entries_month1')); + static::assertEquals([3, 3, 3, 1], array_column($res, 'entries_month2')); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testDefaultAggregateFilterValueArray(): void + { + // Aggregate Filter + $testAggregateFilterMonthModel = $this->getModel('testdata'); + + $testAggregateFilterMonthModel->addAggregateField('entries_month1', 'month', 'testdata_datetime'); + $testAggregateFilterMonthModel->addAggregateField('entries_month2', 'month', 'testdata_date'); + $testAggregateFilterMonthModel->addDefaultAggregateFilter('entries_month1', [1, 3]); + $testAggregateFilterMonthModel->addDefaultAggregateFilter('entries_month2', [1, 3]); + + // WARNING: sqlite doesn't support HAVING without GROUP BY + $testAggregateFilterMonthModel->addGroup('testdata_id'); + + $res = $testAggregateFilterMonthModel->search()->getResult(); + static::assertEquals([3, 3, 3, 1], array_column($res, 'entries_month1')); + static::assertEquals([3, 3, 3, 1], array_column($res, 'entries_month2')); + + // make sure the second query returns the same result + $res2 = $testAggregateFilterMonthModel->search()->getResult(); + static::assertEquals($res, $res2); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAggregateFilterValueArraySimple(): void + { + // Aggregate Filter + $model = $this->getModel('testdata'); + + // Actually, there's no real aggregate field for this test + // Instead, we're just alias existing fields. + $model->addField('testdata_boolean', 'boolean_aliased'); + $model->addField('testdata_integer', 'integer_aliased'); + $model->addField('testdata_number', 'number_aliased'); + + // WARNING: sqlite doesn't support HAVING without GROUP BY + $model->addGroup('testdata_id'); + + $model->saveLastQuery = true; + + static::assertEquals([3.14, 4.25, 5.36, 0.99], array_column($model->search()->getResult(), 'testdata_number')); + + // + // compacted serial tests + // + $filterTests = [ + // + // Datatype estimation for booleans + // + [ + 'field' => 'boolean_aliased', + 'value' => [true], + 'expected' => [3.14, 4.25], + ], + [ + 'field' => 'boolean_aliased', + 'value' => [true, false], + 'expected' => [3.14, 4.25, 5.36, 0.99], + ], + [ + 'field' => 'boolean_aliased', + 'value' => [false], + 'expected' => [5.36, 0.99], + ], + + // + // Datatype estimation for integers + // + [ + 'field' => 'integer_aliased', + 'value' => [1], + 'expected' => [5.36], + ], + [ + 'field' => 'integer_aliased', + 'value' => [1, 2, 3, 42], + 'expected' => [3.14, 4.25, 5.36, 0.99], + ], + [ + 'field' => 'integer_aliased', + 'value' => [3, 42], + 'expected' => [3.14, 0.99], + ], + + // + // Datatype estimation for numbers (floats, doubles, decimals) + // + [ + 'field' => 'number_aliased', + 'value' => [5.36], + 'expected' => [5.36], + ], + [ + 'field' => 'number_aliased', + 'value' => [3.14, 4.25, 5.36, 0.99], + 'expected' => [3.14, 4.25, 5.36, 0.99], + ], + [ + 'field' => 'number_aliased', + 'value' => [3.14, 0.99], + 'expected' => [3.14, 0.99], + ], + ]; + + foreach ($filterTests as $f) { + // use aggregate filter + $model->addAggregateFilter($f['field'], $f['value']); + static::assertEquals($f['expected'], array_column($model->search()->getResult(), 'testdata_number')); + + // the same, but using FCs - NOTE: does not exist yet (model::aggregateFiltercollection) + // this only works for SQLite due to its nature. + // $model->addFilterCollection([[ 'field' => $f['field'], 'operator' => '=', 'value' => $f['value'] ]]); + // static::assertEquals($f['expected'], array_column($model->search()->getResult(), 'testdata_number')); + } + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testFieldAliasWithFilter(): void + { + static::markTestIncomplete('Aliased filter implementation on differing platforms is still unclear'); + $model = $this->getModel('testdata'); + + // + // NOTE/WARNING: + // - on MySQL you can do a HAVING clause without GROUP BY, but not filter for an aliased column in WHERE + // - on SQLite you CANNOT have a HAVING clause without GROUP BY, but you can filter for an aliased column in WHERE + // + $res = $model + ->hideAllFields() + ->addField('testdata_text', 'aliased_text') + ->addFilter('testdata_integer', 3) + ->addAggregateFilter('aliased_text', 'foo') + ->search()->getResult(); + + static::assertCount(1, $res); + static::assertEquals(['aliased_text' => 'foo'], $res[0]); + } + + /** + * Tests the internal datatype fallback + * executed internally when passing an array as filter value + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testFieldAliasWithFilterArrayFallbackDataTypeSuccessful(): void + { + $model = $this->getModel('testdata'); + $res = $model + ->hideAllFields() + ->addField('testdata_text', 'aliased_text') + ->addFilter('testdata_integer', 3) + ->addAggregateFilter('aliased_text', ['foo']) + ->addGroup('testdata_id') // required due to technical limitations in some RDBMS + ->search()->getResult(); + + static::assertCount(1, $res); + static::assertEquals(['aliased_text' => 'foo'], $res[0]); + } + + /** + * Try to pass an unsupported value in a filter value array + * that is not covered by model::getFallbackDatatype() + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testFieldAliasWithFilterArrayFallbackDataTypeFailsUnsupportedData(): void + { + $this->expectExceptionMessage('INVALID_FALLBACK_PARAMETER_TYPE'); + $model = $this->getModel('testdata'); + $model + ->hideAllFields() + ->addField('testdata_text', 'aliased_text') + ->addFilter('testdata_integer', 3) + ->addAggregateFilter('aliased_text', [new stdClass()]) // this must cause an exception + ->addGroup('testdata_id') // required due to technical limitations in some RDBMS + ->search()->getResult(); + } + + /** + * Tests ->addFilter() with an empty array value as to-be-filtered-for value + * This is an edge case that might change in the future. + * CHANGED 2021-09-13: we now trigger an E_USER_NOTICE when an empty array ([]) is provided as filter value + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddFilterWithEmptyArrayValue(): void + { + $model = $this->getModel('testdata'); + + // NOTE: we have to override the error handler for a short period of time + set_error_handler(null, E_USER_NOTICE); + + // + // WARNING: to avoid any issue with error handlers, + // we try to keep the number of calls not covered by the generic handler + // at a minimum + // + try { + @$model->addFilter('testdata_text', []); // this is discarded internally/has no effect + } catch (Throwable) { + } + + restore_error_handler(); + + static::assertEquals('Empty array filter values have no effect on resultset', error_get_last()['message']); + static::assertEquals(4, $model->getCount()); + } + + /** + * see above + * CHANGED 2021-09-13: we now trigger an E_USER_NOTICE when an empty array ([]) is provided as filter value + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddFiltercollectionWithEmptyArrayValue(): void + { + $model = $this->getModel('testdata'); + + // NOTE: we have to override the error handler for a short period of time + set_error_handler(null, E_USER_NOTICE); + + // + // WARNING: to avoid any issue with error handlers, + // we try to keep the number of calls not covered by the generic handler + // at a minimum + // + try { + @$model->addFilterCollection([ + ['field' => 'testdata_text', 'operator' => '=', 'value' => []], + ]); // this is discarded internally/has no effect + } catch (Throwable) { + } + + restore_error_handler(); + + static::assertEquals('Empty array filter values have no effect on resultset', error_get_last()['message']); + static::assertEquals(4, $model->getCount()); + } + + /** + * see above + * CHANGED 2021-09-13: we now trigger an E_USER_NOTICE when an empty array ([]) is provided as filter value + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddDefaultfilterWithEmptyArrayValue(): void + { + $model = $this->getModel('testdata'); + + // NOTE: we have to override the error handler for a short period of time + set_error_handler(null, E_USER_NOTICE); + + // + // WARNING: to avoid any issue with error handlers, + // we try to keep the number of calls not covered by the generic handler + // at a minimum + // + try { + @$model->addDefaultFilter('testdata_text', []); // this is discarded internally/has no effect + } catch (Throwable) { + } + + restore_error_handler(); + + static::assertEquals('Empty array filter values have no effect on resultset', error_get_last()['message']); + static::assertEquals(4, $model->getCount()); + } + + /** + * see above + * CHANGED 2021-09-13: we now trigger an E_USER_NOTICE when an empty array ([]) is provided as filter value + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddDefaultFiltercollectionWithEmptyArrayValue(): void + { + $model = $this->getModel('testdata'); + + // NOTE: we have to override the error handler for a short period of time + set_error_handler(null, E_USER_NOTICE); + + // + // WARNING: to avoid any issue with error handlers, + // we try to keep the number of calls not covered by the generic handler + // at a minimum + // + try { + @$model->addDefaultFilterCollection([ + ['field' => 'testdata_text', 'operator' => '=', 'value' => []], + ]); // this is discarded internally/has no effect + } catch (Throwable) { + } + + restore_error_handler(); + + static::assertEquals('Empty array filter values have no effect on resultset', error_get_last()['message']); + static::assertEquals(4, $model->getCount()); + } + + /** + * Tests ->addAggregateFilter() with an empty array value as to-be-filtered-for value + * This is an edge case that might change in the future. + * CHANGED 2021-09-13: we now trigger an E_USER_NOTICE when an empty array ([]) is provided as filter value + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddAggregateFilterWithEmptyArrayValue(): void + { + $model = $this->getModel('testdata'); + + // NOTE: we have to override the error handler for a short period of time + set_error_handler(null, E_USER_NOTICE); + + // + // WARNING: to avoid any issue with error handlers, + // we try to keep the number of calls not covered by the generic handler + // at a minimum + // + try { + @$model->addAggregateFilter('nonexisting', []); // this is discarded internally/has no effect + } catch (Throwable) { + } + + restore_error_handler(); + + static::assertEquals('Empty array filter values have no effect on resultset', error_get_last()['message']); + + // + // NOTE: we just test if the notice has been triggered + // as we're not using a field that's really available + // + } + + /** + * Tests ->addDefaultAggregateFilter() with an empty array value as to-be-filtered-for value + * This is an edge case that might change in the future. + * CHANGED 2021-09-13: we now trigger an E_USER_NOTICE when an empty array ([]) is provided as filter value + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddDefaultAggregateFilterWithEmptyArrayValue(): void + { + $model = $this->getModel('testdata'); + + // NOTE: we have to override the error handler for a short period of time + set_error_handler(null, E_USER_NOTICE); + + // + // WARNING: to avoid any issue with error handlers, + // we try to keep the number of calls not covered by the generic handler + // at a minimum + // + try { + @$model->addDefaultAggregateFilter('nonexisting', []); // this is discarded internally/has no effect + } catch (Throwable) { + } + + restore_error_handler(); + + static::assertEquals('Empty array filter values have no effect on resultset', error_get_last()['message']); + + // + // NOTE: we just test if the notice has been triggered + // as we're not using a field that's really available + // + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddDefaultfilterWithArrayValue(): void + { + $model = $this->getModel('testdata'); + $model->addDefaultFilter('testdata_date', ['2021-03-22', '2021-03-23']); + static::assertCount(3, $model->search()->getResult()); + + // second call, filter should still be active + static::assertCount(3, $model->search()->getResult()); + + // the third call filter should still be active + // we reset explicitly + $model->reset(); + static::assertCount(3, $model->search()->getResult()); + } + + /** + * test filter with fully qualified field name + * of _nested_ model's field on root level + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddFilterRootLevelNested(): void + { + $model = $this->getModel('testdata') + ->addModel($this->getModel('details')); + $model->addFilter('testschema.details.details_id'); + $res = $model->search()->getResult(); + static::assertCount(4, $res); + + $model->addFilter('testschema.details.details_id', 1, '>'); + $res = $model->search()->getResult(); + static::assertCount(0, $res); + } + + /** + * test filtercollection with fully qualified field name + * of _nested_ model's field on root level + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddFiltercollectionRootLevelNested(): void + { + $model = $this->getModel('testdata') + ->addModel($this->getModel('details')); + $model->addFilterCollection([ + ['field' => 'testschema.details.details_id', 'operator' => '=', 'value' => null], + ], 'OR'); + $res = $model->search()->getResult(); + static::assertCount(4, $res); + + $model->addFilterCollection([ + ['field' => 'testschema.details.details_id', 'operator' => '>', 'value' => 1], + ], 'OR'); + $res = $model->search()->getResult(); + static::assertCount(0, $res); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddFieldFilter(): void + { + $model = $this->getModel('testdata'); + + $model->addFieldFilter('testdata_integer', 'testdata_number', '<'); + $res = $model->search()->getResult(); + static::assertCount(3, $res); + static::assertEquals([3, 2, 1], array_column($res, 'testdata_integer')); + + // vice-versa + $model->addFieldFilter('testdata_integer', 'testdata_number', '>'); + $res = $model->search()->getResult(); + static::assertCount(1, $res); + static::assertEquals([42], array_column($res, 'testdata_integer')); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAddFieldFilterNested(): void + { + $model = $this->getModel('person')->setVirtualFieldResult(true); + $model->addModel($innerModel = $this->getModel('person')); + + if (!($model instanceof sql)) { + static::fail('setup fail'); + } + + $ids = []; + + $model->saveWithChildren([ + 'person_firstname' => 'A', + 'person_lastname' => 'A', + 'person_parent' => [ + 'person_firstname' => 'C', + 'person_lastname' => 'C', + ], + ]); + + // NOTE: take care of order! + $ids[] = $model->lastInsertId(); + $ids[] = $innerModel->lastInsertId(); + + $model->saveWithChildren([ + 'person_firstname' => 'B', + 'person_lastname' => 'B', + 'person_parent' => [ + 'person_firstname' => 'X', + 'person_lastname' => 'Y', + ], + ]); + + // NOTE: take care of order! + $ids[] = $model->lastInsertId(); + $ids[] = $innerModel->lastInsertId(); + + // should be three: A, B, C + $res = $model->addFieldFilter('person_firstname', 'person_lastname')->search()->getResult(); + static::assertCount(3, $res); + static::assertEqualsCanonicalizing(['A', 'B', 'C'], array_column($res, 'person_lastname')); + + // should be one: X/Y + $res = $model->addFieldFilter('person_firstname', 'person_lastname', '!=')->search()->getResult(); + static::assertCount(1, $res); + static::assertEqualsCanonicalizing(['Y'], array_column($res, 'person_lastname')); + + // should be one, we only have one parent with same-names (C) + $model->getNestedJoins('person')[0]->model->addFieldFilter('person_firstname', 'person_lastname'); + $res = $model->search()->getResult(); + static::assertCount(1, $res); + static::assertEqualsCanonicalizing(['A'], array_column($res, 'person_lastname')); + + // see above, non-same-named parents + $model->getNestedJoins('person')[0]->model->addFieldFilter('person_firstname', 'person_lastname', '!='); + $res = $model->search()->getResult(); + static::assertCount(1, $res); + static::assertEqualsCanonicalizing(['B'], array_column($res, 'person_lastname')); + + $personModel = $this->getModel('person'); + foreach ($ids as $id) { + $personModel->delete($id); + } + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testAddFieldFilterWithInvalidOperator(): void + { + $this->expectExceptionMessage('EXCEPTION_INVALID_OPERATOR'); + $model = $this->getModel('testdata'); + $model->addFieldFilter('testdata_integer', 'testdata_number', 'LIKE'); // like is unsupported + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testDefaultfilterSimple(): void + { + $model = $this->getModel('testdata'); + + // generic default filter + $model->addDefaultFilter('testdata_number', 3.5, '>'); + + $res1 = $model->search()->getResult(); + $res2 = $model->search()->getResult(); + static::assertCount(2, $res1); + static::assertEquals($res1, $res2); + + // add a filter on the fly - and we expect + // an empty resultset + $res = $model + ->addFilter('testdata_text', 'nonexisting') + ->search()->getResult(); + static::assertCount(0, $res); + + // try to reduce the resultset to 1 + // in conjunction with the above default filter + $res = $model + ->addFilter('testdata_integer', 1, '<=') + ->search()->getResult(); + static::assertCount(1, $res); + } + + /** + * Tests using a discrete model as root + * and compares equality. + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAdhocDiscreteModelAsRoot(): void + { + $testdataModel = $this->getModel('testdata'); + $originalRes = $testdataModel->search()->getResult(); + if (!($testdataModel instanceof sql)) { + static::fail('setup fail'); + } + $discreteModelTest = new discreteDynamic('sample1', $testdataModel); + $discreteRes = $discreteModelTest->search()->getResult(); + static::assertEquals($originalRes, $discreteRes); + // TODO: add some filters and compare again. + } + + /** + * Fun with discrete models + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testAdhocDiscreteModelComplex(): void + { + $testdataModel = $this->getModel('testdata'); + $testdataModel + ->hideAllFields() + ->addField('testdata_id', 'testdataidaliased') + ->addCalculatedField('calculated', 'testdata_integer * 4') + ->addGroup('testdata_date') + ->addModel($this->getModel('details')); + if (!($testdataModel instanceof sql)) { + static::fail('setup fail'); + } + $discreteModelTest = new discreteDynamic('sample1', $testdataModel); + $res = $discreteModelTest->search()->getResult(); + + static::assertCount(3, $res); + + $rootModel = $this->getModel('testdata')->setVirtualFieldResult(true) + ->addCustomJoin( + $discreteModelTest, + join::TYPE_LEFT, + 'testdata_id', + 'testdataidaliased' + ); + $rootModel->getNestedJoins('sample1')[0]->virtualField = 'virtualSample1'; + + $res2 = $rootModel->search()->getResult(); + + static::assertCount(4, $res2); + static::assertEquals([12, null, 4, 168], array_column(array_column($res2, 'virtualSample1'), 'calculated')); + + $secondaryDiscreteModelTest = new discreteDynamic('sample2', $testdataModel); + $secondaryDiscreteModelTest->addCalculatedField('calcCeption', 'sample2.calculated * sample2.calculated'); + $rootModel->addCustomJoin( + $secondaryDiscreteModelTest, + join::TYPE_LEFT, + 'testdata_id', + 'testdataidaliased' + ); + $rootModel->getNestedJoins('sample2')[0]->virtualField = 'virtualSample2'; + + $rootModel->addCalculatedField('calcCeption2', 'sample1.calculated * sample2.calculated'); + + $res3 = $rootModel->search()->getResult(); + + static::assertEquals([144, null, 16, 28224], array_column(array_column($res3, 'virtualSample2'), 'calcCeption')); + static::assertEquals([144, null, 16, 28224], array_column($res3, 'calcCeption2')); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testDiscreteModelLimitAndOffset(): void + { + $testdataModel = $this->getModel('testdata'); + $testdataModel + ->hideAllFields() + ->addField('testdata_id', 'testdataidaliased') + ->addCalculatedField('calculated', 'testdata_integer * 4') + // ->addDefaultFilter('testdata_id', 2, '>') + ->addGroup('testdata_date') + ->addModel($this->getModel('details')); + + // NOTE limit & offset instances get reset after query + $testdataModel->setLimit(2)->setOffset(1); + + $originalRes = $testdataModel->search()->getResult(); + if (!($testdataModel instanceof sql)) { + static::fail('setup fail'); + } + $discreteModelTest = new discreteDynamic('sample1', $testdataModel); + + // NOTE limit & offset instances get reset after query + $testdataModel->setLimit(2)->setOffset(1); + $discreteRes = $discreteModelTest->search()->getResult(); + + static::assertCount(2, $discreteRes); + static::assertEquals($originalRes, $discreteRes); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testDiscreteModelAddOrder(): void + { + // + // NOTE ORDER BY in a subquery is ignored in MySQL for final output + // See https://mariadb.com/kb/en/why-is-order-by-in-a-from-subquery-ignored/ + // But it is essential for LIMIT/OFFSET used in the subquery! + // + $testdataModel = $this->getModel('testdata'); + + // NOTE order instance gets reset after query + $testdataModel->addOrder('testdata_id', 'DESC'); + $testdataModel->setOffset(2)->setLimit(2); + + $originalRes = $testdataModel->search()->getResult(); + if (!($testdataModel instanceof sql)) { + static::fail('setup fail'); + } + $discreteModelTest = new discreteDynamic('sample1', $testdataModel); + + // NOTE order instance gets reset after query + $testdataModel->addOrder('testdata_id', 'DESC'); + $testdataModel->setOffset(2)->setLimit(2); + $discreteRes = $discreteModelTest->search()->getResult(); + + static::assertCount(2, $discreteRes); + static::assertEquals($originalRes, $discreteRes); + + // finally, query the thing with a zero offset + // to make sure we have ORDER+LIMIT+OFFSET really working + // inside the subquery, + // though the final order might be different. + $testdataModel->addOrder('testdata_id', 'DESC'); + $testdataModel->setOffset(0)->setLimit(2); + $offset0Res = $testdataModel->search()->getResult(); + + static::assertNotEquals($offset0Res, $originalRes); + + static::assertLessThan( + array_sum(array_column($offset0Res, 'testdata_id')), // Offset 0-based results should be topmost => sum of IDs must be greater + array_sum(array_column($originalRes, 'testdata_id')) // ... and this sum must be LESS THAN the above. + ); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testDiscreteModelSimpleAggregate(): void + { + $testdataModel = $this->getModel('testdata') + ->addAggregateField('id_sum', 'sum', 'testdata_integer') + ->addGroup('testdata_date') + ->addDefaultAggregateFilter('id_sum', 10, '<='); + + $originalRes = $testdataModel->search()->getResult(); + if (!($testdataModel instanceof sql)) { + static::fail('setup fail'); + } + $discreteModelTest = new discreteDynamic('sample1', $testdataModel); + + $discreteRes = $discreteModelTest->search()->getResult(); + + static::assertCount(2, $discreteRes); + static::assertEquals($originalRes, $discreteRes); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testDiscreteModelSaveWillThrow(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Not implemented.'); + $testdataModel = $this->getModel('testdata'); + if (!($testdataModel instanceof sql)) { + static::fail('setup fail'); + } + $discreteModelTest = new discreteDynamic('sample1', $testdataModel); + $discreteModelTest->save(['value' => 'doesnt matter']); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testDiscreteModelUpdateWillThrow(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Not implemented.'); + $testdataModel = $this->getModel('testdata'); + if (!($testdataModel instanceof sql)) { + static::fail('setup fail'); + } + $discreteModelTest = new discreteDynamic('sample1', $testdataModel); + $discreteModelTest->update(['value' => 'doesnt matter']); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testDiscreteModelReplaceWillThrow(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Not implemented.'); + $testdataModel = $this->getModel('testdata'); + if (!($testdataModel instanceof sql)) { + static::fail('setup fail'); + } + $discreteModelTest = new discreteDynamic('sample1', $testdataModel); + $discreteModelTest->replace(['value' => 'doesnt matter']); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testDiscreteModelDeleteWillThrow(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Not implemented.'); + $testdataModel = $this->getModel('testdata'); + if (!($testdataModel instanceof sql)) { + static::fail('setup fail'); + } + $discreteModelTest = new discreteDynamic('sample1', $testdataModel); + $discreteModelTest->delete(1); + } + + /** + * Tests a case where the 'aliased' flag on a group plugin was always active + * (and ignoring schema/table - on root, there's no currentAlias (null)) + * and causes severe errors when executing a query + * (ambiguous column) + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testGroupAliasBugFixed(): void + { + $model = $this->getModel('person')->setVirtualFieldResult(true) + ->addModel($this->getModel('person')) + ->addGroup('person_id'); + $model->search()->getResult(); + $this->expectNotToPerformAssertions(); + } + + /** + * @return void [type] [description] + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testNormalizeData(): void + { + $originalDataset = [ + 'testdata_datetime' => '2021-04-01 11:22:33', + 'testdata_text' => 'normalizeTest', + 'testdata_date' => '2021-01-01', + ]; + + $normalizeMe = $originalDataset; + $normalizeMe['crapkey'] = 'crap'; + + $model = $this->getModel('testdata'); + $normalized = $model->normalizeData($normalizeMe); + static::assertEquals($originalDataset, $normalized); + } + + /** + * @return void + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception + */ + protected function testNormalizeDataComplex(): void + { + $fieldComparisons = [ + 'testdata_boolean' => [ + 'nulled boolean' => [ + 'expectedValue' => null, + 'variants' => [ + null, + '', + ], + ], + 'boolean true' => [ + 'expectedValue' => true, + 'variants' => [ + true, + 1, + '1', + 'true', + ], + ], + 'boolean false' => [ + 'expectedValue' => false, + 'variants' => [ + false, + 0, + '0', + 'false', + ], + ], + ], + 'testdata_integer' => [ + 'nulled integer' => [ + 'expectedValue' => null, + 'variants' => [ + null, + '', + ], + ], + ], + 'testdata_date' => [ + 'nulled date' => [ + 'expectedValue' => null, + 'variants' => [ + null, + ], + ], + // throws! + // 'invalid date' => [ + // 'expectedValue' => null, + // 'variants' => [ + // '', + // ] + // ], + ], + ]; + + $model = $this->getModel('testdata'); + + foreach ($fieldComparisons as $field => $tests) { + foreach ($tests as $testName => $test) { + $expectedValue = $test['expectedValue']; + foreach ($test['variants'] as $variant) { + $normalizeMe = [ + $field => $variant, + ]; + $expectedDataset = [ + $field => $expectedValue, + ]; + $normalized = $model->normalizeData($normalizeMe); + static::assertEquals($expectedDataset, $normalized, $testName); + } + } + } + } + + /** + * @return void [type] [description] + * @throws ReflectionException + * @throws exception + */ + protected function testValidateSimple(): void + { + $dataset = [ + 'testdata_datetime' => '2021-13-01 11:22:33', + 'testdata_text' => ['abc' => true], + 'testdata_date' => '0000-01-01', + ]; + + $model = $this->getModel('testdata'); + static::assertFalse($model->isValid($dataset)); + + $validationErrors = $model->validate($dataset)->getErrors(); + static::assertCount(2, $validationErrors); // actually, we should have 3 + } + + /** + * Tests validation fail with a model-validator + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testModelValidator(): void + { + $dataset = [ + 'testdata_text' => 'disallowed_value', + ]; + $model = $this->getModel('testdata'); + static::assertFalse($model->isValid($dataset)); + $validationErrors = $model->validate($dataset)->getErrors(); + static::assertCount(1, $validationErrors); + static::assertEquals('VALIDATION.FIELD_INVALID', $validationErrors[0]['__CODE']); + static::assertEquals('testdata_text', $validationErrors[0]['__IDENTIFIER']); + } + + /** + * Tests a model-validator that has a non-field-specific validation + * that affects the whole dataset (e.g., field value combinations that are invalid) + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testModelValidatorSpecial(): void + { + $dataset = [ + 'testdata_text' => 'disallowed_condition', + 'testdata_date' => '2021-01-01', + ]; + $model = $this->getModel('testdata'); + static::assertFalse($model->isValid($dataset)); + $validationErrors = $model->validate($dataset)->getErrors(); + static::assertCount(1, $validationErrors); + static::assertEquals('DATA', $validationErrors[0]['__IDENTIFIER']); + static::assertEquals('VALIDATION.INVALID', $validationErrors[0]['__CODE']); + static::assertEquals('VALIDATION.DISALLOWED_CONDITION', $validationErrors[0]['__DETAILS'][0]['__CODE']); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testValidateSimpleRequiredField(): void + { + $model = $this->getModel('customer'); + // + // NOTE: the customer model is explicitly loaded + // w/o the collection model (contactentry) + // to test for skipping those checks (for coverage) + // + static::assertFalse($model->isValid(['customer_notes' => 'missing customer_no'])); + static::assertTrue($model->isValid(['customer_no' => 'ABC', 'customer_notes' => 'set customer_no'])); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testValidateCollectionNotUsed(): void + { + $model = $this->getModel('customer'); + // + // NOTE: the customer model is explicitly loaded + // w/o the collection model (contactentry) + // to test for skipping those checks (for coverage) + // but we use the field in the dataset + // + static::assertTrue( + $model->isValid([ + 'customer_no' => 'ABC', + 'customer_contactentries' => [ + ['some_value '], + ], + ]) + ); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testValidateCollectionData(): void + { + $dataset = [ + 'customer_no' => 'example', + 'customer_contactentries' => [ + [ + 'contactentry_name' => 'test-valid-phone', + 'contactentry_telephone' => '+4929292929292', + ], + [ + 'contactentry_name' => 'test-invalid-phone', + 'contactentry_telephone' => 'xyz', + ], + ], + ]; + + $model = $this->getModel('customer') + ->addCollectionModel($this->getModel('contactentry')); + + // Just a rough check for invalidity + static::assertFalse($model->isValid($dataset)); + $validationErrors = $model->validate($dataset)->getErrors(); + static::assertEquals('customer_contactentries', $validationErrors[0]['__IDENTIFIER']); + + $dataset = [ + 'customer_no' => 'example2', + 'customer_contactentries' => [ + [ + // no name specified + 'contactentry_telephone' => '+4934343455555', + ], + ], + ]; + // Just a rough check for invalidity + static::assertFalse($model->isValid($dataset)); + + $dataset = [ + 'customer_no' => 'example2', + 'customer_contactentries' => [ + [ + // no name specified + 'contactentry_name' => 'some-name', // is required + 'contactentry_telephone' => '+4934343455555', + ], + ], + ]; + // Just a rough check for invalidity + static::assertTrue($model->isValid($dataset)); + } + + /** + * Test model::entry* wrapper functions + * NOTE: they might interfere with regular queries + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testEntryFunctions(): void + { + $entryModel = $this->getModel('testdata'); // model used for testing entry* functions + $model = $this->getModel('testdata'); // model used for querying + + $dataset = [ + 'testdata_datetime' => '2021-04-01 11:22:33', + 'testdata_text' => 'entryMakeTest', + 'testdata_date' => '2021-01-01', + 'testdata_number' => 12345.6789, + 'testdata_integer' => 222, + ]; + + $entryModel->entryMake($dataset); + + $entryModel->entryValidate(); // TODO: do something with the validation result? + + $entryModel->entrySave(); + $id = $entryModel->lastInsertId(); + $entryModel->reset(); + + $model->hideAllFields() + ->addField('testdata_datetime') + ->addField('testdata_text') + ->addField('testdata_date') + ->addField('testdata_number') + ->addField('testdata_integer'); + $queriedDataset = $model->load($id); + static::assertEquals($dataset, $queriedDataset); + + $entryModel->entryLoad($id); + + foreach ($dataset as $key => $value) { + static::assertEquals($value, $entryModel->fieldGet(modelfield::getInstance($key))); + } + + $entryModel->entryUpdate([ + 'testdata_text' => 'updated', + ]); + $entryModel->entrySave(); + + $modifiedDataset = $model->load($id); + static::assertEquals('updated', $modifiedDataset['testdata_text']); + + $entryModel->entryLoad($id); + $entryModel->fieldSet(modelfield::getInstance('testdata_integer'), 333); + $entryModel->entrySave(); + + static::assertEquals(333, $model->load($id)['testdata_integer']); + + $entryModel->entryDelete(); + static::assertEmpty($model->load($id)); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testEntryFlags(): void + { + $verificationModel = $this->getModel('testdata'); + $model = $this->getModel('testdata'); + $model->entryMake([ + 'testdata_text' => 'testEntryFlags', + ]); + static::assertEmpty($model->entryValidate()); + $model->entrySave(); + + $id = $model->lastInsertId(); + $model->entryLoad($id); + + $model->entrySetFlag($model->getConfig()->get('flag>foo')); + $model->entrySave(); + static::assertEquals(1, $verificationModel->load($id)['testdata_flag']); + + $model->entrySetFlag($model->getConfig()->get('flag>qux')); + $model->entrySave(); + static::assertEquals(1 + 8, $verificationModel->load($id)['testdata_flag']); + + $model->entryUnsetFlag($model->getConfig()->get('flag>foo')); + $model->entryUnsetFlag($model->getConfig()->get('flag>baz')); // unset not-set + $model->entrySave(); + static::assertEquals(8, $verificationModel->load($id)['testdata_flag']); + + $model->entryDelete(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testEntrySetFlagNonexisting(): void + { + $verificationModel = $this->getModel('testdata'); + $model = $this->getModel('testdata'); + $model->entryMake([ + 'testdata_text' => 'testEntrySetFlagNonexisting', + ]); + $model->entrySave(); + + $id = $model->lastInsertId(); + $model->entryLoad($id); + + // WARNING/NOTE: you can set nonexisting flags + $model->entrySetFlag(64); + $model->entrySave(); + static::assertEquals(64, $verificationModel->load($id)['testdata_flag']); + + // WARNING/NOTE: you may set combined flag values + $model->entrySetFlag(64 + 2 + 8 + 16); + $model->entryUnsetFlag(8); + $model->entrySave(); + static::assertEquals(64 + 2 + 16, $verificationModel->load($id)['testdata_flag']); + + $model->entrySave(); + + $model->entryDelete(); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testEntrySetFlagInvalidFlagValueThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_INVALID_FLAG_VALUE); + $model = $this->getModel('testdata'); + $model->entryMake([ + 'testdata_text' => 'test', + ]); + $model->entrySetFlag(-8); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testEntryUnsetFlagInvalidFlagValueThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_INVALID_FLAG_VALUE); + $model = $this->getModel('testdata'); + $model->entryMake([ + 'testdata_text' => 'test', + ]); + $model->entryUnsetFlag(-8); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testEntrySetFlagNoDatasetLoadedThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_ENTRYSETFLAG_NOOBJECTLOADED); + $model = $this->getModel('testdata'); + $model->entrySetFlag($model->getConfig()->get('flag>foo')); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testEntryUnsetFlagNoDatasetLoadedThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_ENTRYUNSETFLAG_NOOBJECTLOADED); + $model = $this->getModel('testdata'); + $model->entryUnsetFlag($model->getConfig()->get('flag>foo')); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testEntrySetFlagNoFlagsInModelThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_ENTRYSETFLAG_NOFLAGSINMODEL); + $model = $this->getModel('person'); + $model->entryMake(['person_firstname' => 'test']); + $model->entrySetFlag(1); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testEntryUnsetFlagNoFlagsInModelThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_ENTRYUNSETFLAG_NOFLAGSINMODEL); + $model = $this->getModel('person'); + $model->entryMake(['person_firstname' => 'test']); + $model->entryUnsetFlag(1); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testEntrySaveNoDataThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_ENTRYSAVE_NOOBJECTLOADED); + $model = $this->getModel('testdata'); + $model->entrySave(); // we have not defined anything (e.g., internal data store is NULL/does not exist) + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testEntrySaveEmptyDataThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_ENTRYSAVE_NOOBJECTLOADED); + $model = $this->getModel('testdata'); + $model->entryMake(); // define an empty dataset + $model->entrySave(); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testEntryUpdateEmptyDataThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_ENTRYUPDATE_UPDATEELEMENTEMPTY); + $model = $this->getModel('testdata'); + $model->entryUpdate([]); // we've not loaded anything, but this should crash first. + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testEntryUpdateNoDatasetLoaded(): void + { + $this->expectExceptionMessage(model::EXCEPTION_ENTRYUPDATE_NOOBJECTLOADED); + $model = $this->getModel('testdata'); + $model->entryUpdate(['testdata_integer' => 555]); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testEntryLoadNonexistingId(): void + { + $this->expectExceptionMessage(model::EXCEPTION_ENTRYLOAD_FAILED); + $model = $this->getModel('testdata'); + $model->entryLoad(-123); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testEntryDeleteNoDatasetLoadedThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_ENTRYDELETE_NOOBJECTLOADED); + $model = $this->getModel('testdata'); + $model->entryDelete(); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testFieldGetNonexistingThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_FIELDGET_FIELDNOTFOUNDINMODEL); + $model = $this->getModel('testdata'); + $model->fieldGet(modelfield::getInstance('nonexisting')); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testFieldGetNoDatasetLoadedThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_FIELDGET_NOOBJECTLOADED); + $model = $this->getModel('testdata'); + $model->fieldGet(modelfield::getInstance('testdata_integer')); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testFieldSetNonexistingThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_FIELDSET_FIELDNOTFOUNDINMODEL); + $model = $this->getModel('testdata'); + $model->fieldSet(modelfield::getInstance('nonexisting'), 'xyz'); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testFieldSetNoDatasetLoadedThrows(): void + { + $this->expectExceptionMessage(model::EXCEPTION_FIELDSET_NOOBJECTLOADED); + $model = $this->getModel('testdata'); + $model->fieldSet(modelfield::getInstance('testdata_integer'), 999); + } + + /** + * Basic Timemachine functionality + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + protected function testTimemachineDelta(): void + { + $testdataTm = $this->getTimemachineEnabledModel('testdata'); + + $res = $this->getModel('testdata') + ->addFilter('testdata_text', 'foo') + ->addFilter('testdata_date', '2021-03-22') + ->addFilter('testdata_number', 3.14) + ->search()->getResult(); + static::assertCount(1, $res); + $id = $res[0]['testdata_id']; + + $testdataTm->save([ + 'testdata_id' => $id, + 'testdata_integer' => 888, + ]); + + $timemachine = new timemachine($testdataTm); + $timemachine->getHistory($id); + + $delta = $timemachine->getDeltaData($id, 0); + static::assertEquals(['testdata_integer' => 3], $delta); + + $bigbangState = $timemachine->getHistoricData($id, 0); + static::assertEquals(3, $bigbangState['testdata_integer']); + + // restore via delta + $testdataTm->save( + array_merge([ + 'testdata_id' => $id, + ], $delta) + ); + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws exception + */ + protected function tearDown(): void + { + // + // make sure data is cleaned in the test... + // + $models = ['customer', 'person']; + foreach ($models as $model) { + if (!$this->modelDataEmpty($model)) { + static::fail('Model data not empty: ' . $model); + } + } + // rollback any existing transactions + // to allow transaction testing + try { + $db = app::getDb(); + $db->rollback(); + } catch (\Exception) { + } + + // perform teardown steps + parent::tearDown(); + } + + /** + * @param string $model [description] + * @return bool [description] + * @throws ReflectionException + * @throws exception + */ + public function modelDataEmpty(string $model): bool + { + return $this->getModel($model)->getCount() === 0; + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws Throwable + * @throws ErrorException + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws exception + */ + protected function setUp(): void + { + $app = static::createApp(); + + $app::__setApp('modeltest'); + $app::__setVendor('codename'); + $app::__setNamespace('\\codename\\core\\tests\\model'); + $app::__setHomedir(__DIR__); + + $app::getAppstack(); + + // avoid re-init + if (static::$initialized) { + return; + } + + static::$initialized = true; + + static::setEnvironmentConfig([ + 'test' => [ + 'database' => [ + 'default' => $this->getDefaultDatabaseConfig(), + ], + 'cache' => [ + 'default' => [ + 'driver' => 'memory', + ], + ], + 'filesystem' => [ + 'local' => [ + 'driver' => 'local', + ], + ], + 'log' => [ + 'default' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + ], + ], + ]); + + static::createModel('testschema', 'testdata', [ + 'validators' => [ + 'model_testdata', + ], + 'field' => [ + 'testdata_id', + 'testdata_created', + 'testdata_modified', + 'testdata_datetime', + 'testdata_text', + 'testdata_date', + 'testdata_number', + 'testdata_integer', + 'testdata_boolean', + 'testdata_structure', + 'testdata_details_id', + 'testdata_moredetails_id', + 'testdata_flag', + ], + 'primary' => [ + 'testdata_id', + ], + 'flag' => [ + 'foo' => 1, + 'bar' => 2, + 'baz' => 4, + 'qux' => 8, + ], + 'foreign' => [ + 'testdata_details_id' => [ + 'schema' => 'testschema', + 'model' => 'details', + 'key' => 'details_id', + ], + 'testdata_moredetails_id' => [ + 'schema' => 'testschema', + 'model' => 'moredetails', + 'key' => 'moredetails_id', + ], + ], + 'options' => [ + 'testdata_number' => [ + 'length' => 16, + 'precision' => 8, + ], + ], + 'datatype' => [ + 'testdata_id' => 'number_natural', + 'testdata_created' => 'text_timestamp', + 'testdata_modified' => 'text_timestamp', + 'testdata_datetime' => 'text_timestamp', + 'testdata_details_id' => 'number_natural', + 'testdata_moredetails_id' => 'number_natural', + 'testdata_text' => 'text', + 'testdata_date' => 'text_date', + 'testdata_number' => 'number', + 'testdata_integer' => 'number_natural', + 'testdata_boolean' => 'boolean', + 'testdata_structure' => 'structure', + 'testdata_flag' => 'number_natural', + ], + 'connection' => 'default', + ]); + + static::createModel('testschema', 'details', [ + 'field' => [ + 'details_id', + 'details_created', + 'details_modified', + 'details_data', + 'details_virtual', + ], + 'primary' => [ + 'details_id', + ], + 'datatype' => [ + 'details_id' => 'number_natural', + 'details_created' => 'text_timestamp', + 'details_modified' => 'text_timestamp', + 'details_data' => 'structure', + 'details_virtual' => 'virtual', + ], + 'connection' => 'default', + ]); + + static::createModel('testschema', 'moredetails', [ + 'field' => [ + 'moredetails_id', + 'moredetails_created', + 'moredetails_modified', + 'moredetails_data', + ], + 'primary' => [ + 'moredetails_id', + ], + 'datatype' => [ + 'moredetails_id' => 'number_natural', + 'moredetails_created' => 'text_timestamp', + 'moredetails_modified' => 'text_timestamp', + 'moredetails_data' => 'structure', + ], + 'connection' => 'default', + ]); + + static::createModel('multi_fkey', 'table1', [ + 'field' => [ + 'table1_id', + 'table1_created', + 'table1_modified', + 'table1_key1', + 'table1_key2', + 'table1_value', + ], + 'primary' => [ + 'table1_id', + ], + 'foreign' => [ + 'multi_component_fkey' => [ + 'schema' => 'multi_fkey', + 'model' => 'table2', + 'key' => [ + 'table1_key1' => 'table2_key1', + 'table1_key2' => 'table2_key2', + ], + 'optional' => true, + ], + ], + 'options' => [ + 'table1_key1' => [ + 'length' => 16, + ], + ], + 'datatype' => [ + 'table1_id' => 'number_natural', + 'table1_created' => 'text_timestamp', + 'table1_modified' => 'text_timestamp', + 'table1_key1' => 'text', + 'table1_key2' => 'number_natural', + 'table1_value' => 'text', + ], + 'connection' => 'default', + ]); + static::createModel('multi_fkey', 'table2', [ + 'field' => [ + 'table2_id', + 'table2_created', + 'table2_modified', + 'table2_key1', + 'table2_key2', + 'table2_value', + ], + 'primary' => [ + 'table2_id', + ], + 'options' => [ + 'table2_key1' => [ + 'length' => 16, + ], + ], + 'datatype' => [ + 'table2_id' => 'number_natural', + 'table2_created' => 'text_timestamp', + 'table2_modified' => 'text_timestamp', + 'table2_key1' => 'text', + 'table2_key2' => 'number_natural', + 'table2_value' => 'text', + ], + 'connection' => 'default', + ]); + + static::createModel('vfields', 'customer', [ + 'field' => [ + 'customer_id', + 'customer_created', + 'customer_modified', + 'customer_no', + 'customer_person_id', + 'customer_person', + 'customer_contactentries', + 'customer_notes', + ], + 'primary' => [ + 'customer_id', + ], + 'unique' => [ + 'customer_no', + ], + 'required' => [ + 'customer_no', + ], + 'children' => [ + 'customer_person' => [ + 'type' => 'foreign', + 'field' => 'customer_person_id', + ], + 'customer_contactentries' => [ + 'type' => 'collection', + ], + ], + 'collection' => [ + 'customer_contactentries' => [ + 'schema' => 'vfields', + 'model' => 'contactentry', + 'key' => 'contactentry_customer_id', + ], + ], + 'foreign' => [ + 'customer_person_id' => [ + 'schema' => 'vfields', + 'model' => 'person', + 'key' => 'person_id', + ], + ], + 'options' => [ + 'customer_no' => [ + 'length' => 16, + ], + ], + 'datatype' => [ + 'customer_id' => 'number_natural', + 'customer_created' => 'text_timestamp', + 'customer_modified' => 'text_timestamp', + 'customer_no' => 'text', + 'customer_person_id' => 'number_natural', + 'customer_person' => 'virtual', + 'customer_contactentries' => 'virtual', + 'customer_notes' => 'text', + ], + 'connection' => 'default', + ]); + + static::createModel('vfields', 'contactentry', [ + 'field' => [ + 'contactentry_id', + 'contactentry_created', + 'contactentry_modified', + 'contactentry_name', + 'contactentry_telephone', + 'contactentry_customer_id', + ], + 'primary' => [ + 'contactentry_id', + ], + 'required' => [ + 'contactentry_name', + ], + 'foreign' => [ + 'contactentry_customer_id' => [ + 'schema' => 'vfields', + 'model' => 'customer', + 'key' => 'customer_id', + ], + ], + 'datatype' => [ + 'contactentry_id' => 'number_natural', + 'contactentry_created' => 'text_timestamp', + 'contactentry_modified' => 'text_timestamp', + 'contactentry_name' => 'text', + 'contactentry_telephone' => 'text_telephone', + 'contactentry_customer_id' => 'number_natural', + ], + 'connection' => 'default', + ]); + + static::createModel('vfields', 'person', [ + 'field' => [ + 'person_id', + 'person_created', + 'person_modified', + 'person_firstname', + 'person_lastname', + 'person_birthdate', + 'person_country', + 'person_parent_id', + 'person_parent', + ], + 'primary' => [ + 'person_id', + ], + 'children' => [ + 'person_parent' => [ + 'type' => 'foreign', + 'field' => 'person_parent_id', + ], + ], + 'foreign' => [ + 'person_parent_id' => [ + 'schema' => 'vfields', + 'model' => 'person', + 'key' => 'person_id', + ], + 'person_country' => [ + 'schema' => 'json', + 'model' => 'country', + 'key' => 'country_code', + ], + ], + 'options' => [ + 'person_country' => [ + 'length' => 2, + ], + ], + 'datatype' => [ + 'person_id' => 'number_natural', + 'person_created' => 'text_timestamp', + 'person_modified' => 'text_timestamp', + 'person_firstname' => 'text', + 'person_lastname' => 'text', + 'person_birthdate' => 'text_date', + 'person_country' => 'text', + 'person_parent_id' => 'number_natural', + 'person_parent' => 'virtual', + ], + 'connection' => 'default', + ]); + + static::createModel('json', 'country', [ + 'field' => [ + 'country_code', + 'country_name', + ], + 'primary' => [ + 'country_code', + ], + 'datatype' => [ + 'country_code' => 'text', + 'country_name' => 'text', + ], + // No connection, JSON datamodel + ], function ($schema, $model, $config) { + return new jsonModel( + 'tests/model/data/json_country.json', + $schema, + $model, + $config + ); + }); + + static::createModel('timemachine', 'timemachine', [ + 'field' => [ + 'timemachine_id', + 'timemachine_created', + 'timemachine_modified', + 'timemachine_model', + 'timemachine_ref', + 'timemachine_data', + 'timemachine_source', + 'timemachine_user_id', + ], + 'primary' => [ + 'timemachine_id', + ], + 'required' => [ + 'timemachine_model', + 'timemachine_ref', + 'timemachine_data', + ], + 'index' => [ + ['timemachine_model', 'timemachine_ref'], + ], + 'options' => [ + 'timemachine_model' => [ + 'length' => 64, + ], + 'timemachine_ref' => [ + 'db_column_type' => 'bigint', + ], + 'timemachine_data' => [ + 'db_column_type' => 'longtext', + ], + ], + 'datatype' => [ + 'timemachine_id' => 'number_natural', + 'timemachine_created' => 'text_timestamp', + 'timemachine_modified' => 'text_timestamp', + 'timemachine_model' => 'text', + 'timemachine_ref' => 'number_natural', + 'timemachine_data' => 'structure', + 'timemachine_source' => 'text', + 'timemachine_user_id' => 'number_natural', + ], + 'connection' => 'default', + ]); + + + static::architect('modeltest', 'codename', 'test'); + + static::createTestData(); + } + + /** + * should return a database config for 'default' connection + * @return array + */ + abstract protected function getDefaultDatabaseConfig(): array; + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected static function createTestData(): void + { + // Just to make sure... initial cleanup + // If there has been a shutdown failure after the last test + // if this executed using a still running DB. + static::deleteTestData(); + + $testdataModel = static::getModelStatic('testdata'); + + $entries = [ + [ + 'testdata_text' => 'foo', + 'testdata_datetime' => '2021-03-22 12:34:56', + 'testdata_date' => '2021-03-22', + 'testdata_number' => 3.14, + 'testdata_integer' => 3, + 'testdata_structure' => ['foo' => 'bar'], + 'testdata_boolean' => true, + ], + [ + 'testdata_text' => 'bar', + 'testdata_datetime' => '2021-03-22 12:34:56', + 'testdata_date' => '2021-03-22', + 'testdata_number' => 4.25, + 'testdata_integer' => 2, + 'testdata_structure' => ['foo' => 'baz'], + 'testdata_boolean' => true, + ], + [ + 'testdata_text' => 'foo', + 'testdata_datetime' => '2021-03-23 23:34:56', + 'testdata_date' => '2021-03-23', + 'testdata_number' => 5.36, + 'testdata_integer' => 1, + 'testdata_structure' => ['boo' => 'far'], + 'testdata_boolean' => false, + ], + [ + 'testdata_text' => 'bar', + 'testdata_datetime' => '2019-01-01 00:00:01', + 'testdata_date' => '2019-01-01', + 'testdata_number' => 0.99, + 'testdata_integer' => 42, + 'testdata_structure' => ['bar' => 'foo'], + 'testdata_boolean' => false, + ], + ]; + + foreach ($entries as $dataset) { + $testdataModel->save($dataset); + } + } + + /** + * @param array $config [description] + * @return database [description] + */ + abstract protected function getDatabaseInstance(array $config): database; +} + +/** + * Overridden timemachine class + * that allows setting an instance directly (and skip app::getModel internally) + * - needed for these 'staged' unit tests + */ +class overrideableTimemachine extends timemachine +{ + /** + * @param model $modelInstance [description] + * @param string $app [description] + * @param string $vendor [description] + * @return void [type] [description] + * @throws exception + */ + public static function storeInstance(model $modelInstance, string $app = '', string $vendor = ''): void + { + $capableModelName = $modelInstance->getIdentifier(); + $identifier = $capableModelName . '-' . $vendor . '-' . $app; + self::$instances[$identifier] = new self($modelInstance); + } +} + +class timemachineEnabledSqlModel extends sqlModel implements timemachineInterface +{ + /** + * @var model|null + */ + protected ?model $timemachineModelInstance = null; + + /** + * {@inheritDoc} + */ + public function isTimemachineEnabled(): bool + { + return true; + } + + /** + * {@inheritDoc} + */ + public function getTimemachineModel(): model + { + return $this->timemachineModelInstance; + } + + /** + * @param model $instance + * @return void + */ + public function setTimemachineModelInstance(model $instance): void + { + $this->timemachineModelInstance = $instance; + } +} + +class timemachineModel extends sqlModel implements timemachineModelInterface +{ + /** + * current identity, null if not retrieved yet + * @var array|null + */ + protected ?array $identity = null; + + /** + * {@inheritDoc} + */ + public function save(array $data): model + { + if ($data[$this->getPrimaryKey()] ?? null) { + throw new exception('TIMEMACHINE_UPDATE_DENIED', exception::$ERRORLEVEL_FATAL); + } else { + $data = array_replace($data, $this->getIdentity()); + return parent::save($data); + } + } + + /** + * Get identity parameters for injecting + * into the timemachine dataset + * @return array + */ + protected function getIdentity(): array + { + if (!$this->identity) { + $this->identity = [ + 'timemachine_source' => 'unittest', + 'timemachine_user_id' => 123, + ]; + } + return $this->identity; + } + + /** + * {@inheritDoc} + */ + public function getModelField(): string + { + return 'timemachine_model'; + } + + /** + * {@inheritDoc} + */ + public function getRefField(): string + { + return 'timemachine_ref'; + } + + /** + * {@inheritDoc} + */ + public function getDataField(): string + { + return 'timemachine_data'; + } } diff --git a/tests/model/jsonModelTest.php b/tests/model/jsonModelTest.php index 18920e5..182c029 100644 --- a/tests/model/jsonModelTest.php +++ b/tests/model/jsonModelTest.php @@ -1,358 +1,411 @@ getAppstack(); - - // avoid re-init - if(static::$initialized) { - return; +class jsonModelTest extends base +{ + /** + * @var bool + */ + protected static bool $initialized = false; + + /** + * {@inheritDoc} + */ + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + static::$initialized = false; + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSaveThrowsException(): void + { + $this->expectException(Exception::class); + $model = $this->getModel('example'); + $model->save([ + 'example_text' => 'new_must_not_save', + ]); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testDeleteThrowsException(): void + { + $this->expectException(Exception::class); + $model = $this->getModel('example'); + $model->delete('FIRST'); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testVirtualFields(): void + { + $model = $this->getModel('example'); + $model->addVirtualField('example_virtual', function ($dataset) { + return $dataset['example_text'] . $dataset['example_integer']; + }); + $dataset = $model->load('SECOND'); + static::assertEquals('bar234', $dataset['example_virtual']); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFilters(): void + { + $model = $this->getModel('example'); + + // no filters + $res = $model->search()->getResult(); + static::assertCount(3, $res); + + // load + $dataset = $model->load('FIRST'); + static::assertEquals('foo', $dataset['example_text']); + + // filter for PKEY + $model->addFilter('example_id', 'THIRD'); + $res = $model->search()->getResult(); + static::assertCount(1, $res); + + // filters for text value + $model->addFilter('example_text', 'bar'); + $res = $model->search()->getResult(); + static::assertCount(1, $res); + static::assertEquals('SECOND', $res[0][$model->getPrimaryKey()]); + + // filters for text value LIKE + $model->addFilter('example_text', 'ba%', 'LIKE'); + $res = $model->search()->getResult(); + static::assertCount(2, $res); + + // filters for text value NOT EQUAL + $model->addFilter('example_text', 'bar', '!='); + $res = $model->search()->getResult(); + static::assertCount(2, $res); + + // filters for GT + $model->addFilter('example_integer', 234, '>'); + $res = $model->search()->getResult(); + static::assertCount(1, $res); + + // filters for GTE + $model->addFilter('example_integer', 234, '>='); + $res = $model->search()->getResult(); + static::assertCount(2, $res); + + // filters for LT + $model->addFilter('example_integer', 234, '<'); + $res = $model->search()->getResult(); + static::assertCount(1, $res); + + // filters for LTE + $model->addFilter('example_integer', 234, '<='); + $res = $model->search()->getResult(); + static::assertCount(2, $res); + + // special PKEY filter for IN() + $model->addFilter('example_id', ['SECOND', 'invalid']); + $res = $model->search()->getResult(); + static::assertCount(1, $res); + + // filters for IN() + $model->addFilter('example_text', ['foo', 'baz']); + $res = $model->search()->getResult(); + static::assertCount(2, $res); + + // filters for NOT IN() + $model->addFilter('example_text', ['foo', 'baz'], '!='); + $res = $model->search()->getResult(); + static::assertCount(1, $res); + + // multiple filters + $model + ->addFilter('example_integer', 300, '<=') + ->addFilter('example_number', 20.1, '>') + ->addFilter('example_text', 'baz', '!='); + $res = $model->search()->getResult(); + static::assertCount(1, $res); + static::assertEquals('SECOND', $res[0][$model->getPrimaryKey()]); + + // basic OR filtering + $model + ->addFilter('example_integer', 200, '<=') + ->addFilter('example_number', 32.1, '>', 'OR'); + $res = $model->search()->getResult(); + static::assertCount(2, $res); + static::assertContainsEquals('FIRST', array_column($res, $model->getPrimaryKey())); + static::assertContainsEquals('THIRD', array_column($res, $model->getPrimaryKey())); + + // multiple contrary filters + $model + ->addFilter('example_integer', 500, '>') + ->addFilter('example_integer', 500, '<'); + $res = $model->search()->getResult(); + static::assertCount(0, $res); } - static::$initialized = true; - - static::setEnvironmentConfig([ - 'test' => [ - // 'database' => [ - // 'default' => $this->getDefaultDatabaseConfig(), - // ], - 'cache' => [ - 'default' => [ - 'driver' => 'memory' - ] - ], - 'filesystem' =>[ - 'local' => [ - 'driver' => 'local', - ] - ], - 'log' => [ - 'default' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ] - ], - ] - ]); - - static::createModel('json', 'country', [ - 'field' => [ - 'country_code', - 'country_name', - ], - 'primary' => [ - 'country_code' - ], - 'datatype' => [ - 'country_code' => 'text', - 'country_name' => 'text', - ], - // No connection, JSON datamodel - ], function($schema, $model, $config) { - return new \codename\core\tests\jsonModel( - 'tests/model/data/json_country.json', - $schema, - $model, - $config - ); - }); - - static::createModel('json', 'example', [ - 'field' => [ - 'example_id', - 'example_text', - 'example_integer', - 'example_number', - 'example_country', - ], - 'primary' => [ - 'example_id' - ], - 'foreign' => [ - 'example_country' => [ - 'model' => 'country', - 'key' => 'country_code', - ] - ], - 'datatype' => [ - 'example_id' => 'text', - 'example_text' => 'text', - 'example_integer' => 'number_natural', - 'example_number' => 'number', - 'example_country' => 'text', - ], - // No connection, JSON datamodel - ], function($schema, $model, $config) { - return new \codename\core\tests\jsonModel( - 'tests/model/data/json_example.json', - $schema, - $model, - $config - ); - }); - - } - - /** - * [testSaveThrowsException description] - */ - public function testSaveThrowsException(): void { - $this->expectException(\Exception::class); - $model = $this->getModel('example'); - $model->save([ - 'example_text' => 'new_must_not_save' - ]); - } - - /** - * [testDeleteThrowsException description] - */ - public function testDeleteThrowsException(): void { - $this->expectException(\Exception::class); - $model = $this->getModel('example'); - $model->delete('FIRST'); - } - - /** - * [testVirtualFields description] - */ - public function testVirtualFields(): void { - $model = $this->getModel('example'); - $model->addVirtualField('example_virtual', function($dataset) { - return $dataset['example_text'].$dataset['example_integer']; - }); - $dataset = $model->load('SECOND'); - $this->assertEquals('bar234', $dataset['example_virtual']); - } - - /** - * [testFilters description] - */ - public function testFilters(): void { - $model = $this->getModel('example'); - - // no filters - $res = $model->search()->getResult(); - $this->assertCount(3, $res); - - // load - $dataset = $model->load('FIRST'); - $this->assertEquals('foo', $dataset['example_text']); - - // filter for PKEY - $model->addFilter('example_id', 'THIRD'); - $res = $model->search()->getResult(); - $this->assertCount(1, $res); - - // filters for text value - $model->addFilter('example_text', 'bar'); - $res = $model->search()->getResult(); - $this->assertCount(1, $res); - $this->assertEquals($res[0][$model->getPrimarykey()], 'SECOND'); - - // filters for text value LIKE - $model->addFilter('example_text', 'ba%', 'LIKE'); - $res = $model->search()->getResult(); - $this->assertCount(2, $res); - - // filters for text value NOT EQUAL - $model->addFilter('example_text', 'bar', '!='); - $res = $model->search()->getResult(); - $this->assertCount(2, $res); - - // filters for GT - $model->addFilter('example_integer', 234, '>'); - $res = $model->search()->getResult(); - $this->assertCount(1, $res); - - // filters for GTE - $model->addFilter('example_integer', 234, '>='); - $res = $model->search()->getResult(); - $this->assertCount(2, $res); - - // filters for LT - $model->addFilter('example_integer', 234, '<'); - $res = $model->search()->getResult(); - $this->assertCount(1, $res); - - // filters for LTE - $model->addFilter('example_integer', 234, '<='); - $res = $model->search()->getResult(); - $this->assertCount(2, $res); - - // special PKEY filter for IN() - $model->addFilter('example_id', [ 'SECOND', 'invalid' ]); - $res = $model->search()->getResult(); - $this->assertCount(1, $res); - - // filters for IN() - $model->addFilter('example_text', [ 'foo', 'baz' ]); - $res = $model->search()->getResult(); - $this->assertCount(2, $res); - - // filters for NOT IN() - $model->addFilter('example_text', [ 'foo', 'baz' ], '!='); - $res = $model->search()->getResult(); - $this->assertCount(1, $res); - - // multiple filters - $model - ->addFilter('example_integer', 300, '<=') - ->addFilter('example_number', 20.1, '>') - ->addFilter('example_text', 'baz', '!=') - ; - $res = $model->search()->getResult(); - $this->assertCount(1, $res); - $this->assertEquals($res[0][$model->getPrimarykey()], 'SECOND'); - - // basic OR filtering - $model - ->addFilter('example_integer', 200, '<=') - ->addFilter('example_number', 32.1, '>', 'OR') - ; - $res = $model->search()->getResult(); - $this->assertCount(2, $res); - $this->assertContainsEquals('FIRST', array_column($res, $model->getPrimarykey())); - $this->assertContainsEquals('THIRD', array_column($res, $model->getPrimarykey())); - - // multiple contrary filters - $model - ->addFilter('example_integer', 500, '>') - ->addFilter('example_integer', 500, '<') - ; - $res = $model->search()->getResult(); - $this->assertCount(0, $res); - } - - /** - * [testFiltercollections description] - */ - public function testFiltercollections(): void { - $model = $this->getModel('example'); - $model->addFilterCollection([ - [ 'field' => 'example_text', 'operator' => '=', 'value' => 'foo' ], - [ 'field' => 'example_integer', 'operator' => '=', 'value' => 234 ], - ], 'OR'); - $res = $model->search()->getResult(); - $this->assertCount(2, $res); - } - - /** - * [testNamedFiltercollections description] - */ - public function testNamedFiltercollections(): void { - $model = $this->getModel('example'); - - // will match all - $model->addDefaultFilterCollection([ - // will match FIRST, SECOND - [ 'field' => 'example_text', 'operator' => '=', 'value' => 'foo' ], - [ 'field' => 'example_integer', 'operator' => '=', 'value' => 234 ], - ], 'OR', 'g1'); - $model->addDefaultFilterCollection([ - // will match SECOND, THIRD - [ 'field' => 'example_text', 'operator' => '!=', 'value' => 'foo' ], - [ 'field' => 'example_integer', 'operator' => '=', 'value' => 345 ], - ], 'OR', 'g1', 'OR'); - - $res = $model->search()->getResult(); - $this->assertCount(3, $res); - - $model->addFilterCollection([ - // will match SECOND - [ 'field' => 'example_text', 'operator' => '=', 'value' => 'bar' ], - [ 'field' => 'example_integer', 'operator' => '=', 'value' => 999 ], - ], 'OR', 'g2'); - $model->addFilterCollection([ - // will match THIRD - [ 'field' => 'example_text', 'operator' => '=', 'value' => 'baz' ], - [ 'field' => 'example_number', 'operator' => '>=', 'value' => 30 ], - ], 'AND', 'g2', 'OR'); - - $res = $model->search()->getResult(); - - $this->assertCount(2, $res); - $this->assertEqualsCanonicalizing([ 'SECOND', 'THIRD' ], array_column($res, $model->getPrimarykey())); - - - $model->addFilterCollection([ - // will FIRST, THIRD - [ 'field' => 'example_text', 'operator' => '=', 'value' => ['foo', 'baz'] ], - ], 'AND', 'g3', 'OR'); - - $res = $model->search()->getResult(); - - $this->assertCount(2, $res); - $this->assertEqualsCanonicalizing([ 'FIRST', 'THIRD' ], array_column($res, $model->getPrimarykey())); - } - - /** - * [testSimpleJoin description] - */ - public function testSimpleJoin(): void { - $model = $this->getModel('example') - ->addModel($this->getModel('country')); - $res = $model->search()->getResult(); - $this->assertEquals(['Germany', 'Austria', null], array_column($res, 'country_name')); - } - - /** - * [testSimpleInnerJoin description] - */ - public function testSimpleInnerJoin(): void { - $model = $this->getModel('example') - ->addModel( - $this->getModel('country'), - \codename\core\model\plugin\join::TYPE_INNER - ); - $res = $model->search()->getResult(); - $this->assertEquals(['Germany', 'Austria'], array_column($res, 'country_name')); - } - - /** - * Right join with json/bare datamodels is explicitly unsupported - * Make sure the respective exception is thrown. - */ - public function testRightJoinWillFail(): void { - $this->expectExceptionMessage('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE'); - $model = $this->getModel('example') - ->addModel( - $this->getModel('country'), - \codename\core\model\plugin\join::TYPE_RIGHT - ); - $model->search()->getResult(); - } + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFiltercollections(): void + { + $model = $this->getModel('example'); + $model->addFilterCollection([ + ['field' => 'example_text', 'operator' => '=', 'value' => 'foo'], + ['field' => 'example_integer', 'operator' => '=', 'value' => 234], + ], 'OR'); + $res = $model->search()->getResult(); + static::assertCount(2, $res); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testNamedFiltercollections(): void + { + $model = $this->getModel('example'); + + // will match all + $model->addDefaultFilterCollection([ + // will match FIRST, SECOND + ['field' => 'example_text', 'operator' => '=', 'value' => 'foo'], + ['field' => 'example_integer', 'operator' => '=', 'value' => 234], + ], 'OR', 'g1'); + $model->addDefaultFilterCollection([ + // will match SECOND, THIRD + ['field' => 'example_text', 'operator' => '!=', 'value' => 'foo'], + ['field' => 'example_integer', 'operator' => '=', 'value' => 345], + ], 'OR', 'g1', 'OR'); + + $res = $model->search()->getResult(); + static::assertCount(3, $res); + + $model->addFilterCollection([ + // will match SECOND + ['field' => 'example_text', 'operator' => '=', 'value' => 'bar'], + ['field' => 'example_integer', 'operator' => '=', 'value' => 999], + ], 'OR', 'g2'); + $model->addFilterCollection([ + // will match THIRD + ['field' => 'example_text', 'operator' => '=', 'value' => 'baz'], + ['field' => 'example_number', 'operator' => '>=', 'value' => 30], + ], 'AND', 'g2', 'OR'); + + $res = $model->search()->getResult(); + + static::assertCount(2, $res); + static::assertEqualsCanonicalizing(['SECOND', 'THIRD'], array_column($res, $model->getPrimaryKey())); + + + $model->addFilterCollection([ + // will FIRST, THIRD + ['field' => 'example_text', 'operator' => '=', 'value' => ['foo', 'baz']], + ], 'AND', 'g3', 'OR'); + + $res = $model->search()->getResult(); + + static::assertCount(2, $res); + static::assertEqualsCanonicalizing(['FIRST', 'THIRD'], array_column($res, $model->getPrimaryKey())); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSimpleJoin(): void + { + $model = $this->getModel('example') + ->addModel($this->getModel('country')); + $res = $model->search()->getResult(); + static::assertEquals(['Germany', 'Austria', null], array_column($res, 'country_name')); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSimpleInnerJoin(): void + { + $model = $this->getModel('example') + ->addModel( + $this->getModel('country'), + join::TYPE_INNER + ); + $res = $model->search()->getResult(); + static::assertEquals(['Germany', 'Austria'], array_column($res, 'country_name')); + } + + /** + * Right join with json/bare datamodels is explicitly unsupported + * Make sure the respective exception is thrown. + * @return void + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws \codename\core\exception + */ + public function testRightJoinWillFail(): void + { + $this->expectExceptionMessage('EXCEPTION_MODEL_PLUGIN_JOIN_INVALID_JOIN_TYPE'); + $model = $this->getModel('example') + ->addModel( + $this->getModel('country'), + join::TYPE_RIGHT + ); + $model->search()->getResult(); + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws ErrorException + * @throws Throwable + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws \codename\core\exception + */ + protected function setUp(): void + { + $app = static::createApp(); + $app::getAppstack(); + + // avoid re-init + if (static::$initialized) { + return; + } + + static::$initialized = true; + + static::setEnvironmentConfig([ + 'test' => [ + 'cache' => [ + 'default' => [ + 'driver' => 'memory', + ], + ], + 'filesystem' => [ + 'local' => [ + 'driver' => 'local', + ], + ], + 'log' => [ + 'default' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + ], + ], + ]); + + static::createModel('json', 'country', [ + 'field' => [ + 'country_code', + 'country_name', + ], + 'primary' => [ + 'country_code', + ], + 'datatype' => [ + 'country_code' => 'text', + 'country_name' => 'text', + ], + // No connection, JSON datamodel + ], function ($schema, $model, $config) { + return new jsonModel( + 'tests/model/data/json_country.json', + $schema, + $model, + $config + ); + }); + + static::createModel('json', 'example', [ + 'field' => [ + 'example_id', + 'example_text', + 'example_integer', + 'example_number', + 'example_country', + ], + 'primary' => [ + 'example_id', + ], + 'foreign' => [ + 'example_country' => [ + 'model' => 'country', + 'key' => 'country_code', + ], + ], + 'datatype' => [ + 'example_id' => 'text', + 'example_text' => 'text', + 'example_integer' => 'number_natural', + 'example_number' => 'number', + 'example_country' => 'text', + ], + // No connection, JSON datamodel + ], function ($schema, $model, $config) { + return new jsonModel( + 'tests/model/data/json_example.json', + $schema, + $model, + $config + ); + }); + } } diff --git a/tests/model/schematic/mysqlTest.php b/tests/model/schematic/mysqlTest.php index 514721f..25d2f74 100644 --- a/tests/model/schematic/mysqlTest.php +++ b/tests/model/schematic/mysqlTest.php @@ -1,111 +1,1895 @@ query('SHUTDOWN;'); + + parent::tearDownAfterClass(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSetConfigExplicitConnectionValid(): void + { + parent::testSetConfigExplicitConnectionValid(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSetConfigExplicitConnectionInvalid(): void + { + parent::testSetConfigExplicitConnectionInvalid(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSetConfigInvalidValues(): void + { + parent::testSetConfigInvalidValues(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testModelconfigInvalidWithoutCreatedAndModifiedField(): void + { + parent::testModelconfigInvalidWithoutCreatedAndModifiedField(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testModelconfigInvalidWithoutModifiedField(): void + { + parent::testModelconfigInvalidWithoutModifiedField(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testDeleteWithoutArgsWillFail(): void + { + parent::testDeleteWithoutArgsWillFail(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testUpdateWithoutArgsWillFail(): void + { + parent::testUpdateWithoutArgsWillFail(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddCalculatedFieldExistsWillFail(): void + { + parent::testAddCalculatedFieldExistsWillFail(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testHideFieldSingle(): void + { + parent::testHideFieldSingle(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testHideFieldMultipleCommaTrim(): void + { + parent::testHideFieldMultipleCommaTrim(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testHideAllFieldsAddOne(): void + { + parent::testHideAllFieldsAddOne(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testHideAllFieldsAddMultiple(): void + { + parent::testHideAllFieldsAddMultiple(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFieldComplexEdgeCaseNoVfr(): void + { + parent::testAddFieldComplexEdgeCaseNoVfr(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFieldComplexEdgeCasePartialVfr(): void + { + parent::testAddFieldComplexEdgeCasePartialVfr(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFieldComplexEdgeCaseFullVfr(): void + { + parent::testAddFieldComplexEdgeCaseFullVfr(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFieldComplexEdgeCaseRegularFieldFullVfr(): void + { + parent::testAddFieldComplexEdgeCaseRegularFieldFullVfr(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFieldComplexEdgeCaseNestedRegularFieldFullVfr(): void + { + parent::testAddFieldComplexEdgeCaseNestedRegularFieldFullVfr(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFieldFailsWithNonexistingField(): void + { + parent::testAddFieldFailsWithNonexistingField(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFieldFailsWithMultipleFieldsAndAliasProvided(): void + { + parent::testAddFieldFailsWithMultipleFieldsAndAliasProvided(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testHideAllFieldsAddAliasedField(): void + { + parent::testHideAllFieldsAddAliasedField(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSimpleModelJoin(): void + { + parent::testSimpleModelJoin(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSimpleModelJoinWithVirtualFields(): void + { + parent::testSimpleModelJoinWithVirtualFields(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testConditionalJoin(): void + { + parent::testConditionalJoin(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testConditionalJoinFail(): void + { + parent::testConditionalJoinFail(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testReverseJoinEquality(): void + { + parent::testReverseJoinEquality(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testReplace(): void + { + parent::testReplace(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testMultiComponentForeignKeyJoin(): void + { + parent::testMultiComponentForeignKeyJoin(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testTimemachineHistory(): void + { + parent::testTimemachineHistory(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testDeleteSinglePkeyTimemachineEnabled(): void + { + parent::testDeleteSinglePkeyTimemachineEnabled(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testBulkUpdateAndDelete(): void + { + parent::testBulkUpdateAndDelete(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testBulkUpdateAndDeleteTimemachineEnabled(): void + { + parent::testBulkUpdateAndDeleteTimemachineEnabled(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testRecursiveModelJoin(): void + { + parent::testRecursiveModelJoin(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSetRecursiveTwiceWillThrow(): void + { + parent::testSetRecursiveTwiceWillThrow(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSetRecursiveInvalidConfigWillThrow(): void + { + parent::testSetRecursiveInvalidConfigWillThrow(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSetRecursiveNonexistingFieldWillThrow(): void + { + parent::testSetRecursiveNonexistingFieldWillThrow(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddRecursiveModelNonexistingFieldWillThrow(): void + { + parent::testAddRecursiveModelNonexistingFieldWillThrow(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFiltercollectionValueArray(): void + { + parent::testFiltercollectionValueArray(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testDefaultFiltercollectionValueArray(): void + { + parent::testDefaultFiltercollectionValueArray(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testLeftJoinForcedVirtualNoReferenceDataset(): void + { + parent::testLeftJoinForcedVirtualNoReferenceDataset(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testInnerJoinRegular(): void + { + parent::testInnerJoinRegular(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testInnerJoinForcedVirtualJoin(): void + { + parent::testInnerJoinForcedVirtualJoin(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testJoinVirtualFieldResultEnabledMissingVKey(): void + { + parent::testJoinVirtualFieldResultEnabledMissingVKey(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testJoinVirtualFieldResultEnabledCustomVKey(): void + { + parent::testJoinVirtualFieldResultEnabledCustomVKey(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testJoinHiddenFieldsNoVirtualFieldResult(): void + { + parent::testJoinHiddenFieldsNoVirtualFieldResult(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSameNamedCalculatedFieldsInVirtualFieldResults(): void + { + parent::testSameNamedCalculatedFieldsInVirtualFieldResults(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testRecursiveModelVirtualFieldDisabledWithAliasedFields(): void + { + parent::testRecursiveModelVirtualFieldDisabledWithAliasedFields(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSaveWithChildrenAuthoritativeDatasetsAndIdentifiers(): void + { + parent::testSaveWithChildrenAuthoritativeDatasetsAndIdentifiers(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testComplexVirtualRenormalizeForcedVirtualJoin(): void + { + parent::testComplexVirtualRenormalizeForcedVirtualJoin(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testComplexVirtualRenormalizeRegular(): void + { + parent::testComplexVirtualRenormalizeRegular(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testComplexJoin(): void + { + parent::testComplexJoin(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testJoinNestingLimitExceededWillFail(): void + { + parent::testJoinNestingLimitExceededWillFail(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testJoinNestingLimitMaxxedOut(): void + { + parent::testJoinNestingLimitMaxxedOut(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testJoinNestingLimitMaxxedOutSaving(): void + { + parent::testJoinNestingLimitMaxxedOutSaving(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testJoinNestingBypassLimitation1(): void + { + parent::testJoinNestingBypassLimitation1(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testJoinNestingBypassLimitation2(): void + { + parent::testJoinNestingBypassLimitation2(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testJoinNestingBypassLimitation3(): void + { + parent::testJoinNestingBypassLimitation3(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testGetCount(): void + { + parent::testGetCount(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFlags(): void + { + parent::testFlags(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFlagfieldValueNoFlagsInModel(): void + { + parent::testFlagfieldValueNoFlagsInModel(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFlagfieldValue(): void + { + parent::testFlagfieldValue(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testGetFlagNonexisting(): void + { + parent::testGetFlagNonexisting(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testIsFlagNoFlagField(): void + { + parent::testIsFlagNoFlagField(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFlagNormalization(): void + { + parent::testFlagNormalization(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddModelExplicitModelfieldValid(): void + { + parent::testAddModelExplicitModelfieldValid(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddModelExplicitModelfieldInvalid(): void + { + parent::testAddModelExplicitModelfieldInvalid(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddModelInvalidNoRelation(): void + { + parent::testAddModelInvalidNoRelation(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testVirtualFieldResultSaving(): void + { + parent::testVirtualFieldResultSaving(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testVirtualFieldResultCollectionHandling(): void + { + parent::testVirtualFieldResultCollectionHandling(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddCollectionModelMissingCollectionConfig(): void + { + parent::testAddCollectionModelMissingCollectionConfig(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddCollectionModelIncompatible(): void + { + parent::testAddCollectionModelIncompatible(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddCollectionModelInvalidModelField(): void + { + parent::testAddCollectionModelInvalidModelField(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddCollectionModelValidModelFieldIncompatibleModel(): void + { + parent::testAddCollectionModelValidModelFieldIncompatibleModel(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testGetNestedCollections(): void + { + parent::testGetNestedCollections(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testVirtualFieldResultSavingFailedAmbiguousJoins(): void + { + parent::testVirtualFieldResultSavingFailedAmbiguousJoins(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testVirtualFieldQuery(): void + { + parent::testVirtualFieldQuery(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testForcedVirtualJoinWithVirtualFieldResult(): void + { + parent::testForcedVirtualJoinWithVirtualFieldResult(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testForcedVirtualJoinWithoutVirtualFieldResult(): void + { + parent::testForcedVirtualJoinWithoutVirtualFieldResult(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testModelJoinWithJson(): void + { + parent::testModelJoinWithJson(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testInvalidFilterOperator(): void + { + parent::testInvalidFilterOperator(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testLikeFilters(): void + { + parent::testLikeFilters(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testSuccessfulCreateAndDeleteTransaction(): void + { + parent::testSuccessfulCreateAndDeleteTransaction(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testTransactionUntrackedRunning(): void + { + parent::testTransactionUntrackedRunning(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testTransactionRolledBackPrematurely(): void + { + parent::testTransactionRolledBackPrematurely(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testNestedOrder(): void + { + parent::testNestedOrder(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testOrderLimitOffset(): void + { + parent::testOrderLimitOffset(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testLimitOffsetReset(): void + { + parent::testLimitOffsetReset(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddOrderOnNonexistingFieldWillThrow(): void + { + parent::testAddOrderOnNonexistingFieldWillThrow(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testStructureData(): void + { + parent::testStructureData(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testStructureEncoding(): void + { + parent::testStructureEncoding(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testGroupedGetCount(): void + { + parent::testGroupedGetCount(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testGetCountAliasing(): void + { + parent::testGetCountAliasing(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddGroupOnCalculatedFieldDoesNotCrash(): void + { + parent::testAddGroupOnCalculatedFieldDoesNotCrash(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddGroupOnNestedCalculatedFieldDoesNotCrash(): void + { + parent::testAddGroupOnNestedCalculatedFieldDoesNotCrash(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddGroupNonExistingField(): void + { + parent::testAddGroupNonExistingField(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAmbiguousAliasFieldsNormalization(): void + { + parent::testAmbiguousAliasFieldsNormalization(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateCount(): void + { + parent::testAggregateCount(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateCountDistinct(): void + { + parent::testAggregateCountDistinct(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddAggregateFieldDuplicateFixedFieldWillThrow(): void + { + parent::testAddAggregateFieldDuplicateFixedFieldWillThrow(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddAggregateFieldSameNamedWithVirtualFieldResult(): void + { + parent::testAddAggregateFieldSameNamedWithVirtualFieldResult(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateSum(): void + { + parent::testAggregateSum(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateAvg(): void + { + parent::testAggregateAvg(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateMax(): void + { + parent::testAggregateMax(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateMin(): void + { + parent::testAggregateMin(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateDatetimeYear(): void + { + parent::testAggregateDatetimeYear(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateGroupedSumOrderByAggregateField(): void + { + parent::testAggregateGroupedSumOrderByAggregateField(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateDatetimeQuarter(): void + { + parent::testAggregateDatetimeQuarter(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateDatetimeMonth(): void + { + parent::testAggregateDatetimeMonth(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateDatetimeDay(): void + { + parent::testAggregateDatetimeDay(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateFilterSimple(): void + { + parent::testAggregateFilterSimple(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateFilterValueArray(): void + { + parent::testAggregateFilterValueArray(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testDefaultAggregateFilterValueArray(): void + { + parent::testDefaultAggregateFilterValueArray(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAggregateFilterValueArraySimple(): void + { + parent::testAggregateFilterValueArraySimple(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFieldAliasWithFilter(): void + { + parent::testFieldAliasWithFilter(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFieldAliasWithFilterArrayFallbackDataTypeSuccessful(): void + { + parent::testFieldAliasWithFilterArrayFallbackDataTypeSuccessful(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFieldAliasWithFilterArrayFallbackDataTypeFailsUnsupportedData(): void + { + parent::testFieldAliasWithFilterArrayFallbackDataTypeFailsUnsupportedData(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFilterWithEmptyArrayValue(): void + { + parent::testAddFilterWithEmptyArrayValue(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFiltercollectionWithEmptyArrayValue(): void + { + parent::testAddFiltercollectionWithEmptyArrayValue(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddDefaultfilterWithEmptyArrayValue(): void + { + parent::testAddDefaultfilterWithEmptyArrayValue(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddDefaultFiltercollectionWithEmptyArrayValue(): void + { + parent::testAddDefaultFiltercollectionWithEmptyArrayValue(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddAggregateFilterWithEmptyArrayValue(): void + { + parent::testAddAggregateFilterWithEmptyArrayValue(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddDefaultAggregateFilterWithEmptyArrayValue(): void + { + parent::testAddDefaultAggregateFilterWithEmptyArrayValue(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddDefaultfilterWithArrayValue(): void + { + parent::testAddDefaultfilterWithArrayValue(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFilterRootLevelNested(): void + { + parent::testAddFilterRootLevelNested(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFiltercollectionRootLevelNested(): void + { + parent::testAddFiltercollectionRootLevelNested(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFieldFilter(): void + { + parent::testAddFieldFilter(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFieldFilterNested(): void + { + parent::testAddFieldFilterNested(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAddFieldFilterWithInvalidOperator(): void + { + parent::testAddFieldFilterWithInvalidOperator(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testDefaultfilterSimple(): void + { + parent::testDefaultfilterSimple(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAdhocDiscreteModelAsRoot(): void + { + parent::testAdhocDiscreteModelAsRoot(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testAdhocDiscreteModelComplex(): void + { + parent::testAdhocDiscreteModelComplex(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testDiscreteModelLimitAndOffset(): void + { + parent::testDiscreteModelLimitAndOffset(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testDiscreteModelAddOrder(): void + { + parent::testDiscreteModelAddOrder(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testDiscreteModelSimpleAggregate(): void + { + parent::testDiscreteModelSimpleAggregate(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testDiscreteModelSaveWillThrow(): void + { + parent::testDiscreteModelSaveWillThrow(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testDiscreteModelUpdateWillThrow(): void + { + parent::testDiscreteModelUpdateWillThrow(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testDiscreteModelReplaceWillThrow(): void + { + parent::testDiscreteModelReplaceWillThrow(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testDiscreteModelDeleteWillThrow(): void + { + parent::testDiscreteModelDeleteWillThrow(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testGroupAliasBugFixed(): void + { + parent::testGroupAliasBugFixed(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testNormalizeData(): void + { + parent::testNormalizeData(); + } + + /** + * @return void + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws \codename\core\exception + */ + public function testNormalizeDataComplex(): void + { + parent::testNormalizeDataComplex(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testValidateSimple(): void + { + parent::testValidateSimple(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testModelValidator(): void + { + parent::testModelValidator(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testModelValidatorSpecial(): void + { + parent::testModelValidatorSpecial(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testValidateSimpleRequiredField(): void + { + parent::testValidateSimpleRequiredField(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testValidateCollectionNotUsed(): void + { + parent::testValidateCollectionNotUsed(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testValidateCollectionData(): void + { + parent::testValidateCollectionData(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntryFunctions(): void + { + parent::testEntryFunctions(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntryFlags(): void + { + parent::testEntryFlags(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntrySetFlagNonexisting(): void + { + parent::testEntrySetFlagNonexisting(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntrySetFlagInvalidFlagValueThrows(): void + { + parent::testEntrySetFlagInvalidFlagValueThrows(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntryUnsetFlagInvalidFlagValueThrows(): void + { + parent::testEntryUnsetFlagInvalidFlagValueThrows(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntrySetFlagNoDatasetLoadedThrows(): void + { + parent::testEntrySetFlagNoDatasetLoadedThrows(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntryUnsetFlagNoDatasetLoadedThrows(): void + { + parent::testEntryUnsetFlagNoDatasetLoadedThrows(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntrySetFlagNoFlagsInModelThrows(): void + { + parent::testEntrySetFlagNoFlagsInModelThrows(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntryUnsetFlagNoFlagsInModelThrows(): void + { + parent::testEntryUnsetFlagNoFlagsInModelThrows(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntrySaveNoDataThrows(): void + { + parent::testEntrySaveNoDataThrows(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntrySaveEmptyDataThrows(): void + { + parent::testEntrySaveEmptyDataThrows(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntryUpdateEmptyDataThrows(): void + { + parent::testEntryUpdateEmptyDataThrows(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntryUpdateNoDatasetLoaded(): void + { + parent::testEntryUpdateNoDatasetLoaded(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntryLoadNonexistingId(): void + { + parent::testEntryLoadNonexistingId(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testEntryDeleteNoDatasetLoadedThrows(): void + { + parent::testEntryDeleteNoDatasetLoadedThrows(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFieldGetNonexistingThrows(): void + { + parent::testFieldGetNonexistingThrows(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFieldGetNoDatasetLoadedThrows(): void + { + parent::testFieldGetNoDatasetLoadedThrows(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFieldSetNonexistingThrows(): void + { + parent::testFieldSetNonexistingThrows(); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testFieldSetNoDatasetLoadedThrows(): void + { + parent::testFieldSetNoDatasetLoadedThrows(); + } + + /** + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testTimemachineDelta(): void + { + parent::testTimemachineDelta(); + } -class mysqlTest extends abstractModelTest { - /** - * @inheritDoc - */ - protected function getDefaultDatabaseConfig(): array - { - return [ - 'driver' => 'mysql', - 'host' => getenv('unittest_core_db_mysql_host'), - 'user' => getenv('unittest_core_db_mysql_user'), - 'pass' => getenv('unittest_core_db_mysql_pass'), - 'database' => getenv('unittest_core_db_mysql_database'), - 'autoconnect_database' => false, - 'port' => 3306, - 'charset' => 'utf8', - ]; - } - - /** - * @inheritDoc - */ - public static function setUpBeforeClass(): void - { - parent::setUpBeforeClass(); - - // wait for rmysql to come up - if(getenv('unittest_core_db_mysql_host')) { - if(!\codename\core\tests\helper::waitForIt(getenv('unittest_core_db_mysql_host'), 3306, 3, 3, 5)) { - throw new \Exception('Failed to connect to mysql server'); - } - } else { - static::markTestSkipped('Mysql host unavailable'); - } - } - - /** - * @inheritDoc - */ - public function testConnectionErrorExceptionIsSensitive(): void - { - // - // Important test to make sure a database error exception - // doesn't leak any credentials (wrapped in a sensitiveException) - // - $this->expectException(\codename\core\sensitiveException::class); - $this->getDatabaseInstance([ - 'driver' => 'mysql', - 'host' => 'nonexisting-host', - 'user' => 'some-username', - 'pass' => 'some-password', - ]); - } - - /** - * @inheritDoc - */ - protected function getDatabaseInstance(array $config): \codename\core\database - { - return new \codename\core\database\mysql($config); - } - - /** - * @inheritDoc - */ - public static function tearDownAfterClass(): void - { - // shutdown the mysql server - // At this point, we assume the DB data is stored on a volatile medium - // to implicitly clear all data after shutdown - - // NOTE/WARNING: shutdown on the DB Server - // will crash PDO connections that try to close themselves - // after testing - STMT_CLOSE will fail due to passed-away DB - // in a more or less random fashion. - // As long as you use volatile containers, everything will be fine. - // app::getDb('default')->query('SHUTDOWN;'); - - parent::tearDownAfterClass(); - } - - /** - * [testInstanceClass description] - */ - public function testInstanceClass(): void { - $this->assertInstanceOf(\codename\core\database\mysql::class, \codename\core\app::getDb()); - } - - /** - * @inheritDoc - */ - protected function getJoinNestingLimit(): int - { - return 61; - } - - /** - * @inheritDoc - */ - public function testAggregateDatetimeInvalid(): void - { - $this->expectExceptionMessage('EXCEPTION_MODEL_PLUGIN_CALCULATION_MYSQL_UNKKNOWN_CALCULATION_TYPE'); - parent::testAggregateDatetimeInvalid(); - } + /** + * @return void + * @throws \codename\core\exception + * @throws sensitiveException + */ + public function testConnectionErrorExceptionIsSensitive(): void + { + // + // Important test to make sure a database error exception + // doesn't leak any credentials (wrapped in a sensitiveException) + // + $this->expectException(sensitiveException::class); + $this->getDatabaseInstance([ + 'driver' => 'mysql', + 'host' => 'nonexisting-host', + 'user' => 'some-username', + 'pass' => 'some-password', + ]); + } + + /** + * {@inheritDoc} + * @param array $config + * @return database + * @throws \codename\core\exception + * @throws sensitiveException + */ + protected function getDatabaseInstance(array $config): database + { + return new mysql($config); + } + + /** + * @return void + * @throws ReflectionException + * @throws \codename\core\exception + */ + public function testInstanceClass(): void + { + static::assertInstanceOf(mysql::class, app::getDb()); + } + + /** + * {@inheritDoc} + */ + public function testAggregateDatetimeInvalid(): void + { + $this->expectExceptionMessage('EXCEPTION_MODEL_PLUGIN_CALCULATION_MYSQL_UNKNOWN_CALCULATION_TYPE'); + parent::testAggregateDatetimeInvalid(); + } + + /** + * {@inheritDoc} + */ + protected function getDefaultDatabaseConfig(): array + { + return [ + 'driver' => 'mysql', + 'host' => getenv('unittest_core_db_mysql_host'), + 'user' => getenv('unittest_core_db_mysql_user'), + 'pass' => getenv('unittest_core_db_mysql_pass'), + 'database' => getenv('unittest_core_db_mysql_database'), + 'autoconnect_database' => false, + 'port' => 3306, + 'charset' => 'utf8', + ]; + } + + /** + * {@inheritDoc} + */ + protected function getJoinNestingLimit(): int + { + return 61; + } } diff --git a/tests/model/schematic/sqliteTest.php b/tests/model/schematic/sqliteTest.php index e592f0d..edaeeff 100644 --- a/tests/model/schematic/sqliteTest.php +++ b/tests/model/schematic/sqliteTest.php @@ -1,50 +1,1831 @@ expectExceptionMessage('EXCEPTION_MODEL_PLUGIN_CALCULATION_SQLITE_UNKNOWN_CALCULATION_TYPE'); + parent::testAggregateDatetimeInvalid(); + } + + /** + * {@inheritDoc} + */ + protected function getDefaultDatabaseConfig(): array + { + return [ + 'driver' => 'sqlite', + 'database_file' => ':memory:', + ]; + } -class sqliteTest extends abstractModelTest { - /** - * @inheritDoc - */ - protected function getDefaultDatabaseConfig(): array - { - return [ - 'driver' => 'sqlite', - 'database_file' => ':memory:', - ]; - } - - /** - * @inheritDoc - */ - protected function getDatabaseInstance(array $config): \codename\core\database - { - return new \codename\core\database\sqlite($config); - } - - /** - * [testInstanceClass description] - */ - public function testInstanceClass(): void { - $this->assertInstanceOf(\codename\core\database\sqlite::class, \codename\core\app::getDb()); - } - - /** - * @inheritDoc - */ - protected function getJoinNestingLimit(): int - { - return 64; - } - - /** - * @inheritDoc - */ - public function testAggregateDatetimeInvalid(): void - { - $this->expectExceptionMessage('EXCEPTION_MODEL_PLUGIN_CALCULATION_SQLITE_UNKKNOWN_CALCULATION_TYPE'); - parent::testAggregateDatetimeInvalid(); - } + /** + * {@inheritDoc} + * @param array $config + * @return database + * @throws exception + * @throws sensitiveException + */ + protected function getDatabaseInstance(array $config): database + { + return new sqlite($config); + } + /** + * {@inheritDoc} + */ + protected function getJoinNestingLimit(): int + { + return 64; + } } diff --git a/tests/model/validator/model/testdata.php b/tests/model/validator/model/testdata.php index cd87ba7..40cfb28 100644 --- a/tests/model/validator/model/testdata.php +++ b/tests/model/validator/model/testdata.php @@ -1,26 +1,30 @@ errorstack->addError($field, 'FIELD_INVALID', $value[$field]); - } +class testdata extends structure +{ + /** + * {@inheritDoc} + */ + public function validate(mixed $value): array + { + parent::validate($value); - if(($value['testdata_text'] == 'disallowed_condition') && ($value['testdata_date'] == '2021-01-01')) { - $this->errorstack->addError('GENERIC_ERROR', 'DISALLOWED_CONDITION', [ - 'testdata_text' => $value['testdata_text'], - 'testdata_date' => $value['testdata_date'] - ]); - } + $field = 'testdata_text'; + if ($value[$field] == 'disallowed_value') { + $this->errorstack->addError($field, 'FIELD_INVALID', $value[$field]); + } - return $this->getErrors(); - } + if (($value['testdata_text'] == 'disallowed_condition') && ($value['testdata_date'] == '2021-01-01')) { + $this->errorstack->addError('GENERIC_ERROR', 'DISALLOWED_CONDITION', [ + 'testdata_text' => $value['testdata_text'], + 'testdata_date' => $value['testdata_date'], + ]); + } + + return $this->getErrors(); + } } diff --git a/tests/overrideableApp.php b/tests/overrideableApp.php index 62486c8..a46d891 100644 --- a/tests/overrideableApp.php +++ b/tests/overrideableApp.php @@ -1,9 +1,10 @@ markTestIncomplete('CLI Request may contain phpunit/unittest arguments!'); - - $request = new \codename\core\request\cli(); - $this->assertEquals(array_merge([ 'lang' => 'de_DE']), $request->getData()); - } +class cliTest extends requestTest +{ + /** + * @return void + */ + public function testRequestDatacontainer(): void + { + static::markTestIncomplete('CLI Request may contain phpunit/unittest arguments!'); + $request = new cli(); + static::assertEquals(array_merge(['lang' => 'de_DE']), $request->getData()); + } } diff --git a/tests/request/httpTest.php b/tests/request/httpTest.php index f136b8b..6037d79 100644 --- a/tests/request/httpTest.php +++ b/tests/request/httpTest.php @@ -1,26 +1,31 @@ assertEquals(array_merge($_GET, $_POST, [ 'lang' => 'de_DE']), $request->getData()); - } - - /** - * [testDatacontainer description] - */ - public function testHttpsSupport(): void { - $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; - $request = new \codename\core\request\http([]); - $this->assertEquals('on', $_SERVER['HTTPS']); - } +class httpTest extends requestTest +{ + /** + * @return void + */ + public function testRequestDatacontainer(): void + { + $request = new http(); + static::assertEquals(array_merge($_GET, $_POST, ['lang' => 'de_DE']), $request->getData()); + } + /** + * @return void + */ + public function testHttpsSupport(): void + { + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + new http(); + static::assertEquals('on', $_SERVER['HTTPS']); + } } diff --git a/tests/requestTest.php b/tests/requestTest.php index 147da64..7803d2d 100644 --- a/tests/requestTest.php +++ b/tests/requestTest.php @@ -1,17 +1,21 @@ assertEquals([], $request->getData()); - } + static::assertEquals([], $request->getData()); + } } diff --git a/tests/session/abstractSessionTest.php b/tests/session/abstractSessionTest.php index 48e5b73..cc21e68 100644 --- a/tests/session/abstractSessionTest.php +++ b/tests/session/abstractSessionTest.php @@ -1,228 +1,270 @@ getAppstack(); - - static::setEnvironmentConfig([ - 'test' => array_merge([ - 'session' => [ - 'default' => $this->getDefaultSessionConfig() - ], - // 'filesystem' =>[ - // 'local' => [ - // 'driver' => 'local', - // ] - // ], - // 'log' => [ - // 'debug' => [ - // 'driver' => 'system', - // 'data' => [ - // 'name' => 'dummy' - // ] - // ] - // ] - ], - $this->getAdditionalEnvironmentConfig() - )]); - } - - /** - * @inheritDoc - */ - protected function tearDown(): void - { - $this->emulateSession(null); - app::getSession()->destroy(); - parent::tearDown(); - } - - /** - * Emulates a session (or none, if null) - * if key 'valid' is not supplied, we automatically assume - * a valid session to be emulated - * $data might be an array of one or more of these keys: - * (string) identifier (e.g. a cookie value or header) - * (bool) valid (whether the session was recently evaluated as valid) - * (string) valid_until (ISO datetime of expiry) - * @param array|null $data [description] - */ - protected function emulateSession(?array $data): void { - return; - } - - /** - * [testUnidentified description] - */ - public function testUnidentified(): void { - $this->emulateSession(null); - $this->assertFalse(app::getSession()->identify()); - } - - /** - * [testStart description] - */ - public function testBasicIo(): void { - $this->emulateSession(null); - $this->assertFalse(app::getSession()->identify()); - - app::getSession()->start([ - 'session_data' => [ - 'dummy' => true - ], - 'dummy' => true, - ]); - - $this->assertTrue(app::getSession()->identify()); - $this->assertTrue(app::getSession()->isDefined('dummy')); - $this->assertFalse(app::getSession()->isDefined('nonexisting')); - - $this->assertEquals(true, app::getSession()->getData('dummy')); - - app::getSession()->setData('dummy', 'some-value'); - $this->assertEquals('some-value', app::getSession()->getData('dummy')); - - // TODO: Not supported for every driver right now: - // app::getSession()->unsetData('dummy'); - // $this->assertFalse(app::getSession()->isDefined('dummy')); - - app::getSession()->destroy(); - - $this->assertFalse(app::getSession()->identify()); - } - - /** - * @inheritDoc - */ - public function testEmulatedSessionIo(): void - { - // Emulate a nonexisting session - $this->emulateSession(null); - $this->assertFalse(app::getSession()->identify()); - - // Emulate an existing session afterwards - $this->emulateSession([ - 'identifier' => 'some-random-session', - ]); - - // due to cookie limitations on CLI - // this might throw a WarningException, if not suppressed this way - @app::getSession()->start([ - 'session_data' => [ - 'dummy' => true - ], - 'dummy' => true, - ]); - - $this->assertTrue(app::getSession()->identify()); - // print_r(app::getSession()->getData()); - $this->assertTrue(app::getSession()->isDefined('dummy')); - $this->assertFalse(app::getSession()->isDefined('nonexisting')); - - $this->assertEquals(true, app::getSession()->getData('dummy')); - - app::getSession()->setData('dummy', 'some-value'); - $this->assertEquals('some-value', app::getSession()->getData('dummy')); - - // TODO: Not supported for every driver right now: - // app::getSession()->unsetData('dummy'); - // $this->assertFalse(app::getSession()->isDefined('dummy')); - - // due to cookie limitations on CLI - // this might throw a WarningException, if not suppressed this way - @app::getSession()->destroy(); - - $this->assertFalse(app::getSession()->identify()); - - // Emulate a nonexisting session again - $this->emulateSession(null); - } - - /** - * [testInvalidSessionIdentify description] - */ - public function testInvalidSessionIdentify(): void { - // Emulate an existing session - $this->emulateSession([ - 'identifier' => 'some-valid-session', - 'valid' => true, - ]); - $this->assertTrue(app::getSession()->identify()); - - $this->emulateSession([ - 'identifier' => 'some-invalid-session', - 'valid' => false, - ]); - $this->assertFalse(app::getSession()->identify()); - } - - /** - * [testInvalidSession description] - */ - public function testExpiredSession(): void { - // Emulate an existing session - $this->emulateSession([ - 'identifier' => 'some-expired-session', - 'valid' => true, - 'valid_until' => (new \DateTime('now'))->modify('- 1 day')->format('Y-m-d H:i:s') - ]); - $this->assertFalse(@app::getSession()->identify()); - } - - /** - * [testInvalidateSession description] - */ - public function testInvalidateSession(): void { - // Emulate an existing session - $this->emulateSession([ - 'identifier' => 'some-valid-session', - 'valid' => true, - ]); - - @app::getSession()->start([ - 'session_data' => [ - 'dummy' => true - ], - 'dummy' => true, - ]); - - $this->assertTrue(app::getSession()->identify()); - $this->assertNull(app::getSession()->invalidate('some-valid-session')); - $this->assertFalse(app::getSession()->identify()); - } - - /** - * [testInvalidateInvalidSession description] - */ - public function testInvalidateInvalidSession(): void { - $this->expectException(\Exception::class); - app::getSession()->invalidate(null); - } - +use DateMalformedStringException; +use DateTime; +use ErrorException; +use ReflectionException; +use Throwable; + +abstract class abstractSessionTest extends base +{ + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testUnidentified(): void + { + $this->emulateSession(null); + static::assertFalse(app::getSession()->identify(), var_export(app::getSession()->getData(), true)); + } + + /** + * Emulates a session (or none, if null) + * is key 'valid' is not supplied, we automatically assume + * a valid session to be emulated + * $data might be an array of one or more of these keys: + * (string) identifier (e.g., a cookie value or header) + * (bool) valid (whether the session was recently evaluated as valid) + * (string) valid_until (ISO datetime of expiry) + * @param array|null $data + * @return void + */ + protected function emulateSession(?array $data): void + { + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testBasicIo(): void + { + $this->emulateSession([]); + static::assertFalse(app::getSession()->identify()); + + app::getSession()->start([ + 'session_data' => [ + 'dummy' => true, + ], + 'dummy' => true, + ]); + + static::assertTrue(app::getSession()->identify()); + static::assertTrue(app::getSession()->isDefined('dummy')); + static::assertFalse(app::getSession()->isDefined('nonexisting')); + + static::assertTrue(app::getSession()->getData('dummy')); + + app::getSession()->setData('dummy', 'some-value'); + static::assertEquals('some-value', app::getSession()->getData('dummy')); + + // TODO: Not supported for every driver right now: + // app::getSession()->unsetData('dummy'); + // static::assertFalse(app::getSession()->isDefined('dummy')); + + app::getSession()->destroy(); + + static::assertFalse(app::getSession()->identify()); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testEmulatedSessionIo(): void + { + // Emulate a nonexisting session + $this->emulateSession(null); + static::assertFalse(app::getSession()->identify()); + + // Emulate an existing session afterward + $this->emulateSession([ + 'identifier' => 'some-random-session', + ]); + + // due to cookie limitations on CLI, + // this might throw a warningException, if not suppressed this way + @app::getSession()->start([ + 'session_data' => [ + 'dummy' => true, + ], + 'dummy' => true, + ]); + + static::assertTrue(app::getSession()->identify()); + // print_r(app::getSession()->getData()); + static::assertTrue(app::getSession()->isDefined('dummy')); + static::assertFalse(app::getSession()->isDefined('nonexisting')); + + static::assertTrue(app::getSession()->getData('dummy')); + + app::getSession()->setData('dummy', 'some-value'); + static::assertEquals('some-value', app::getSession()->getData('dummy')); + + // TODO: Not supported for every driver right now: + // app::getSession()->unsetData('dummy'); + // static::assertFalse(app::getSession()->isDefined('dummy')); + + // due to cookie limitations on CLI, + // this might throw a warningException, if not suppressed this way + @app::getSession()->destroy(); + + static::assertFalse(app::getSession()->identify()); + + // Emulate a nonexisting session again + $this->emulateSession(null); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testInvalidSessionIdentify(): void + { + // Emulate an existing session + $this->emulateSession([ + 'identifier' => 'some-valid-session', + 'valid' => true, + ]); + static::assertTrue(app::getSession()->identify()); + + $this->emulateSession([ + 'identifier' => 'some-invalid-session', + 'valid' => false, + ]); + static::assertFalse(app::getSession()->identify()); + } + + /** + * @return void + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception + */ + protected function testExpiredSession(): void + { + // Emulate an existing session + $this->emulateSession([ + 'identifier' => 'some-expired-session', + 'valid' => true, + 'valid_until' => (new DateTime('now'))->modify('- 1 day')->format('Y-m-d H:i:s'), + ]); + static::assertFalse(@app::getSession()->identify()); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testInvalidateSession(): void + { + // Emulate an existing session + $this->emulateSession([ + 'identifier' => 'some-valid-session', + 'valid' => true, + ]); + + @app::getSession()->start([ + 'session_data' => [ + 'dummy' => true, + ], + 'dummy' => true, + ]); + + static::assertTrue(app::getSession()->identify()); + try { + app::getSession()->invalidate('some-valid-session'); + } catch (exception) { + static::fail(); + } + static::assertFalse(app::getSession()->identify()); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + protected function testInvalidateInvalidSession(): void + { + app::getSession()->invalidate(''); + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws ErrorException + * @throws Throwable + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws exception + */ + protected function setUp(): void + { + $app = static::createApp(); + $app::getAppstack(); + + static::setEnvironmentConfig([ + 'test' => array_merge( + [ + 'session' => [ + 'default' => $this->getDefaultSessionConfig(), + ], + ], + $this->getAdditionalEnvironmentConfig() + ), + ]); + } + + /** + * should return a database config for 'default' connection + * @return array + */ + abstract protected function getDefaultSessionConfig(): array; + + /** + * @return array [description] + */ + protected function getAdditionalEnvironmentConfig(): array + { + return []; + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws exception + */ + protected function tearDown(): void + { + $this->emulateSession(null); + app::getSession()->destroy(); + parent::tearDown(); + } } diff --git a/tests/session/cacheTest.php b/tests/session/cacheTest.php index 23c0c3f..5d6a075 100644 --- a/tests/session/cacheTest.php +++ b/tests/session/cacheTest.php @@ -1,84 +1,128 @@ 'cache' - ]; - } +class cacheTest extends abstractSessionTest +{ + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testUnidentified(): void + { + parent::testUnidentified(); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testBasicIo(): void + { + parent::testBasicIo(); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testEmulatedSessionIo(): void + { + parent::testEmulatedSessionIo(); + } + + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testClassInstance(): void + { + static::assertInstanceOf(cache::class, app::getSession()); + } - /** - * @inheritDoc - */ - protected function getAdditionalEnvironmentConfig(): array - { - return [ - 'cache' => [ - 'default' => [ - 'driver' => 'memory', - ] - ] - ]; - } + /** + * {@inheritDoc} + */ + public function testExpiredSession(): void + { + static::markTestSkipped('Session expiry not applicable for this session driver.'); + } - /** - * [testClassInstance description] - */ - public function testClassInstance(): void { - $this->assertInstanceOf(\codename\core\session\cache::class, app::getSession()); - } + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testSessionInvalidateThrowsException(): void + { + $this->expectException(LogicException::class); + app::getSession()->invalidate('whatever'); + } - /** - * @inheritDoc - */ - public function testExpiredSession(): void - { - $this->markTestSkipped('Session expiry not applicable for this session driver.'); - } + /** + * {@inheritDoc} + */ + public function testInvalidSessionIdentify(): void + { + // Session invalidation is not supported in this session driver and will throw an exception + $this->expectException(LogicException::class); + parent::testInvalidateInvalidSession(); + } - /** - * [testSessionInvalidateThrowsException description] - */ - public function testSessionInvalidateThrowsException(): void { - $this->expectException(\LogicException::class); - app::getSession()->invalidate('whatever'); - } + /** + * {@inheritDoc} + */ + public function testInvalidateInvalidSession(): void + { + // Session invalidation is not supported in this session driver and will throw an exception + $this->expectException(LogicException::class); + parent::testInvalidateInvalidSession(); + } - /** - * @inheritDoc - */ - public function testInvalidSessionIdentify(): void - { - $this->markTestSkipped('Session identification works differently on cache driver.'); - } + /** + * {@inheritDoc} + */ + public function testInvalidateSession(): void + { + // Session invalidation is not supported in this session driver and will throw an exception + $this->expectException(LogicException::class); + parent::testInvalidateSession(); + } - /** - * @inheritDoc - */ - public function testInvalidateSession(): void - { - // Session invalidation is not supported in this session driver and will throw an exception - $this->expectException(\LogicException::class); - parent::testInvalidateSession(); - } + /** + * {@inheritDoc} + */ + protected function getDefaultSessionConfig(): array + { + return [ + 'driver' => 'cache', + ]; + } - /** - * @inheritDoc - */ - public function testInvalidateInvalidSession(): void - { - // Session invalidation is not supported in this session driver and will throw an exception - $this->expectException(\LogicException::class); - parent::testInvalidateInvalidSession(); - } + /** + * {@inheritDoc} + */ + protected function getAdditionalEnvironmentConfig(): array + { + return [ + 'cache' => [ + 'default' => [ + 'driver' => 'memory', + ], + ], + ]; + } } diff --git a/tests/session/databaseTest.php b/tests/session/databaseTest.php index 2d0548f..e401068 100644 --- a/tests/session/databaseTest.php +++ b/tests/session/databaseTest.php @@ -1,188 +1,229 @@ 'database' - ]; - } - - /** - * [protected description] - * @var bool - */ - protected static $initialized = false; - - /** - * @inheritDoc - */ - public static function tearDownAfterClass(): void - { - parent::tearDownAfterClass(); - static::$initialized = false; - } - - /** - * @inheritDoc - */ - protected function setUp(): void - { - parent::setUp(); - - // avoid re-init - if(!static::$initialized) { - - static::$initialized = true; - - static::createModel('testschema', 'session', [ - "field" => [ - "session_id", - "session_created", - "session_modified", - "session_valid", - "session_valid_until", - "session_data", - "session_sessionid" - ], - "primary" => [ - "session_id" - ], - "index" => [ - "session_sessionid", - "session_created", - "session_valid", - [ "session_sessionid", "session_valid" ] - ], - "options" => [ - "session_sessionid" => [ - "length" => 128 - ] - ], - "datatype" => [ - "session_id" => "number_natural", - "session_created"=> "text_timestamp", - "session_modified" => "text_timestamp", - "session_valid" => "boolean", - "session_valid_until" => "text_timestamp", - "session_data" => "structure", - "session_sessionid"=> "text", - ], - "connection" => "default" - ]); - - static::architect('sessiontest', 'codename', 'test'); + /** + * @return void + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception + */ + public function testExpiredSession(): void + { + parent::testExpiredSession(); } - $sessionModel = $this->getModel('session'); - $sessionClient = new sessionDatabaseOverridden([], $sessionModel); - overrideableApp::__injectClientInstance('session', 'default', $sessionClient); - } - - /** - * @inheritDoc - */ - protected function getAdditionalEnvironmentConfig(): array - { - return [ - 'cache' => [ - 'default' => [ - 'driver' => 'memory', - ] - ], - 'database' => [ - 'default' => [ - 'driver' => 'sqlite', - 'database_file' => ':memory:', - ] - ], - 'filesystem' =>[ - 'local' => [ - 'driver' => 'local', - ] - ], - ]; - } - - /** - * [testClassInstance description] - */ - public function testClassInstance(): void { - $this->assertInstanceOf(\codename\core\session\database::class, app::getSession()); - } - - /** - * @inheritDoc - */ - public function testBasicIo(): void - { - $this->markTestSkipped('Generic BasicIo test for database-session not applicable due to cookies'); - } - - /** - * @inheritDoc - */ - protected function emulateSession(?array $data): void - { - if($data) { - $cookieValue = $data['identifier']; - $_COOKIE['core-session'] = $cookieValue; - $this->getModel('session')->save([ - 'session_sessionid' => $cookieValue, - 'session_valid' => $data['valid'] ?? true, - 'session_valid_until' => $data['valid_until'] ?? null - ]); - } else { - unset($_COOKIE['core-session']); + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testInvalidateSession(): void + { + parent::testInvalidateSession(); } - } - - /** - * @inheritDoc - */ - public function testInvalidateInvalidSession(): void - { - $this->expectException(\codename\core\exception::class); - $this->expectExceptionMessage('EXCEPTION_SESSION_INVALIDATE_NO_SESSIONID_PROVIDED'); - parent::testInvalidateInvalidSession(); - } + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testClassInstance(): void + { + static::assertInstanceOf(database::class, app::getSession()); + } + + /** + * {@inheritDoc} + */ + public function testBasicIo(): void + { + static::markTestSkipped('Generic BasicIo test for database-session not applicable due to cookies'); + } + + /** + * {@inheritDoc} + */ + public function testInvalidateInvalidSession(): void + { + $this->expectException(exception::class); + $this->expectExceptionMessage('EXCEPTION_SESSION_INVALIDATE_NO_SESSIONID_PROVIDED'); + parent::testInvalidateInvalidSession(); + } + + /** + * {@inheritDoc} + */ + protected function getDefaultSessionConfig(): array + { + return [ + 'driver' => 'database', + ]; + } + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + parent::setUp(); + + // avoid re-init + if (!static::$initialized) { + static::$initialized = true; + + static::createModel('testschema', 'session', [ + "field" => [ + "session_id", + "session_created", + "session_modified", + "session_valid", + "session_valid_until", + "session_data", + "session_sessionid", + ], + "primary" => [ + "session_id", + ], + "index" => [ + "session_sessionid", + "session_created", + "session_valid", + ["session_sessionid", "session_valid"], + ], + "options" => [ + "session_sessionid" => [ + "length" => 128, + ], + ], + "datatype" => [ + "session_id" => "number_natural", + "session_created" => "text_timestamp", + "session_modified" => "text_timestamp", + "session_valid" => "boolean", + "session_valid_until" => "text_timestamp", + "session_data" => "structure", + "session_sessionid" => "text", + ], + "connection" => "default", + ]); + + static::architect('sessiontest', 'codename', 'test'); + } + + $sessionModel = $this->getModel('session'); + $sessionClient = new sessionDatabaseOverridden([], $sessionModel); + overrideableApp::__injectClientInstance('session', 'default', $sessionClient); + } + + /** + * {@inheritDoc} + */ + protected function getAdditionalEnvironmentConfig(): array + { + return [ + 'cache' => [ + 'default' => [ + 'driver' => 'memory', + ], + ], + 'database' => [ + 'default' => [ + 'driver' => 'sqlite', + 'database_file' => ':memory:', + ], + ], + 'filesystem' => [ + 'local' => [ + 'driver' => 'local', + ], + ], + ]; + } + + /** + * {@inheritDoc} + * @param array|null $data + * @throws ReflectionException + * @throws exception + */ + protected function emulateSession(?array $data): void + { + if ($data) { + $cookieValue = $data['identifier']; + $_COOKIE['core-session'] = $cookieValue; + $this->getModel('session')->save([ + 'session_sessionid' => $cookieValue, + 'session_valid' => $data['valid'] ?? true, + 'session_valid_until' => $data['valid_until'] ?? null, + ]); + } else { + unset($_COOKIE['core-session']); + } + } } -class sessionDatabaseOverridden extends \codename\core\session\database { - - /** - * @inheritDoc - */ - public function __construct(array $data, \codename\core\model $sessionModelInstance) - { - // $this->staticSessionModel = $sessionModelInstance; - // parent::__construct($data); - $this->sessionModel = $sessionModelInstance; - } - // /** - // * [protected description] - // * @var \codename\core\model - // */ - // protected $staticSessionModel = null; - // - // /** - // * @inheritDoc - // */ - // protected function internalGetModel(string $model): \codename\core\model - // { - // if($model == 'session') { - // return $this->staticSessionModel; - // } else { - // throw new \Exception('Unsupported'); - // } - // } +class sessionDatabaseOverridden extends database +{ + /** + * {@inheritDoc} + */ + public function __construct(array $data, model $sessionModelInstance) + { + $this->sessionModel = $sessionModelInstance; + } } diff --git a/tests/session/memoryTest.php b/tests/session/memoryTest.php index 97b3b9b..27d9bd9 100644 --- a/tests/session/memoryTest.php +++ b/tests/session/memoryTest.php @@ -1,64 +1,102 @@ 'memory' - ]; - } + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testEmulatedSessionIo(): void + { + parent::testEmulatedSessionIo(); + } - /** - * [testClassInstance description] - */ - public function testClassInstance(): void { - $this->assertInstanceOf(\codename\core\session\memory::class, app::getSession()); - } + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testClassInstance(): void + { + static::assertInstanceOf(memory::class, app::getSession()); + } - /** - * @inheritDoc - */ - public function testExpiredSession(): void - { - $this->markTestSkipped('Session expiry not applicable for this session driver.'); - } + /** + * {@inheritDoc} + */ + public function testExpiredSession(): void + { + static::markTestSkipped('Session expiry not applicable for this session driver.'); + } - /** - * @inheritDoc - */ - public function testInvalidSessionIdentify(): void - { - // - // NOTE: this is a test for testing session validity check - nothing else. - // For this driver, this is unavailable anyways and *must* be overridden to ::markTestSkipped() - // - $this->markTestSkipped('Session invalid check not applicable for this session driver.'); - } + /** + * {@inheritDoc} + */ + public function testInvalidSessionIdentify(): void + { + // + // NOTE: this is a test for testing session validity check - nothing else. + // For this driver, this is unavailable anyway and *must* be overridden to ::markTestSkipped() + // + static::markTestSkipped('Session invalid check not applicable for this session driver.'); + } - /** - * @inheritDoc - */ - public function testInvalidateSession(): void - { - // Session invalidation is not supported in this session driver and will throw an exception - $this->expectException(\LogicException::class); - parent::testInvalidateSession(); - } + /** + * {@inheritDoc} + */ + public function testInvalidateSession(): void + { + // Session invalidation is not supported in this session driver and will throw an exception + $this->expectException(LogicException::class); + parent::testInvalidateSession(); + } - /** - * @inheritDoc - */ - public function testInvalidateInvalidSession(): void - { - // Session invalidation is not supported in this session driver and will throw an exception - $this->expectException(\LogicException::class); - parent::testInvalidateInvalidSession(); - } + /** + * {@inheritDoc} + */ + public function testInvalidateInvalidSession(): void + { + // Session invalidation is not supported in this session driver and will throw an exception + $this->expectException(LogicException::class); + parent::testInvalidateInvalidSession(); + } + /** + * {@inheritDoc} + */ + protected function getDefaultSessionConfig(): array + { + return [ + 'driver' => 'memory', + ]; + } } diff --git a/tests/sqlModel.php b/tests/sqlModel.php index 1799b7d..24cad50 100644 --- a/tests/sqlModel.php +++ b/tests/sqlModel.php @@ -1,9 +1,10 @@ reset()) - \codename\core\tests\overrideableApp::reset(); - } - - /** - * [getValidator description] - * @return \codename\core\validator [description] - */ - protected function getValidator() : \codename\core\validator { - // load the respective validator via namespace, by instanciated class name - // we have to remove __CLASS__ (THIS exact class here) - - // extract validator name from current class name, stripped by validator base namespace - $validatorClass = str_replace(__CLASS__.'\\', '', (new \ReflectionClass($this))->getName()); - - // replace \ by _ - $validatorName = str_replace('\\', '_', $validatorClass); - - $validator = \codename\core\app::getValidator($validatorName); - $validator->reset(); - return $validator; - } - +abstract class validator extends TestCase +{ + /** + * {@inheritDoc} + */ + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + + // WARNING: you _HAVE_ to reset this right here + // as far as you use app::getValidator() somewhere in your tests + // as it may have side effects on other tests + // that rely on a 'fresh' validator (e.g., lifecycle) + // (due to the fact validators keep their latest state until ->reset()) + overrideableApp::reset(); + } + + /** + * [getValidator description] + * @return \codename\core\validator [description] + * @throws ReflectionException + * @throws exception + */ + protected function getValidator(): \codename\core\validator + { + // load the respective validator via namespace, by instanced class name + // we have to remove __CLASS__ (THIS exact class here) + + // extract validator name from current class name, stripped by validator base namespace + $validatorClass = str_replace(__CLASS__ . '\\', '', (new ReflectionClass($this))->getName()); + + // replace \ by _ + $validatorName = str_replace('\\', '_', $validatorClass); + + $validator = app::getValidator($validatorName); + $validator->reset(); + return $validator; + } } diff --git a/tests/validator/boolean.php b/tests/validator/boolean.php index 3110b87..ff46779 100644 --- a/tests/validator/boolean.php +++ b/tests/validator/boolean.php @@ -1,17 +1,24 @@ assertEquals('VALIDATION.VALUE_NOT_BOOLEAN', $this->getValidator()->validate(array())[0]['__CODE'] ); - } - +class boolean extends validator +{ + /** + * simple non-text value test + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueNotABoolean(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_BOOLEAN', $this->getValidator()->validate([])[0]['__CODE']); + } } diff --git a/tests/validator/boolean/number.php b/tests/validator/boolean/number.php index eb4f675..cd494c4 100644 --- a/tests/validator/boolean/number.php +++ b/tests/validator/boolean/number.php @@ -1,30 +1,43 @@ assertEquals('VALIDATION.VALUE_NOT_NUMERIC_BOOLEAN', $this->getValidator()->validate(2)[0]['__CODE'] ); - } - - /** - * @return void - */ - public function testValueIsBooleanNumber() { - $this->assertEquals(0, count($this->getValidator()->validate(1)) ); - } +class number extends boolean +{ + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueIsNotBooleanNumber(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_NUMERIC_BOOLEAN', $this->getValidator()->validate(2)[0]['__CODE']); + } - /** - * @return void - */ - public function testValueIsBoolean() { - $this->assertEquals(0, count($this->getValidator()->validate(true)) ); - } + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueIsBooleanNumber(): void + { + static::assertCount(0, $this->getValidator()->validate(1)); + } + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueIsBoolean(): void + { + static::assertCount(0, $this->getValidator()->validate(true)); + } } diff --git a/tests/validator/file.php b/tests/validator/file.php index bfb2250..6c847f2 100644 --- a/tests/validator/file.php +++ b/tests/validator/file.php @@ -1,24 +1,127 @@ validate(null); + + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.VALUE_IS_NULL', $errors[0]['__CODE']); + } + + /** + * simple file not found test + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testFileNotFound(): void + { + $validator = new validator\file(false); + $errors = $validator->validate(__DIR__ . '/file_not_found.txt'); + + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.FILE_NOT_FOUND', $errors[0]['__CODE']); + } + + /** + * simple forbidden mime type test + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testForbiddenMimeType(): void + { + $validator = new validator\file(false); + $errors = $validator->validate(__DIR__ . '/file.txt'); - /** - * simple non-text value test - * @return void - */ - public function testValueIsNullNotAllowed() { - $validator = new validator\file(false); - $errors = $validator->validate(null); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.FORBIDDEN_MIME_TYPE', $errors[0]['__CODE']); + } - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.VALUE_IS_NULL', $errors[0]['__CODE']); - } + /** + * simple file not found test + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testFileValid(): void + { + $validator = new overrideableFile(false); + $errors = $validator->validate(__DIR__ . '/file.txt'); + + static::assertEmpty($errors); + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws ErrorException + * @throws Throwable + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws exception + */ + protected function setUp(): void + { + $app = static::createApp(); + $app::getAppstack(); + + static::setEnvironmentConfig([ + 'test' => [ + 'filesystem' => [ + 'local' => [ + 'driver' => 'local', + ], + ], + ], + ]); + } + +} +class overrideableFile extends validator\file +{ + protected array $mime_whitelist = [ + 'text/plain', + ]; } diff --git a/tests/validator/file.txt b/tests/validator/file.txt new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/tests/validator/file.txt @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/tests/validator/number.php b/tests/validator/number.php index a6bdb65..5108612 100644 --- a/tests/validator/number.php +++ b/tests/validator/number.php @@ -1,24 +1,31 @@ assertEquals('VALIDATION.VALUE_NOT_A_NUMBER', $this->getValidator()->validate(array())[0]['__CODE'] ); - } - - /* - public function testValueTooSmall() { - $this->assertEquals('VALIDATION.VALUE_TOO_SMALL', $this->getValidator()->validate( insert too small number )[0]['__CODE'] ); - } +class number extends validator +{ + /** + * simple non-text value test + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueNotANumber(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_A_NUMBER', $this->getValidator()->validate([])[0]['__CODE']); + } - */ + /* + public function testValueTooSmall() { + static::assertEquals('VALIDATION.VALUE_TOO_SMALL', $this->getValidator()->validate( insert too small number )[0]['__CODE'] ); + } + */ } diff --git a/tests/validator/number/integer.php b/tests/validator/number/integer.php index 7180cee..6b0e548 100644 --- a/tests/validator/number/integer.php +++ b/tests/validator/number/integer.php @@ -1,23 +1,33 @@ assertEquals('VALIDATION.VALUE_TOO_PRECISE', $this->getValidator()->validate(1.2)[0]['__CODE'] ); - } - - /** - * @return void - */ - public function testValueIsInteger() { - $this->assertEquals(0, count($this->getValidator()->validate(345)) ); - } +class integer extends number +{ + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueTooPrecise(): void + { + static::assertEquals('VALIDATION.VALUE_TOO_PRECISE', $this->getValidator()->validate(1.2)[0]['__CODE']); + } + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueIsInteger(): void + { + static::assertCount(0, $this->getValidator()->validate(345)); + } } diff --git a/tests/validator/number/money.php b/tests/validator/number/money.php index d623fa1..ab4ed97 100644 --- a/tests/validator/number/money.php +++ b/tests/validator/number/money.php @@ -1,23 +1,23 @@ assertEquals('VALIDATION.TOO_MANY_DIGITS_AFTER_COMMA', $this->getValidator()->validate(1.222)[0]['__CODE'] ); - } - - /** - * @return void - */ - public function testValueIsMoney() { - $this->assertEquals(0, count($this->getValidator()->validate(1.23)) ); - } - +class money extends number +{ + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueIsMoney(): void + { + static::assertCount(0, $this->getValidator()->validate(1.23)); + } } diff --git a/tests/validator/number/natural.php b/tests/validator/number/natural.php index 64e9d7d..4a42a4a 100644 --- a/tests/validator/number/natural.php +++ b/tests/validator/number/natural.php @@ -1,9 +1,12 @@ assertEquals('VALIDATION.VALUE_TOO_PRECISE', $this->getValidator()->validate(15.123)[0]['__CODE'] ); - } +class port extends number +{ + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueTooPrecise(): void + { + static::assertEquals('VALIDATION.VALUE_TOO_PRECISE', $this->getValidator()->validate(15.123)[0]['__CODE']); + } - /** - * @return void - */ - public function testValueIsSomePort() { - $this->assertEquals(0, count($this->getValidator()->validate(3306)) ); - } + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueIsSomePort(): void + { + static::assertCount(0, $this->getValidator()->validate(3306)); + } - /** - * @return void - */ - public function testValueTooSmall() { - $this->assertEquals('VALIDATION.VALUE_TOO_SMALL', $this->getValidator()->validate(0)[0]['__CODE'] ); - } + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueTooSmall(): void + { + static::assertEquals('VALIDATION.VALUE_TOO_SMALL', $this->getValidator()->validate(0)[0]['__CODE']); + } - /** - * @return void - */ - public function testValueTooBig() { - $this->assertEquals('VALIDATION.VALUE_TOO_BIG', $this->getValidator()->validate(65536)[0]['__CODE'] ); - } - + /** + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueTooBig(): void + { + static::assertEquals('VALIDATION.VALUE_TOO_BIG', $this->getValidator()->validate(65536)[0]['__CODE']); + } } diff --git a/tests/validator/structure.php b/tests/validator/structure.php index 715450f..a76ecdd 100644 --- a/tests/validator/structure.php +++ b/tests/validator/structure.php @@ -1,49 +1,59 @@ assertEquals('VALIDATION.VALUE_NOT_A_ARRAY', $this->getValidator()->validate('')[0]['__CODE'] ); - } - - /** - * simple non-text value test - * @return void - */ - public function testValueIsNull() { - $this->assertEmpty($this->getValidator()->validate(null)); - } - - /** - * simple non-text value test - * @return void - */ - public function testValueIsNullNotAllowed() { - $validator = new validator\structure(false); - $errors = $validator->validate(null); - - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.VALUE_IS_NULL', $errors[0]['__CODE']); - } - - /** - * simple non-text value test - * @return void - */ - public function testValueIsValid() { - $validator = new validator\structure(); - $this->assertTrue($validator->isValid(null)); - } - +class structure extends \codename\core\tests\validator +{ + /** + * simple non-text value test + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueNotAArray(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_A_ARRAY', $this->getValidator()->validate('')[0]['__CODE']); + } + + /** + * simple non-text value test + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueIsNull(): void + { + static::assertEmpty($this->getValidator()->validate(null)); + } + + /** + * simple non-text value test + * @return void + */ + public function testValueIsNullNotAllowed(): void + { + $validator = new validator\structure(false); + $errors = $validator->validate(null); + + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.VALUE_IS_NULL', $errors[0]['__CODE']); + } + + /** + * simple non-text value test + * @return void + */ + public function testValueIsValid(): void + { + $validator = new validator\structure(); + static::assertTrue($validator->isValid(null)); + } } diff --git a/tests/validator/structure/api/codename/response.php b/tests/validator/structure/api/codename/response.php index 1502c37..919d543 100644 --- a/tests/validator/structure/api/codename/response.php +++ b/tests/validator/structure/api/codename/response.php @@ -1,54 +1,65 @@ getValidator()->validate([]); - $this->assertNotEmpty($errors); - $this->assertCount(2, $errors); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(2, $errors); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidKeySuccess() { + public function testTextInvalidKeySuccess(): void + { $config = [ 'success' => 'A', - 'data' => 'example' + 'data' => 'example', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.INVALID_SUCCESS_IDENTIFIER', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.INVALID_SUCCESS_IDENTIFIER', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { + public function testValueValid(): void + { $config = [ 'success' => 1, - 'data' => 'example' + 'data' => 'example', ]; - $this->assertEmpty($this->getValidator()->validate($config)); + static::assertEmpty($this->getValidator()->validate($config)); } - } diff --git a/tests/validator/structure/api/codename/serviceprovider.php b/tests/validator/structure/api/codename/serviceprovider.php index 283365f..86670a3 100644 --- a/tests/validator/structure/api/codename/serviceprovider.php +++ b/tests/validator/structure/api/codename/serviceprovider.php @@ -1,70 +1,84 @@ getValidator()->validate([]); - $this->assertNotEmpty($errors); - $this->assertCount(2, $errors); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(2, $errors); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidKeyHost() { + public function testTextInvalidKeyHost(): void + { $config = [ - 'host' => '://example.com', - 'port' => '80' + 'host' => '://example.com', + 'port' => '80', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.HOST_INVALID', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.HOST_INVALID', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidKeySuccess() { + public function testTextInvalidKeySuccess(): void + { $config = [ - 'host' => 'http://example.com', - 'port' => 'example' + 'host' => 'https://example.com', + 'port' => 'example', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.PORT_INVALID', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.PORT_INVALID', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { + public function testValueValid(): void + { $config = [ - 'host' => 'http://example.com', - 'port' => '80' + 'host' => 'https://example.com', + 'port' => '80', ]; - $this->assertEmpty($this->getValidator()->validate($config)); + static::assertEmpty($this->getValidator()->validate($config)); } - } diff --git a/tests/validator/structure/appstack.php b/tests/validator/structure/appstack.php index 8e7e99a..2dbfbc2 100644 --- a/tests/validator/structure/appstack.php +++ b/tests/validator/structure/appstack.php @@ -1,30 +1,37 @@ assertEquals('VALIDATION.APPSTACK_EMPTY', $this->getValidator()->validate([])[0]['__CODE'] ); - return; + public function testTextAppstackEmpty(): void + { + static::assertEquals('VALIDATION.APPSTACK_EMPTY', $this->getValidator()->validate([])[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate(['core'])); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate(['core'])); } - } diff --git a/tests/validator/structure/cart.php b/tests/validator/structure/cart.php index 0fe810f..98ad7fa 100644 --- a/tests/validator/structure/cart.php +++ b/tests/validator/structure/cart.php @@ -1,30 +1,37 @@ assertEquals('VALIDATION.INVALID_PRODUCT_FOUND', $this->getValidator()->validate([[]])[0]['__CODE'] ); - return; + public function testTextInvalidProduct(): void + { + static::assertEquals('VALIDATION.INVALID_PRODUCT_FOUND', $this->getValidator()->validate([[]])[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate([])); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate([])); } - } diff --git a/tests/validator/structure/config/app.php b/tests/validator/structure/config/app.php index a37b279..1c7e651 100644 --- a/tests/validator/structure/config/app.php +++ b/tests/validator/structure/config/app.php @@ -1,99 +1,121 @@ getValidator()->validate([]); - $this->assertNotEmpty($errors); - $this->assertCount(3, $errors); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[2]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(3, $errors); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[2]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidContext() { - $config = [ - 'context' => [ - [] - ], - 'defaultcontext' => '*123ABC', - 'defaulttemplate' => 'core', - ]; - $this->assertEquals('VALIDATION.KEY_CONTEXT_INVALID', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValueInvalidContext(): void + { + $config = [ + 'context' => [ + [], + ], + 'defaultcontext' => '*123ABC', + 'defaulttemplate' => 'core', + ]; + static::assertEquals('VALIDATION.KEY_CONTEXT_INVALID', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidDefaultcontext() { - $config = [ - 'context' => [ - ], - 'defaultcontext' => '*123ABC', - 'defaulttemplate' => 'core', - ]; - $this->assertEquals('VALIDATION.KEY_DEFAULTCONTEXT_INVALID', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValueInvalidDefaultcontext(): void + { + $config = [ + 'context' => [ + ], + 'defaultcontext' => '*123ABC', + 'defaulttemplate' => 'core', + ]; + static::assertEquals('VALIDATION.KEY_DEFAULTCONTEXT_INVALID', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidDefaulttemplate() { - $config = [ - 'context' => [ - ], - 'defaultcontext' => 'core', - 'defaulttemplate' => '*123ABC', - ]; - $this->assertEquals('VALIDATION.KEY_DEFAULTTEMPLATE_INVALID', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValueInvalidDefaulttemplate(): void + { + $config = [ + 'context' => [ + ], + 'defaultcontext' => 'core', + 'defaulttemplate' => '*123ABC', + ]; + static::assertEquals('VALIDATION.KEY_DEFAULTTEMPLATE_INVALID', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueContextCustom() { + public function testValueContextCustom(): void + { $config = [ - 'context' => [ + 'context' => [ [ 'custom' => true, - ] + ], ], - 'defaultcontext' => 'core', + 'defaultcontext' => 'core', 'defaulttemplate' => 'core', ]; - $this->assertEmpty($this->getValidator()->validate($config)); + static::assertEmpty($this->getValidator()->validate($config)); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { + public function testValueValid(): void + { $config = [ - 'context' => [ + 'context' => [ ], - 'defaultcontext' => 'core', + 'defaultcontext' => 'core', 'defaulttemplate' => 'core', ]; - $this->assertEmpty($this->getValidator()->validate($config)); + static::assertEmpty($this->getValidator()->validate($config)); } - } diff --git a/tests/validator/structure/config/bucket/ftp.php b/tests/validator/structure/config/bucket/ftp.php index dfa29e8..0793a1d 100644 --- a/tests/validator/structure/config/bucket/ftp.php +++ b/tests/validator/structure/config/bucket/ftp.php @@ -1,95 +1,112 @@ getValidator()->validate([]); - $this->assertNotEmpty($errors); - $this->assertCount(2, $errors); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(2, $errors); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidKeyPublic() { + public function testTextInvalidKeyPublic(): void + { $config = [ - 'public' => 'AAA', - 'basedir' => 'AAA', - 'ftpserver' => 'AAA' + 'public' => 'AAA', + 'basedir' => 'AAA', + 'ftpserver' => 'AAA', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.PUBLIC_KEY_INVALID', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.PUBLIC_KEY_INVALID', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidKeyBaseurl() { + public function testTextInvalidKeyBaseurl(): void + { $config = [ - 'public' => true, - 'basedir' => 'AAA', - 'ftpserver' => 'AAA' + 'public' => true, + 'basedir' => 'AAA', + 'ftpserver' => 'AAA', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.BASEURL_NOT_FOUND', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.BASEURL_NOT_FOUND', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidKeySftpserver() { + public function testTextInvalidKeySftpserver(): void + { $config = [ - 'public' => false, - 'basedir' => 'AAA', - 'ftpserver' => 'AAA' + 'public' => false, + 'basedir' => 'AAA', + 'ftpserver' => 'AAA', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.FTP_CONTAINER_INVALID', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.FTP_CONTAINER_INVALID', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { + public function testValueValid(): void + { $config = [ - 'public' => false, - 'basedir' => 'AAA', + 'public' => false, + 'basedir' => 'AAA', 'ftpserver' => [ - 'host' => 'TODO?', - 'port' => 'TODO?', - 'user' => 'TODO?', - 'pass' => 'TODO?', - ] + 'host' => 'TODO?', + 'port' => 'TODO?', + 'user' => 'TODO?', + 'pass' => 'TODO?', + ], ]; - $this->assertEmpty($this->getValidator()->validate($config)); + static::assertEmpty($this->getValidator()->validate($config)); } - } diff --git a/tests/validator/structure/config/bucket/local.php b/tests/validator/structure/config/bucket/local.php index d9da545..cc752f5 100644 --- a/tests/validator/structure/config/bucket/local.php +++ b/tests/validator/structure/config/bucket/local.php @@ -1,90 +1,107 @@ getValidator()->validate([]); - $this->assertNotEmpty($errors); - $this->assertCount(2, $errors); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(2, $errors); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidKeyPublic() { + public function testTextInvalidKeyPublic(): void + { $config = [ - 'public' => 'AAA', - 'basedir' => 'AAA', + 'public' => 'AAA', + 'basedir' => 'AAA', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.PUBLIC_KEY_NOT_FOUND', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.PUBLIC_KEY_NOT_FOUND', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidKeyBaseurl() { + public function testTextInvalidKeyBaseurl(): void + { $config = [ - 'public' => true, - 'basedir' => 'AAA', + 'public' => true, + 'basedir' => 'AAA', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.BASEURL_NOT_FOUND', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.BASEURL_NOT_FOUND', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextDirectoryNotFound() { - $this->markTestIncomplete('TODO: app::getFilesystem()'); + public function testTextDirectoryNotFound(): void + { + static::markTestIncomplete('TODO: app::getFilesystem()'); $config = [ - 'public' => false, - 'basedir' => 'AAA', + 'public' => false, + 'basedir' => 'AAA', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.DIRECTORY_NOT_FOUND', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.DIRECTORY_NOT_FOUND', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->markTestIncomplete('TODO: app::getFilesystem()'); - + public function testValueValid(): void + { + static::markTestIncomplete('TODO: app::getFilesystem()'); + $config = [ - 'public' => false, - 'basedir' => 'AAA', + 'public' => false, + 'basedir' => 'AAA', ]; - $this->assertEmpty($this->getValidator()->validate($config)); + static::assertEmpty($this->getValidator()->validate($config)); } - } diff --git a/tests/validator/structure/config/bucket/sftp.php b/tests/validator/structure/config/bucket/sftp.php index 623ed71..c181cc1 100644 --- a/tests/validator/structure/config/bucket/sftp.php +++ b/tests/validator/structure/config/bucket/sftp.php @@ -1,94 +1,111 @@ getValidator()->validate([]); - $this->assertNotEmpty($errors); - $this->assertCount(2, $errors); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(2, $errors); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidKeyPublic() { + public function testTextInvalidKeyPublic(): void + { $config = [ - 'public' => 'AAA', - 'basedir' => 'AAA', - 'sftpserver' => 'AAA' + 'public' => 'AAA', + 'basedir' => 'AAA', + 'sftpserver' => 'AAA', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.PUBLIC_KEY_INVALID', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.PUBLIC_KEY_INVALID', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidKeyBaseurl() { + public function testTextInvalidKeyBaseurl(): void + { $config = [ - 'public' => true, - 'basedir' => 'AAA', - 'sftpserver' => 'AAA' + 'public' => true, + 'basedir' => 'AAA', + 'sftpserver' => 'AAA', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.BASEURL_NOT_FOUND', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.BASEURL_NOT_FOUND', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidKeySftpserver() { + public function testTextInvalidKeySftpserver(): void + { $config = [ - 'public' => false, - 'basedir' => 'AAA', - 'sftpserver' => 'AAA' + 'public' => false, + 'basedir' => 'AAA', + 'sftpserver' => 'AAA', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.SFTP_CONTAINER_INVALID', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.SFTP_CONTAINER_INVALID', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { + public function testValueValid(): void + { $config = [ - 'public' => false, - 'basedir' => 'AAA', - 'sftpserver' => [ - 'host' => 'TODO?', - 'port' => 'TODO?', - 'user' => 'TODO?', - ] + 'public' => false, + 'basedir' => 'AAA', + 'sftpserver' => [ + 'host' => 'TODO?', + 'port' => 'TODO?', + 'user' => 'TODO?', + ], ]; - $this->assertEmpty($this->getValidator()->validate($config)); + static::assertEmpty($this->getValidator()->validate($config)); } - } diff --git a/tests/validator/structure/config/crud.php b/tests/validator/structure/config/crud.php index ce62082..0219249 100644 --- a/tests/validator/structure/config/crud.php +++ b/tests/validator/structure/config/crud.php @@ -1,87 +1,104 @@ getValidator()->validate([]); - $this->assertNotEmpty($errors); - $this->assertCount(3, $errors); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[2]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(3, $errors); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[2]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidPaginationLimit() { - $config = [ - 'pagination' => [ - 'limit' => 'AAA' - ], - 'visibleFields' => 'AAA', - 'order' => 'AAA', - ]; - $this->assertEquals('VALIDATION.PAGINATION_CONFIGURATION_INVALID', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValueInvalidPaginationLimit(): void + { + $config = [ + 'pagination' => [ + 'limit' => 'AAA', + ], + 'visibleFields' => 'AAA', + 'order' => 'AAA', + ]; + static::assertEquals('VALIDATION.PAGINATION_CONFIGURATION_INVALID', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValuePaginationLimitTooSmall() { - $config = [ - 'pagination' => [ - 'limit' => -1 - ], - 'visibleFields' => 'AAA', - 'order' => 'AAA', - ]; - $this->assertEquals('VALIDATION.PAGINATION_CONFIGURATION_INVALID', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValuePaginationLimitTooSmall(): void + { + $config = [ + 'pagination' => [ + 'limit' => -1, + ], + 'visibleFields' => 'AAA', + 'order' => 'AAA', + ]; + static::assertEquals('VALIDATION.PAGINATION_CONFIGURATION_INVALID', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValuePaginationLimitTooHigh() { - $config = [ - 'pagination' => [ - 'limit' => 1111111111111111111111111111111111111 - ], - 'visibleFields' => 'AAA', - 'order' => 'AAA', - ]; - $this->assertEquals('VALIDATION.PAGINATION_CONFIGURATION_INVALID', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValuePaginationLimitTooHigh(): void + { + $config = [ + 'pagination' => [ + 'limit' => 1111111111111111111111111111111111111, + ], + 'visibleFields' => 'AAA', + 'order' => 'AAA', + ]; + static::assertEquals('VALIDATION.PAGINATION_CONFIGURATION_INVALID', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { + public function testValueValid(): void + { $config = [ - 'pagination' => [ - 'limit' => 10 + 'pagination' => [ + 'limit' => 10, ], 'visibleFields' => 'AAA', - 'order' => 'AAA', + 'order' => 'AAA', ]; - $this->assertEmpty($this->getValidator()->validate($config)); + static::assertEmpty($this->getValidator()->validate($config)); } - } diff --git a/tests/validator/structure/config/crud/action.php b/tests/validator/structure/config/crud/action.php index 434f34d..5a57ac0 100644 --- a/tests/validator/structure/config/crud/action.php +++ b/tests/validator/structure/config/crud/action.php @@ -1,47 +1,55 @@ getValidator()->validate([]); - $this->assertNotEmpty($errors); - $this->assertCount(5, $errors); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[2]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[3]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[4]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(5, $errors); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[2]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[3]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[4]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { + public function testValueValid(): void + { $config = [ - 'name' => 'AAA', - 'view' => 'AAA', - 'context' => 'AAA', - 'icon' => 'AAA', - 'btnClass' => 'AAA', - 'pagination' => [ - 'limit' => 10 + 'name' => 'AAA', + 'view' => 'AAA', + 'context' => 'AAA', + 'icon' => 'AAA', + 'btnClass' => 'AAA', + 'pagination' => [ + 'limit' => 10, ], ]; - $this->assertEmpty($this->getValidator()->validate($config)); + static::assertEmpty($this->getValidator()->validate($config)); } - } diff --git a/tests/validator/structure/config/crud/pagination.php b/tests/validator/structure/config/crud/pagination.php index 887e2b0..42d6f7a 100644 --- a/tests/validator/structure/config/crud/pagination.php +++ b/tests/validator/structure/config/crud/pagination.php @@ -1,69 +1,86 @@ getValidator()->validate([]); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidLimit() { - $config = [ - 'limit' => 'AAA' - ]; - $this->assertEquals('VALIDATION.INVALID_LIMIT', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValueInvalidLimit(): void + { + $config = [ + 'limit' => 'AAA', + ]; + static::assertEquals('VALIDATION.INVALID_LIMIT', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooSmall() { - $config = [ - 'limit' => -1 - ]; - $this->assertEquals('VALIDATION.LIMIT_TOO_SMALL', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValueTooSmall(): void + { + $config = [ + 'limit' => -1, + ]; + static::assertEquals('VALIDATION.LIMIT_TOO_SMALL', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooHigh() { - $config = [ - 'limit' => 1111111111111111111111111111111111111 - ]; - $this->assertEquals('VALIDATION.LIMIT_TOO_HIGH', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValueTooHigh(): void + { + $config = [ + 'limit' => 1111111111111111111111111111111111111, + ]; + static::assertEquals('VALIDATION.LIMIT_TOO_HIGH', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { + public function testValueValid(): void + { $config = [ - 'limit' => 10, + 'limit' => 10, ]; - $this->assertEmpty($this->getValidator()->validate($config)); + static::assertEmpty($this->getValidator()->validate($config)); } - } diff --git a/tests/validator/structure/config/model.php b/tests/validator/structure/config/model.php index 4f87156..431a600 100644 --- a/tests/validator/structure/config/model.php +++ b/tests/validator/structure/config/model.php @@ -1,141 +1,169 @@ getValidator()->validate([]); - $this->assertNotEmpty($errors); - $this->assertCount(3, $errors); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[2]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(3, $errors); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[2]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueKeyFieldNotAArray() { - $config = [ - 'field' => 'AAA', - 'primary' => [ - ], - 'datatype' => [ - ], - ]; - $this->assertEquals('VALIDATION.KEY_FIELD_NOT_A_ARRAY', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValueKeyFieldNotAArray(): void + { + $config = [ + 'field' => 'AAA', + 'primary' => [ + ], + 'datatype' => [ + ], + ]; + static::assertEquals('VALIDATION.KEY_FIELD_NOT_A_ARRAY', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueKeyPrimaryNotAArray() { - $config = [ - 'field' => [ - ], - 'primary' => 'AAA', - 'datatype' => [ - ], - ]; - $this->assertEquals('VALIDATION.KEY_PRIMARY_NOT_A_ARRAY', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValueKeyPrimaryNotAArray(): void + { + $config = [ + 'field' => [ + ], + 'primary' => 'AAA', + 'datatype' => [ + ], + ]; + static::assertEquals('VALIDATION.KEY_PRIMARY_NOT_A_ARRAY', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueKeyDatatypeNotAArray() { - $config = [ - 'field' => [ - 'AAA' - ], - 'primary' => [ - ], - 'datatype' => 'AAA', - ]; - $this->assertEquals('VALIDATION.KEY_DATATYPE_NOT_A_ARRAY', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValueKeyDatatypeNotAArray(): void + { + $config = [ + 'field' => [ + 'AAA', + ], + 'primary' => [ + ], + 'datatype' => 'AAA', + ]; + static::assertEquals('VALIDATION.KEY_DATATYPE_NOT_A_ARRAY', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidKeyField() { - $config = [ - 'field' => [ - 'A' - ], - 'primary' => [ - ], - 'datatype' => [ - ], - ]; - $this->assertEquals('VALIDATION.KEY_FIELD_INVALID', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValueInvalidKeyField(): void + { + $config = [ + 'field' => [ + 'A', + ], + 'primary' => [ + ], + 'datatype' => [ + ], + ]; + static::assertEquals('VALIDATION.KEY_FIELD_INVALID', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueKeyPrimaryNotInKeyField() { - $config = [ - 'field' => [ - 'AAA' - ], - 'primary' => [ - 'BBB' - ], - 'datatype' => [ - 'AAA' => 'AAA' - ], - ]; - $this->assertEquals('VALIDATION.PRIMARY_KEY_NOT_CONTAINED_IN_FIELD_ARRAY', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValueKeyPrimaryNotInKeyField(): void + { + $config = [ + 'field' => [ + 'AAA', + ], + 'primary' => [ + 'BBB', + ], + 'datatype' => [ + 'AAA' => 'AAA', + ], + ]; + static::assertEquals('VALIDATION.PRIMARY_KEY_NOT_CONTAINED_IN_FIELD_ARRAY', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueMissingDatatypeConfig() { - $config = [ - 'field' => [ - 'AAA' - ], - 'primary' => [ - ], - 'datatype' => [ - 'BBB' => 'AAA' - ], - ]; - $this->assertEquals('VALIDATION.DATATYPE_CONFIG_MISSING', $this->getValidator()->validate($config)[0]['__CODE'] ); + public function testValueMissingDatatypeConfig(): void + { + $config = [ + 'field' => [ + 'AAA', + ], + 'primary' => [ + ], + 'datatype' => [ + 'BBB' => 'AAA', + ], + ]; + static::assertEquals('VALIDATION.DATATYPE_CONFIG_MISSING', $this->getValidator()->validate($config)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { + public function testValueValid(): void + { $config = [ - 'field' => [ + 'field' => [ ], - 'primary' => [ + 'primary' => [ ], 'datatype' => [ ], ]; - $this->assertEmpty($this->getValidator()->validate($config)); + static::assertEmpty($this->getValidator()->validate($config)); } - } diff --git a/tests/validator/structure/mailform.php b/tests/validator/structure/mailform.php index 3455df0..d3c9a74 100644 --- a/tests/validator/structure/mailform.php +++ b/tests/validator/structure/mailform.php @@ -1,149 +1,175 @@ getValidator()->validate([]); - $this->assertNotEmpty($errors); - $this->assertCount(3, $errors); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE'] ); - $this->assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[2]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(3, $errors); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[1]['__CODE']); + static::assertEquals('VALIDATION.ARRAY_MISSING_KEY', $errors[2]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidEmailRecipient() { + public function testTextInvalidEmailRecipient(): void + { $config = [ 'recipient' => '.@example.com', - 'subject' => 'example', - 'body' => 'example' + 'subject' => 'example', + 'body' => 'example', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(2, $errors); - $this->assertEquals('VALIDATION.EMAIL_INVALID', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.INVALID_EMAIL_ADDRESS', $errors[1]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(2, $errors); + static::assertEquals('VALIDATION.EMAIL_INVALID', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.INVALID_EMAIL_ADDRESS', $errors[1]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidEmailCc() { + public function testTextInvalidEmailCc(): void + { $config = [ 'recipient' => 'example@example.com', - 'cc' => '.@example.com', - 'subject' => 'example', - 'body' => 'example' + 'cc' => '.@example.com', + 'subject' => 'example', + 'body' => 'example', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(2, $errors); - $this->assertEquals('VALIDATION.EMAIL_INVALID', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.INVALID_EMAIL_ADDRESS', $errors[1]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(2, $errors); + static::assertEquals('VALIDATION.EMAIL_INVALID', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.INVALID_EMAIL_ADDRESS', $errors[1]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidEmailBcc() { + public function testTextInvalidEmailBcc(): void + { $config = [ 'recipient' => 'example@example.com', - 'bcc' => '.@example.com', - 'subject' => 'example', - 'body' => 'example' + 'bcc' => '.@example.com', + 'subject' => 'example', + 'body' => 'example', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(2, $errors); - $this->assertEquals('VALIDATION.EMAIL_INVALID', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.INVALID_EMAIL_ADDRESS', $errors[1]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(2, $errors); + static::assertEquals('VALIDATION.EMAIL_INVALID', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.INVALID_EMAIL_ADDRESS', $errors[1]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidEmailReplyTo() { + public function testTextInvalidEmailReplyTo(): void + { $config = [ 'recipient' => 'example@example.com', - 'reply-to' => '.@example.com', - 'subject' => 'example', - 'body' => 'example' + 'reply-to' => '.@example.com', + 'subject' => 'example', + 'body' => 'example', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(2, $errors); - $this->assertEquals('VALIDATION.EMAIL_INVALID', $errors[0]['__CODE'] ); - $this->assertEquals('VALIDATION.INVALID_EMAIL_ADDRESS', $errors[1]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(2, $errors); + static::assertEquals('VALIDATION.EMAIL_INVALID', $errors[0]['__CODE']); + static::assertEquals('VALIDATION.INVALID_EMAIL_ADDRESS', $errors[1]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidSubject() { + public function testTextInvalidSubject(): void + { $config = [ 'recipient' => 'example@example.com', - 'subject' => '', - 'body' => 'example' + 'subject' => '', + 'body' => 'example', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.INVALID_EMAIL_SUBJECT', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.INVALID_EMAIL_SUBJECT', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testTextInvalidBody() { + public function testTextInvalidBody(): void + { $config = [ 'recipient' => 'example@example.com', - 'subject' => 'example', - 'body' => '' + 'subject' => 'example', + 'body' => '', ]; $errors = $this->getValidator()->validate($config); - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.INVALID_EMAIL_BODY', $errors[0]['__CODE'] ); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.INVALID_EMAIL_BODY', $errors[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { + public function testValueValid(): void + { $config = [ 'recipient' => 'example@example.com', - 'subject' => 'example', - 'body' => 'example' + 'subject' => 'example', + 'body' => 'example', ]; - $this->assertEmpty($this->getValidator()->validate($config)); + static::assertEmpty($this->getValidator()->validate($config)); } - } diff --git a/tests/validator/structure/text/telephone.php b/tests/validator/structure/text/telephone.php index 1c94e38..4fda127 100644 --- a/tests/validator/structure/text/telephone.php +++ b/tests/validator/structure/text/telephone.php @@ -1,27 +1,37 @@ assertEquals('VALIDATION.INVALID_PHONE_NUMBER', $this->getValidator()->validate([ 'AAA' ])[0]['__CODE'] ); - } + public function testValueInvalidPhoneNumber(): void + { + static::assertEquals('VALIDATION.INVALID_PHONE_NUMBER', $this->getValidator()->validate(['AAA'])[0]['__CODE']); + } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate([])); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate([])); } - } diff --git a/tests/validator/text.php b/tests/validator/text.php index 6a92faa..7a25915 100644 --- a/tests/validator/text.php +++ b/tests/validator/text.php @@ -1,32 +1,38 @@ assertEquals('VALIDATION.VALUE_NOT_A_STRING', $this->getValidator()->validate(array())[0]['__CODE'] ); - } - - /** - * simple non-text value test - * @return void - */ - public function testValueIsNullNotAllowed() { - $validator = new validator\text(false); - $errors = $validator->validate(null); +class text extends \codename\core\tests\validator +{ + /** + * simple non-text value test + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueNotAString(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_A_STRING', $this->getValidator()->validate([])[0]['__CODE']); + } - $this->assertNotEmpty($errors); - $this->assertCount(1, $errors); - $this->assertEquals('VALIDATION.VALUE_IS_NULL', $errors[0]['__CODE']); - } + /** + * simple non-text value test + * @return void + */ + public function testValueIsNullNotAllowed(): void + { + $validator = new validator\text(false); + $errors = $validator->validate(null); + static::assertNotEmpty($errors); + static::assertCount(1, $errors); + static::assertEquals('VALIDATION.VALUE_IS_NULL', $errors[0]['__CODE']); + } } diff --git a/tests/validator/text/apploader.php b/tests/validator/text/apploader.php index 1f8a233..2c5e283 100644 --- a/tests/validator/text/apploader.php +++ b/tests/validator/text/apploader.php @@ -1,58 +1,70 @@ assertEquals('VALIDATION.VALUE_NOT_A_STRING', $this->getValidator()->validate(123)[0]['__CODE'] ); - return; + public function testTextApploaderValueStringMustBeLowercase(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_A_STRING', $this->getValidator()->validate(123)[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueStringTooShort() { - $this->assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('a')[0]['__CODE'] ); - return; + public function testValueStringTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('a')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueStringTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); - return; + public function testValueStringTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueStringContainsInvalidCharacters() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE'] ); - return; + public function testValueStringContainsInvalidCharacters(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('codename\\core')); - return; + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('codename\\core')); } - } diff --git a/tests/validator/text/appname.php b/tests/validator/text/appname.php index ebf4a6d..fd6d212 100644 --- a/tests/validator/text/appname.php +++ b/tests/validator/text/appname.php @@ -1,45 +1,59 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('core')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('core')); } - } diff --git a/tests/validator/text/authhash.php b/tests/validator/text/authhash.php index 1572579..b6602c0 100644 --- a/tests/validator/text/authhash.php +++ b/tests/validator/text/authhash.php @@ -1,45 +1,59 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('qyDdMHWwcDuC2VZwgKuG3RfDTkZcqzB92EPCHkQvKhpBvFa3QCWzKQ724AmfPMgJ4SLApc5fKvMEkFnS3rXGaYbdkP2F2sZbZXn8pqbBPVMARtnVEzgvzRua6de62An$')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('qyDdMHWwcDuC2VZwgKuG3RfDTkZcqzB92EPCHkQvKhpBvFa3QCWzKQ724AmfPMgJ4SLApc5fKvMEkFnS3rXGaYbdkP2F2sZbZXn8pqbBPVMARtnVEzgvzRua6de62An$')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('qyDdMHWwcDuC2VZwgKuG3RfDTkZcqzB92EPCHkQvKhpBvFa3QCWzKQ724AmfPMgJ4SLApc5fKvMEkFnS3rXGaYbdkP2F2sZbZXn8pqbBPVMARtnVEzgvzRua6de62Anz')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('qyDdMHWwcDuC2VZwgKuG3RfDTkZcqzB92EPCHkQvKhpBvFa3QCWzKQ724AmfPMgJ4SLApc5fKvMEkFnS3rXGaYbdkP2F2sZbZXn8pqbBPVMARtnVEzgvzRua6de62Anz')); } - } diff --git a/tests/validator/text/bic.php b/tests/validator/text/bic.php index c1e31e1..c64f424 100644 --- a/tests/validator/text/bic.php +++ b/tests/validator/text/bic.php @@ -1,53 +1,70 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*AASDASDASD')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*AASDASDASD')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidScheme() { - $this->assertEquals('VALIDATION.VALUE_NOT_A_BIC', $this->getValidator()->validate('12345678901')[0]['__CODE'] ); + public function testValueInvalidScheme(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_A_BIC', $this->getValidator()->validate('12345678901')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('GENODEF1BEB')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('GENODEF1BEB')); } - } diff --git a/tests/validator/text/bucketname.php b/tests/validator/text/bucketname.php index de27652..4596d67 100644 --- a/tests/validator/text/bucketname.php +++ b/tests/validator/text/bucketname.php @@ -1,45 +1,59 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('core')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('core')); } - } diff --git a/tests/validator/text/color/hex.php b/tests/validator/text/color/hex.php index 1cb3e9b..7e42663 100644 --- a/tests/validator/text/color/hex.php +++ b/tests/validator/text/color/hex.php @@ -1,53 +1,70 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidHexString() { - $this->assertEquals('VALIDATION.VALUE_NOT_HEX_STRING', $this->getValidator()->validate('ABCDEFF')[0]['__CODE'] ); + public function testValueInvalidHexString(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_HEX_STRING', $this->getValidator()->validate('ABCDEFF')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('#AABBCC')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('#AABBCC')); } - } diff --git a/tests/validator/text/color/rgb.php b/tests/validator/text/color/rgb.php index a8a1d2a..635edf8 100644 --- a/tests/validator/text/color/rgb.php +++ b/tests/validator/text/color/rgb.php @@ -1,53 +1,70 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('1')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('1')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD123456')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD123456')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidRgbString() { - $this->assertEquals('VALIDATION.VALUE_NOT_RGB_STRING', $this->getValidator()->validate('rgb(rgb, 255, 0)')[0]['__CODE'] ); + public function testValueInvalidRgbString(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_RGB_STRING', $this->getValidator()->validate('rgb(rgb, 255, 0)')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('rgb(223, 255, 0)')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('rgb(223, 255, 0)')); } - } diff --git a/tests/validator/text/color/rgba.php b/tests/validator/text/color/rgba.php index b5d0a0d..8cfb278 100644 --- a/tests/validator/text/color/rgba.php +++ b/tests/validator/text/color/rgba.php @@ -1,53 +1,70 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('1')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('1')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD123456')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD123456')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidRgbString() { - $this->assertEquals('VALIDATION.VALUE_NOT_RGBA_STRING', $this->getValidator()->validate('rgba(rgba,100,100,0.9)')[0]['__CODE'] ); + public function testValueInvalidRgbString(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_RGBA_STRING', $this->getValidator()->validate('rgba(rgba,100,100,0.9)')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('rgba(100,100,100,0.9)')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('rgba(100,100,100,0.9)')); } - } diff --git a/tests/validator/text/colorhexadecimal.php b/tests/validator/text/colorhexadecimal.php index 2b246c6..983ba0a 100644 --- a/tests/validator/text/colorhexadecimal.php +++ b/tests/validator/text/colorhexadecimal.php @@ -1,45 +1,59 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*AAAAAA')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*AAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('#FF0000')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('#FF0000')); } - } diff --git a/tests/validator/text/contextname.php b/tests/validator/text/contextname.php index 09ff4be..2675c25 100644 --- a/tests/validator/text/contextname.php +++ b/tests/validator/text/contextname.php @@ -1,45 +1,59 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('core')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('core')); } - } diff --git a/tests/validator/text/date.php b/tests/validator/text/date.php index 82afee5..f609ff3 100644 --- a/tests/validator/text/date.php +++ b/tests/validator/text/date.php @@ -1,69 +1,92 @@ assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('111111111111111111111111111')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('111111111111111111111111111')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooShort() { - $this->assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('11')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('11')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.INVALID_COUNT_AREAS', $this->getValidator()->validate('1-1-1-1-1-')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.INVALID_COUNT_AREAS', $this->getValidator()->validate('1-1-1-1-1-')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidYear() { - $this->assertEquals('VALIDATION.INVALID_YEAR', $this->getValidator()->validate('19922-1-11')[0]['__CODE'] ); + public function testValueInvalidYear(): void + { + static::assertEquals('VALIDATION.INVALID_YEAR', $this->getValidator()->validate('19922-1-11')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidMonth() { - $this->assertEquals('VALIDATION.INVALID_MONTH', $this->getValidator()->validate('1992-222-1')[0]['__CODE'] ); + public function testValueInvalidMonth(): void + { + static::assertEquals('VALIDATION.INVALID_MONTH', $this->getValidator()->validate('1992-222-1')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidDate() { - $this->assertEquals('VALIDATION.INVALID_DATE', $this->getValidator()->validate('1991-02-31')[0]['__CODE'] ); + public function testValueInvalidDate(): void + { + static::assertEquals('VALIDATION.INVALID_DATE', $this->getValidator()->validate('1991-02-31')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('1991-04-13')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('1991-04-13')); } - } diff --git a/tests/validator/text/datetime/relative.php b/tests/validator/text/datetime/relative.php index c870e5a..876425e 100644 --- a/tests/validator/text/datetime/relative.php +++ b/tests/validator/text/datetime/relative.php @@ -1,29 +1,37 @@ assertEquals('VALIDATION.INVALID_RELATIVE_DATETIME', $this->getValidator()->validate('won')[0]['__CODE'] ); + public function testValueInvalid(): void + { + static::assertEquals('VALIDATION.INVALID_RELATIVE_DATETIME', $this->getValidator()->validate('won')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('now')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('now')); } - } diff --git a/tests/validator/text/domain.php b/tests/validator/text/domain.php index b29df22..48405ea 100644 --- a/tests/validator/text/domain.php +++ b/tests/validator/text/domain.php @@ -1,67 +1,86 @@ assertEquals('VALIDATION.NO_PERIOD_FOUND', $this->getValidator()->validate('blaah')[0]['__CODE'] ); + public function testValueHasNoDots(): void + { + static::assertEquals('VALIDATION.NO_PERIOD_FOUND', $this->getValidator()->validate('blaah')[0]['__CODE']); } /** * [testInvalidDomain description] - * @return [type] [description] + * @return void [type] [description] + * @throws ReflectionException + * @throws exception */ - public function testValueIsUrl() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('some-domain.com/blarp')[0]['__CODE'] ); + public function testValueIsUrl(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('some-domain.com/blarp')[0]['__CODE']); } /** * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { + public function testValueTooLong(): void + { // We're creating a 250+4 char string // breaking the default ASCII 253-char limit - // this should be done correctly as we can only have 63 chars in a "label" e.g. <63chars>.<63chars>.com - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate( str_repeat('k', 250).'.com' )[0]['__CODE'] ); + // this should be done correctly as we can only have 63 chars in a "label" e.g., <63chars>.<63chars>.com + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate(str_repeat('k', 250) . '.com')[0]['__CODE']); } /** * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooShort() { - $this->assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('a.x')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('a.x')[0]['__CODE']); } /** * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testDomainResolves() { + public function testDomainResolves(): void + { // @see: https://en.wikipedia.org/wiki/.invalid // @see: https://tools.ietf.org/html/rfc2606 - $this->assertEquals('VALIDATION.DOMAIN_NOT_RESOLVED', $this->getValidator()->validate('domain.invalid')[0]['__CODE'] ); + static::assertEquals('VALIDATION.DOMAIN_NOT_RESOLVED', $this->getValidator()->validate('domain.invalid')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('example.com')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('example.com')); } - - } diff --git a/tests/validator/text/dummy.php b/tests/validator/text/dummy.php index adfd367..9b5b1b7 100644 --- a/tests/validator/text/dummy.php +++ b/tests/validator/text/dummy.php @@ -1,21 +1,26 @@ assertEmpty($this->getValidator()->validate('core')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('core')); } - } diff --git a/tests/validator/text/email.php b/tests/validator/text/email.php index 815f0ff..6606cce 100644 --- a/tests/validator/text/email.php +++ b/tests/validator/text/email.php @@ -1,113 +1,143 @@ assertEquals('VALIDATION.VALUE_NOT_A_STRING', $this->getValidator()->validate(array())[0]['__CODE'] ); + public function testValueNotString(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_A_STRING', $this->getValidator()->validate([])[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueAtNotFound() { - $this->assertEquals('VALIDATION.EMAIL_AT_NOT_FOUND', $this->getValidator()->validate('invalid')[0]['__CODE'] ); + public function testValueAtNotFound(): void + { + static::assertEquals('VALIDATION.EMAIL_AT_NOT_FOUND', $this->getValidator()->validate('invalid')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueDomainInvalid() { + public function testValueDomainInvalid(): void + { $validationResult = $this->getValidator()->validate('invalid@'); - $this->assertEquals( + static::assertTrue( in_array( $validationResult[0]['__CODE'], [ 'VALIDATION.EMAIL_DOMAIN_INVALID', 'VALIDATION.EMAIL_INVALID', ] - ), - true + ) ); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueAtNotUnique() { - $validationResult = $this->getValidator()->validate('invalid@sadas@as'); - $this->assertEquals( - in_array( - $validationResult[0]['__CODE'], - [ - 'VALIDATION.EMAIL_AT_NOT_UNIQUE', - 'VALIDATION.EMAIL_INVALID', - ] - ), - true - ); + public function testValueAtNotUnique(): void + { + $validationResult = $this->getValidator()->validate('invalid@sadas@as'); + static::assertTrue( + in_array( + $validationResult[0]['__CODE'], + [ + 'VALIDATION.EMAIL_AT_NOT_UNIQUE', + 'VALIDATION.EMAIL_INVALID', + ] + ) + ); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueDomainBlocked() { - $this->assertEquals('VALIDATION.EMAIL_DOMAIN_BLOCKED', $this->getValidator()->validate('invalid@whyspam.me')[0]['__CODE'] ); + public function testValueDomainBlocked(): void + { + static::assertEquals('VALIDATION.EMAIL_DOMAIN_BLOCKED', $this->getValidator()->validate('invalid@whyspam.me')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueEmptyString() { - $this->assertEmpty($this->getValidator()->validate('')); + public function testValueEmptyString(): void + { + static::assertEmpty($this->getValidator()->validate('')); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalid() { - $this->assertEquals('VALIDATION.EMAIL_INVALID', $this->getValidator()->validate('.@example.com')[0]['__CODE'] ); + public function testValueInvalid(): void + { + static::assertEquals('VALIDATION.EMAIL_INVALID', $this->getValidator()->validate('.@example.com')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('mymail@example.com')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('mymail@example.com')); } - } diff --git a/tests/validator/text/endpoint.php b/tests/validator/text/endpoint.php index 8bce9ba..467f7f9 100644 --- a/tests/validator/text/endpoint.php +++ b/tests/validator/text/endpoint.php @@ -1,61 +1,81 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueNotFoundProtocol() { - $this->assertEquals('VALIDATION.NO_PROTOCOL_FOUND', $this->getValidator()->validate('example.com')[0]['__CODE'] ); + public function testValueNotFoundProtocol(): void + { + static::assertEquals('VALIDATION.NO_PROTOCOL_FOUND', $this->getValidator()->validate('example.com')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidProtocol() { - $this->assertEquals('VALIDATION.PROTOCOL_NOT_ALLOWED', $this->getValidator()->validate('error://example.com')[0]['__CODE'] ); + public function testValueInvalidProtocol(): void + { + static::assertEquals('VALIDATION.PROTOCOL_NOT_ALLOWED', $this->getValidator()->validate('error://example.com')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('http://example.com')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('https://example.com')); } - } diff --git a/tests/validator/text/fax.php b/tests/validator/text/fax.php index bda6619..7b9d6ac 100644 --- a/tests/validator/text/fax.php +++ b/tests/validator/text/fax.php @@ -1,37 +1,48 @@ assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('+346759823475982347659234759234865923487562394875692384756923487659238476598237652398756329876')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('+346759823475982347659234759234865923487562394875692384756923487659238476598237652398756329876')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('459"§!§345934')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('459"§!§345934')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('+496622918818')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('+496622918818')); } - } diff --git a/tests/validator/text/filename.php b/tests/validator/text/filename.php index 7e915ea..dd5fef7 100644 --- a/tests/validator/text/filename.php +++ b/tests/validator/text/filename.php @@ -1,45 +1,59 @@ assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('fzagdsbfkqwegsrbiqkuwhgrd3nq4wu5rbd3iqzw4uergxinaesudkrfgixskdfgxqiwi7eurz2x0oqurzq2o83i4ezy10qturz3woeiurgqwakrfjagwesorijawesfiljkd')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('fzagdsbfkqwegsrbiqkuwhgrd3nq4wu5rbd3iqzw4uergxinaesudkrfgixskdfgxqiwi7eurz2x0oqurzq2o83i4ezy10qturz3woeiurgqwakrfjagwesorijawesfiljkd')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooShort() { - $this->assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('a.a')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('a.a')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('/tmp/test.file')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('/tmp/test.file')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('test.pdf')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('test.pdf')); } - } diff --git a/tests/validator/text/filepath/absolute.php b/tests/validator/text/filepath/absolute.php index 5128fef..0f47ecf 100644 --- a/tests/validator/text/filepath/absolute.php +++ b/tests/validator/text/filepath/absolute.php @@ -1,37 +1,48 @@ assertEquals('VALIDATION.MUST_BEGIN_WITH_SLASH', $this->getValidator()->validate('example/example')[0]['__CODE'] ); + public function testValueNotSetBeginSlash(): void + { + static::assertEquals('VALIDATION.MUST_BEGIN_WITH_SLASH', $this->getValidator()->validate('example/example')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueSetEndSlash() { - $this->assertEquals('VALIDATION.MUST_NOT_END_WITH_SLASH', $this->getValidator()->validate('/example/example/')[0]['__CODE'] ); + public function testValueSetEndSlash(): void + { + static::assertEquals('VALIDATION.MUST_NOT_END_WITH_SLASH', $this->getValidator()->validate('/example/example/')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('/example/example')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('/example/example')); } - } diff --git a/tests/validator/text/filepath/relative.php b/tests/validator/text/filepath/relative.php index 0070871..0ce6b0e 100644 --- a/tests/validator/text/filepath/relative.php +++ b/tests/validator/text/filepath/relative.php @@ -1,37 +1,48 @@ assertEquals('VALIDATION.MUST_NOT_BEGIN_WITH_SLASH', $this->getValidator()->validate('/example/example')[0]['__CODE'] ); + public function testValueSetBeginSlash(): void + { + static::assertEquals('VALIDATION.MUST_NOT_BEGIN_WITH_SLASH', $this->getValidator()->validate('/example/example')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueSetEndSlash() { - $this->assertEquals('VALIDATION.MUST_NOT_END_WITH_SLASH', $this->getValidator()->validate('example/example/')[0]['__CODE'] ); + public function testValueSetEndSlash(): void + { + static::assertEquals('VALIDATION.MUST_NOT_END_WITH_SLASH', $this->getValidator()->validate('example/example/')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('example/example')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('example/example')); } - } diff --git a/tests/validator/text/hostname.php b/tests/validator/text/hostname.php index 7ef1b20..2e7b022 100644 --- a/tests/validator/text/hostname.php +++ b/tests/validator/text/hostname.php @@ -1,45 +1,59 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('example.com')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('example.com')); } - } diff --git a/tests/validator/text/iban.php b/tests/validator/text/iban.php index 443a13e..a3730c1 100644 --- a/tests/validator/text/iban.php +++ b/tests/validator/text/iban.php @@ -1,53 +1,70 @@ assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('DE7953290000001042200.')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('DE7953290000001042200.')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueCountryNotFound() { - $this->assertEquals('VALIDATION.IBAN_COUNTRY_NOT_FOUND', $this->getValidator()->validate('XH13127953290000001042200')[0]['__CODE'] ); + public function testValueCountryNotFound(): void + { + static::assertEquals('VALIDATION.IBAN_COUNTRY_NOT_FOUND', $this->getValidator()->validate('XH13127953290000001042200')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueLengthMismatch() { - $this->assertEquals('VALIDATION.IBAN_LENGH_NOT_MATCHING_COUNTRY', $this->getValidator()->validate('DE795329000000104220001')[0]['__CODE'] ); + public function testValueLengthMismatch(): void + { + static::assertEquals('VALIDATION.IBAN_LENGTH_NOT_MATCHING_COUNTRY', $this->getValidator()->validate('DE795329000000104220001')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueChecksumMismatch() { - $this->assertEquals('VALIDATION.IBAN_CHECKSUM_FAILED', $this->getValidator()->validate('DE42532900000010422000')[0]['__CODE'] ); + public function testValueChecksumMismatch(): void + { + static::assertEquals('VALIDATION.IBAN_CHECKSUM_FAILED', $this->getValidator()->validate('DE42532900000010422000')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('DE79532900000010422000')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('DE79532900000010422000')); } - } diff --git a/tests/validator/text/ipv4.php b/tests/validator/text/ipv4.php index 4242bbd..ebbaa0e 100644 --- a/tests/validator/text/ipv4.php +++ b/tests/validator/text/ipv4.php @@ -1,53 +1,70 @@ assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('346759823475982347659234759234865923487562394875692384756923487659238476598237652398756329876')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('346759823475982347659234759234865923487562394875692384756923487659238476598237652398756329876')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooShort() { - $this->assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('534')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('534')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('459"§!§345934')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('459"§!§345934')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidIp() { - $this->assertEquals('VALIDATION.VALUE_NOT_AN_IP', $this->getValidator()->validate('256.321.212.999')[0]['__CODE'] ); + public function testValueInvalidIp(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_AN_IP', $this->getValidator()->validate('256.321.212.999')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('192.168.100.12')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('192.168.100.12')); } - } diff --git a/tests/validator/text/json.php b/tests/validator/text/json.php index e14a08d..ada25c2 100644 --- a/tests/validator/text/json.php +++ b/tests/validator/text/json.php @@ -1,37 +1,48 @@ assertEmpty($this->getValidator()->validate('') ); + public function testValueEmptyString(): void + { + static::assertEmpty($this->getValidator()->validate('')); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidJson() { - $this->assertEquals('VALIDATION.JSON_INVALID', $this->getValidator()->validate('AAAAA')[0]['__CODE'] ); + public function testValueInvalidJson(): void + { + static::assertEquals('VALIDATION.JSON_INVALID', $this->getValidator()->validate('AAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('{"AAAAA":"AAAAA"}')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('{"AAAAA":"AAAAA"}')); } - } diff --git a/tests/validator/text/mac.php b/tests/validator/text/mac.php index 833fd2d..e9c9e4b 100644 --- a/tests/validator/text/mac.php +++ b/tests/validator/text/mac.php @@ -1,53 +1,70 @@ assertEquals('VALIDATION.VALUE_NOT_A_STRING', $this->getValidator()->validate(array())[0]['__CODE'] ); + public function testValueNotString(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_A_STRING', $this->getValidator()->validate([])[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*0123456789ABCDEF')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*0123456789ABCDEF')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalid() { - $this->assertEquals('VALIDATION.VALUE_NOT_A_MACADDRESS', $this->getValidator()->validate('FFFFFFFFFFFFFFFFF')[0]['__CODE'] ); + public function testValueInvalid(): void + { + static::assertEquals('VALIDATION.VALUE_NOT_A_MACADDRESS', $this->getValidator()->validate('FFFFFFFFFFFFFFFFF')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('00:00:00:00:00:00')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('00:00:00:00:00:00')); } - } diff --git a/tests/validator/text/mobile.php b/tests/validator/text/mobile.php index 4e8788e..f926600 100644 --- a/tests/validator/text/mobile.php +++ b/tests/validator/text/mobile.php @@ -1,37 +1,48 @@ assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('+346759823475982347659234759234865923487562394875692384756923487659238476598237652398756329876')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('+346759823475982347659234759234865923487562394875692384756923487659238476598237652398756329876')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('459"§!§345934')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('459"§!§345934')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('+496622918818')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('+496622918818')); } - } diff --git a/tests/validator/text/modelfield/relaxed.php b/tests/validator/text/modelfield/relaxed.php index a32c440..2b2d44f 100644 --- a/tests/validator/text/modelfield/relaxed.php +++ b/tests/validator/text/modelfield/relaxed.php @@ -1,45 +1,59 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('core')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('core')); } - } diff --git a/tests/validator/text/modelfield/virtual.php b/tests/validator/text/modelfield/virtual.php index 426e94f..5045773 100644 --- a/tests/validator/text/modelfield/virtual.php +++ b/tests/validator/text/modelfield/virtual.php @@ -1,45 +1,59 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('core')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('core')); } - } diff --git a/tests/validator/text/telephone.php b/tests/validator/text/telephone.php index 6b8c9d0..7c2446a 100644 --- a/tests/validator/text/telephone.php +++ b/tests/validator/text/telephone.php @@ -1,37 +1,48 @@ assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('+346759823475982347659234759234865923487562394875692384756923487659238476598237652398756329876')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('+346759823475982347659234759234865923487562394875692384756923487659238476598237652398756329876')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('459"§!§345934')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('459"§!§345934')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('+496622918818')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('+496622918818')); } - } diff --git a/tests/validator/text/templatename.php b/tests/validator/text/templatename.php index a2f6782..5922260 100644 --- a/tests/validator/text/templatename.php +++ b/tests/validator/text/templatename.php @@ -1,45 +1,59 @@ assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE'] ); + public function testValueTooShort(): void + { + static::assertEquals('VALIDATION.STRING_TOO_SHORT', $this->getValidator()->validate('A')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueTooLong() { - $this->assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE'] ); + public function testValueTooLong(): void + { + static::assertEquals('VALIDATION.STRING_TOO_LONG', $this->getValidator()->validate('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueInvalidchars() { - $this->assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE'] ); + public function testValueInvalidchars(): void + { + static::assertEquals('VALIDATION.STRING_CONTAINS_INVALID_CHARACTERS', $this->getValidator()->validate('*ASDASD')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('core')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('core')); } - } diff --git a/tests/validator/text/time.php b/tests/validator/text/time.php index da1e472..a41fd73 100644 --- a/tests/validator/text/time.php +++ b/tests/validator/text/time.php @@ -1,53 +1,70 @@ assertEquals('VALIDATION.VALUE_INVALID_TIME_STRING', $this->getValidator()->validate('123')[0]['__CODE'] ); + public function testValueInvalideString(): void + { + static::assertEquals('VALIDATION.VALUE_INVALID_TIME_STRING', $this->getValidator()->validate('123')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueIsHoursInvalid() { - $this->assertEquals('VALIDATION.VALUE_INVALID_TIME_HOURS', $this->getValidator()->validate('25:10:10')[0]['__CODE'] ); + public function testValueIsHoursInvalid(): void + { + static::assertEquals('VALIDATION.VALUE_INVALID_TIME_HOURS', $this->getValidator()->validate('25:10:10')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueIsMinutesInvalid() { - $this->assertEquals('VALIDATION.VALUE_INVALID_TIME_MINUTES', $this->getValidator()->validate('10:61:01')[0]['__CODE'] ); + public function testValueIsMinutesInvalid(): void + { + static::assertEquals('VALIDATION.VALUE_INVALID_TIME_MINUTES', $this->getValidator()->validate('10:61:01')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueIsSecondsInvalid() { - $this->assertEquals('VALIDATION.VALUE_INVALID_TIME_SECONDS', $this->getValidator()->validate('10:10:61')[0]['__CODE'] ); + public function testValueIsSecondsInvalid(): void + { + static::assertEquals('VALIDATION.VALUE_INVALID_TIME_SECONDS', $this->getValidator()->validate('10:10:61')[0]['__CODE']); } /** - * Testing validators for Erors + * Testing validators for Errors * @return void + * @throws ReflectionException + * @throws exception */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('01:02:03')); + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('01:02:03')); } - } diff --git a/tests/validator/text/timestamp/weekday.php b/tests/validator/text/timestamp/weekday.php index 1c1dd78..81fd523 100644 --- a/tests/validator/text/timestamp/weekday.php +++ b/tests/validator/text/timestamp/weekday.php @@ -1,53 +1,65 @@ getValidator(false)->validate('2021-04-17')[0]['__CODE']); } - return new \codename\core\validator\text\timestamp\weekday(false, $weekdays); - } - - /** - * Testing validators for Erors - * @return void - */ - public function testValueAllowedWeekdaysNotSet() { - $this->assertEquals('VALIDATION.ALLOWED_WEEKDAYS_NOT_SET', $this->getValidator(false)->validate('2021-04-17')[0]['__CODE'] ); - } - /** - * Testing validators for Erors - * @return void - */ - public function testValueNotAllowed() { - $this->assertEquals('VALIDATION.WEEKDAY_NOT_ALLOWED', $this->getValidator()->validate('2021-04-17')[0]['__CODE'] ); - } + /** + * {@inheritDoc} + */ + public function getValidator($allWeekdays = true): validator + { + $weekdays = []; + if ($allWeekdays) { + $weekdays[] = validator\text\timestamp\weekday::MONDAY; + $weekdays[] = validator\text\timestamp\weekday::TUESDAY; + $weekdays[] = validator\text\timestamp\weekday::WEDNESDAY; + $weekdays[] = validator\text\timestamp\weekday::THURSDAY; + $weekdays[] = validator\text\timestamp\weekday::FRIDAY; + } + return new validator\text\timestamp\weekday(false, $weekdays); + } - /** - * Testing validators for Erors - * @return void - */ - public function testValueValid() { - $this->assertEmpty($this->getValidator()->validate('2021-04-13')); - } + /** + * Testing validators for Errors + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueNotAllowed(): void + { + static::assertEquals('VALIDATION.WEEKDAY_NOT_ALLOWED', $this->getValidator()->validate('2021-04-17')[0]['__CODE']); + } + /** + * Testing validators for Errors + * @return void + * @throws ReflectionException + * @throws exception + */ + public function testValueValid(): void + { + static::assertEmpty($this->getValidator()->validate('2021-04-13')); + } } diff --git a/tests/validator/text/timezone.php b/tests/validator/text/timezone.php index 1cb7695..d05bdaf 100644 --- a/tests/validator/text/timezone.php +++ b/tests/validator/text/timezone.php @@ -1,60 +1,69 @@ getValidator(); // // NOTE: Different PHP versions may differ regarding their available timezones // - foreach($identifiers as $idx => $id) { - $validator->reset(); - $this->assertEmpty($res = $validator->validate($id), $id . print_r($res, true)); + foreach ($identifiers as $id) { + $validator->reset(); + static::assertEmpty($res = $validator->validate($id), $id . print_r($res, true)); } } /** * Testing an invalid timezone identifier on a foreign planet * @return void + * @throws ReflectionException + * @throws exception */ - public function testInvalidTimezoneIdentifier() { - $this->assertEquals('VALIDATION.INVALID_TIMEZONE', $this->getValidator()->validate('Mars/Phobos')[0]['__CODE'] ); + public function testInvalidTimezoneIdentifier(): void + { + static::assertEquals('VALIDATION.INVALID_TIMEZONE', $this->getValidator()->validate('Mars/Phobos')[0]['__CODE']); } - // /** - // * Testing an invalid time offset (+25 hrs) - // * @return void - // */ - // public function testInvalidTimezoneOffset() { - // $this->assertEquals('VALIDATION.INVALID_TIMEZONE', $this->getValidator()->validate('+2500')[0]['__CODE']); - // } - /** * Testing a valid timezone offset * @return void + * @throws ReflectionException + * @throws exception */ - public function testValidTimezoneOffset() { - $this->assertEmpty($this->getValidator()->validate('+0200')); + public function testValidTimezoneOffset(): void + { + static::assertEmpty($this->getValidator()->validate('+0200')); } /** * Testing a valid timezone offset * @return void + * @throws ReflectionException + * @throws exception */ - public function testValidTimezoneIdentifier() { - $this->assertEmpty($this->getValidator()->validate('Europe/Berlin')); + public function testValidTimezoneIdentifier(): void + { + static::assertEmpty($this->getValidator()->validate('Europe/Berlin')); } - } diff --git a/tests/virtualFieldsTest.php b/tests/virtualFieldsTest.php index 13a3a83..b372391 100644 --- a/tests/virtualFieldsTest.php +++ b/tests/virtualFieldsTest.php @@ -1,242 +1,291 @@ createApp(); - $app->getAppstack(); - - static::setEnvironmentConfig([ - 'test' => [ - 'database' => [ - 'default' => [ - 'driver' => 'sqlite', - 'database_file' => ':memory:', - ] - ], - 'cache' => [ - 'default' => [ - 'driver' => 'memory' - ] - ], - 'filesystem' =>[ - 'local' => [ - 'driver' => 'local', - ] - ], - 'log' => [ - 'default' => [ - 'driver' => 'system', - 'data' => [ - 'name' => 'dummy' - ] - ] - ], - ] - ]); - - static::createModel('schema1', 'model1', [ - 'field' => [ - 'model1_id', - 'model1_created', - 'model1_modified', - 'model1_value', - ], - 'primary' => [ - 'model1_id' - ], - 'datatype' => [ - 'model1_id' => 'number_natural', - 'model1_created' => 'text_timestamp', - 'model1_modified' => 'text_timestamp', - 'model1_value' => 'text', - ], - 'connection' => 'default' - ]); - - // - // A secondary model with a reference to model1 - // including a virtual field that may display/represent a model1-dataset - // - static::createModel('schema2', 'model2', [ - 'field' => [ - 'model2_id', - 'model2_created', - 'model2_modified', - 'model2_value', - 'model2_model1_id', - 'model2_model1', - 'model2_model3_items' - ], - 'primary' => [ - 'model2_id' - ], - 'children' => [ - 'model2_model1' => [ - 'type' => 'foreign', - 'field' => 'model2_model1_id' - ], - 'model2_model3_items' => [ - 'type' => 'collection', - ] - ], - 'collection' => [ - 'model2_model3_items' => [ - 'schema' => 'schema3', - 'model' => 'model3', - 'key' => 'model3_model2_id' - ] - ], - 'foreign' => [ - 'model2_model1_id' => [ - 'schema' => 'schema1', - 'model' => 'model1', - 'key' => 'model1_id' - ] - ], - 'datatype' => [ - 'model2_id' => 'number_natural', - 'model2_created' => 'text_timestamp', - 'model2_modified' => 'text_timestamp', - 'model2_value' => 'text', - 'model2_model1_id' => 'number_natural', - 'model2_model1' => 'virtual', - 'model2_model3_items' => 'virtual', - ], - 'connection' => 'default' - ]); - // - // A secondary model with a reference to model1 - // including a virtual field that may display/represent a model1-dataset - // - static::createModel('schema3', 'model3', [ - 'field' => [ - 'model3_id', - 'model3_created', - 'model3_modified', - 'model3_value', - 'model3_model2_id' - ], - 'primary' => [ - 'model3_id' - ], - 'foreign' => [ - 'model3_model2_id' => [ - 'schema' => 'schema2', - 'model' => 'model2', - 'key' => 'model2_id' - ] - ], - 'datatype' => [ - 'model3_id' => 'number_natural', - 'model3_created' => 'text_timestamp', - 'model3_modified' => 'text_timestamp', - 'model3_value' => 'text', - 'model3_model2_id' => 'number_natural', - ], - 'connection' => 'default' - ]); - - static::architect('vfieldstest', 'codename', 'test'); - } - - /** - * Tests saving virtual field data with enabled models - * @return void - */ - public function testVirtualFieldSaving() { - - $model2 = $this->getModel('model2')->setVirtualFieldResult(true) - ->addModel($model1 = $this->getModel('model1')->setVirtualFieldResult(true)) - ; - - $model2->saveWithChildren([ - 'model2_value' => 'm2', - 'model2_model1' => [ - 'model1_value' => 'm1' - ] - ]); - - // Assert we have a lastInsertId generated in *both* models - $this->assertGreaterThan(0, $model1->lastInsertId()); - $this->assertGreaterThan(0, $model2->lastInsertId()); - } - - /** - * Tests saving virtual field data with enabled models - * and checks result output, including hidden fields - * @return void - */ - public function testVirtualFieldWithRedactedFields() { - $model2 = $this->getModel('model2')->setVirtualFieldResult(true) - ->addModel($model1 = $this->getModel('model1')->setVirtualFieldResult(true)) - ; - - $model2->saveWithChildren([ - 'model2_value' => 'abc', - 'model2_model1' => [ - 'model1_value' => 'def' - ] - ]); - $id = $model2->lastInsertId(); - - // just allow one field for the child model - $model1->hideAllFields()->addField('model1_value'); - - $res = $model2 - ->addFilter($model2->getPrimarykey(), $id) - ->search()->getResult(); - - // Make sure we have the right count being returned - // and the data is the same as in the beginning - // NOTE: we have reduced field output here! - $this->assertCount(1, $res); - $this->assertEquals([ 'model1_value' => 'def' ], $res[0]['model2_model1']); - } - - /** - * Tests saving virtual field data with enabled models - * and checks result output, including hidden fields - * @return void - */ - public function testVirtualFieldWithCollections() { - $model2 = $this->getModel('model2')->setVirtualFieldResult(true) - ->addCollectionModel($this->getModel('model3'), 'model2_model3_items') - ->addModel($model1 = $this->getModel('model1')->setVirtualFieldResult(true)) - ; - - $model2->saveWithChildren([ - 'model2_value' => 'xyz', - 'model2_model3_items' => [ - [ 'model3_value' => 'first' ], - [ 'model3_value' => 'second' ], - [ 'model3_value' => 'third' ], - ], - 'model2_model1' => [ - 'model1_value' => 'vwu' - ] - ]); - $id = $model2->lastInsertId(); - - $res = $model2 - ->addFilter($model2->getPrimarykey(), $id) - ->search()->getResult(); - - // Make sure we have the right count being returned - // and the data is the same as in the beginning - // NOTE: we have reduced field output here! - $this->assertCount(1, $res); - - $this->assertEquals([ 'first', 'second', 'third'], array_column($res[0]['model2_model3_items'], 'model3_value')); - // $this->assertEquals([ 'model1_value' => 'def' ], $res[0]['model2_model1']); - } +class virtualFieldsTest extends base +{ + /** + * Tests saving virtual field data with enabled models + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function testVirtualFieldSaving(): void + { + $model2 = $this->getModel('model2')->setVirtualFieldResult(true) + ->addModel($model1 = $this->getModel('model1')->setVirtualFieldResult(true)); + + if (!($model2 instanceof sql) && !($model2 instanceof abstractDynamicValueModel)) { + static::fail('model2 fail'); + } + + $model2->saveWithChildren([ + 'model2_value' => 'm2', + 'model2_model1' => [ + 'model1_value' => 'm1', + ], + ]); + + // Assert we have a lastInsertId generated in *both* models + static::assertGreaterThan(0, $model1->lastInsertId()); + static::assertGreaterThan(0, $model2->lastInsertId()); + } + + /** + * Tests saving virtual field data with enabled models + * and checks result output, including hidden fields + * @return void + * @throws DateMalformedStringException + * @throws ReflectionException + * @throws exception + */ + public function testVirtualFieldWithRedactedFields(): void + { + $model2 = $this->getModel('model2')->setVirtualFieldResult(true) + ->addModel($model1 = $this->getModel('model1')->setVirtualFieldResult(true)); + + if (!($model2 instanceof sql) && !($model2 instanceof abstractDynamicValueModel)) { + static::fail('model2 fail'); + } + + $model2->saveWithChildren([ + 'model2_value' => 'abc', + 'model2_model1' => [ + 'model1_value' => 'def', + ], + ]); + $id = $model2->lastInsertId(); + + // allow one field for the child model + $model1->hideAllFields()->addField('model1_value'); + + $res = $model2 + ->addFilter($model2->getPrimaryKey(), $id) + ->search()->getResult(); + + // Make sure we have the right count being returned + // and the data is the same as in the beginning + // NOTE: we have reduced field output here! + static::assertCount(1, $res); + static::assertEquals(['model1_value' => 'def'], $res[0]['model2_model1']); + } + + /** + * Tests saving virtual field data with enabled models + * and checks result output, including hidden fields + * @return void + * @throws ReflectionException + * @throws DateMalformedStringException + * @throws exception + */ + public function testVirtualFieldWithCollections(): void + { + $model2 = $this->getModel('model2')->setVirtualFieldResult(true) + ->addCollectionModel($this->getModel('model3'), 'model2_model3_items') + ->addModel($this->getModel('model1')->setVirtualFieldResult(true)); + + if (!($model2 instanceof sql) && !($model2 instanceof abstractDynamicValueModel)) { + static::fail('model2 fail'); + } + + $model2->saveWithChildren([ + 'model2_value' => 'xyz', + 'model2_model3_items' => [ + ['model3_value' => 'first'], + ['model3_value' => 'second'], + ['model3_value' => 'third'], + ], + 'model2_model1' => [ + 'model1_value' => 'vwu', + ], + ]); + $id = $model2->lastInsertId(); + + $res = $model2 + ->addFilter($model2->getPrimaryKey(), $id) + ->search()->getResult(); + + // Make sure we have the right count being returned + // and the data is the same as in the beginning + // NOTE: we have reduced field output here! + static::assertCount(1, $res); + + static::assertEquals(['first', 'second', 'third'], array_column($res[0]['model2_model3_items'], 'model3_value')); + } + + /** + * {@inheritDoc} + * @throws ReflectionException + * @throws ErrorException + * @throws Throwable + * @throws compileErrorException + * @throws coreErrorException + * @throws coreWarningException + * @throws parseException + * @throws recoverableErrorException + * @throws strictException + * @throws userErrorException + * @throws userWarningException + * @throws warningException + * @throws exception + */ + protected function setUp(): void + { + $app = static::createApp(); + $app::getAppstack(); + + static::setEnvironmentConfig([ + 'test' => [ + 'database' => [ + 'default' => [ + 'driver' => 'sqlite', + 'database_file' => ':memory:', + ], + ], + 'cache' => [ + 'default' => [ + 'driver' => 'memory', + ], + ], + 'filesystem' => [ + 'local' => [ + 'driver' => 'local', + ], + ], + 'log' => [ + 'default' => [ + 'driver' => 'system', + 'data' => [ + 'name' => 'dummy', + ], + ], + ], + ], + ]); + + static::createModel('schema1', 'model1', [ + 'field' => [ + 'model1_id', + 'model1_created', + 'model1_modified', + 'model1_value', + ], + 'primary' => [ + 'model1_id', + ], + 'datatype' => [ + 'model1_id' => 'number_natural', + 'model1_created' => 'text_timestamp', + 'model1_modified' => 'text_timestamp', + 'model1_value' => 'text', + ], + 'connection' => 'default', + ]); + + // + // A secondary model with a reference to model1 + // including a virtual field that may display/represent a model1-dataset + // + static::createModel('schema2', 'model2', [ + 'field' => [ + 'model2_id', + 'model2_created', + 'model2_modified', + 'model2_value', + 'model2_model1_id', + 'model2_model1', + 'model2_model3_items', + ], + 'primary' => [ + 'model2_id', + ], + 'children' => [ + 'model2_model1' => [ + 'type' => 'foreign', + 'field' => 'model2_model1_id', + ], + 'model2_model3_items' => [ + 'type' => 'collection', + ], + ], + 'collection' => [ + 'model2_model3_items' => [ + 'schema' => 'schema3', + 'model' => 'model3', + 'key' => 'model3_model2_id', + ], + ], + 'foreign' => [ + 'model2_model1_id' => [ + 'schema' => 'schema1', + 'model' => 'model1', + 'key' => 'model1_id', + ], + ], + 'datatype' => [ + 'model2_id' => 'number_natural', + 'model2_created' => 'text_timestamp', + 'model2_modified' => 'text_timestamp', + 'model2_value' => 'text', + 'model2_model1_id' => 'number_natural', + 'model2_model1' => 'virtual', + 'model2_model3_items' => 'virtual', + ], + 'connection' => 'default', + ]); + // + // A secondary model with a reference to model1 + // including a virtual field that may display/represent a model1-dataset + // + static::createModel('schema3', 'model3', [ + 'field' => [ + 'model3_id', + 'model3_created', + 'model3_modified', + 'model3_value', + 'model3_model2_id', + ], + 'primary' => [ + 'model3_id', + ], + 'foreign' => [ + 'model3_model2_id' => [ + 'schema' => 'schema2', + 'model' => 'model2', + 'key' => 'model2_id', + ], + ], + 'datatype' => [ + 'model3_id' => 'number_natural', + 'model3_created' => 'text_timestamp', + 'model3_modified' => 'text_timestamp', + 'model3_value' => 'text', + 'model3_model2_id' => 'number_natural', + ], + 'connection' => 'default', + ]); + static::architect('vfieldstest', 'codename', 'test'); + } } diff --git a/unittest.md b/unittest.md index 135a0b7..96d4dbb 100644 --- a/unittest.md +++ b/unittest.md @@ -1,28 +1,37 @@ - # install composer packages, NOTE: --no-deps to not wake the bees. + # NOTE: add auth.json with private repository credentials, if required. -docker-compose -f docker-compose.unittest.yml run --no-deps unittest-php73 composer update + +docker-compose -f docker-compose.unittest.yml run --no-deps unittest-php82 composer update # alternative, locally + composer update --ignore-platform-reqs -# full run, with coverage -docker-compose -f docker-compose.unittest.yml up unittest-php73 +# full run with coverage + +docker-compose -f docker-compose.unittest.yml up unittest-php82 + +# single run w/ deps, e.g., other containers -# single run w/ deps, e.g. other containers -docker-compose -f docker-compose.unittest.yml run unittest-php73 vendor/bin/phpunit --no-coverage +docker-compose -f docker-compose.unittest.yml run unittest-php82 vendor/bin/phpunit --no-coverage -# single run w/ deps, e.g. other containers + process isolation -docker-compose -f docker-compose.unittest.yml run unittest-php73 vendor/bin/phpunit --no-coverage --process-isolation +# single run w/ deps, e.g., other containers + process isolation -# single run w/o deps, e.g. other containers -docker-compose -f docker-compose.unittest.yml run --no-deps unittest-php73 vendor/bin/phpunit --no-coverage +docker-compose -f docker-compose.unittest.yml run unittest-php82 vendor/bin/phpunit --no-coverage --process-isolation + +# single run w/o deps, e.g., other containers + +docker-compose -f docker-compose.unittest.yml run --no-deps unittest-php82 vendor/bin/phpunit --no-coverage # interactive -docker-compose -f docker-compose.unittest.yml run --no-deps unittest-php73 vendor/bin/phpunit --no-coverage -# stop environment afterwards - especially if something went wrong during tests. +docker-compose -f docker-compose.unittest.yml run --no-deps unittest-php82 vendor/bin/phpunit --no-coverage + +# stop the environment afterward—especially if something went wrong during tests. + docker-compose -f docker-compose.unittest.yml stop # destroy env + docker-compose -f docker-compose.unittest.yml down