├── LICENSE ├── README.md ├── composer.json └── src ├── Commands ├── PostsDelete.php ├── PostsList.php └── PostsShow.php ├── Database └── Connection.php ├── Entities └── Post.php ├── Exceptions └── ReaderException.php ├── Helpers └── wordpress_helper.php ├── Language └── en │ └── WordPress.php ├── Libraries └── Reader.php ├── Models ├── BaseModel.php └── PostModel.php ├── Structures └── MetaHandler.php └── Test ├── Database └── Migrations │ └── 2020-11-07-023559_CreateWordPressTables.php └── Fakers └── PostFaker.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tatter Software 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tatter\WordPress 2 | WordPress content management for CodeIgniter 4 3 | 4 | [![](https://github.com/tattersoftware/codeigniter4-wordpress/workflows/PHPUnit/badge.svg)](https://github.com/tattersoftware/codeigniter4-wordpress/actions?query=workflow%3A%22PHPUnit) 5 | [![](https://github.com/tattersoftware/codeigniter4-wordpress/workflows/PHPStan/badge.svg)](https://github.com/tattersoftware/codeigniter4-wordpress/actions?query=workflow%3A%22PHPStan) 6 | 7 | ## Quick Start 8 | 9 | 1. Install with Composer: `> composer require tatter/wordpress` 10 | 2. Add a new database connection: 11 | ``` 12 | public $wordpress = [ 13 | 'DBDriver' => 'Tatter\WordPress\Database', 14 | 'WPConfig' => '/path/to/wp-config.php', 15 | ]; 16 | ``` 17 | 18 | ## Description 19 | 20 | **Tatter\WordPress** provides a way for you to connect your CodeIgniter 4 instance to an 21 | existing WordPress installation. 22 | 23 | ## Usage 24 | 25 | This library comes with the `Reader` class, a parser designed to read configuration values 26 | from WordPress' **wp-config.php** file. By extracting database information and installation 27 | path, `Tatter\WordPress` can connect to the same database and modify information using the 28 | supplied models. 29 | 30 | ## Database 31 | 32 | In order to use the database you need to define a new database group that uses the 33 | connection details provided by `Reader`. Add a property to **app/Config/Database.php** 34 | with the driver and the path to your **wp-config.php** file, like this: 35 | 36 | ``` 37 | class Database extends BaseConfig 38 | { 39 | public $wordpress = [ 40 | 'DBDriver' => 'Tatter\WordPress\Database', 41 | 'WPConfig' => '/path/to/wp-config.php', 42 | ]; 43 | ``` 44 | 45 | ## Models and Entities 46 | 47 | This library defines Models and Entities that correspond to WordPress's database tables. 48 | You may use them like ordinary CodeIgniter 4 Models, but pay attention to WordPress's 49 | [particular database structure](https://codex.wordpress.org/Database_Description). "Meta" 50 | tables are handled via a special Entity extension `MetaHandler`, which allows read/write 51 | access to individual meta rows as class properties: 52 | ``` 53 | // Get a particular Post 54 | $post = model('Tatter\WordPress\Models\PostModel')->find($postId); 55 | 56 | // Access post metadata 57 | echo $post->meta->_wp_page_template; // 'default' 58 | 59 | // Update post metadata 60 | $post->meta->_wp_page_template = 'mobile'; 61 | ``` 62 | 63 | ## Commands 64 | 65 | There are a few commands to make it easier to interact with your configuration - these are 66 | also a great way to make sure your WordPress database is set up correctly. 67 | 68 | * `posts:list` - Lists all Posts in a table format 69 | * `posts:show [postId]` - Displays details for a single Post 70 | * `posts:delete [postId]...` - Deletes one or more Posts by their ID 71 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tatter/wordpress", 3 | "description": "WordPress content management for CodeIgniter 4", 4 | "keywords": [ 5 | "codeigniter", 6 | "codeigniter4", 7 | "wordpress", 8 | "content", 9 | "management" 10 | ], 11 | "homepage": "https://github.com/tattersoftware/codeigniter4-wordpress", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Matthew Gatner", 16 | "email": "mgatner@tattersoftware.com", 17 | "homepage": "https://tattersoftware.com", 18 | "role": "Developer" 19 | } 20 | ], 21 | "repositories": [ 22 | { 23 | "type": "vcs", 24 | "url": "https://github.com/codeigniter4/CodeIgniter4" 25 | } 26 | ], 27 | "minimum-stability": "dev", 28 | "prefer-stable": true, 29 | "require": { 30 | "php" : ">=7.2" 31 | }, 32 | "require-dev": { 33 | "codeigniter4/codeigniter4": "dev-develop", 34 | "fakerphp/faker": "^1.10", 35 | "phpunit/phpunit": "^8.5", 36 | "phpstan/phpstan": "^0.12", 37 | "squizlabs/php_codesniffer": "^3.5", 38 | "codeigniter4/codeigniter4-standard": "^1.0", 39 | "wp-cli/wp-cli-bundle": "^2.4" 40 | }, 41 | "autoload": { 42 | "psr-4": { 43 | "Tatter\\WordPress\\": "src" 44 | } 45 | }, 46 | "autoload-dev": { 47 | "psr-4": { 48 | "Tests\\Support\\": "tests/_support" 49 | } 50 | }, 51 | "scripts": { 52 | "analyze": "phpstan analyze", 53 | "style": "phpcs --standard=./vendor/codeigniter4/codeigniter4-standard/CodeIgniter4 src/ tests/", 54 | "test": "phpunit" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Commands/PostsDelete.php: -------------------------------------------------------------------------------- 1 | 'The ID of the Post(s) to delete', 16 | ]; 17 | 18 | public function run(array $params) 19 | { 20 | // Make sure there is at least one ID 21 | $postId = array_shift($params); 22 | if (! is_numeric($postId)) 23 | { 24 | $this->call('posts:list'); 25 | CLI::write(lang('WordPress.commandMissingId'), 'red'); 26 | CLI::write('Usage: php spark ' . $this->usage); 27 | return; 28 | } 29 | 30 | do 31 | { 32 | // Make sure the Post exists 33 | if (! $post = model(PostModel::class)->find($postId)) 34 | { 35 | CLI::write(lang('WordPress.commandMissingPost', [$postId]), 'yellow'); 36 | } 37 | else 38 | { 39 | model(PostModel::class)->delete($postId); 40 | CLI::write(lang('WordPress.postDeleted', [$post->post_title]), 'green'); 41 | } 42 | } while ($postId = array_shift($params)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Commands/PostsList.php: -------------------------------------------------------------------------------- 1 | first()) 17 | { 18 | CLI::write('There are no posts.', 'yellow'); 19 | return; 20 | } 21 | 22 | $thead = ['ID', 'Date', 'Title', 'Status', 'Type', 'Size']; 23 | $rows = []; 24 | 25 | foreach (model(PostModel::class)->orderBy('post_date', 'asc')->find() as $post) 26 | { 27 | $rows[] = [ 28 | $post->ID, 29 | $post->post_date->format('m/d/Y'), 30 | $post->post_title, 31 | $post->post_status, 32 | $post->post_type, 33 | '' 34 | ]; 35 | } 36 | 37 | CLI::table($rows, $thead); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Commands/PostsShow.php: -------------------------------------------------------------------------------- 1 | 'The ID of the Post to display', 16 | ]; 17 | 18 | protected static function displayValue(string $name, $data, int $indent = 0) 19 | { 20 | $tabs = ''; 21 | for ($i = $indent; $i > 0; $i--) 22 | { 23 | $tabs .= "\t"; 24 | } 25 | 26 | if (! is_array($data)) 27 | { 28 | CLI::write($tabs . CLI::color($name, 'white', null, 'underline') . ': ' . $data); 29 | return; 30 | } 31 | 32 | CLI::write($tabs . CLI::color($name, 'white', null, 'underline')); 33 | foreach ($data as $key => $value) 34 | { 35 | self::displayValue($key, $value, $indent + 1); 36 | } 37 | } 38 | 39 | public function run(array $params) 40 | { 41 | // Make sure there is an ID 42 | $postId = array_shift($params); 43 | if (! is_numeric($postId)) 44 | { 45 | $this->call('posts:list'); 46 | CLI::write(lang('WordPress.commandMissingId'), 'red'); 47 | CLI::write('Usage: php spark ' . $this->usage); 48 | return; 49 | } 50 | 51 | // Make sure there is a Post 52 | if (! $post = model(PostModel::class)->find($postId)) 53 | { 54 | $this->call('posts:list'); 55 | CLI::write(lang('WordPress.commandMissingPost', [$postId]), 'red'); 56 | return; 57 | } 58 | helper('text'); 59 | 60 | CLI::write('*** POST DETAILS ***', 'light_cyan'); 61 | foreach ($post->toArray() as $key => $value) 62 | { 63 | if ($key === 'post_content') 64 | { 65 | continue; 66 | } 67 | 68 | $this->displayValue($key, $value); 69 | } 70 | CLI::write(''); 71 | 72 | CLI::write('*** POST META ***', 'light_cyan'); 73 | // Use getRows() to get all the keys 74 | foreach ($post->meta->getRows() as $meta) 75 | { 76 | $key = $meta['meta_key']; 77 | self::displayValue($key, $post->meta->$key); 78 | } 79 | CLI::write(''); 80 | 81 | CLI::write('*** POST EXCERPT ***', 'light_cyan'); 82 | $content = word_limiter(strip_tags($post->post_content)); 83 | CLI::write(CLI::wrap($content, 60)); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Database/Connection.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | protected static $readerKeys = [ 14 | 'DB_NAME' => 'database', 15 | 'DB_USER' => 'username', 16 | 'DB_PASSWORD' => 'password', 17 | 'DB_HOST' => 'hostname', 18 | 'DB_CHARSET' => 'charset', 19 | 'DB_COLLATE' => 'DBCollat', 20 | 'table_prefix' => 'DBPrefix', 21 | ]; 22 | 23 | /** 24 | * Reader for the specific wp-config used 25 | * 26 | * @var Reader 27 | */ 28 | public $reader; 29 | 30 | /** 31 | * Parses and stores WordPress connection settings. 32 | * 33 | * @param array $params 34 | * @throws DatabaseException 35 | */ 36 | public function __construct(array $params) 37 | { 38 | if (empty($params['WPConfig'])) 39 | { 40 | throw new DatabaseException('Missing WPConfig parameter for Tatter\WordPress database!'); 41 | } 42 | 43 | // Use the Reader to extract from wp-config 44 | $this->reader = new Reader($params['WPConfig']); 45 | foreach (self::$readerKeys as $wp => $ci) 46 | { 47 | // Do not overwrite passed values 48 | if (isset($params[$ci])) 49 | { 50 | continue; 51 | } 52 | 53 | $params[$ci] = $this->reader->$wp; 54 | } 55 | 56 | // Create the class aliases 57 | self::aliasClasses(); 58 | 59 | parent::__construct($params); 60 | } 61 | 62 | /** 63 | * Aliases the companion classes to the MySQLi driver. 64 | */ 65 | private static function aliasClasses() 66 | { 67 | foreach (['Builder', 'Forge', 'PreparedQuery', 'Result', 'Utils'] as $name) 68 | { 69 | $original = 'CodeIgniter\Database\MySQLi\\' . $name; 70 | $alias = 'Tatter\WordPress\Database\\' . $name; 71 | if (! class_exists($alias)) 72 | { 73 | class_alias($original, $alias); 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Entities/Post.php: -------------------------------------------------------------------------------- 1 | 'int', 17 | 'post_parent' => 'int', 18 | 'menu_order' => 'int', 19 | 'comment_count' => 'int', 20 | ]; 21 | 22 | /** 23 | * Default attributes. 24 | * 25 | * @var array 26 | */ 27 | protected $attributes = [ 28 | 'post_author' => 1, 29 | 'post_content' => '', 30 | 'post_excerpt' => '', 31 | 'post_status' => 'inherit', 32 | 'comment_status' => 'open', 33 | 'ping_status' => 'closed', 34 | 'post_parent' => 0, 35 | 'menu_order' => 0, 36 | 'comment_count' => 0, 37 | ]; 38 | 39 | /** 40 | * Handler for postmeta. 41 | * 42 | * @var MetaHandler|null 43 | */ 44 | protected $meta; 45 | 46 | /** 47 | * Returns the MetaHandler. Uses the database connection 48 | * from PostModel to be sure the group matches. 49 | * 50 | * @return MetaHandler 51 | */ 52 | public function getMeta(): MetaHandler 53 | { 54 | // If a MetaHandler is not set then initialize one 55 | if (is_null($this->meta)) 56 | { 57 | $this->meta = new MetaHandler( 58 | model(PostModel::class)->db->table('postmeta'), 59 | ['post_id' => $this->attributes['ID']] 60 | ); 61 | } 62 | 63 | return $this->meta; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Exceptions/ReaderException.php: -------------------------------------------------------------------------------- 1 | 'Failed to read the WordPress config file: {0}', 5 | 'readerDirectoryFail' => 'Failed to locate ABSPATH in the WordPress config file: {0}', 6 | 'commandMissingId' => 'You must supply a Post ID.', 7 | 'commandMissingPost' => 'No Post found with ID {0}.', 8 | 'postDeleted' => 'Deleted Post "{0}".', 9 | ]; 10 | -------------------------------------------------------------------------------- /src/Libraries/Reader.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | protected $attributes = []; 25 | 26 | /** 27 | * Verifies the config path, loads it into a File, and 28 | * parses out the values. 29 | * 30 | * @param string $path 31 | * @throws ReaderException 32 | */ 33 | public function __construct(string $path) 34 | { 35 | // Catch file exceptions to re-throw as ReaderException 36 | try 37 | { 38 | $this->file = new File($path, true); 39 | } 40 | catch (FileNotFoundException $e) 41 | { 42 | throw new ReaderException($e->getMessage(), $e->getCode(), $e); 43 | } 44 | 45 | $this->parse(); 46 | 47 | // Make sure a minimum number of properties were detected as a sanity check 48 | if (count($this->attributes) < 8) 49 | { 50 | throw ReaderException::forParseFail($path); 51 | } 52 | } 53 | 54 | /** 55 | * Parses database values from the file. 56 | * 57 | * @return $this 58 | */ 59 | protected function parse(): self 60 | { 61 | $lines = file((string) $this->file); 62 | 63 | // Match lines like: define( 'DB_NAME', 'database_name_here' ); 64 | $matched = preg_grep("/define\(/", $lines); 65 | 66 | // Explode each line and extract values 67 | foreach ($matched as $line) 68 | { 69 | $array = explode("'", $line); 70 | if (count($array) === 5) 71 | { 72 | $this->attributes[$array[1]] = $array[3]; 73 | } 74 | } 75 | 76 | // Grab the table prefix as well 77 | if ($matched = preg_grep("/^\$table_prefix/", $lines)) 78 | { 79 | $array = explode("'", $lines[0]); 80 | if (count($array) === 3) 81 | { 82 | $this->attributes['table_prefix'] = $array[1]; 83 | } 84 | } 85 | 86 | // If no table prefix was detected then use the default 87 | if (! isset($this->attributes['table_prefix'])) 88 | { 89 | $this->attributes['table_prefix'] = 'wp_'; 90 | } 91 | 92 | return $this; 93 | } 94 | 95 | //-------------------------------------------------------------------- 96 | 97 | /** 98 | * Returns this File instance for the 99 | * wp-config.php used. 100 | * 101 | * @return File 102 | */ 103 | public function getFile(): File 104 | { 105 | return $this->file; 106 | } 107 | 108 | /** 109 | * Deteremines the WordPress installation 110 | * directory using ABSPATH. 111 | * 112 | * @return string 113 | * @throws ReaderException 114 | */ 115 | public function getDirectory(): string 116 | { 117 | if (! isset($this->attributes['ABSPATH'])) 118 | { 119 | throw ReaderException::forDirectoryFail((string) $this->file); 120 | } 121 | 122 | $path = $this->file->getPath() . DIRECTORY_SEPARATOR . $this->attributes['ABSPATH']; 123 | $path = realpath($path) ?: $path; 124 | $path = rtrim($path, '/\\ ') . DIRECTORY_SEPARATOR; 125 | 126 | if (! is_dir($path)) 127 | { 128 | $e = FileNotFoundException::forFileNotFound($path); 129 | throw new ReaderException($e->getMessage(), $e->getCode(), $e); 130 | } 131 | 132 | return $path; 133 | } 134 | 135 | //-------------------------------------------------------------------- 136 | 137 | /** 138 | * Magic method to allow retrieval of attributes. 139 | * 140 | * @param string $key 141 | * 142 | * @return mixed 143 | */ 144 | public function __get(string $key) 145 | { 146 | if (array_key_exists($key, $this->attributes)) 147 | { 148 | return $this->attributes[$key]; 149 | } 150 | 151 | return null; 152 | } 153 | 154 | /** 155 | * Magic method for setting properties. 156 | * 157 | * @param string $key 158 | * @param mixed $value 159 | * 160 | * @return $this 161 | */ 162 | public function __set(string $key, $value = null): self 163 | { 164 | $this->attributes[$key] = $value; 165 | 166 | return $this; 167 | } 168 | 169 | /** 170 | * Unsets an attribute property. 171 | * 172 | * @param string $key 173 | */ 174 | public function __unset(string $key) 175 | { 176 | unset($this->attributes[$key]); 177 | } 178 | 179 | /** 180 | * Returns true if the $key attribute exists. 181 | * 182 | * @param string $key 183 | * 184 | * @return boolean 185 | */ 186 | public function __isset(string $key): bool 187 | { 188 | return isset($this->attributes[$key]); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/Models/BaseModel.php: -------------------------------------------------------------------------------- 1 | 'permit_empty|max_length[20]', 50 | 'post_password' => 'permit_empty|max_length[20]', 51 | 'post_name' => 'permit_empty|max_length[200]', 52 | 'post_type' => 'permit_empty|max_length[20]', 53 | 'post_mime_type' => 'permit_empty|max_length[100]', 54 | 'ping_status' => 'permit_empty|max_length[20]', 55 | 'comment_status' => 'permit_empty|max_length[20]', 56 | 'guid' => 'permit_empty|max_length[255]', 57 | ]; 58 | 59 | /** 60 | * Returns an "attachment" type post from a file path. 61 | * Moves the file if it is not already in the WordPress directory. 62 | * Does not insert into the database. 63 | * 64 | * @param string $path Path to the file 65 | * 66 | * @return Post 67 | * 68 | * @throws FileNotFoundException, \RuntimeException 69 | */ 70 | public function fromFile(string $path): Post 71 | { 72 | $path = realpath($path) ?: $path; 73 | 74 | // Get and verify the file and target folder 75 | $file = new File($path, true); 76 | $base = $this->db->reader->getDirectory(); 77 | $dir = $base . 'wp-content' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . date('Y') . DIRECTORY_SEPARATOR . date('m') . DIRECTORY_SEPARATOR; 78 | 79 | // Determine if we need to move the file 80 | if (strpos($path, $base) === false) 81 | { 82 | // Make sure the directory is there 83 | if (! is_dir($dir) && ! mkdir($dir, 0775, true)) 84 | { 85 | throw new \RuntimeException('Unable to create destination for file move: ' . $dir); 86 | } 87 | 88 | // Move the file and set permissions 89 | $file = $file->move($dir); 90 | chmod((string) $file, 0664); 91 | 92 | $path = $file->getRealPath() ?: (string) $file; 93 | } 94 | 95 | // Build the Post 96 | return new Post([ 97 | 'post_type' => 'attachment', 98 | 'post_title' => $file->getFilename(), 99 | 'post_name' => $file->getFilename(), 100 | 'post_mime_type' => $file->getMimeType(), 101 | 'guid' => base_url(str_replace($base, '', $path)), 102 | ]); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Structures/MetaHandler.php: -------------------------------------------------------------------------------- 1 | 4] 23 | * 24 | * @var array 25 | */ 26 | protected $filter; 27 | 28 | /** 29 | * Cache for the actual data. 30 | * 31 | * @var array[]|null Array of raw array rows return from $builder 32 | */ 33 | protected $rows; 34 | 35 | /** 36 | * Stores the table-specific builder to use. 37 | * 38 | * @param BaseBuilder $builder A Builder for the model's group and the meta table 39 | * @param array $filter Primary key criterium for the originating entity 40 | * @param array|null $rows Preloaded data to inject 41 | */ 42 | public function __construct(BaseBuilder $builder, array $filter, array $rows = null) 43 | { 44 | $this->builder = $builder; 45 | $this->filter = $filter; 46 | $this->rows = $rows; 47 | 48 | helper('wordpress'); 49 | } 50 | 51 | /** 52 | * Determines the primary key for this meta table, 53 | * because stupid `usermeta` has a different one. 54 | * 55 | * @return string 56 | */ 57 | public function primaryKey(): string 58 | { 59 | return isset($this->filter['user_id']) ? 'umeta_id' : 'meta_id'; 60 | } 61 | 62 | /** 63 | * Returns the raw $rows array. Does not unserialze. 64 | * 65 | * @return array 66 | */ 67 | public function getRows(): array 68 | { 69 | return $this->fetch()->rows; 70 | } 71 | 72 | /** 73 | * Fetches the data if it has not been loaded. Called at the last possible 74 | * moment to minimize redudant work. 75 | * 76 | * @return $this 77 | */ 78 | protected function fetch(): self 79 | { 80 | if (is_null($this->rows)) 81 | { 82 | $this->rows = $this->builder 83 | ->where($this->filter) 84 | ->get()->getResultArray(); 85 | } 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * Returns the index to the row holding meta_key, if it exists. 92 | * 93 | * @param string $key 94 | * 95 | * @return int|null 96 | */ 97 | protected function find(string $key): ?int 98 | { 99 | $this->fetch(); 100 | 101 | foreach ($this->rows as $i => $row) 102 | { 103 | if ($row['meta_key'] === $key) 104 | { 105 | return $i; 106 | } 107 | } 108 | 109 | return null; 110 | } 111 | 112 | //-------------------------------------------------------------------- 113 | 114 | /** 115 | * Returns the meta_value for a key, if it exists. 116 | * Since meta_value is nullable, a null return 117 | * could indicate either "not found" or "null" - 118 | * use has() if actual existence matters. 119 | * 120 | * @param string $key 121 | * 122 | * @return mixed 123 | */ 124 | public function get(string $key) 125 | { 126 | if (is_null($i = $this->find($key))) 127 | { 128 | return null; 129 | } 130 | 131 | return maybe_unserialize($this->rows[$i]['meta_value']); 132 | } 133 | 134 | /** 135 | * Returns true if a meta_key exists named $key. 136 | * 137 | * @param string $key 138 | * 139 | * @return boolean 140 | */ 141 | public function has(string $key): bool 142 | { 143 | return is_int($this->find($key)); 144 | } 145 | 146 | /** 147 | * Adds a key-value pair. 148 | * 149 | * @param string $key 150 | * @param mixed|null $value 151 | * 152 | * @return array The new row 153 | * 154 | * @throws DatabaseException 155 | */ 156 | public function add(string $key, $value = null): array 157 | { 158 | $this->fetch(); 159 | 160 | // Build the new row 161 | $row = $this->filter; 162 | $row['meta_key'] = $key; 163 | $row['meta_value'] = is_serialized($value) ? $value : maybe_serialize($value); 164 | 165 | // Insert it 166 | if (! $this->builder->insert($row)) 167 | { 168 | $error = $this->builder->db()->error(); 169 | throw new DatabaseException($error['message']); 170 | } 171 | 172 | // Add the new ID to the row and cache it 173 | $row[$this->primaryKey()] = $this->builder->db()->insertID(); // @phpstan-ignore-line 174 | $this->rows[] = $row; 175 | 176 | return $row; 177 | } 178 | 179 | /** 180 | * Updates a key to a value. 181 | * 182 | * @param string $key 183 | * @param mixed|null $value 184 | * 185 | * @return array The updated row 186 | * 187 | * @throws DataException 188 | * @throws DatabaseException 189 | */ 190 | public function update(string $key, $value = null): array 191 | { 192 | if (is_null($i = $this->find($key))) 193 | { 194 | throw new DataException('Key does not exist: ' . $key); 195 | } 196 | $value = is_serialized($value) ? $value : maybe_serialize($value); 197 | 198 | // Build the inputs 199 | $where = [ 200 | $this->primaryKey() => $this->rows[$i][$this->primaryKey()], 201 | ]; 202 | $set = [ 203 | 'meta_value' => $value, 204 | ]; 205 | 206 | // Update it 207 | if (! $this->builder->update($set, $where)) 208 | { 209 | $error = $this->builder->db()->error(); 210 | throw new DatabaseException($error['message']); 211 | } 212 | 213 | // Change the cached value 214 | $this->rows[$i]['meta_value'] = $value; 215 | 216 | return $this->rows[$i]; 217 | } 218 | 219 | /** 220 | * Deletes the first occurence of a key. 221 | * 222 | * @param string $key 223 | * 224 | * @throws DataException 225 | * @throws DatabaseException 226 | */ 227 | public function delete(string $key) 228 | { 229 | if (is_null($i = $this->find($key))) 230 | { 231 | throw new DataException('Key does not exist: ' . $key); 232 | } 233 | 234 | // Build the inputs 235 | $where = [ 236 | $this->primaryKey() => $this->rows[$i][$this->primaryKey()], 237 | ]; 238 | 239 | // Delete it 240 | if (! $this->builder->delete($where)) 241 | { 242 | $error = $this->builder->db()->error(); 243 | throw new DatabaseException($error['message']); 244 | } 245 | 246 | // Remove the stored row 247 | unset($this->rows[$i]); 248 | } 249 | 250 | //-------------------------------------------------------------------- 251 | 252 | /** 253 | * Returns the meta_value of the meta_key matching $key. 254 | * 255 | * @param string $key 256 | * 257 | * @return mixed 258 | */ 259 | public function __get(string $key) 260 | { 261 | return $this->get($key); 262 | } 263 | 264 | /** 265 | * Returns true if a meta_key exists named $key. 266 | * 267 | * @param string $key 268 | * 269 | * @return boolean 270 | */ 271 | public function __isset(string $key): bool 272 | { 273 | return $this->has($key); 274 | } 275 | 276 | /** 277 | * Adds or updates a row. 278 | * 279 | * @param string $key 280 | * @param mixed|null $value 281 | * 282 | * @return $this 283 | * 284 | * @throws DatabaseException 285 | */ 286 | public function __set(string $key, $value = null): self 287 | { 288 | // Add or update for a new value 289 | $this->has($key) ? $this->update($key, $value) : $this->add($key, $value); 290 | 291 | return $this; 292 | } 293 | 294 | /** 295 | * Removes and deletes a row. 296 | * 297 | * @param string $key 298 | * 299 | * @throws DatabaseException 300 | */ 301 | public function __unset(string $key) 302 | { 303 | $this->has($key) && $this->delete($key); 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /src/Test/Database/Migrations/2020-11-07-023559_CreateWordPressTables.php: -------------------------------------------------------------------------------- 1 | ['type' => 'bigint', 'unsigned' => true, 'auto_increment' => true], 12 | 'post_author' => ['type' => 'bigint', 'unsigned' => true], 13 | 'post_date' => ['type' => 'datetime'], 14 | 'post_date_gmt' => ['type' => 'datetime'], 15 | 'post_content' => ['type' => 'longtext'], 16 | 'post_title' => ['type' => 'text'], 17 | 'post_excerpt' => ['type' => 'text'], 18 | 'post_status' => ['type' => 'varchar', 'constraint' => 20], 19 | 'comment_status' => ['type' => 'varchar', 'constraint' => 20], 20 | 'post_password' => ['type' => 'varchar', 'constraint' => 20], 21 | 'post_name' => ['type' => 'varchar', 'constraint' => 200], 22 | 'to_ping' => ['type' => 'text'], 23 | 'pinged' => ['type' => 'text'], 24 | 'post_modified' => ['type' => 'datetime'], 25 | 'post_modified_gmt' => ['type' => 'datetime'], 26 | 'post_content_filtered' => ['type' => 'longtext'], 27 | 'post_parent' => ['type' => 'bigint', 'unsigned' => true], 28 | 'guid' => ['type' => 'varchar', 'constraint' => 255], 29 | 'menu_order' => ['type' => 'int', 'constraint' => 11], 30 | 'post_type' => ['type' => 'varchar', 'constraint' => 20], 31 | 'post_mime_type' => ['type' => 'varchar', 'constraint' => 100], 32 | 'comment_count' => ['type' => 'bigint', 'unsigned' => true], 33 | ]; 34 | $this->forge->addField($fields); 35 | 36 | $this->forge->addKey('ID', true); 37 | $this->forge->addKey('post_name'); 38 | $this->forge->addKey(['post_type', 'post_status', 'post_date']); 39 | $this->forge->addKey('post_parent'); 40 | $this->forge->addKey('post_author'); 41 | 42 | $this->forge->createTable('posts'); 43 | 44 | // postmeta 45 | $fields = [ 46 | 'meta_id' => ['type' => 'bigint', 'unsigned' => true], 47 | 'post_id' => ['type' => 'bigint', 'unsigned' => true], 48 | 'meta_key' => ['type' => 'varchar', 'constraint' => 255], 49 | 'meta_value' => ['type' => 'longtext'], 50 | ]; 51 | $this->forge->addField($fields); 52 | 53 | $this->forge->addKey('meta_id', true); 54 | $this->forge->addKey('post_id'); 55 | $this->forge->addKey('meta_key'); 56 | 57 | $this->forge->createTable('postmeta'); 58 | } 59 | 60 | //-------------------------------------------------------------------- 61 | 62 | public function down() 63 | { 64 | $this->forge->dropTable('posts'); 65 | $this->forge->dropTable('postmeta'); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Test/Fakers/PostFaker.php: -------------------------------------------------------------------------------- 1 | company . '.' . $faker->fileExtension; 21 | 22 | return new Post([ 23 | 'guid' => site_url($faker->word . '/' . $faker->word), 24 | 'post_author' => rand(1, Fabricator::getCount('users') ?: 10), 25 | 'post_content' => $faker->paragraph, 26 | 'post_content_filtered' => $faker->paragraph, 27 | 'post_excerpt' => $faker->sentence, 28 | 'post_mime_type' => $faker->mimeType, 29 | 'post_password' => substr($faker->md5, 0, 20), 30 | 'post_name' => $faker->name, 31 | 'post_parent' => rand(0, 1) ? rand(0, Fabricator::getCount('posts')) : 0, 32 | 'post_status' => ['inherit', 'draft', 'publish'][rand(0,2)], 33 | 'post_title' => $faker->catchPhrase, 34 | 'post_type' => rand(0, 3) ? 'post' : 'page', 35 | 'comment_count' => rand(0, 3), 36 | 'comment_status' => rand(0, 3) ? 'open' : 'closed', 37 | 'menu_order' => rand(0, 20), 38 | 'ping_status' => rand(0, 3) ? 'open' : 'closed', 39 | 'pinged' => '', 40 | 'to_ping' => '', 41 | ]); 42 | } 43 | } 44 | --------------------------------------------------------------------------------