1: <?php
2: /*
3: You may not change or alter any portion of this comment or credits
4: of supporting developers from this source code or any supporting source code
5: which is considered copyrighted (c) material of the original comment or credit authors.
6:
7: This program is distributed in the hope that it will be useful,
8: but WITHOUT ANY WARRANTY; without even the implied warranty of
9: MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10: */
11:
12: namespace Xmf\Database;
13:
14: use Xmf\Module\Helper;
15: use Xmf\Yaml;
16:
17: /**
18: * Xmf\Database\Migrate
19: *
20: * For a given module, compare the existing tables with a defined target schema
21: * and build a work queue of DDL/SQL to transform the existing tables to the
22: * target definitions.
23: *
24: * Typically, Migrate will be extended by a module specific class that will supply custom
25: * logic (see preSyncActions() method.)
26: *
27: * @category Xmf\Database\Migrate
28: * @package Xmf
29: * @author Richard Griffith <richard@geekwright.com>
30: * @copyright 2018-2023 XOOPS Project (https://xoops.org)
31: * @license GNU GPL 2 or later (https://www.gnu.org/licenses/gpl-2.0.html)
32: * @link https://xoops.org
33: */
34: class Migrate
35: {
36:
37: /** @var false|\Xmf\Module\Helper|\Xoops\Module\Helper\HelperAbstract */
38: protected $helper;
39:
40: /** @var string[] table names used by module */
41: protected $moduleTables;
42:
43: /** @var Tables */
44: protected $tableHandler;
45:
46: /** @var string yaml definition file */
47: protected $tableDefinitionFile;
48:
49: /** @var array target table definitions in Xmf\Database\Tables::dumpTables() format */
50: protected $targetDefinitions;
51:
52: /**
53: * Migrate constructor
54: *
55: * @param string $dirname module directory name that defines the tables to be migrated
56: *
57: * @throws \InvalidArgumentException
58: * @throws \RuntimeException
59: */
60: public function __construct($dirname)
61: {
62: $this->helper = Helper::getHelper($dirname);
63: if (false === $this->helper) {
64: throw new \InvalidArgumentException("Invalid module $dirname specified");
65: }
66: $module = $this->helper->getModule();
67: $this->moduleTables = $module->getInfo('tables');
68: if (empty($this->moduleTables)) {
69: throw new \RuntimeException("No tables established in module");
70: }
71:
72: $version = preg_replace_callback(
73: '/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/',
74: function ($match) {
75: $semver = $match[1] . '_' . $match[2] . '_' .$match[3];
76: if (!empty($match[4])) {
77: $semver .= '_' . substr($match[4], 0, 8);
78: }
79: return $semver;
80: },
81: $module->getInfo('version'));
82:
83: $this->tableDefinitionFile = $this->helper->path("sql/{$dirname}_{$version}_migrate.yml");
84: $this->tableHandler = new Tables();
85: }
86:
87: /**
88: * Save current table definitions to a file
89: *
90: * This is intended for developer use when setting up the migration by using the current database state
91: *
92: * @internal intended for module developers only
93: *
94: * @return int|false count of bytes written or false on error
95: */
96: public function saveCurrentSchema()
97: {
98: $this->tableHandler = new Tables(); // start fresh
99:
100: $schema = $this->getCurrentSchema();
101:
102: foreach ($schema as $tableName => $tableData) {
103: unset($schema[$tableName]['name']);
104: }
105:
106: return Yaml::save($schema, $this->tableDefinitionFile);
107: }
108:
109: /**
110: * get the current definitions
111: *
112: * @return array
113: */
114: public function getCurrentSchema()
115: {
116: foreach ($this->moduleTables as $tableName) {
117: if (false === $this->tableHandler->useTable($tableName)) {
118: $this->tableHandler->addTable($tableName);
119: }
120: }
121:
122: return $this->tableHandler->dumpTables();
123: }
124:
125: /**
126: * Return the target database condition
127: *
128: * @return array|bool table structure or false on error
129: *
130: * @throws \RuntimeException
131: */
132: public function getTargetDefinitions()
133: {
134: if (!isset($this->targetDefinitions)) {
135: $this->targetDefinitions = Yaml::read($this->tableDefinitionFile);
136: if (null === $this->targetDefinitions) {
137: throw new \RuntimeException("No schema definition " . $this->tableDefinitionFile);
138: }
139: }
140: return $this->targetDefinitions;
141: }
142:
143: /**
144: * Execute synchronization to transform current schema to target
145: *
146: * @param bool $force true to force updates even if this is a 'GET' request
147: *
148: * @return bool true if no errors, false if errors encountered
149: */
150: public function synchronizeSchema($force = true)
151: {
152: $this->tableHandler = new Tables(); // start fresh
153: $this->getSynchronizeDDL();
154: return $this->tableHandler->executeQueue($force);
155: }
156:
157: /**
158: * Compare target and current schema, building work queue in $this->migrate to synchronized
159: *
160: * @return string[] array of DDL/SQL statements to transform current to target schema
161: */
162: public function getSynchronizeDDL()
163: {
164: $this->getTargetDefinitions();
165: $this->preSyncActions();
166: foreach ($this->moduleTables as $tableName) {
167: if ($this->tableHandler->useTable($tableName)) {
168: $this->synchronizeTable($tableName);
169: } else {
170: $this->addMissingTable($tableName);
171: }
172: }
173: return $this->tableHandler->dumpQueue();
174: }
175:
176: /**
177: * Perform any upfront actions before synchronizing the schema.
178: *
179: * The schema comparison cannot recognize changes such as renamed columns or renamed tables. By overriding
180: * this method, an implementation can provide the logic to accomplish these types of changes, and leave
181: * the other details to be handled by synchronizeSchema().
182: *
183: * An suitable implementation should be provided by a module by extending Migrate to define any required
184: * actions.
185: *
186: * Some typical uses include:
187: * - table and column renames
188: * - data conversions
189: * - move column data
190: *
191: * @return void
192: */
193: protected function preSyncActions()
194: {
195: }
196:
197: /**
198: * Add table create DDL to the work queue
199: *
200: * @param string $tableName table to add
201: *
202: * @return void
203: */
204: protected function addMissingTable($tableName)
205: {
206: $this->tableHandler->addTable($tableName);
207: $this->tableHandler->setTableOptions($tableName, $this->targetDefinitions[$tableName]['options']);
208: foreach ($this->targetDefinitions[$tableName]['columns'] as $column) {
209: $this->tableHandler->addColumn($tableName, $column['name'], $column['attributes']);
210: }
211: foreach ($this->targetDefinitions[$tableName]['keys'] as $key => $keyData) {
212: if ($key === 'PRIMARY') {
213: $this->tableHandler->addPrimaryKey($tableName, $keyData['columns']);
214: } else {
215: $this->tableHandler->addIndex($key, $tableName, $keyData['columns'], $keyData['unique']);
216: }
217: }
218: }
219:
220: /**
221: * Build any DDL required to synchronize an existing table to match the target schema
222: *
223: * @param string $tableName table to synchronize
224: *
225: * @return void
226: */
227: protected function synchronizeTable($tableName)
228: {
229: foreach ($this->targetDefinitions[$tableName]['columns'] as $column) {
230: $attributes = $this->tableHandler->getColumnAttributes($tableName, $column['name']);
231: if ($attributes === false) {
232: $this->tableHandler->addColumn($tableName, $column['name'], $column['attributes']);
233: } elseif ($column['attributes'] !== $attributes) {
234: $this->tableHandler->alterColumn($tableName, $column['name'], $column['attributes']);
235: }
236: }
237:
238: $tableDef = $this->tableHandler->dumpTables();
239: if (isset($tableDef[$tableName])) {
240: foreach ($tableDef[$tableName]['columns'] as $columnData) {
241: if (!$this->targetHasColumn($tableName, $columnData['name'])) {
242: $this->tableHandler->dropColumn($tableName, $columnData['name']);
243: }
244: }
245: }
246:
247: $existingIndexes = $this->tableHandler->getTableIndexes($tableName);
248: if (isset($this->targetDefinitions[$tableName]['keys'])) {
249: foreach ($this->targetDefinitions[$tableName]['keys'] as $key => $keyData) {
250: if ($key === 'PRIMARY') {
251: if (!isset($existingIndexes[$key])) {
252: $this->tableHandler->addPrimaryKey($tableName, $keyData['columns']);
253: } elseif ($existingIndexes[$key]['columns'] !== $keyData['columns']) {
254: $this->tableHandler->dropPrimaryKey($tableName);
255: $this->tableHandler->addPrimaryKey($tableName, $keyData['columns']);
256: }
257: } else {
258: if (!isset($existingIndexes[$key])) {
259: $this->tableHandler->addIndex($key, $tableName, $keyData['columns'], $keyData['unique']);
260: } elseif ($existingIndexes[$key]['unique'] !== $keyData['unique']
261: || $existingIndexes[$key]['columns'] !== $keyData['columns']
262: ) {
263: $this->tableHandler->dropIndex($key, $tableName);
264: $this->tableHandler->addIndex($key, $tableName, $keyData['columns'], $keyData['unique']);
265: }
266: }
267: }
268: }
269: if (false !== $existingIndexes) {
270: foreach ($existingIndexes as $key => $keyData) {
271: if (!isset($this->targetDefinitions[$tableName]['keys'][$key])) {
272: $this->tableHandler->dropIndex($key, $tableName);
273: }
274: }
275: }
276: }
277:
278: /**
279: * determine if a column on a table exists in the target definitions
280: *
281: * @param string $tableName table containing the column
282: * @param string $columnName column to check
283: *
284: * @return bool true if table and column combination is defined, otherwise false
285: */
286: protected function targetHasColumn($tableName, $columnName)
287: {
288: if (isset($this->targetDefinitions[$tableName])) {
289: foreach ($this->targetDefinitions[$tableName]['columns'] as $col) {
290: if (strcasecmp($col['name'], $columnName) === 0) {
291: return true;
292: }
293: }
294: }
295:
296: return false;
297: }
298:
299: /**
300: * determine if a table exists in the target definitions
301: *
302: * @param string $tableName table containing the column
303: *
304: * @return bool true if table is defined, otherwise false
305: */
306: protected function targetHasTable($tableName)
307: {
308: if (isset($this->targetDefinitions[$tableName])) {
309: return true;
310: }
311: return false;
312: }
313:
314: /**
315: * Return message from last error encountered
316: *
317: * @return string last error message
318: */
319: public function getLastError()
320: {
321: return $this->tableHandler->getLastError();
322: }
323:
324: /**
325: * Return code from last error encountered
326: *
327: * @return int last error number
328: */
329: public function getLastErrNo()
330: {
331: return $this->tableHandler->getLastErrNo();
332: }
333: }
334: