diff --git a/example/test-memo.php b/example/test-memo.php index 228ba3e..7dc6df1 100644 --- a/example/test-memo.php +++ b/example/test-memo.php @@ -20,7 +20,7 @@ die('Bad path to file realpath '.$argv[1]); } -$table = new Table($filepath, null, 'cp1252'); +$table = new Table($filepath, ['encoding' => 'cp1252']); echo 'Record count: '.$table->getRecordCount() . PHP_EOL; $columns = $table->getColumns(); diff --git a/src/XBase/BlocksMerger.php b/src/XBase/BlocksMerger.php index bcc6c9f..9f2eeb2 100644 --- a/src/XBase/BlocksMerger.php +++ b/src/XBase/BlocksMerger.php @@ -30,6 +30,9 @@ public function get(): array return $this->squeeze(); } + /** + * Combines several adjacent blocks into one. + */ private function squeeze(): array { $pointers = array_column($this->blocksToDelete, 0); diff --git a/src/XBase/Memo/AbstractMemo.php b/src/XBase/Memo/AbstractMemo.php index bd81586..8ad24bd 100644 --- a/src/XBase/Memo/AbstractMemo.php +++ b/src/XBase/Memo/AbstractMemo.php @@ -16,23 +16,38 @@ abstract class AbstractMemo implements MemoInterface /** @var string */ protected $filepath; - /** @var string */ - protected $convertFrom; + /** @var array */ + protected $options = []; /** - * Memo constructor. - * - * @param string $convertFrom + * @param string $filepath Path to memo file + * @param array $options Array of options:
+ * encoding - convert text data from
+ * writable - edit mode
*/ - public function __construct(Table $table, string $filepath, ?string $convertFrom = null) + public function __construct(Table $table, string $filepath, $options = []) { $this->table = $table; $this->filepath = $filepath; - $this->convertFrom = $convertFrom; //todo autodetect from languageCode + $this->options = $this->resolveOptions($options); $this->open(); $this->readHeader(); } + protected function resolveOptions($options = []): array + { + if (is_string($options)) { + @trigger_error('You should pass convertFrom as `encoding` option'); + $options = ['encoding' => $options]; + } + + $options = array_merge([ + 'encoding' => null, + ], $options); + + return $options; + } + public function __destruct() { $this->close(); diff --git a/src/XBase/Memo/AbstractWritableMemo.php b/src/XBase/Memo/AbstractWritableMemo.php index 5bc657a..0a257fa 100644 --- a/src/XBase/Memo/AbstractWritableMemo.php +++ b/src/XBase/Memo/AbstractWritableMemo.php @@ -4,16 +4,12 @@ use XBase\BlocksMerger; use XBase\Stream\Stream; -use XBase\Table; use XBase\Traits\CloneTrait; abstract class AbstractWritableMemo extends AbstractMemo implements WritableMemoInterface { use CloneTrait; - /** @var bool */ - protected $writable = false; - /** * @var BlocksMerger Garbage blocks. Delete blocks while saving. */ @@ -26,15 +22,16 @@ abstract protected function getBlockLengthInBytes(): int; abstract protected function calculateBlockCount(string $data): int; - public function __construct(Table $table, string $filepath, ?string $convertFrom = null, bool $writable = false) + protected function resolveOptions($options = []): array { - $this->writable = $writable; - parent::__construct($table, $filepath, $convertFrom); + return array_merge([ + 'writable' => false, + ], parent::resolveOptions($options)); } public function open(): void { - if (!$this->writable) { + if (!$this->options['writable']) { parent::open(); return; @@ -54,7 +51,7 @@ protected function readHeader(): void public function close(): void { parent::close(); - if ($this->writable && $this->cloneFilepath) { + if ($this->options['writable'] && $this->cloneFilepath) { unlink($this->cloneFilepath); $this->cloneFilepath = null; } diff --git a/src/XBase/Memo/DBase3Memo.php b/src/XBase/Memo/DBase3Memo.php index 0073381..a51fe76 100644 --- a/src/XBase/Memo/DBase3Memo.php +++ b/src/XBase/Memo/DBase3Memo.php @@ -47,8 +47,8 @@ public function get(int $pointer): ?MemoObject if (chr(0x00) === substr($result, -1)) { $result = substr($result, 0, -1); // remove endline symbol (0x00) } - if ($this->convertFrom) { - $result = iconv($this->convertFrom, 'utf-8', $result); + if ($this->options['encoding']) { + $result = iconv($this->options['encoding'], 'utf-8', $result); } } diff --git a/src/XBase/Memo/DBase4Memo.php b/src/XBase/Memo/DBase4Memo.php index d79d1d0..c535469 100644 --- a/src/XBase/Memo/DBase4Memo.php +++ b/src/XBase/Memo/DBase4Memo.php @@ -46,8 +46,8 @@ public function get(int $pointer): ?MemoObject // $result = $this->fp->read($memoLength[1] - self::BLOCK_SIGN_LENGTH - self::BLOCK_LENGTH_LENGTH); $type = $this->guessDataType($result); - if (MemoObject::TYPE_TEXT === $type && $this->convertFrom) { - $result = iconv($this->convertFrom, 'utf-8', $result); + if (MemoObject::TYPE_TEXT === $type && $this->options['encoding']) { + $result = iconv($this->options['encoding'], 'utf-8', $result); } return new MemoObject($result, $type, $pointer, $memoLength[1]); diff --git a/src/XBase/Memo/FoxproMemo.php b/src/XBase/Memo/FoxproMemo.php index 7c86683..d974424 100644 --- a/src/XBase/Memo/FoxproMemo.php +++ b/src/XBase/Memo/FoxproMemo.php @@ -54,8 +54,8 @@ public function get(int $pointer): ?MemoObject $result = $this->fp->read($memoLength[1]); $type = $this->guessDataType($result); - if ($this->convertFrom) { - $result = iconv($this->convertFrom, 'utf-8', $result); + if ($this->options['encoding']) { + $result = iconv($this->options['encoding'], 'utf-8', $result); } return new MemoObject($result, $type, $pointer, $memoLength[1]); diff --git a/src/XBase/Memo/MemoFactory.php b/src/XBase/Memo/MemoFactory.php index c1f27e6..7038b1b 100644 --- a/src/XBase/Memo/MemoFactory.php +++ b/src/XBase/Memo/MemoFactory.php @@ -7,7 +7,7 @@ class MemoFactory { - public static function create(Table $table, bool $writable = false): ?MemoInterface + public static function create(Table $table, array $options = []): ?MemoInterface { $class = self::getClass($table->getVersion()); $refClass = new \ReflectionClass($class); @@ -26,7 +26,7 @@ public static function create(Table $table, bool $writable = false): ?MemoInterf return null; //todo create file? } - return $refClass->newInstance($table, $memoFilepath, $table->getConvertFrom(), $writable); + return $refClass->newInstance($table, $memoFilepath, $options); } private static function getClass(int $version): string diff --git a/src/XBase/Table.php b/src/XBase/Table.php index 88d7873..18f7af9 100755 --- a/src/XBase/Table.php +++ b/src/XBase/Table.php @@ -30,9 +30,6 @@ class Table /** @var string Table filepath. */ protected $filepath; - /** @var array|null */ - protected $availableColumns; - /** @var Stream */ protected $fp; @@ -48,9 +45,6 @@ class Table /** @var RecordInterface|null */ protected $record; - /** @var string|null */ - protected $convertFrom; - /** * @var int * @@ -137,25 +131,51 @@ class Table /** @var string|null DBase7 only */ protected $languageName; + /** @var array */ + protected $options = []; + /** * Table constructor. * - * @param array|null $availableColumns - * @param string|null $convertFrom Encoding of file + * @param array $options Array of options:
+ * encoding - convert text data from
+ * columns - available columns
* * @throws \Exception */ - public function __construct(string $filepath, $availableColumns = null, $convertFrom = null) + public function __construct(string $filepath, $options = [], $convertFrom = null) { $this->filepath = $filepath; - $this->availableColumns = $availableColumns; - $this->convertFrom = $convertFrom; //todo autodetect from languageCode + $this->options = $this->resolveOptions($options, $convertFrom); $this->open(); $this->readHeader(); $this->openMemo(); } + protected function resolveOptions($options, $convertFrom = null): array + { + // right options + if (!empty($options) && array_intersect(['encoding', 'columns'], array_keys($options))) { + return array_merge([ + 'columns' => [], + 'encoding' => null, + ], $options); + } + + if (!empty($options)) { + @trigger_error('You should pass availableColumns as `columns` option'); + } + if (!empty($convertFrom)) { + @trigger_error('You should pass convertFrom as `encoding` option'); + } + + return [ + 'columns' => $options ?? [], + 'encoding' => $convertFrom, + ]; + } + protected function open(): void { if (!file_exists($this->filepath)) { @@ -206,7 +226,7 @@ protected function readHeader(): void protected function openMemo(): void { if (TableType::hasMemo($this->getVersion())) { - $this->memo = MemoFactory::create($this); + $this->memo = MemoFactory::create($this, $this->options); } } @@ -517,7 +537,7 @@ public function getDeleteCount() public function getConvertFrom(): ?string { - return $this->convertFrom; + return $this->options['encoding']; } /** diff --git a/src/XBase/Traits/CloneTrait.php b/src/XBase/Traits/CloneTrait.php index ed833d1..da92580 100644 --- a/src/XBase/Traits/CloneTrait.php +++ b/src/XBase/Traits/CloneTrait.php @@ -7,7 +7,7 @@ */ trait CloneTrait { - /** @var string */ + /** @var string|null */ private $cloneFilepath; /** diff --git a/src/XBase/WritableTable.php b/src/XBase/WritableTable.php index cb86d5e..e0d0b0d 100644 --- a/src/XBase/WritableTable.php +++ b/src/XBase/WritableTable.php @@ -18,6 +18,16 @@ class WritableTable extends Table { use CloneTrait; + /** + * Perform any edits on clone file and replace original file after call `save` method. + */ + public const EDIT_MODE_CLONE = 'clone'; + + /** + * Perform edits immediately on original file. + */ + public const EDIT_MODE_REALTIME = 'realtime'; + /** * @var bool * @@ -30,29 +40,48 @@ class WritableTable extends Table */ private $insertion = false; + protected function resolveOptions($options, $convertFrom = null): array + { + return array_merge( + ['editMode' => self::EDIT_MODE_CLONE], + parent::resolveOptions($options, $convertFrom) + ); + } + protected function open(): void { - $this->clone(); - $this->fp = Stream::createFromFile($this->cloneFilepath, 'rb+'); + switch ($this->options['editMode']) { + case self::EDIT_MODE_CLONE: + $this->clone(); + $this->fp = Stream::createFromFile($this->cloneFilepath, 'rb+'); + break; + + case self::EDIT_MODE_REALTIME: + $this->fp = Stream::createFromFile($this->filepath, 'rb+'); + break; + } } protected function openMemo(): void { if (TableType::hasMemo($this->getVersion())) { - $this->memo = MemoFactory::create($this, true); + $memoOptions = array_merge($this->options, ['writable' => true]); + $this->memo = MemoFactory::create($this, $memoOptions); } } public function close(): void { - if ($this->autoSave) { + if (self::EDIT_MODE_CLONE === $this->options['editMode'] && $this->autoSave) { @trigger_error('You should call `save` method directly.'); $this->save(); } parent::close(); - unlink($this->cloneFilepath); + if ($this->cloneFilepath && file_exists($this->cloneFilepath)) { + unlink($this->cloneFilepath); + } } /** @@ -185,29 +214,6 @@ protected function writeHeader(): void foreach ($this->columns as $column) { $column->toBinaryString($this->fp); -// $this->fp->write(str_pad(substr($column->getRawName(), 0, 11), 11, chr(0))); // 0-10 -// $this->fp->write($column->getType());// 11 -// $this->fp->writeUInt($column->getMemAddress());//12-15 -// $this->fp->writeUChar($column->getLength());//16 -// $this->fp->writeUChar($column->getDecimalCount());//17 -// $this->fp->write( -// method_exists($column, 'getReserved1') -// ? call_user_func([$column, 'getReserved1']) -// : str_pad('', 2, chr(0)) -// );//18-19 -// $this->fp->writeUChar($column->getWorkAreaID());//20 -// $this->fp->write( -// method_exists($column, 'getReserved2') -// ? call_user_func([$column, 'getReserved2']) -// : str_pad('', 2, chr(0)) -// );//21-22 -// $this->fp->write(chr($column->isSetFields() ? 1 : 0));//23 -// $this->fp->write( -// method_exists($column, 'getReserved3') -// ? call_user_func([$column, 'getReserved3']) -// : str_pad('', 7, chr(0)) -// );//24-30 -// $this->fp->write(chr($column->isIndexed() ? 1 : 0));//31 } $this->fp->writeUChar(0x0d); @@ -247,6 +253,10 @@ public function writeRecord(RecordInterface $record = null): self $this->fp->flush(); + if (self::EDIT_MODE_REALTIME === $this->options['editMode'] && $this->insertion) { + $this->save(); + } + $this->insertion = false; return $this; @@ -305,11 +315,14 @@ public function pack(): self } $this->recordCount = $newRecordCount; - $this->writeHeader(); $size = $this->headerLength + ($this->recordCount * $this->recordByteLength); $this->fp->truncate($size); + if (self::EDIT_MODE_REALTIME === $this->options['editMode']) { + $this->save(); + } + return $this; } @@ -327,7 +340,9 @@ public function save(): self $this->fp->writeUChar(self::END_OF_FILE_MARKER); } - copy($this->cloneFilepath, $this->filepath); + if (self::EDIT_MODE_CLONE === $this->options['editMode']) { + copy($this->cloneFilepath, $this->filepath); + } return $this; } @@ -335,7 +350,7 @@ public function save(): self /** * @internal * - * @todo Find better solution for notifying table from memo. + * @todo Find better solution to notify table from Memo. */ public function onMemoBlocksDelete(array $blocks): void { diff --git a/tests/Writable/DBase3TableTest.php b/tests/Writable/DBase3TableTest.php index 28d605d..849f844 100644 --- a/tests/Writable/DBase3TableTest.php +++ b/tests/Writable/DBase3TableTest.php @@ -15,7 +15,7 @@ class DBase3TableTest extends TestCase public function testAppendRecord(): void { - self:self::markTestIncomplete(); + self::markTestIncomplete(); $copyTo = $this->duplicateFile(self::FILEPATH); $size = filesize($copyTo); diff --git a/tests/Writable/FoxproTableTest.php b/tests/Writable/FoxproTableTest.php index e1476cd..8d8b3ba 100644 --- a/tests/Writable/FoxproTableTest.php +++ b/tests/Writable/FoxproTableTest.php @@ -14,7 +14,7 @@ class FoxproTableTest extends TestCase const FILEPATH = __DIR__.'/../Resources/foxpro/Foxpro2.dbf'; /** - * Method appendRecord must not increase recordCount. Only after writeRecord will it will be increased. + * Method appendRecord must not increase recordCount. Only after call writeRecord it will be increased. */ public function testAppendNotIncreaseRecordsCount(): void { diff --git a/tests/WritableTableTest.php b/tests/WritableTableTest.php index d0e2eb0..fcd9e5f 100644 --- a/tests/WritableTableTest.php +++ b/tests/WritableTableTest.php @@ -179,4 +179,78 @@ public function testIssue78(): void self::assertSame(5000.0, $record->getNum('salario')); $table->close(); } + + /** + * Issue #97 + * Write data in clone mode. Need to call `save` method. + */ + public function testCloneMode(): void + { + $copyTo = $this->duplicateFile(self::FILEPATH); + + $tableRead = new Table($copyTo, ['encoding' => 'cp866']); + $recordsCountBeforeInsert = $tableRead->getRecordCount(); + $tableRead->close(); + + $tableWrite = new WritableTable($copyTo, [ + 'encoding' => 'cp866', + 'editMode' => 'clone', + ]); + $recordWrite = $tableWrite->appendRecord(); + $recordWrite + ->set('regn', 2) + ->set('plan', 'Ы'); + $tableWrite->writeRecord($recordWrite); + + // nothing has changed + $tableRead = new Table($copyTo, ['encoding' => 'cp866']); + self::assertSame($recordsCountBeforeInsert, $tableRead->getRecordCount()); + $tableRead->close(); + + // save changes + $tableWrite + ->save() + ->close(); + + $tableRead = new Table($copyTo, ['encoding' => 'cp866']); + self::assertSame($recordsCountBeforeInsert + 1, $tableRead->getRecordCount()); + $recordRead = $tableRead->pickRecord($recordsCountBeforeInsert); + self::assertNotEmpty($recordRead); + self::assertSame(2, $recordRead->get('regn')); + self::assertSame('Ы', $recordRead->get('plan')); + $tableRead->close(); + } + + /** + * Issue #97 + * Write data in realtime mode. No need to call `save` method. + */ + public function testRealtimeMode(): void + { + $copyTo = $this->duplicateFile(self::FILEPATH); + + $tableRead = new Table($copyTo, ['encoding' => 'cp866']); + $recordsCountBeforeInsert = $tableRead->getRecordCount(); + $tableRead->close(); + + $tableWrite = new WritableTable($copyTo, [ + 'encoding' => 'cp866', + 'editMode' => 'realtime', + ]); + $recordWrite = $tableWrite->appendRecord(); + $recordWrite + ->set('regn', 2) + ->set('plan', 'Ы'); + $tableWrite->writeRecord($recordWrite); + + $tableRead = new Table($copyTo, ['encoding' => 'cp866']); + self::assertSame($recordsCountBeforeInsert + 1, $tableRead->getRecordCount()); + $recordRead = $tableRead->pickRecord($recordsCountBeforeInsert); + self::assertNotEmpty($recordRead); + self::assertSame(2, $recordRead->get('regn')); + self::assertSame('Ы', $recordRead->get('plan')); + + $tableWrite->close(); + $tableRead->close(); + } }