Passed
Push — master ( c48b96...96b3cc )
by smiley
01:24
created

DTBLReader::toDB()   B

Complexity

Conditions 8
Paths 14

Size

Total Lines 28
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 16
dl 0
loc 28
c 0
b 0
f 0
rs 8.4444
cc 8
nc 14
nop 1
1
<?php
2
/**
3
 * Class DTBLReader
4
 *
5
 * @link         https://arctium.io/wiki/index.php?title=WildStar_Client_Database_(.tbl)
6
 * @link         https://bitbucket.org/mugadr_m/wildstar-studio/src/973583416d4436e4980de840c2c91cfc5972fb2a/WildstarStudio/DataTable.h
7
 *
8
 * @filesource   DTBLReader.php
9
 * @created      04.01.2019
10
 * @package      codemasher\WildstarDB
11
 * @author       smiley <[email protected]>
12
 * @copyright    2019 smiley
13
 * @license      MIT
14
 */
15
16
namespace codemasher\WildstarDB;
17
18
use chillerlan\Database\Database;
19
use Psr\Log\{LoggerAwareTrait, LoggerInterface, NullLogger};
20
21
/**
22
 * @property string $dtbl
23
 * @property string $name
24
 * @property array  $header
25
 * @property array  $cols
26
 * @property array  $data
27
 */
