1<?php 2 3namespace dokuwiki\plugin\struct\meta; 4 5/** 6 * Class SchemaBuilder 7 * 8 * This class builds and updates the schema definitions for our tables. This includes CREATEing and ALTERing 9 * the actual data tables as well as updating the meta information in our meta data tables. 10 * 11 * To use, simply instantiate a new object of the Builder and run the build() method on it. 12 * 13 * Note: even though data tables use a data_ prefix in the database, this prefix is internal only and should 14 * never be passed as $table anywhere! 15 * 16 * @package dokuwiki\plugin\struct\meta 17 */ 18class SchemaBuilder { 19 20 /** 21 * @var array The posted new data for the schema 22 * @see Schema::AdminEditor() 23 */ 24 protected $data = array(); 25 26 protected $user; 27 28 /** 29 * @var string The table name associated with the schema 30 */ 31 protected $table = ''; 32 33 /** 34 * @var Schema the previously valid schema for this table 35 */ 36 protected $oldschema; 37 38 /** @var int the ID of the newly created schema */ 39 protected $newschemaid = 0; 40 41 /** @var \helper_plugin_struct_db */ 42 protected $helper; 43 44 /** @var \helper_plugin_sqlite|null */ 45 protected $sqlite; 46 47 /** @var int the time for which this schema should be created - default to time() can be overriden for tests */ 48 protected $time = 0; 49 50 /** 51 * SchemaBuilder constructor. 52 * 53 * @param string $table The table's name 54 * @param array $data The defining of the table (basically what get's posted in the schema editor form) 55 * @see Schema::AdminEditor() 56 */ 57 public function __construct($table, $data) { 58 $this->table = $table; 59 $this->data = $data; 60 $this->oldschema = new Schema($table, 0); 61 62 $this->helper = plugin_load('helper', 'struct_db'); 63 $this->sqlite = $this->helper->getDB(); 64 $this->user = $_SERVER['REMOTE_USER']; 65 } 66 67 /** 68 * Create the new schema 69 * 70 * @param int $time when to create this schema 0 for now 71 * @return bool|int the new schema id on success 72 */ 73 public function build($time=0) { 74 $this->time = $time; 75 $this->fixLabelUniqueness(); 76 77 $this->sqlite->query('BEGIN TRANSACTION'); 78 $ok = true; 79 // create the data table if new schema 80 if(!$this->oldschema->getId()) { 81 $ok = $this->newDataTable(); 82 } 83 84 // create a new schema 85 $ok = $ok && $this->newSchema(); 86 87 // update column info 88 $ok = $ok && $this->updateColumns(); 89 $ok = $ok && $this->addColumns(); 90 91 if (!$ok) { 92 $this->sqlite->query('ROLLBACK TRANSACTION'); 93 return false; 94 } 95 $this->sqlite->query('COMMIT TRANSACTION'); 96 97 return $this->newschemaid; 98 } 99 100 /** 101 * Makes sure all labels in the schema to save are unique 102 */ 103 protected function fixLabelUniqueness() { 104 $labels = array(); 105 106 if(isset($this->data['cols'])) foreach($this->data['cols'] as $idx => $column) { 107 $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 108 } 109 110 if(isset($this->data['new'])) foreach($this->data['new'] as $idx => $column) { 111 $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 112 } 113 } 114 115 /** 116 * Creates a unique label from the given one 117 * 118 * @param string $wantedlabel 119 * @param array $labels list of already assigned labels (will be filled) 120 * @return string 121 */ 122 protected function fixLabel($wantedlabel, &$labels) { 123 $wantedlabel = trim($wantedlabel); 124 $fixedlabel = $wantedlabel; 125 $idx = 1; 126 while(isset($labels[utf8_strtolower($fixedlabel)])) { 127 $fixedlabel = $wantedlabel.$idx++; 128 } 129 // did we actually do a rename? apply it. 130 if($fixedlabel != $wantedlabel) { 131 msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1); 132 $this->data['cols']['label'] = $fixedlabel; 133 } 134 $labels[utf8_strtolower($fixedlabel)] = 1; 135 return $fixedlabel; 136 } 137 138 /** 139 * Creates a new schema 140 */ 141 protected function newSchema() { 142 if(!$this->time) $this->time = time(); 143 144 $config = $this->data['config'] ?: '{}'; 145 146 /** @noinspection SqlResolve */ 147 $sql = "INSERT INTO schemas (tbl, ts, islookup, user, config) VALUES (?, ?, ?, ?, ?)"; 148 // FIXME magic 0 for islookup 149 $this->sqlite->query($sql, $this->table, $this->time, 0, $this->user, $config); 150 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 151 $this->newschemaid = $this->sqlite->res2single($res); 152 $this->sqlite->res_close($res); 153 if(!$this->newschemaid) return false; 154 return true; 155 } 156 157 /** 158 * Updates all the existing column infos and adds them to the new schema 159 */ 160 protected function updateColumns() { 161 foreach($this->oldschema->getColumns() as $column) { 162 $oldEntry = $column->getType()->getAsEntry(); 163 $oldTid = $column->getTid(); 164 $newEntry = $oldEntry; 165 $newTid = $oldTid; 166 $sort = $column->getSort(); 167 if(isset($this->data['cols'][$column->getColref()])){ 168 // todo I'm not too happy with this hardcoded here - we should probably have a list of fields at one place 169 $newEntry['config'] = $this->data['cols'][$column->getColref()]['config']; 170 $newEntry['label'] = $this->data['cols'][$column->getColref()]['label']; 171 $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti']; 172 $newEntry['class'] = $this->data['cols'][$column->getColref()]['class']; 173 $sort = $this->data['cols'][$column->getColref()]['sort']; 174 $enabled = (bool) $this->data['cols'][$column->getColref()]['isenabled']; 175 176 // when the type definition has changed, we create a new one 177 if(array_diff_assoc($oldEntry, $newEntry)) { 178 $ok = $this->sqlite->storeEntry('types', $newEntry); 179 if(!$ok) return false; 180 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 181 if(!$res) return false; 182 $newTid = $this->sqlite->res2single($res); 183 $this->sqlite->res_close($res); 184 if ($oldEntry['ismulti'] == false && $newEntry['ismulti'] == '1') { 185 $this->migrateSingleToMulti($this->oldschema->getTable(), $column->getColref()); 186 } 187 } 188 } else { 189 $enabled = false; // no longer there for some reason 190 } 191 192 // add this type to the schema columns 193 $schemaEntry = array( 194 'sid' => $this->newschemaid, 195 'colref' => $column->getColref(), 196 'enabled' => $enabled, 197 'tid' => $newTid, 198 'sort' => $sort 199 ); 200 $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry); 201 if(!$ok) return false; 202 } 203 return true; 204 } 205 206 /** 207 * Write the latest value from an entry in a data_ table to the corresponding multi_table 208 * 209 * @param string $table 210 * @param int $colref 211 */ 212 protected function migrateSingleToMulti($table, $colref) { 213 /** @noinspection SqlResolve */ 214 $sqlSelect = "SELECT pid, rev, col$colref AS value FROM data_$table WHERE latest = 1"; 215 $res = $this->sqlite->query($sqlSelect); 216 $valueSet = $this->sqlite->res2arr($res); 217 $this->sqlite->res_close($res); 218 $valueString = array(); 219 $arguments = array(); 220 foreach ($valueSet as $values) { 221 if (blank($values['value']) || trim($values['value']) == '') { 222 continue; 223 } 224 $valueString[] = "(?, ?, ?, ?, ?)"; 225 $arguments = array_merge($arguments, array($colref, $values['pid'], $values['rev'], 1, $values['value'])); 226 } 227 if (empty($valueString)) { 228 return; 229 } 230 $valueString = join(',', $valueString); 231 /** @noinspection SqlResolve */ 232 $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, row, value) VALUES $valueString"; 233 $this->sqlite->query($sqlInsert, $arguments); 234 } 235 236 /** 237 * Adds new columns to the new schema 238 * 239 * @return bool 240 */ 241 protected function addColumns() { 242 if(!isset($this->data['new'])) return true; 243 244 $colref = count($this->oldschema->getColumns())+1; 245 246 foreach($this->data['new'] as $column) { 247 if(!$column['isenabled']) continue; // we do not add a disabled column 248 249 // todo this duplicates the hardcoding as in the function above 250 $newEntry = array(); 251 $newEntry['config'] = $column['config']; 252 $newEntry['label'] = $column['label']; 253 $newEntry['ismulti'] = $column['ismulti']; 254 $newEntry['class'] = $column['class']; 255 $sort = $column['sort']; 256 257 258 // only save if the column got a name 259 if(!$newEntry['label']) continue; 260 261 // add new column to the data table 262 if(!$this->addDataTableColumn($colref)) { 263 return false; 264 } 265 266 // save the type 267 $ok = $this->sqlite->storeEntry('types', $newEntry); 268 if(!$ok) return false; 269 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 270 if(!$res) return false; 271 $newTid = $this->sqlite->res2single($res); 272 $this->sqlite->res_close($res); 273 274 275 // add this type to the schema columns 276 $schemaEntry = array( 277 'sid' => $this->newschemaid, 278 'colref' => $colref, 279 'enabled' => true, 280 'tid' => $newTid, 281 'sort' => $sort 282 ); 283 $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry); 284 if(!$ok) return false; 285 $colref++; 286 } 287 288 return true; 289 } 290 291 /** 292 * Create a completely new data table with no columns yet also create the appropriate 293 * multi value table for the schema 294 * 295 * @todo how do we want to handle indexes? 296 * @return bool 297 */ 298 protected function newDataTable() { 299 $ok = true; 300 301 $tbl = 'data_' . $this->table; 302 $sql = "CREATE TABLE $tbl ( 303 pid TEXT DEFAULT NULL, 304 rid INTEGER, 305 rev INTEGER, 306 latest BOOLEAN NOT NULL DEFAULT 0, 307 PRIMARY KEY(pid, rid, rev) 308 )"; 309 $ok = $ok && (bool) $this->sqlite->query($sql); 310 311 $tbl = 'multi_' . $this->table; 312 $sql = "CREATE TABLE $tbl ( 313 colref INTEGER NOT NULL, 314 pid TEXT, 315 rid INTEGER, 316 rev INTEGER, 317 latest INTEGER NOT NULL DEFAULT 0, 318 row INTEGER NOT NULL, 319 value, 320 PRIMARY KEY(colref, pid, rid, rev, row) 321 );"; 322 $ok = $ok && (bool) $this->sqlite->query($sql); 323 324 return $ok; 325 } 326 327 /** 328 * Add an additional column to the existing data table 329 * 330 * @param int $index the new column index to add 331 * @return bool 332 */ 333 protected function addDataTableColumn($index) { 334 $tbl = 'data_' . $this->table; 335 $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''"; 336 if(! $this->sqlite->query($sql)) { 337 return false; 338 } 339 return true; 340 } 341 342 /** 343 * @param string $user 344 * @return SchemaBuilder 345 */ 346 public function setUser($user) { 347 $this->user = $user; 348 return $this; 349 } 350 351 352} 353