28
class DTBLReader{
29
	use LoggerAwareTrait;
30
31
	protected const FORMAT_HEADER = 'a4Signature/LVersion/QTableNameLength/QUnknown1/QRecordSize/QFieldCount/QDescriptionOffset/QRecordCount/QFullRecordSize/QEntryOffset/QNextId/QIDLookupOffset/QUnknown2';
32
	protected const FORMAT_COLUMN = 'LNameLength/LUnknown1/QNameOffset/SDataType/SUnknown2/LUnknown3';
33
34
	/**
35
	 * @var string
36
	 */
37
	protected $dtbl = '';
38
39
	/**
40
	 * @var string
41
	 */
42
	protected $name = '';
43
44
	/**
45
	 * @var array
46
	 */
47
	protected $header = [];
48
49
	/**
50
	 * @var array
51
	 */
52
	protected $cols = [];
53
54
	/**
55
	 * @var array
56
	 */
57
	protected $data = [];
58
59
	/**
60
	 * @var resource
61
	 */
62
	private $fh;
63
64
	/**
65
	 * DTBLReader constructor.
66
	 *
67
	 * @param \Psr\Log\LoggerInterface|null $logger
68
	 *
69
	 * @throws \codemasher\WildstarDB\WSDBException
70
	 */
71
	public function __construct(LoggerInterface $logger = null){
72
73
		if(PHP_INT_SIZE < 8){
74
			throw new WSDBException('64-bit PHP required');
75
		}
76
77
		$this->setLogger($logger ?? new NullLogger);
78
	}
79
80
	/**
81
	 * @return void
82
	 */
83
	public function __destruct(){
84
85
		if(is_resource($this->fh)){
86
			fclose($this->fh);
87
		}
88
89
	}
90
91
	/**
92
	 * @param string $name
93
	 *
94
	 * @return mixed|null
95
	 */
96
	public function __get(string $name){
97
		return property_exists($this, $name) && $name !== 'fh' ? $this->{$name} : null;
98
	}
99
100
	/**
101
	 * @param string $str
102
	 *
103
	 * @return string
104
	 */
105
	protected function decodeString(string $str):string{
106
		return trim(mb_convert_encoding($str, 'UTF-8', 'UTF-16LE'));
107
	}
108
109
	/**
110
	 * @throws \codemasher\WildstarDB\WSDBException
111
	 * @return void
112
	 */
113
	protected function init():void{
114
		$this->logger->info('init: '.$this->dtbl);
115
116
		$this->fh   = fopen($this->dtbl, 'rb');
0 ignored issues
show
Documentation Bug introduced by
It seems like fopen($this->dtbl, 'rb') can also be of type false. However, the property $fh is declared as type resource. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
117
		$header     = fread($this->fh, 0x60);
0 ignored issues
show
Bug introduced by
It seems like $this->fh can also be of type false; however, parameter $handle of fread() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

117
		$header     = fread(/** @scrutinizer ignore-type */ $this->fh, 0x60);
Loading history...
118
		$this->cols = [];
119
		$this->data = [];
120
121
		if(strlen($header) !== 0x60){
122
			throw new WSDBException('cannot read DTBL header');
123
		}
124
125
		$this->header = unpack($this::FORMAT_HEADER, $header);
126
127
		if($this->header['Signature'] !== "\x4c\x42\x54\x44"){ // LBTD
128
			throw new WSDBException('invalid DTBL');
129
		}
130
131
		$this->name = $this->decodeString(fread($this->fh, $this->header['TableNameLength'] * 2));
132
133
		$this->logger->info($this->name.', fields: '.$this->header['FieldCount'].', rows: '.$this->header['RecordCount']);
134
135
		fseek($this->fh, $this->header['DescriptionOffset'] + 0x60);
0 ignored issues
show
Bug introduced by
It seems like $this->fh can also be of type false; however, parameter $handle of fseek() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

135
		fseek(/** @scrutinizer ignore-type */ $this->fh, $this->header['DescriptionOffset'] + 0x60);
Loading history...
136
137
		for($i = 0; $i < $this->header['FieldCount']; $i++){
138
			$this->cols[$i]['header'] = unpack($this::FORMAT_COLUMN, fread($this->fh, 0x18));
139
		}
140
141
		$offset = $this->header['FieldCount'] * 0x18 + $this->header['DescriptionOffset'] + 0x60;
142
143
		if($this->header['FieldCount'] % 2){
144
			$offset += 8;
145
		}
146
147
		foreach($this->cols as $i => $col){
148
			fseek($this->fh, $offset + $col['header']['NameOffset']);
149
150
			$this->cols[$i]['name'] = $this->decodeString(fread($this->fh, $col['header']['NameLength'] * 2));
151
		}
152
153
	}
154
155
	/**
156
	 * @param string $dtbl
157
	 *
158
	 * @return \codemasher\WildstarDB\DTBLReader
159
	 * @throws \codemasher\WildstarDB\WSDBException
160
	 */
161
	public function read(string $dtbl):DTBLReader{
162
163
		if(!is_file($dtbl) || !is_readable($dtbl)){
164
			throw new WSDBException('DTBL not readable');
165
		}
166
167
		$this->dtbl = $dtbl;
168
169
		$this->init();
170
171
		fseek($this->fh, $this->header['EntryOffset'] + 0x60);
0 ignored issues
show
Bug introduced by
It seems like $this->fh can also be of type false; however, parameter $handle of fseek() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

171
		fseek(/** @scrutinizer ignore-type */ $this->fh, $this->header['EntryOffset'] + 0x60);
Loading history...
172
173
		for($i = 0; $i < $this->header['RecordCount']; $i++){
174
			$data = fread($this->fh, $this->header['RecordSize']);
0 ignored issues
show
Bug introduced by
It seems like $this->fh can also be of type false; however, parameter $handle of fread() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

174
			$data = fread(/** @scrutinizer ignore-type */ $this->fh, $this->header['RecordSize']);
Loading history...
175
			$row  = [];
176
			$j    = 0;
177
			$skip = false;
178
179
			foreach($this->cols as $c => $col){
180
181
				if($skip === true && ($c > 0 && $this->cols[$c - 1]['header']['DataType'] === 130) && $col['header']['DataType'] !== 130){
182
					$j += 4;
183
				}
184
185
				switch($col['header']['DataType']){
186
					case 3:  // uint32
187
					case 11: // booleans (stored as uint32 0/1)
188
						$v = unpack('L', substr($data, $j, 4))[1]; $j += 4; break;
189
					case 4:  // float
190
						$v = round(unpack('f', substr($data, $j, 4))[1], 3); $j += 4; break;
191
					case 20: // uint64
192
						$v = unpack('Q', substr($data, $j, 8))[1]; $j += 8; break;
193
					case 130: // string
194
						$v = $this->readString($data, $j, $skip); $j += 8; break;
195
196
					default: $v = null;
197
				}
198
199
				$row[$col['name']] = $v;
200
			}
201
202
			if(count($row) !== $this->header['FieldCount']){
203
				throw new WSDBException('invalid field count');
204
			}
205
206
			$this->data[$i] = $row;
207
		}
208
209
		fclose($this->fh);
0 ignored issues
show
Bug introduced by
It seems like $this->fh can also be of type false; however, parameter $handle of fclose() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

209
		fclose(/** @scrutinizer ignore-type */ $this->fh);
Loading history...
210
211
		if(count($this->data) !== $this->header['RecordCount']){
212
			throw new WSDBException('invalid row count');
213
		}
214
215
		return $this;
216
	}
217
218
	/**
219
	 * @param string $data
220
	 * @param int    $j
221
	 * @param bool   $skip
222
	 *
223
	 * @return string
224
	 */
225
	protected function readString(string $data, int $j, bool &$skip):string{
226
		$o    = unpack('L', substr($data, $j, 4))[1];
227
		$p    = ftell($this->fh);
228
		$skip = $o === 0;
229
230
		fseek($this->fh, $this->header['EntryOffset'] + 0x60 + ($o > 0 ? $o : unpack('L', substr($data, $j + 4, 4))[1]));
231
232
		$v = '';
233
234
		do{
235
			$s = fread($this->fh, 2);
236
237
			$v .= $s;
238
		}
239
		while($s !== "\x00\x00" && $s !== '');
240
241
		fseek($this->fh, $p);
242
243
		return $this->decodeString($v);
244
	}
245
246
	/**
247
	 * @throws \codemasher\WildstarDB\WSDBException
248
	 * @return void
249
	 */
250
	protected function checkData():void{
251
252
		if(empty($this->data)){
253
			throw new WSDBException('empty data, run DTBLReader::read() first');
254
		}
255
256
	}
257
258
	/**
259
	 * @param string $data
260
	 * @param string $file
261
	 *
262
	 * @return bool
263
	 * @throws \codemasher\WildstarDB\WSDBException
264
	 */
265
	protected function saveToFile(string $data, string $file):bool{
266
267
		if(!is_writable(dirname($file))){
268
			throw new WSDBException('cannot write data to file: '.$file.', target directory is not writable');
269
		}
270
271
		return (bool)file_put_contents($file, $data);
272
	}
273
274
	/**
275
	 * @param string|null $file
276
	 * @param int|null    $jsonOptions
277
	 *
278
	 * @return string
279
	 */
280
	public function toJSON(string $file = null, int $jsonOptions = 0):string{
281
		$this->checkData();
282
283
		$json = json_encode($this->data, $jsonOptions);
284
285
		if($file !== null){
286
			$this->saveToFile($json, $file);
287
		}
288
289
		return $json;
290
	}
291
292
	/**
293
	 * @param string|null $file
294
	 * @param string      $delimiter
295
	 * @param string      $enclosure
296
	 * @param string      $escapeChar
297
	 *
298
	 * @return string
299
	 */
300
	public function toCSV(string $file = null, string $delimiter = ',', string $enclosure = '"', string $escapeChar = '\\'):string{
301
		$this->checkData();
302
303
		$mh = fopen('php://memory', 'r+');
304
305
		fputcsv($mh, array_column($this->cols, 'name'), $delimiter, $enclosure, $escapeChar);
0 ignored issues
show
Bug introduced by
It seems like $mh can also be of type false; however, parameter $handle of fputcsv() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

305
		fputcsv(/** @scrutinizer ignore-type */ $mh, array_column($this->cols, 'name'), $delimiter, $enclosure, $escapeChar);
Loading history...
306
307
		foreach($this->data as $row){
308
			fputcsv($mh, array_values($row), $delimiter, $enclosure, $escapeChar);
309
		}
310
311
		rewind($mh);
0 ignored issues
show
Bug introduced by
It seems like $mh can also be of type false; however, parameter $handle of rewind() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

311
		rewind(/** @scrutinizer ignore-type */ $mh);
Loading history...
312
313
		$csv = stream_get_contents($mh);
0 ignored issues
show
Bug introduced by
It seems like $mh can also be of type false; however, parameter $handle of stream_get_contents() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

313
		$csv = stream_get_contents(/** @scrutinizer ignore-type */ $mh);
Loading history...
314
315
		fclose($mh);
0 ignored issues
show
Bug introduced by
It seems like $mh can also be of type false; however, parameter $handle of fclose() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

315
		fclose(/** @scrutinizer ignore-type */ $mh);
Loading history...
316
317
		if($file !== null){
318
			$this->saveToFile($csv, $file);
319
		}
320
321
		return $csv;
322
	}
323
324
	/**
325
	 * ugh!
326
	 *
327
	 * @param string|null $file
328
	 *
329
	 * @return string
330
	 */
331
	public function toXML(string $file = null):string{
332
		$this->checkData();
333
334
		$sxe = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><root></root>', LIBXML_BIGLINES);
335
336
		$types = [3 => 'uint32', 4 => 'float', 11 => 'bool', 20 => 'uint64', 130 => 'string'];
337
338
		foreach($this->data as $row){
339
			$item = $sxe->addChild('item');
340
341
			foreach(array_values($row) as $i => $value){
342
				$item
343
					->addChild($this->cols[$i]['name'], $value)
344
					->addAttribute('dataType', $types[$this->cols[$i]['header']['DataType']]);
345
				;
346
			}
347
		}
348
349
		$xml = $sxe->asXML();
350
351
		if($file !== null){
352
			$this->saveToFile($xml, $file);
353
		}
354
355
		return $xml;
356
	}
357
358
	/**
359
	 * @param \chillerlan\Database\Database $db
360
	 * @return void
361
	 */
362
	public function toDB(Database $db):void{
363
		// Windows: https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_lower_case_table_names
364
		$createTable = $db->create
365
			->table($this->name)
366
			->primaryKey($this->cols[0]['name'])
367
			->ifNotExists()
368
		;
369
370
		foreach($this->cols as $i => $col){
371
372
			switch($col['header']['DataType']){
373
				case 3:   $createTable->int($col['name'], 10, null, null, 'UNSIGNED'); break;
374
				case 4:   $createTable->decimal($col['name'], '7,3', 0); break;
375
				case 11:  $createTable->field($col['name'], 'BOOLEAN'); break;
376
				case 20:  $createTable->field($col['name'], 'BIGINT', null, 'UNSIGNED'); break;
377
				case 130: $createTable->text($col['name']); break;
378
			}
379
380
		}
381
382
		$createTable->query();
383
384
		if(count($this->data) < 1){
385
			$this->logger->notice('no records available for table '.$this->name);
386
			return;
387
		}
388
389
		$db->insert->into($this->name)->values($this->data)->multi();
390
	}
391
392
}
393