├── phpstan.neon ├── .gitignore ├── tests ├── bootstrap.php ├── Integration │ ├── TestEventSubscribers.php │ ├── RowsQueryTest.php │ ├── BaseCase.php │ └── BasicTest.php └── Unit │ ├── Gtid │ ├── GtidCollectionTest.php │ └── GtidTest.php │ ├── Event │ └── RowEvent │ │ └── TableMapTest.php │ ├── Cache │ └── ArrayCacheTest.php │ ├── Repository │ └── MySQLRepositoryTest.php │ ├── BinaryDataReader │ └── BinaryDataReaderTest.php │ └── Config │ └── ConfigTest.php ├── src └── MySQLReplication │ ├── BinLog │ ├── BinLogException.php │ ├── BinLogAuthPluginMode.php │ ├── BinLogCurrent.php │ ├── BinLogServerInfo.php │ └── BinLogSocketConnect.php │ ├── BinaryDataReader │ ├── BinaryDataReaderException.php │ └── BinaryDataReader.php │ ├── Gtid │ ├── GtidException.php │ ├── GtidCollection.php │ └── Gtid.php │ ├── Socket │ ├── SocketInterface.php │ ├── SocketException.php │ └── Socket.php │ ├── JsonBinaryDecoder │ ├── JsonBinaryDecoderValue.php │ ├── JsonBinaryDecoderException.php │ ├── JsonBinaryDecoderFormatter.php │ └── JsonBinaryDecoderService.php │ ├── Event │ ├── DTO │ │ ├── WriteRowsDTO.php │ │ ├── DeleteRowsDTO.php │ │ ├── UpdateRowsDTO.php │ │ ├── EventDTO.php │ │ ├── HeartbeatDTO.php │ │ ├── FormatDescriptionEventDTO.php │ │ ├── XidDTO.php │ │ ├── RowsQueryDTO.php │ │ ├── GTIDLogDTO.php │ │ ├── RotateDTO.php │ │ ├── RowsDTO.php │ │ ├── MariaDbGtidLogDTO.php │ │ ├── TableMapDTO.php │ │ └── QueryDTO.php │ ├── RowEvent │ │ ├── ColumnDTOCollection.php │ │ ├── TableMap.php │ │ ├── RowEventFactory.php │ │ ├── TableMapCache.php │ │ ├── RowEventBuilder.php │ │ └── ColumnDTO.php │ ├── EventCommon.php │ ├── XidEvent.php │ ├── MariaDbGtidEvent.php │ ├── RowsQueryEvent.php │ ├── GtidEvent.php │ ├── QueryEvent.php │ ├── RotateEvent.php │ ├── EventInfo.php │ ├── EventSubscribers.php │ └── Event.php │ ├── Repository │ ├── RepositoryInterface.php │ ├── MasterStatusDTO.php │ ├── PingableConnection.php │ ├── FieldDTOCollection.php │ ├── FieldDTO.php │ └── MySQLRepository.php │ ├── Exception │ └── MySQLReplicationException.php │ ├── Definitions │ ├── ConstEventsNames.php │ ├── ConstFieldType.php │ └── ConstEventType.php │ ├── Config │ ├── ConfigException.php │ ├── Config.php │ └── ConfigBuilder.php │ ├── Cache │ └── ArrayCache.php │ └── MySQLReplicationFactory.php ├── ISSUE_TEMPLATE.md ├── docker-compose.yml ├── phpunit.xml ├── LICENSE ├── .github └── workflows │ └── tests.yml ├── composer.json ├── example ├── dump_events.php ├── resuming.php └── benchmark.php ├── ecs.php ├── CHANGELOG.md └── README.md /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 9 3 | checkMissingIterableValueType: false 4 | tmpDir: .cache/phpstan/ 5 | paths: 6 | - src 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out.log 2 | log 3 | /vendor 4 | composer.phar 5 | composer.lock 6 | .php_cs.cache 7 | /example/profiler.php 8 | .idea/ 9 | .cache/ 10 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | type->value; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/DTO/DeleteRowsDTO.php: -------------------------------------------------------------------------------- 1 | type->value; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/DTO/UpdateRowsDTO.php: -------------------------------------------------------------------------------- 1 | type->value; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/MySQLReplication/JsonBinaryDecoder/JsonBinaryDecoderException.php: -------------------------------------------------------------------------------- 1 | Please provide the following details. 2 | 3 | * *Operating System*: 4 | * *PHP Version*: <5.6 | 7.0 | ...> 5 | * *php-mysql-replication Version*: <1.0.0> 6 | * *mysql version (```SELECT VERSION();```): <5.6.9 Percona | 10 Maria | ... > 7 | 8 | > Steps required to reproduce the problem. 9 | 10 | 1. 11 | 2. 12 | 3. 13 | 14 | > Expected Result. 15 | 16 | * 17 | 18 | > Actual Result. 19 | 20 | * 21 | -------------------------------------------------------------------------------- /src/MySQLReplication/Repository/RepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class ColumnDTOCollection extends ArrayCollection implements JsonSerializable 14 | { 15 | public function jsonSerialize(): array 16 | { 17 | return $this->toArray(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/EventCommon.php: -------------------------------------------------------------------------------- 1 | eventInfo, (string)$this->binaryDataReader->readUInt64()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Integration/TestEventSubscribers.php: -------------------------------------------------------------------------------- 1 | baseTest->setEvent($event); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/MySQLReplication/Repository/PingableConnection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class FieldDTOCollection extends ArrayCollection 13 | { 14 | public static function makeFromArray(array $fields): self 15 | { 16 | $collection = new self(); 17 | foreach ($fields as $field) { 18 | $collection->add(FieldDTO::makeFromArray($field)); 19 | } 20 | 21 | return $collection; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/RowEvent/TableMap.php: -------------------------------------------------------------------------------- 1 | binaryDataReader->readUInt64(); 14 | $domainId = $this->binaryDataReader->readUInt32(); 15 | $flag = $this->binaryDataReader->readUInt8(); 16 | 17 | $this->eventInfo->binLogCurrent 18 | ->setMariaDbGtid($mariaDbGtid); 19 | 20 | return new MariaDbGtidLogDTO($this->eventInfo, $flag, $domainId, $mariaDbGtid); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/RowEvent/RowEventFactory.php: -------------------------------------------------------------------------------- 1 | rowEventBuilder->withBinaryDataReader($binaryDataReader); 20 | $this->rowEventBuilder->withEventInfo($eventInfo); 21 | 22 | return $this->rowEventBuilder->build(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/RowsQueryEvent.php: -------------------------------------------------------------------------------- 1 | binaryDataReader->advance(1); 20 | return new RowsQueryDTO( 21 | $this->eventInfo, 22 | $this->binaryDataReader->read($this->eventInfo->getSizeNoHeader() - 1), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/DTO/EventDTO.php: -------------------------------------------------------------------------------- 1 | eventInfo; 27 | } 28 | 29 | abstract public function getType(): string; 30 | } 31 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/RowEvent/TableMapCache.php: -------------------------------------------------------------------------------- 1 | cache->get($tableId); 20 | return $tableMap; 21 | } 22 | 23 | public function has(string $tableId): bool 24 | { 25 | return $this->cache->has($tableId); 26 | } 27 | 28 | public function set(string $tableId, TableMap $tableMap): void 29 | { 30 | $this->cache->set($tableId, $tableMap); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | replication-test-mysql-percona: 3 | container_name: replication-test-mysql-percona 4 | hostname: replication-test-mysql-percona 5 | image: mysql:8.0 6 | platform: linux/amd64 7 | command: [ 8 | '--character-set-server=utf8mb4', 9 | '--collation-server=utf8mb4_unicode_ci', 10 | # '--default-authentication-plugin=caching_sha2_password', 11 | #'--default-authentication-plugin=mysql_native_password', 12 | '--log_bin=binlog', 13 | '--max_binlog_size=8M', 14 | '--binlog_format=row', 15 | '--server-id=1', 16 | '--binlog_rows_query_log_events=ON' 17 | ] 18 | environment: 19 | - MYSQL_ROOT_PASSWORD=root 20 | - MYSQL_DATABASE=mysqlreplication_test 21 | ports: 22 | - "3306:3306/tcp" 23 | restart: unless-stopped 24 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | ./tests/Integration 8 | 9 | 10 | ./tests/Unit 11 | 12 | 13 | 14 | 15 | src/ 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/MySQLReplication/BinLog/BinLogAuthPluginMode.php: -------------------------------------------------------------------------------- 1 | getType() . ' === ' . PHP_EOL . 17 | 'Date: ' . $this->eventInfo->getDateTime() . PHP_EOL . 18 | 'Log position: ' . $this->eventInfo->pos . PHP_EOL . 19 | 'Event size: ' . $this->eventInfo->size . PHP_EOL; 20 | } 21 | 22 | public function getType(): string 23 | { 24 | return $this->type->value; 25 | } 26 | 27 | public function jsonSerialize(): array 28 | { 29 | return get_object_vars($this); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/GtidEvent.php: -------------------------------------------------------------------------------- 1 | binaryDataReader->readUInt8() === 1; 15 | $sid = BinaryDataReader::unpack('H*', $this->binaryDataReader->read(16))[1]; 16 | $gno = $this->binaryDataReader->readUInt64(); 17 | 18 | $gtid = vsprintf( 19 | '%s%s%s%s%s%s%s%s-%s%s%s%s-%s%s%s%s-%s%s%s%s-%s%s%s%s%s%s%s%s%s%s%s%s', 20 | str_split($sid) 21 | ) . ':' . $gno; 22 | 23 | $this->eventInfo->binLogCurrent 24 | ->setGtid($gtid); 25 | 26 | return new GTIDLogDTO($this->eventInfo, $commit_flag, $gtid); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/DTO/FormatDescriptionEventDTO.php: -------------------------------------------------------------------------------- 1 | getType() . ' === ' . PHP_EOL . 17 | 'Date: ' . $this->eventInfo->getDateTime() . PHP_EOL . 18 | 'Log position: ' . $this->eventInfo->pos . PHP_EOL . 19 | 'Event size: ' . $this->eventInfo->size . PHP_EOL; 20 | } 21 | 22 | public function getType(): string 23 | { 24 | return $this->type->value; 25 | } 26 | 27 | public function jsonSerialize(): array 28 | { 29 | return get_object_vars($this); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 krowinski 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 | -------------------------------------------------------------------------------- /src/MySQLReplication/Config/ConfigException.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class GtidCollection extends ArrayCollection 14 | { 15 | public static function makeCollectionFromString(string $gtids): self 16 | { 17 | $collection = new self(); 18 | foreach (array_filter(explode(',', $gtids)) as $gtid) { 19 | $collection->add(new Gtid($gtid)); 20 | } 21 | 22 | return $collection; 23 | } 24 | 25 | public function getEncodedLength(): int 26 | { 27 | $l = 8; 28 | foreach ($this->toArray() as $gtid) { 29 | $l += $gtid->getEncodedLength(); 30 | } 31 | 32 | return $l; 33 | } 34 | 35 | public function getEncoded(): string 36 | { 37 | $s = BinaryDataReader::pack64bit($this->count()); 38 | foreach ($this->toArray() as $gtid) { 39 | $s .= $gtid->getEncoded(); 40 | } 41 | 42 | return $s; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/DTO/XidDTO.php: -------------------------------------------------------------------------------- 1 | getType() . ' === ' . PHP_EOL . 25 | 'Date: ' . $this->eventInfo->getDateTime() . PHP_EOL . 26 | 'Log position: ' . $this->eventInfo->pos . PHP_EOL . 27 | 'Event size: ' . $this->eventInfo->size . PHP_EOL . 28 | 'Transaction ID: ' . $this->xid . PHP_EOL; 29 | } 30 | 31 | public function getType(): string 32 | { 33 | return $this->type->value; 34 | } 35 | 36 | public function jsonSerialize(): array 37 | { 38 | return get_object_vars($this); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/DTO/RowsQueryDTO.php: -------------------------------------------------------------------------------- 1 | getType() . ' === ' . PHP_EOL . 25 | 'Date: ' . $this->eventInfo->getDateTime() . PHP_EOL . 26 | 'Log position: ' . $this->eventInfo->pos . PHP_EOL . 27 | 'Event size: ' . $this->eventInfo->size . PHP_EOL . 28 | 'Query: ' . $this->query . PHP_EOL; 29 | } 30 | 31 | public function getType(): string 32 | { 33 | return $this->type->value; 34 | } 35 | 36 | public function jsonSerialize(): array 37 | { 38 | return get_object_vars($this); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/QueryEvent.php: -------------------------------------------------------------------------------- 1 | binaryDataReader->readUInt32(); 17 | $executionTime = $this->binaryDataReader->readUInt32(); 18 | $schemaLength = $this->binaryDataReader->readUInt8(); 19 | $this->binaryDataReader->advance(2); 20 | $statusVarsLength = $this->binaryDataReader->readUInt16(); 21 | $this->binaryDataReader->advance($statusVarsLength); 22 | $schema = $this->binaryDataReader->read($schemaLength); 23 | $this->binaryDataReader->advance(1); 24 | $query = $this->binaryDataReader->read( 25 | $this->eventInfo->getSizeNoHeader() - 13 - $statusVarsLength - $schemaLength - 1 26 | ); 27 | 28 | return new QueryDTO($this->eventInfo, $schema, $executionTime, $query, $threadId); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/RotateEvent.php: -------------------------------------------------------------------------------- 1 | binaryDataReader->readUInt64(); 17 | $binFileName = $this->binaryDataReader->read( 18 | $this->eventInfo->getSizeNoHeader() - $this->getSizeToRemoveByVersion() 19 | ); 20 | 21 | $this->eventInfo->binLogCurrent 22 | ->setBinLogPosition($binFilePos); 23 | $this->eventInfo->binLogCurrent 24 | ->setBinFileName($binFileName); 25 | 26 | return new RotateDTO($this->eventInfo, $binFilePos, $binFileName); 27 | } 28 | 29 | private function getSizeToRemoveByVersion(): int 30 | { 31 | if ($this->binLogServerInfo->versionRevision <= 10 && $this->binLogServerInfo->isMariaDb()) { 32 | return 0; 33 | } 34 | 35 | return 8; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/DTO/GTIDLogDTO.php: -------------------------------------------------------------------------------- 1 | getType() . ' === ' . PHP_EOL . 26 | 'Date: ' . $this->eventInfo->getDateTime() . PHP_EOL . 27 | 'Log position: ' . $this->eventInfo->pos . PHP_EOL . 28 | 'Event size: ' . $this->eventInfo->size . PHP_EOL . 29 | 'Commit: ' . var_export($this->commit, true) . PHP_EOL . 30 | 'GTID NEXT: ' . $this->gtid . PHP_EOL; 31 | } 32 | 33 | public function getType(): string 34 | { 35 | return $this->type->value; 36 | } 37 | 38 | public function jsonSerialize(): array 39 | { 40 | return get_object_vars($this); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/DTO/RotateDTO.php: -------------------------------------------------------------------------------- 1 | getType() . ' === ' . PHP_EOL . 26 | 'Date: ' . $this->eventInfo->getDateTime() . PHP_EOL . 27 | 'Log position: ' . $this->eventInfo->pos . PHP_EOL . 28 | 'Event size: ' . $this->eventInfo->size . PHP_EOL . 29 | 'Binlog position: ' . $this->position . PHP_EOL . 30 | 'Binlog filename: ' . $this->nextBinlog . PHP_EOL; 31 | } 32 | 33 | public function getType(): string 34 | { 35 | return $this->type->value; 36 | } 37 | 38 | public function jsonSerialize(): array 39 | { 40 | return get_object_vars($this); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/DTO/RowsDTO.php: -------------------------------------------------------------------------------- 1 | getType() . ' === ' . PHP_EOL . 25 | 'Date: ' . $this->eventInfo->getDateTime() . PHP_EOL . 26 | 'Log position: ' . $this->eventInfo->pos . PHP_EOL . 27 | 'Event size: ' . $this->eventInfo->size . PHP_EOL . 28 | 'Table: ' . $this->tableMap->table . PHP_EOL . 29 | 'Affected columns: ' . $this->tableMap->columnsAmount . PHP_EOL . 30 | 'Changed rows: ' . $this->changedRows . PHP_EOL . 31 | 'Values: ' . print_r($this->values, true) . PHP_EOL; 32 | } 33 | 34 | public function jsonSerialize(): array 35 | { 36 | return get_object_vars($this); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/MySQLReplication/Repository/FieldDTO.php: -------------------------------------------------------------------------------- 1 | 'DROPPED_COLUMN_' . $index, 24 | 'COLLATION_NAME' => null, 25 | 'CHARACTER_SET_NAME' => null, 26 | 'COLUMN_COMMENT' => '', 27 | 'COLUMN_TYPE' => 'BLOB', 28 | 'COLUMN_KEY' => '', 29 | ] 30 | ); 31 | } 32 | 33 | public static function makeFromArray(array $field): self 34 | { 35 | return new self( 36 | $field['COLUMN_NAME'], 37 | $field['COLLATION_NAME'], 38 | $field['CHARACTER_SET_NAME'], 39 | $field['COLUMN_COMMENT'], 40 | $field['COLUMN_TYPE'], 41 | $field['COLUMN_KEY'] 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/MySQLReplication/Definitions/ConstFieldType.php: -------------------------------------------------------------------------------- 1 | getType() . ' === ' . PHP_EOL . 27 | 'Date: ' . $this->eventInfo->getDateTime() . PHP_EOL . 28 | 'Log position: ' . $this->eventInfo->pos . PHP_EOL . 29 | 'Event size: ' . $this->eventInfo->size . PHP_EOL . 30 | 'Flag: ' . var_export($this->flag, true) . PHP_EOL . 31 | 'Domain Id: ' . $this->domainId . PHP_EOL . 32 | 'Sequence Number: ' . $this->mariaDbGtid . PHP_EOL; 33 | } 34 | 35 | public function getType(): string 36 | { 37 | return $this->type->value; 38 | } 39 | 40 | public function jsonSerialize(): array 41 | { 42 | return get_object_vars($this); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Unit/Gtid/GtidCollectionTest.php: -------------------------------------------------------------------------------- 1 | gtidCollection = new GtidCollection(); 20 | 21 | $this->gtidCollection->add(new Gtid('9b1c8d18-2a76-11e5-a26b-000c2976f3f3:1-177592')); 22 | $this->gtidCollection->add(new Gtid('BBBBBBBB-CCCC-FFFF-DDDD-AAAAAAAAAAAA:1')); 23 | } 24 | 25 | public function testShouldGetEncodedLength(): void 26 | { 27 | self::assertSame(88, $this->gtidCollection->getEncodedLength()); 28 | } 29 | 30 | public function testShouldGetEncoded(): void 31 | { 32 | self::assertSame( 33 | '02000000000000009b1c8d182a7611e5a26b000c2976f3f301000000000000000100000000000000b8b5020000000000bbbbbbbbccccffffddddaaaaaaaaaaaa010000000000000001000000000000000200000000000000', 34 | bin2hex($this->gtidCollection->getEncoded()) 35 | ); 36 | } 37 | 38 | public function testShouldCreateCollection(): void 39 | { 40 | self::assertCount(1, GtidCollection::makeCollectionFromString('9b1c8d18-2a76-11e5-a26b-000c2976f3f3:1-177592')); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/DTO/TableMapDTO.php: -------------------------------------------------------------------------------- 1 | getType() . ' === ' . PHP_EOL . 26 | 'Date: ' . $this->eventInfo->getDateTime() . PHP_EOL . 27 | 'Log position: ' . $this->eventInfo->pos . PHP_EOL . 28 | 'Event size: ' . $this->eventInfo->size . PHP_EOL . 29 | 'Table: ' . $this->tableMap->table . PHP_EOL . 30 | 'Database: ' . $this->tableMap->database . PHP_EOL . 31 | 'Table Id: ' . $this->tableMap->tableId . PHP_EOL . 32 | 'Columns amount: ' . $this->tableMap->columnsAmount . PHP_EOL; 33 | } 34 | 35 | public function getType(): string 36 | { 37 | return $this->type->value; 38 | } 39 | 40 | public function jsonSerialize(): array 41 | { 42 | return get_object_vars($this); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/MySQLReplication/BinLog/BinLogCurrent.php: -------------------------------------------------------------------------------- 1 | binLogPosition; 22 | } 23 | 24 | public function setBinLogPosition(string $binLogPosition): void 25 | { 26 | $this->binLogPosition = $binLogPosition; 27 | } 28 | 29 | public function getBinFileName(): string 30 | { 31 | return $this->binFileName; 32 | } 33 | 34 | public function setBinFileName(string $binFileName): void 35 | { 36 | $this->binFileName = $binFileName; 37 | } 38 | 39 | public function getGtid(): string 40 | { 41 | return $this->gtid; 42 | } 43 | 44 | public function setGtid(string $gtid): void 45 | { 46 | $this->gtid = $gtid; 47 | } 48 | 49 | public function getMariaDbGtid(): string 50 | { 51 | return $this->mariaDbGtid; 52 | } 53 | 54 | public function setMariaDbGtid(string $mariaDbGtid): void 55 | { 56 | $this->mariaDbGtid = $mariaDbGtid; 57 | } 58 | 59 | public function jsonSerialize(): array 60 | { 61 | return get_object_vars($this); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/DTO/QueryDTO.php: -------------------------------------------------------------------------------- 1 | getType() . ' === ' . PHP_EOL . 28 | 'Date: ' . $this->eventInfo->getDateTime() . PHP_EOL . 29 | 'Log position: ' . $this->eventInfo->pos . PHP_EOL . 30 | 'Event size: ' . $this->eventInfo->size . PHP_EOL . 31 | 'Database: ' . $this->database . PHP_EOL . 32 | 'Execution time: ' . $this->executionTime . PHP_EOL . 33 | 'Query: ' . $this->query . PHP_EOL . 34 | 'Thread id: ' . $this->threadId . PHP_EOL; 35 | } 36 | 37 | public function getType(): string 38 | { 39 | return $this->type->value; 40 | } 41 | 42 | public function jsonSerialize(): array 43 | { 44 | return get_object_vars($this); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Unit/Event/RowEvent/TableMapTest.php: -------------------------------------------------------------------------------- 1 | 'foo', 17 | 'table' => 'bar', 18 | 'tableId' => '1', 19 | 'columnsAmount' => 2, 20 | 'columnDTOCollection' => new ColumnDTOCollection(), 21 | ]; 22 | 23 | $tableMap = new TableMap( 24 | $expected['database'], 25 | $expected['table'], 26 | $expected['tableId'], 27 | $expected['columnsAmount'], 28 | $expected['columnDTOCollection'] 29 | ); 30 | 31 | self::assertSame($expected['database'], $tableMap->database); 32 | self::assertSame($expected['table'], $tableMap->table); 33 | self::assertSame($expected['tableId'], $tableMap->tableId); 34 | self::assertSame($expected['columnsAmount'], $tableMap->columnsAmount); 35 | self::assertSame($expected['columnDTOCollection'], $tableMap->columnDTOCollection); 36 | 37 | self::assertInstanceOf(\JsonSerializable::class, $tableMap); 38 | /** @noinspection JsonEncodingApiUsageInspection */ 39 | self::assertSame(json_encode($expected), json_encode($tableMap)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Unit/Gtid/GtidTest.php: -------------------------------------------------------------------------------- 1 | getGtid('9b1c8d18-2a76-11e5-a26b-000c2976f3f3:1-177592')->getEncoded()) 20 | ); 21 | self::assertSame( 22 | '9b1c8d182a7611e5a26b000c2976f3f3010000000000000001000000000000000200000000000000', 23 | bin2hex($this->getGtid('9b1c8d18-2a76-11e5-a26b-000c2976f3f3:1')->getEncoded()) 24 | ); 25 | } 26 | 27 | public function testShouldGetEncodedLength(): void 28 | { 29 | self::assertSame(40, $this->getGtid('9b1c8d18-2a76-11e5-a26b-000c2976f3f3:1-177592')->getEncodedLength()); 30 | } 31 | 32 | public function testShouldThrowErrorOnIncrrectGtid(): void 33 | { 34 | $this->expectException(GtidException::class); 35 | $this->expectExceptionMessage(GtidException::INCORRECT_GTID_MESSAGE); 36 | $this->expectExceptionCode(GtidException::INCORRECT_GTID_CODE); 37 | 38 | $this->getGtid('not gtid'); 39 | } 40 | 41 | private function getGtid(string $data): Gtid 42 | { 43 | return new Gtid($data); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/RowEvent/RowEventBuilder.php: -------------------------------------------------------------------------------- 1 | binaryDataReader = $binaryDataReader; 32 | } 33 | 34 | public function build(): RowEvent 35 | { 36 | return new RowEvent( 37 | $this->repository, 38 | $this->binaryDataReader, 39 | $this->eventInfo, 40 | new TableMapCache($this->cache), 41 | $this->config, 42 | $this->binLogServerInfo, 43 | $this->logger 44 | ); 45 | } 46 | 47 | public function withEventInfo(EventInfo $eventInfo): void 48 | { 49 | $this->eventInfo = $eventInfo; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/MySQLReplication/Gtid/Gtid.php: -------------------------------------------------------------------------------- 1 | sid = $matches[1]; 25 | foreach (array_filter(explode(':', $matches[2])) as $k) { 26 | $this->intervals[] = explode('-', $k); 27 | } 28 | $this->sid = str_replace('-', '', $this->sid); 29 | } 30 | 31 | public function getEncoded(): string 32 | { 33 | $buffer = pack('H*', $this->sid); 34 | $buffer .= BinaryDataReader::pack64bit(count($this->intervals)); 35 | 36 | foreach ($this->intervals as $interval) { 37 | $buffer .= BinaryDataReader::pack64bit((int)$interval[0]); 38 | if (count($interval) !== 1) { 39 | $buffer .= BinaryDataReader::pack64bit((int)$interval[1]); 40 | } else { 41 | $buffer .= BinaryDataReader::pack64bit($interval[0] + 1); 42 | } 43 | } 44 | 45 | return $buffer; 46 | } 47 | 48 | public function getEncodedLength(): int 49 | { 50 | return 40 * count($this->intervals); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: PHP Tests 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | strategy: 8 | matrix: 9 | php: [ '8.2', '8.3', '8.4' ] 10 | mysql-version: [ '5.7', '8.0', '8.4' ] 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - uses: shogo82148/actions-setup-mysql@v1 17 | with: 18 | mysql-version: "${{ matrix.mysql-version }}" 19 | my-cnf: | 20 | server-id=1 21 | binlog_format=row 22 | binlog_rows_query_log_events=ON 23 | log_bin=binlog 24 | root-password: root 25 | 26 | - name: set up timezones 27 | run: mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql -proot 28 | 29 | - name: Setup PHP, with composer and extensions 30 | uses: shivammathur/setup-php@v2 31 | with: 32 | php-version: ${{ matrix.php }} 33 | coverage: xdebug 34 | 35 | - name: Validate composer.json and composer.lock 36 | run: composer validate 37 | 38 | - name: Cache Composer packages 39 | id: composer-cache 40 | uses: actions/cache@v4 41 | with: 42 | path: vendor 43 | key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} 44 | restore-keys: | 45 | ${{ runner.os }}-${{ matrix.php }}- 46 | 47 | - name: Install dependencies 48 | if: steps.composer-cache.outputs.cache-hit != 'true' 49 | run: composer install --prefer-dist --no-progress --no-suggest 50 | 51 | - name: Run tests 52 | run: vendor/bin/phpunit --coverage-text 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "krowinski/php-mysql-replication", 3 | "description": "Pure PHP Implementation of MySQL replication protocol. This allow you to receive event like insert, update, delete with their data and raw SQL queries.", 4 | "keywords": [ 5 | "mysql-replication", 6 | "php-library", 7 | "mysql", 8 | "mysql-binlog", 9 | "mysql-replication-protocol", 10 | "replication", 11 | "binlog" 12 | ], 13 | "type": "library", 14 | "require": { 15 | "php": ">=8.2", 16 | "ext-bcmath": "*", 17 | "ext-json": "*", 18 | "ext-sockets": "*", 19 | "doctrine/collections": "^2.1", 20 | "doctrine/dbal": "^4.0", 21 | "psr/log": "^3.0", 22 | "psr/simple-cache": "^3.0", 23 | "symfony/event-dispatcher": "^6.0|^7.0" 24 | }, 25 | "require-dev": { 26 | "kubawerlos/php-cs-fixer-custom-fixers": "^3.19", 27 | "monolog/monolog": "^3.5", 28 | "phpstan/phpstan": "^1.10", 29 | "phpunit/phpunit": "^11.0", 30 | "symplify/easy-coding-standard": "^12.1" 31 | }, 32 | "license": "MIT", 33 | "authors": [ 34 | { 35 | "name": "Kacper Rowiński", 36 | "email": "kacper.rowinski@gmail.com" 37 | } 38 | ], 39 | "autoload": { 40 | "psr-4": { 41 | "MySQLReplication\\": "src/MySQLReplication/" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "MySQLReplication\\Tests\\": "tests/" 47 | } 48 | }, 49 | "minimum-stability": "stable", 50 | "config": { 51 | "sort-packages": true 52 | }, 53 | "scripts": { 54 | "cs:check": "ecs check", 55 | "cs:fix": "ecs check --fix", 56 | "phpstan:analyse": "phpstan analyse -cphpstan.neon", 57 | "sa": [ 58 | "@cs:check", 59 | "@phpstan:analyse" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /example/dump_events.php: -------------------------------------------------------------------------------- 1 | withUser('root') 25 | ->withHost('0.0.0.0') 26 | ->withPassword('root') 27 | ->withPort(3306) 28 | ->withHeartbeatPeriod(60) 29 | ->withEventsIgnore([ConstEventType::HEARTBEAT_LOG_EVENT->value]) 30 | ->build(), 31 | logger: new Logger('replicator', [new StreamHandler(STDOUT)]) 32 | ); 33 | 34 | /** 35 | * Register your events handler 36 | * @see EventSubscribers 37 | */ 38 | $binLogStream->registerSubscriber( 39 | new class() extends EventSubscribers { 40 | public function allEvents(EventDTO $event): void 41 | { 42 | // all events got __toString() implementation 43 | #echo $event; 44 | 45 | // all events got JsonSerializable implementation 46 | echo json_encode($event, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); 47 | 48 | echo 'Memory usage ' . round(memory_get_usage() / 1048576, 2) . ' MB' . PHP_EOL; 49 | } 50 | } 51 | ); 52 | 53 | // start consuming events 54 | $binLogStream->run(); 55 | -------------------------------------------------------------------------------- /tests/Integration/RowsQueryTest.php: -------------------------------------------------------------------------------- 1 | connection->executeStatement( 19 | 'CREATE TABLE test (id INT NOT NULL AUTO_INCREMENT, data VARCHAR (50) NOT NULL, PRIMARY KEY (id))' 20 | ); 21 | 22 | $this->connection->executeStatement($query); 23 | 24 | // The Create Table Query ... irrelevant content for this test 25 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 26 | // The BEGIN Query ... irrelevant content for this test 27 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 28 | 29 | $rowsQueryEvent = $this->getEvent(); 30 | self::assertInstanceOf(RowsQueryDTO::class, $rowsQueryEvent); 31 | self::assertSame($query, $rowsQueryEvent->query); 32 | } 33 | 34 | public static function provideQueries(): Generator 35 | { 36 | yield 'Short Query' => ['INSERT INTO test (data) VALUES(\'Hello\') /* Foo:Bar; */']; 37 | 38 | $comment = '/* Foo:Bar; Bar:Baz; Baz:Quo; Quo:Foo; Quo:Foo; Quo:Foo; Quo:Foo; Foo:Baz; */'; 39 | yield 'Extra Long Query' => [$comment . ' INSERT INTO test (data) VALUES(\'Hello\') ' . $comment]; 40 | } 41 | 42 | protected function getIgnoredEvents(): array 43 | { 44 | return [ConstEventType::GTID_LOG_EVENT->value]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/EventInfo.php: -------------------------------------------------------------------------------- 1 | 0) { 28 | $this->binLogCurrent->setBinLogPosition($pos); 29 | } 30 | $this->sizeNoHeader = $this->dateTime = null; 31 | $this->typeName = ConstEventType::from($this->type)->name; 32 | } 33 | 34 | public function getTypeName(): ?string 35 | { 36 | return $this->typeName; 37 | } 38 | 39 | public function getDateTime(): ?string 40 | { 41 | if ($this->timestamp === 0) { 42 | return null; 43 | } 44 | 45 | if (empty($this->dateTime)) { 46 | $this->dateTime = date('c', $this->timestamp); 47 | } 48 | 49 | return $this->dateTime; 50 | } 51 | 52 | public function getSizeNoHeader(): int 53 | { 54 | if (empty($this->sizeNoHeader)) { 55 | $this->sizeNoHeader = ($this->checkSum === true ? $this->size - 23 : $this->size - 19); 56 | } 57 | 58 | return $this->sizeNoHeader; 59 | } 60 | 61 | public function jsonSerialize(): array 62 | { 63 | return get_object_vars($this); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | parallel(); 21 | $ecsConfig->sets([ 22 | SetList::PSR_12, 23 | SetList::CLEAN_CODE, 24 | SetList::STRICT, 25 | SetList::ARRAY, 26 | SetList::PHPUNIT, 27 | SetList::DOCTRINE_ANNOTATIONS, 28 | SetList::COMMENTS, 29 | SetList::SYMPLIFY, 30 | SetList::CONTROL_STRUCTURES, 31 | ]); 32 | 33 | $ecsConfig->rules([ 34 | NativeTypeDeclarationCasingFixer::class, 35 | ReturnToYieldFromFixer::class, 36 | TypeDeclarationSpacesFixer::class, 37 | YieldFromArrayToYieldsFixer::class, 38 | PhpdocToPropertyTypeFixer::class, 39 | PhpdocToParamTypeFixer::class, 40 | PhpdocToReturnTypeFixer::class, 41 | PromotedConstructorPropertyFixer::class, 42 | NoLeadingSlashInGlobalNamespaceFixer::class, 43 | PhpdocNoSuperfluousParamFixer::class, 44 | PhpdocAddMissingParamAnnotationFixer::class, 45 | ]); 46 | 47 | $ecsConfig->fileExtensions(['php']); 48 | $ecsConfig->cacheDirectory('.cache/ecs'); 49 | $ecsConfig->paths([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/example',]); 50 | }; 51 | -------------------------------------------------------------------------------- /src/MySQLReplication/JsonBinaryDecoder/JsonBinaryDecoderFormatter.php: -------------------------------------------------------------------------------- 1 | jsonString .= var_export($bool, true); 14 | } 15 | 16 | public function formatValueNumeric(int|string|null|float|bool $val): void 17 | { 18 | $this->jsonString .= $val; 19 | } 20 | 21 | public function formatValue(int|string|null|float|bool $val): void 22 | { 23 | $this->jsonString .= '"' . self::escapeJsonString($val) . '"'; 24 | } 25 | 26 | public function formatEndObject(): void 27 | { 28 | $this->jsonString .= '}'; 29 | } 30 | 31 | public function formatBeginArray(): void 32 | { 33 | $this->jsonString .= '['; 34 | } 35 | 36 | public function formatEndArray(): void 37 | { 38 | $this->jsonString .= ']'; 39 | } 40 | 41 | public function formatBeginObject(): void 42 | { 43 | $this->jsonString .= '{'; 44 | } 45 | 46 | public function formatNextEntry(): void 47 | { 48 | $this->jsonString .= ','; 49 | } 50 | 51 | public function formatName(string $name): void 52 | { 53 | $this->jsonString .= '"' . $name . '":'; 54 | } 55 | 56 | public function formatValueNull(): void 57 | { 58 | $this->jsonString .= 'null'; 59 | } 60 | 61 | public function getJsonString(): string 62 | { 63 | return $this->jsonString; 64 | } 65 | 66 | /** 67 | * Some characters need to be escaped 68 | * @see http://www.json.org/ 69 | * @see https://stackoverflow.com/questions/1048487/phps-json-encode-does-not-escape-all-json-control-characters 70 | */ 71 | private static function escapeJsonString(int|string|null|float|bool $value): string 72 | { 73 | return str_replace( 74 | ['\\', '/', '"', "\n", "\r", "\t", "\x08", "\x0c"], 75 | ['\\\\', '\\/', '\\"', '\\n', '\\r', '\\t', '\\f', '\\b'], 76 | (string)$value 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/MySQLReplication/Cache/ArrayCache.php: -------------------------------------------------------------------------------- 1 | has($key) ? self::$tableMapCache[$key] : $default; 22 | } 23 | 24 | public function has($key): bool 25 | { 26 | return isset(self::$tableMapCache[$key]); 27 | } 28 | 29 | public function clear(): bool 30 | { 31 | self::$tableMapCache = []; 32 | 33 | return true; 34 | } 35 | 36 | public function getMultiple(iterable $keys, mixed $default = null): iterable 37 | { 38 | $data = []; 39 | foreach ($keys as $key) { 40 | if ($this->has($key)) { 41 | $data[$key] = self::$tableMapCache[$key]; 42 | } 43 | } 44 | 45 | return $data !== [] ? $data : (array)$default; 46 | } 47 | 48 | public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool 49 | { 50 | foreach ($values as $key => $value) { 51 | $this->set($key, $value); 52 | } 53 | 54 | return true; 55 | } 56 | 57 | public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool 58 | { 59 | // automatically clear table cache to save memory 60 | if (count(self::$tableMapCache) > $this->tableCacheSize) { 61 | self::$tableMapCache = array_slice(self::$tableMapCache, (int)($this->tableCacheSize / 2), null, true); 62 | } 63 | 64 | self::$tableMapCache[$key] = $value; 65 | 66 | return true; 67 | } 68 | 69 | public function deleteMultiple(iterable $keys): bool 70 | { 71 | foreach ($keys as $key) { 72 | $this->delete($key); 73 | } 74 | 75 | return true; 76 | } 77 | 78 | public function delete(string $key): bool 79 | { 80 | unset(self::$tableMapCache[$key]); 81 | 82 | return true; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/MySQLReplication/Socket/Socket.php: -------------------------------------------------------------------------------- 1 | socket); 16 | socket_close($this->socket); 17 | } 18 | 19 | public function connectToStream(string $host, int $port): void 20 | { 21 | $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); 22 | if ($socket === false) { 23 | throw new SocketException( 24 | SocketException::SOCKET_UNABLE_TO_CREATE_MESSAGE . $this->getSocketErrorMessage(), 25 | SocketException::SOCKET_UNABLE_TO_CREATE_CODE 26 | ); 27 | } 28 | $this->socket = $socket; 29 | socket_set_block($this->socket); 30 | socket_set_option($this->socket, SOL_SOCKET, SO_KEEPALIVE, 1); 31 | 32 | if (!socket_connect($this->socket, $host, $port)) { 33 | throw new SocketException($this->getSocketErrorMessage(), $this->getSocketErrorCode()); 34 | } 35 | } 36 | 37 | public function readFromSocket(int $length): string 38 | { 39 | $received = socket_recv($this->socket, $buf, $length, MSG_WAITALL); 40 | if ($length === $received) { 41 | return $buf; 42 | } 43 | 44 | // http://php.net/manual/en/function.socket-recv.php#47182 45 | if ($received === 0) { 46 | throw new SocketException( 47 | SocketException::SOCKET_DISCONNECTED_MESSAGE, 48 | SocketException::SOCKET_DISCONNECTED_CODE 49 | ); 50 | } 51 | 52 | throw new SocketException($this->getSocketErrorMessage(), $this->getSocketErrorCode()); 53 | } 54 | 55 | public function writeToSocket(string $data): void 56 | { 57 | if (!socket_write($this->socket, $data, strlen($data))) { 58 | throw new SocketException( 59 | SocketException::SOCKET_UNABLE_TO_WRITE_MESSAGE . $this->getSocketErrorMessage(), 60 | SocketException::SOCKET_UNABLE_TO_WRITE_CODE 61 | ); 62 | } 63 | } 64 | 65 | private function getSocketErrorMessage(): string 66 | { 67 | return socket_strerror($this->getSocketErrorCode()); 68 | } 69 | 70 | private function getSocketErrorCode(): int 71 | { 72 | return socket_last_error(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/MySQLReplication/Definitions/ConstEventType.php: -------------------------------------------------------------------------------- 1 | arrayCache = new ArrayCache(); 21 | } 22 | 23 | public function testShouldGet(): void 24 | { 25 | $this->arrayCache->set('foo', 'bar'); 26 | self::assertSame('bar', $this->arrayCache->get('foo')); 27 | } 28 | 29 | public function testShouldSet(): void 30 | { 31 | $this->arrayCache->set('foo', 'bar'); 32 | self::assertSame('bar', $this->arrayCache->get('foo')); 33 | } 34 | 35 | public function testShouldClearCacheOnSet(): void 36 | { 37 | (new ConfigBuilder())->withTableCacheSize(1) 38 | ->build(); 39 | 40 | $this->arrayCache->set('foo', 'bar'); 41 | $this->arrayCache->set('foo', 'bar'); 42 | self::assertSame('bar', $this->arrayCache->get('foo')); 43 | } 44 | 45 | public function testShouldDelete(): void 46 | { 47 | $this->arrayCache->set('foo', 'bar'); 48 | $this->arrayCache->delete('foo'); 49 | self::assertNull($this->arrayCache->get('foo')); 50 | } 51 | 52 | public function testShouldClear(): void 53 | { 54 | $this->arrayCache->set('foo', 'bar'); 55 | $this->arrayCache->set('foo1', 'bar1'); 56 | $this->arrayCache->clear(); 57 | self::assertNull($this->arrayCache->get('foo')); 58 | } 59 | 60 | public function testShouldGetMultiple(): void 61 | { 62 | $expect = [ 63 | 'foo' => 'bar', 64 | 'foo1' => 'bar1', 65 | ]; 66 | $this->arrayCache->setMultiple($expect); 67 | self::assertSame([ 68 | 'foo' => 'bar', 69 | ], $this->arrayCache->getMultiple(['foo'])); 70 | } 71 | 72 | public function testShouldSetMultiple(): void 73 | { 74 | $expect = [ 75 | 'foo' => 'bar', 76 | 'foo1' => 'bar1', 77 | ]; 78 | $this->arrayCache->setMultiple($expect); 79 | self::assertSame($expect, $this->arrayCache->getMultiple(['foo', 'foo1'])); 80 | } 81 | 82 | public function testShouldDeleteMultiple(): void 83 | { 84 | $expect = [ 85 | 'foo' => 'bar', 86 | 'foo1' => 'bar1', 87 | 'foo2' => 'bar2', 88 | ]; 89 | $this->arrayCache->setMultiple($expect); 90 | $this->arrayCache->deleteMultiple(['foo', 'foo1']); 91 | self::assertSame([ 92 | 'foo2' => 'bar2', 93 | ], $this->arrayCache->getMultiple(['foo2'])); 94 | } 95 | 96 | public function testShouldHas(): void 97 | { 98 | self::assertFalse($this->arrayCache->has('foo')); 99 | $this->arrayCache->set('foo', 'bar'); 100 | self::assertTrue($this->arrayCache->has('foo')); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/MySQLReplication/Repository/MySQLRepository.php: -------------------------------------------------------------------------------- 1 | connection->close(); 22 | } 23 | 24 | public function getFields(string $database, string $table): FieldDTOCollection 25 | { 26 | $sql = ' 27 | SELECT 28 | `COLUMN_NAME`, 29 | `COLLATION_NAME`, 30 | `CHARACTER_SET_NAME`, 31 | `COLUMN_COMMENT`, 32 | `COLUMN_TYPE`, 33 | `COLUMN_KEY` 34 | FROM 35 | `information_schema`.`COLUMNS` 36 | WHERE 37 | `TABLE_SCHEMA` = ? 38 | AND 39 | `TABLE_NAME` = ? 40 | ORDER BY 41 | ORDINAL_POSITION 42 | '; 43 | 44 | return FieldDTOCollection::makeFromArray( 45 | $this->getConnection() 46 | ->fetchAllAssociative($sql, [$database, $table]) 47 | ); 48 | } 49 | 50 | public function isCheckSum(): bool 51 | { 52 | $res = $this->getConnection() 53 | ->fetchAssociative('SHOW GLOBAL VARIABLES LIKE "BINLOG_CHECKSUM"'); 54 | 55 | return isset($res['Value']) && $res['Value'] !== 'NONE'; 56 | } 57 | 58 | public function getVersion(): string 59 | { 60 | $res = $this->getConnection() 61 | ->fetchAssociative('SHOW VARIABLES LIKE "version"'); 62 | 63 | return $res['Value'] ?? ''; 64 | } 65 | 66 | public function getMasterStatus(): MasterStatusDTO 67 | { 68 | $query = 'SHOW MASTER STATUS'; 69 | 70 | if (str_starts_with($this->getVersion(), '8.4')) { 71 | $query = 'SHOW BINARY LOG STATUS'; 72 | } 73 | 74 | $data = $this->getConnection() 75 | ->fetchAssociative($query); 76 | if (empty($data)) { 77 | throw new BinLogException( 78 | MySQLReplicationException::BINLOG_NOT_ENABLED, 79 | MySQLReplicationException::BINLOG_NOT_ENABLED_CODE 80 | ); 81 | } 82 | 83 | return MasterStatusDTO::makeFromArray($data); 84 | } 85 | 86 | public function ping(Connection $connection): bool 87 | { 88 | try { 89 | $connection->executeQuery($connection->getDatabasePlatform()->getDummySelectSQL()); 90 | return true; 91 | } catch (Exception) { 92 | return false; 93 | } 94 | } 95 | 96 | private function getConnection(): Connection 97 | { 98 | // In DBAL 4.x, connections handle reconnection automatically 99 | // No need for manual ping/reconnect logic 100 | return $this->connection; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /example/resuming.php: -------------------------------------------------------------------------------- 1 | withUser('root') 23 | ->withHost('127.0.0.1') 24 | ->withPort(3306) 25 | ->withPassword('root') 26 | ->build() 27 | ); 28 | 29 | class MyEventSubscribers extends EventSubscribers 30 | { 31 | /** 32 | * @param EventDTO $event (your own handler more in EventSubscribers class ) 33 | */ 34 | public function allEvents(EventDTO $event): void 35 | { 36 | // all events got __toString() implementation 37 | echo $event; 38 | 39 | // all events got JsonSerializable implementation 40 | //echo json_encode($event, JSON_PRETTY_PRINT); 41 | 42 | echo 'Memory usage ' . round(memory_get_usage() / 1048576, 2) . ' MB' . PHP_EOL; 43 | 44 | // save event for resuming it later 45 | BinLogBootstrap::save($event->getEventInfo()->binLogCurrent); 46 | } 47 | } 48 | 49 | class BinLogBootstrap 50 | { 51 | private static ?string $fileAndPath = null; 52 | 53 | public static function save(BinLogCurrent $binLogCurrent): void 54 | { 55 | echo 'saving file:' . $binLogCurrent->getBinFileName() . ', position:' . $binLogCurrent->getBinLogPosition() . ' bin log position' . PHP_EOL; 56 | 57 | // can be redis/nosql/file - something fast! 58 | // to speed up you can save every xxx time 59 | // you can also use signal handler for ctrl + c exiting script to wait for last event 60 | file_put_contents(self::getFileAndPath(), serialize($binLogCurrent)); 61 | } 62 | 63 | public static function startFromPosition(ConfigBuilder $builder): ConfigBuilder 64 | { 65 | if (!is_file(self::getFileAndPath())) { 66 | return $builder; 67 | } 68 | 69 | /** @var BinLogCurrent $binLogCurrent */ 70 | $binLogCurrent = unserialize(file_get_contents(self::getFileAndPath())); 71 | 72 | echo 'starting from file:' . $binLogCurrent->getBinFileName() . ', position:' . $binLogCurrent->getBinLogPosition() . ' bin log position' . PHP_EOL; 73 | 74 | return $builder 75 | ->withBinLogFileName($binLogCurrent->getBinFileName()) 76 | ->withBinLogPosition($binLogCurrent->getBinLogPosition()); 77 | } 78 | 79 | private static function getFileAndPath(): string 80 | { 81 | if (self::$fileAndPath === null) { 82 | self::$fileAndPath = sys_get_temp_dir() . '/bin-log-replicator-last-position'; 83 | } 84 | return self::$fileAndPath; 85 | } 86 | } 87 | 88 | // register your events handler here 89 | $binLogStream->registerSubscriber(new MyEventSubscribers()); 90 | 91 | // start consuming events 92 | $binLogStream->run(); 93 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/EventSubscribers.php: -------------------------------------------------------------------------------- 1 | value => 'onTableMap', 29 | ConstEventsNames::UPDATE->value => 'onUpdate', 30 | ConstEventsNames::DELETE->value => 'onDelete', 31 | ConstEventsNames::GTID->value => 'onGTID', 32 | ConstEventsNames::QUERY->value => 'onQuery', 33 | ConstEventsNames::ROTATE->value => 'onRotate', 34 | ConstEventsNames::XID->value => 'onXID', 35 | ConstEventsNames::WRITE->value => 'onWrite', 36 | ConstEventsNames::MARIADB_GTID->value => 'onMariaDbGtid', 37 | ConstEventsNames::FORMAT_DESCRIPTION->value => 'onFormatDescription', 38 | ConstEventsNames::HEARTBEAT->value => 'onHeartbeat', 39 | ConstEventsNames::ROWS_QUERY->value => 'onRowsQuery', 40 | ]; 41 | } 42 | 43 | public function onUpdate(UpdateRowsDTO $event): void 44 | { 45 | $this->allEvents($event); 46 | } 47 | 48 | public function onTableMap(TableMapDTO $event): void 49 | { 50 | $this->allEvents($event); 51 | } 52 | 53 | public function onDelete(DeleteRowsDTO $event): void 54 | { 55 | $this->allEvents($event); 56 | } 57 | 58 | public function onGTID(GTIDLogDTO $event): void 59 | { 60 | $this->allEvents($event); 61 | } 62 | 63 | public function onQuery(QueryDTO $event): void 64 | { 65 | $this->allEvents($event); 66 | } 67 | 68 | public function onRotate(RotateDTO $event): void 69 | { 70 | $this->allEvents($event); 71 | } 72 | 73 | public function onXID(XidDTO $event): void 74 | { 75 | $this->allEvents($event); 76 | } 77 | 78 | public function onWrite(WriteRowsDTO $event): void 79 | { 80 | $this->allEvents($event); 81 | } 82 | 83 | public function onMariaDbGtid(MariaDbGtidLogDTO $event): void 84 | { 85 | $this->allEvents($event); 86 | } 87 | 88 | public function onFormatDescription(FormatDescriptionEventDTO $event): void 89 | { 90 | $this->allEvents($event); 91 | } 92 | 93 | public function onHeartbeat(HeartbeatDTO $event): void 94 | { 95 | $this->allEvents($event); 96 | } 97 | 98 | public function onRowsQuery(RowsQueryDTO $event): void 99 | { 100 | $this->allEvents($event); 101 | } 102 | 103 | protected function allEvents(EventDTO $event): void 104 | { 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/Unit/Repository/MySQLRepositoryTest.php: -------------------------------------------------------------------------------- 1 | connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->getMock(); 30 | $this->connection->method('getDatabasePlatform') 31 | ->willReturn(new MySQLPlatform()); 32 | $this->mySQLRepositoryTest = new MySQLRepository($this->connection); 33 | } 34 | 35 | public function testShouldGetFields(): void 36 | { 37 | $expected = [ 38 | [ 39 | 'COLUMN_NAME' => 'cname', 40 | 'COLLATION_NAME' => 'colname', 41 | 'CHARACTER_SET_NAME' => 'charname', 42 | 'COLUMN_COMMENT' => 'colcommnet', 43 | 'COLUMN_TYPE' => 'coltype', 44 | 'COLUMN_KEY' => 'colkey', 45 | ], 46 | ]; 47 | 48 | $this->connection->method('fetchAllAssociative') 49 | ->willReturn($expected); 50 | 51 | self::assertEquals( 52 | FieldDTOCollection::makeFromArray($expected), 53 | $this->mySQLRepositoryTest->getFields('foo', 'bar') 54 | ); 55 | } 56 | 57 | public function testShouldIsCheckSum(): void 58 | { 59 | self::assertFalse($this->mySQLRepositoryTest->isCheckSum()); 60 | 61 | $this->connection->method('fetchAssociative') 62 | ->willReturnOnConsecutiveCalls([ 63 | 'Value' => 'CRC32', 64 | ], [ 65 | 'Value' => 'NONE', 66 | ]); 67 | 68 | self::assertTrue($this->mySQLRepositoryTest->isCheckSum()); 69 | self::assertFalse($this->mySQLRepositoryTest->isCheckSum()); 70 | } 71 | 72 | public function testShouldGetVersion(): void 73 | { 74 | $expected = [ 75 | 'Value' => 'version', 76 | ]; 77 | 78 | $this->connection->method('fetchAssociative') 79 | ->willReturn($expected); 80 | 81 | self::assertEquals('version', $this->mySQLRepositoryTest->getVersion()); 82 | } 83 | 84 | public function testShouldGetMasterStatus(): void 85 | { 86 | $expected = [ 87 | 'File' => 'mysql-bin.000002', 88 | 'Position' => 4587305, 89 | 'Binlog_Do_DB' => '', 90 | 'Binlog_Ignore_DB' => '', 91 | 'Executed_Gtid_Set' => '041de05f-a36a-11e6-bc73-000c2976f3f3:1-8023', 92 | ]; 93 | 94 | $this->connection->method('fetchAssociative') 95 | ->willReturn($expected); 96 | 97 | self::assertEquals(MasterStatusDTO::makeFromArray($expected), $this->mySQLRepositoryTest->getMasterStatus()); 98 | } 99 | 100 | public function testShouldReconnect(): void 101 | { 102 | // just to cover private getConnection 103 | $exception = $this->createMock(ConnectionException::class); 104 | 105 | $this->connection->method('executeQuery') 106 | ->willThrowException($exception); 107 | 108 | $this->connection->method('fetchAssociative') 109 | ->willReturn(['Value' => 'NONE']); 110 | 111 | $this->mySQLRepositoryTest->isCheckSum(); 112 | self::assertTrue(true); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/MySQLReplication/BinLog/BinLogServerInfo.php: -------------------------------------------------------------------------------- 1 | 0 73 | $saltLen = ord($data[$i]); 74 | ++$i; 75 | 76 | $saltLen = max(12, $saltLen - 9); 77 | 78 | $i += 10; 79 | 80 | //next salt 81 | if ($length >= $i + $saltLen) { 82 | for ($j = $i; $j < $i + $saltLen; ++$j) { 83 | $salt .= $data[$j]; 84 | } 85 | } 86 | $authPlugin = ''; 87 | $i += $saltLen + 1; 88 | for ($j = $i; $j < $length - 1; ++$j) { 89 | $authPlugin .= $data[$j]; 90 | } 91 | 92 | return new self( 93 | $protocolVersion, 94 | $serverVersion, 95 | $connectionId, 96 | $salt, 97 | BinLogAuthPluginMode::make($authPlugin), 98 | self::parseVersion($serverVersion), 99 | self::parseRevision($version) 100 | ); 101 | } 102 | 103 | public function isMariaDb(): bool 104 | { 105 | return $this->versionName === self::MYSQL_VERSION_MARIADB; 106 | } 107 | 108 | public function isPercona(): bool 109 | { 110 | return $this->versionName === self::MYSQL_VERSION_PERCONA; 111 | } 112 | 113 | public function isGeneric(): bool 114 | { 115 | return $this->versionName === self::MYSQL_VERSION_GENERIC; 116 | } 117 | 118 | /** 119 | * @see http://stackoverflow.com/questions/37317869/determine-if-mysql-or-percona-or-mariadb 120 | */ 121 | private static function parseVersion(string $version): string 122 | { 123 | if ($version !== '') { 124 | if (str_contains($version, self::MYSQL_VERSION_MARIADB)) { 125 | return self::MYSQL_VERSION_MARIADB; 126 | } 127 | if (str_contains($version, self::MYSQL_VERSION_PERCONA)) { 128 | return self::MYSQL_VERSION_PERCONA; 129 | } 130 | } 131 | 132 | return self::MYSQL_VERSION_GENERIC; 133 | } 134 | 135 | private static function parseRevision(string $version): float 136 | { 137 | return (float)$version; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/MySQLReplication/MySQLReplicationFactory.php: -------------------------------------------------------------------------------- 1 | validate(); 43 | 44 | if ($repository === null) { 45 | $this->connection = DriverManager::getConnection( 46 | [ 47 | 'user' => $config->user, 48 | 'password' => $config->password, 49 | 'host' => $config->host, 50 | 'port' => $config->port, 51 | 'driver' => 'pdo_mysql', 52 | 'charset' => $config->charset, 53 | ] 54 | ); 55 | $repository = new MySQLRepository($this->connection); 56 | } 57 | 58 | $cache = $cache ?: new ArrayCache($config->tableCacheSize); 59 | $logger = $logger ?: new NullLogger(); 60 | $socket = $socket ?: new Socket(); 61 | 62 | $this->eventDispatcher = $eventDispatcher ?: new EventDispatcher(); 63 | 64 | $this->binLogSocketConnect = new BinLogSocketConnect($repository, $socket, $logger, $config); 65 | 66 | $this->event = new Event( 67 | $this->binLogSocketConnect, 68 | new RowEventFactory( 69 | new RowEventBuilder( 70 | $repository, 71 | $cache, 72 | $config, 73 | $this->binLogSocketConnect->getBinLogServerInfo(), 74 | $logger 75 | ) 76 | ), 77 | $this->eventDispatcher, 78 | $cache, 79 | $config, 80 | $this->binLogSocketConnect->getBinLogServerInfo() 81 | ); 82 | } 83 | 84 | public function registerSubscriber(EventSubscriberInterface $eventSubscribers): void 85 | { 86 | $this->eventDispatcher->addSubscriber($eventSubscribers); 87 | } 88 | 89 | public function unregisterSubscriber(EventSubscriberInterface $eventSubscribers): void 90 | { 91 | $this->eventDispatcher->removeSubscriber($eventSubscribers); 92 | } 93 | 94 | public function getDbConnection(): ?Connection 95 | { 96 | return $this->connection; 97 | } 98 | 99 | public function run(): void 100 | { 101 | /** @phpstan-ignore-next-line */ 102 | while (1) { 103 | $this->consume(); 104 | } 105 | } 106 | 107 | /** 108 | * Run replication, checking $shouldStop callback on each iteration, to be able to gracefully stop the process. 109 | * 110 | * @param callable $shouldStop Returns true if the process should stop 111 | */ 112 | public function runWithStopCheck(callable $shouldStop): void 113 | { 114 | while (true) { 115 | if ($shouldStop()) { 116 | break; 117 | } 118 | 119 | $this->consume(); 120 | } 121 | } 122 | 123 | public function consume(): void 124 | { 125 | $this->event->consume(); 126 | } 127 | 128 | public function getServerInfo(): BinLogServerInfo 129 | { 130 | return $this->binLogSocketConnect->getBinLogServerInfo(); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /example/benchmark.php: -------------------------------------------------------------------------------- 1 | getConnection(); 35 | $conn->executeStatement('DROP DATABASE IF EXISTS ' . self::DB_NAME); 36 | $conn->executeStatement('CREATE DATABASE ' . self::DB_NAME); 37 | $conn->executeStatement('USE ' . self::DB_NAME); 38 | $conn->executeStatement('CREATE TABLE test (i INT) ENGINE = MEMORY'); 39 | $conn->executeStatement('INSERT INTO test VALUES(1)'); 40 | $conn->executeStatement('CREATE TABLE test2 (i INT) ENGINE = MEMORY'); 41 | $conn->executeStatement('INSERT INTO test2 VALUES(1)'); 42 | $conn->executeStatement('RESET MASTER'); 43 | 44 | $this->binLogStream = new MySQLReplicationFactory( 45 | (new ConfigBuilder()) 46 | ->withUser(self::DB_USER) 47 | ->withPassword(self::DB_PASS) 48 | ->withHost(self::DB_HOST) 49 | ->withPort(self::DB_PORT) 50 | ->withEventsOnly( 51 | [ 52 | ConstEventType::UPDATE_ROWS_EVENT_V2->value, 53 | // for mariadb v1 54 | ConstEventType::UPDATE_ROWS_EVENT_V1->value, 55 | ] 56 | ) 57 | ->withSlaveId(9999) 58 | ->withDatabasesOnly([self::DB_NAME]) 59 | ->build() 60 | ); 61 | 62 | $this->binLogStream->registerSubscriber( 63 | new class() extends EventSubscribers { 64 | private float $start; 65 | 66 | private int $counter = 0; 67 | 68 | public function __construct() 69 | { 70 | $this->start = microtime(true); 71 | } 72 | 73 | public function onUpdate(UpdateRowsDTO $event): void 74 | { 75 | ++$this->counter; 76 | if (0 === ($this->counter % 1000)) { 77 | echo ((int)($this->counter / (microtime( 78 | true 79 | ) - $this->start)) . ' event by seconds (' . $this->counter . ' total)') . PHP_EOL; 80 | } 81 | } 82 | } 83 | ); 84 | } 85 | 86 | public function run(): void 87 | { 88 | $pid = pcntl_fork(); 89 | if ($pid === -1) { 90 | throw new InvalidArgumentException('Could not fork'); 91 | } 92 | 93 | if ($pid) { 94 | $this->consume(); 95 | pcntl_wait($status); 96 | } else { 97 | $this->produce(); 98 | } 99 | } 100 | 101 | private function getConnection(): Connection 102 | { 103 | return DriverManager::getConnection( 104 | [ 105 | 'user' => self::DB_USER, 106 | 'password' => self::DB_PASS, 107 | 'host' => self::DB_HOST, 108 | 'port' => self::DB_PORT, 109 | 'driver' => 'pdo_mysql', 110 | 'dbname' => self::DB_NAME, 111 | ] 112 | ); 113 | } 114 | 115 | private function consume(): void 116 | { 117 | $this->binLogStream->run(); 118 | } 119 | 120 | private function produce(): void 121 | { 122 | $conn = $this->getConnection(); 123 | 124 | echo 'Start insert data' . PHP_EOL; 125 | 126 | /** @phpstan-ignore-next-line */ 127 | while (1) { 128 | $conn->executeStatement('UPDATE test SET i = i + 1;'); 129 | $conn->executeStatement('UPDATE test2 SET i = i + 1;'); 130 | } 131 | } 132 | } 133 | 134 | (new benchmark())->run(); 135 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/RowEvent/ColumnDTO.php: -------------------------------------------------------------------------------- 1 | readUInt16(); 41 | } elseif ($columnType === ConstFieldType::DOUBLE) { 42 | $size = $binaryDataReader->readUInt8(); 43 | } elseif ($columnType === ConstFieldType::FLOAT) { 44 | $size = $binaryDataReader->readUInt8(); 45 | } elseif ($columnType === ConstFieldType::TIMESTAMP2) { 46 | $fsp = $binaryDataReader->readUInt8(); 47 | } elseif ($columnType === ConstFieldType::DATETIME2) { 48 | $fsp = $binaryDataReader->readUInt8(); 49 | } elseif ($columnType === ConstFieldType::TIME2) { 50 | $fsp = $binaryDataReader->readUInt8(); 51 | } elseif ($columnType === ConstFieldType::VAR_STRING || $columnType === ConstFieldType::STRING) { 52 | $metadata = ($binaryDataReader->readUInt8() << 8) + $binaryDataReader->readUInt8(); 53 | $realType = $metadata >> 8; 54 | if ($realType === ConstFieldType::SET || $realType === ConstFieldType::ENUM) { 55 | $columnType = $realType; 56 | $size = $metadata & 0x00ff; 57 | } else { 58 | $maxLength = ((($metadata >> 4) & 0x300) ^ 0x300) + ($metadata & 0x00ff); 59 | } 60 | } elseif ($columnType === ConstFieldType::BLOB || $columnType === ConstFieldType::IGNORE) { 61 | $lengthSize = $binaryDataReader->readUInt8(); 62 | } elseif ($columnType === ConstFieldType::GEOMETRY) { 63 | $lengthSize = $binaryDataReader->readUInt8(); 64 | } elseif ($columnType === ConstFieldType::JSON) { 65 | $lengthSize = $binaryDataReader->readUInt8(); 66 | } elseif ($columnType === ConstFieldType::NEWDECIMAL) { 67 | $precision = $binaryDataReader->readUInt8(); 68 | $decimals = $binaryDataReader->readUInt8(); 69 | } elseif ($columnType === ConstFieldType::BIT) { 70 | $bits = $binaryDataReader->readUInt8(); 71 | $bytes = $binaryDataReader->readUInt8(); 72 | 73 | $bits = ($bytes * 8) + $bits; 74 | $bytes = (int)(($bits + 7) / 8); 75 | } 76 | 77 | return new self( 78 | $fieldDTO, 79 | $columnType, 80 | $maxLength, 81 | $size, 82 | $fsp, 83 | $lengthSize, 84 | $precision, 85 | $decimals, 86 | $bits, 87 | $bytes 88 | ); 89 | } 90 | 91 | public function getName(): string 92 | { 93 | return $this->fieldDTO->columnName; 94 | } 95 | 96 | public function getEnumValues(): array 97 | { 98 | if ($this->type === ConstFieldType::ENUM) { 99 | return explode(',', str_replace(['enum(', ')', '\''], '', $this->fieldDTO->columnType)); 100 | } 101 | 102 | return []; 103 | } 104 | 105 | public function getSetValues(): array 106 | { 107 | if ($this->type === ConstFieldType::SET) { 108 | return explode(',', str_replace(['set(', ')', '\''], '', $this->fieldDTO->columnType)); 109 | } 110 | 111 | return []; 112 | } 113 | 114 | public function isUnsigned(): bool 115 | { 116 | return !(stripos($this->fieldDTO->columnType, 'unsigned') === false); 117 | } 118 | 119 | public function isPrimary(): bool 120 | { 121 | return $this->fieldDTO->columnKey === 'PRI'; 122 | } 123 | 124 | public function jsonSerialize(): array 125 | { 126 | return get_object_vars($this); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/Integration/BaseCase.php: -------------------------------------------------------------------------------- 1 | configBuilder = (new ConfigBuilder()) 40 | ->withUser('root') 41 | ->withHost('0.0.0.0') 42 | ->withPassword('root') 43 | ->withPort(3306) 44 | ->withEventsIgnore($this->getIgnoredEvents()); 45 | 46 | $this->connect(); 47 | 48 | if ($this->mySQLReplicationFactory?->getServerInfo()->versionRevision >= 8 && $this->mySQLReplicationFactory?->getServerInfo()->isGeneric()) { 49 | self::assertInstanceOf(RotateDTO::class, $this->getEvent()); 50 | } 51 | self::assertInstanceOf(FormatDescriptionEventDTO::class, $this->getEvent()); 52 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 53 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 54 | } 55 | 56 | protected function tearDown(): void 57 | { 58 | parent::tearDown(); 59 | 60 | $this->disconnect(); 61 | } 62 | 63 | public function setEvent(EventDTO $eventDTO): void 64 | { 65 | $this->currentEvent = $eventDTO; 66 | } 67 | 68 | public function connect(): void 69 | { 70 | $this->mySQLReplicationFactory = new MySQLReplicationFactory($this->configBuilder->build()); 71 | $this->testEventSubscribers = new TestEventSubscribers($this); 72 | $this->mySQLReplicationFactory->registerSubscriber($this->testEventSubscribers); 73 | 74 | $connection = $this->mySQLReplicationFactory->getDbConnection(); 75 | if ($connection === null) { 76 | throw new RuntimeException('Connection not initialized'); 77 | } 78 | $this->connection = $connection; 79 | $this->connection->executeStatement('SET SESSION time_zone = "UTC"'); 80 | $this->connection->executeStatement('DROP DATABASE IF EXISTS ' . $this->database); 81 | $this->connection->executeStatement('CREATE DATABASE ' . $this->database); 82 | $this->connection->executeStatement('USE ' . $this->database); 83 | $this->connection->executeStatement('SET SESSION sql_mode = \'\';'); 84 | } 85 | 86 | protected function getIgnoredEvents(): array 87 | { 88 | return [ 89 | ConstEventType::GTID_LOG_EVENT->value, // Generally in here 90 | ConstEventType::ROWS_QUERY_LOG_EVENT->value, // Just debugging, there is a special test for it 91 | ]; 92 | } 93 | 94 | protected function getEvent(): EventDTO 95 | { 96 | if ($this->mySQLReplicationFactory === null) { 97 | throw new RuntimeException('MySQLReplicationFactory not initialized'); 98 | } 99 | 100 | // events can be null lets us continue until we find event 101 | $this->currentEvent = null; 102 | while ($this->currentEvent === null) { 103 | $this->mySQLReplicationFactory->consume(); 104 | } 105 | /** @phpstan-ignore-next-line */ 106 | return $this->currentEvent; 107 | } 108 | 109 | protected function disconnect(): void 110 | { 111 | if ($this->mySQLReplicationFactory === null) { 112 | return; 113 | } 114 | $this->mySQLReplicationFactory->unregisterSubscriber($this->testEventSubscribers); 115 | $this->mySQLReplicationFactory = null; 116 | } 117 | 118 | protected function checkForVersion(float $version): bool 119 | { 120 | /** @phpstan-ignore-next-line */ 121 | return $this->mySQLReplicationFactory->getServerInfo() 122 | ->versionRevision < $version; 123 | } 124 | 125 | protected function createAndInsertValue(string $createQuery, string $insertQuery): EventDTO 126 | { 127 | $this->connection->executeStatement($createQuery); 128 | $this->connection->executeStatement($insertQuery); 129 | 130 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 131 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 132 | self::assertInstanceOf(TableMapDTO::class, $this->getEvent()); 133 | 134 | return $this->getEvent(); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/MySQLReplication/Config/Config.php: -------------------------------------------------------------------------------- 1 | host)) { 38 | $ip = gethostbyname($this->host); 39 | if (filter_var($ip, FILTER_VALIDATE_IP) === false) { 40 | throw new ConfigException(ConfigException::IP_ERROR_MESSAGE, ConfigException::IP_ERROR_CODE); 41 | } 42 | } 43 | if (!empty($this->port) && filter_var( 44 | $this->port, 45 | FILTER_VALIDATE_INT, 46 | [ 47 | 'options' => [ 48 | 'min_range' => 0, 49 | ], 50 | ] 51 | ) === false) { 52 | throw new ConfigException(ConfigException::PORT_ERROR_MESSAGE, ConfigException::PORT_ERROR_CODE); 53 | } 54 | if (!empty($this->gtid)) { 55 | foreach (explode(',', $this->gtid) as $gtid) { 56 | if (!(bool)preg_match( 57 | '/^([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})((?::[0-9-]+)+)$/', 58 | $gtid, 59 | $matches 60 | )) { 61 | throw new ConfigException(ConfigException::GTID_ERROR_MESSAGE, ConfigException::GTID_ERROR_CODE); 62 | } 63 | } 64 | } 65 | if (!empty($this->slaveId) && filter_var( 66 | $this->slaveId, 67 | FILTER_VALIDATE_INT, 68 | [ 69 | 'options' => [ 70 | 'min_range' => 0, 71 | ], 72 | ] 73 | ) === false) { 74 | throw new ConfigException( 75 | ConfigException::SLAVE_ID_ERROR_MESSAGE, 76 | ConfigException::SLAVE_ID_ERROR_CODE 77 | ); 78 | } 79 | if (bccomp($this->binLogPosition, '0') === -1) { 80 | throw new ConfigException( 81 | ConfigException::BIN_LOG_FILE_POSITION_ERROR_MESSAGE, 82 | ConfigException::BIN_LOG_FILE_POSITION_ERROR_CODE 83 | ); 84 | } 85 | if (filter_var($this->tableCacheSize, FILTER_VALIDATE_INT, [ 86 | 'options' => [ 87 | 'min_range' => 0, 88 | ], 89 | ]) === false) { 90 | throw new ConfigException( 91 | ConfigException::TABLE_CACHE_SIZE_ERROR_MESSAGE, 92 | ConfigException::TABLE_CACHE_SIZE_ERROR_CODE 93 | ); 94 | } 95 | if ($this->heartbeatPeriod !== 0.0 && false === ( 96 | $this->heartbeatPeriod >= 0.001 && $this->heartbeatPeriod <= 4294967.0 97 | )) { 98 | throw new ConfigException( 99 | ConfigException::HEARTBEAT_PERIOD_ERROR_MESSAGE, 100 | ConfigException::HEARTBEAT_PERIOD_ERROR_CODE 101 | ); 102 | } 103 | } 104 | 105 | 106 | public function checkDataBasesOnly(string $database): bool 107 | { 108 | return ($this->databasesOnly !== [] && !in_array($database, $this->databasesOnly, true)) 109 | || ($this->databasesRegex !== [] && !self::matchNames($database, $this->databasesRegex)); 110 | } 111 | 112 | 113 | public function checkTablesOnly(string $table): bool 114 | { 115 | return ($this->tablesOnly !== [] && !in_array($table, $this->tablesOnly, true)) 116 | || ($this->tablesRegex !== [] && !self::matchNames($table, $this->tablesRegex)); 117 | } 118 | 119 | public function checkEvent(int $type): bool 120 | { 121 | if ($this->eventsOnly !== [] && !in_array($type, $this->eventsOnly, true)) { 122 | return false; 123 | } 124 | 125 | if (in_array($type, $this->eventsIgnore, true)) { 126 | return false; 127 | } 128 | 129 | return true; 130 | } 131 | 132 | public function jsonSerialize(): array 133 | { 134 | return get_class_vars(self::class); 135 | } 136 | 137 | private static function matchNames(string $name, array $patterns): bool 138 | { 139 | foreach ($patterns as $pattern) { 140 | if (preg_match($pattern, $name)) { 141 | return true; 142 | } 143 | } 144 | 145 | return false; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/MySQLReplication/Config/ConfigBuilder.php: -------------------------------------------------------------------------------- 1 | slaveUuid = $slaveUuid; 52 | 53 | return $this; 54 | } 55 | 56 | public function withUser(string $user): self 57 | { 58 | $this->user = $user; 59 | 60 | return $this; 61 | } 62 | 63 | public function withHost(string $host): self 64 | { 65 | $this->host = $host; 66 | 67 | return $this; 68 | } 69 | 70 | public function withPort(int $port): self 71 | { 72 | $this->port = $port; 73 | 74 | return $this; 75 | } 76 | 77 | public function withPassword(string $password): self 78 | { 79 | $this->password = $password; 80 | 81 | return $this; 82 | } 83 | 84 | public function withCharset(string $charset): self 85 | { 86 | $this->charset = $charset; 87 | 88 | return $this; 89 | } 90 | 91 | public function withGtid(string $gtid): self 92 | { 93 | $this->gtid = $gtid; 94 | 95 | return $this; 96 | } 97 | 98 | public function withSlaveId(int $slaveId): self 99 | { 100 | $this->slaveId = $slaveId; 101 | 102 | return $this; 103 | } 104 | 105 | public function withBinLogFileName(string $binLogFileName): self 106 | { 107 | $this->binLogFileName = $binLogFileName; 108 | 109 | return $this; 110 | } 111 | 112 | public function withBinLogPosition(string $binLogPosition): self 113 | { 114 | $this->binLogPosition = $binLogPosition; 115 | 116 | return $this; 117 | } 118 | 119 | public function withEventsOnly(array $eventsOnly): self 120 | { 121 | $this->eventsOnly = $eventsOnly; 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * @param array $eventsIgnore 128 | */ 129 | public function withEventsIgnore(array $eventsIgnore): self 130 | { 131 | $this->eventsIgnore = $eventsIgnore; 132 | 133 | return $this; 134 | } 135 | 136 | public function withTablesOnly(array $tablesOnly): self 137 | { 138 | $this->tablesOnly = $tablesOnly; 139 | 140 | return $this; 141 | } 142 | 143 | public function withDatabasesOnly(array $databasesOnly): self 144 | { 145 | $this->databasesOnly = $databasesOnly; 146 | 147 | return $this; 148 | } 149 | 150 | public function withDatabasesRegex(array $databasesRegex): self 151 | { 152 | $this->databasesRegex = $databasesRegex; 153 | return $this; 154 | } 155 | 156 | public function withTablesRegex(array $tablesRegex): self 157 | { 158 | $this->tablesRegex = $tablesRegex; 159 | return $this; 160 | } 161 | 162 | public function withMariaDbGtid(string $mariaDbGtid): self 163 | { 164 | $this->mariaDbGtid = $mariaDbGtid; 165 | 166 | return $this; 167 | } 168 | 169 | public function withTableCacheSize(int $tableCacheSize): self 170 | { 171 | $this->tableCacheSize = $tableCacheSize; 172 | 173 | return $this; 174 | } 175 | 176 | public function withCustom(array $custom): self 177 | { 178 | $this->custom = $custom; 179 | 180 | return $this; 181 | } 182 | 183 | /** 184 | * @see https://dev.mysql.com/doc/refman/5.6/en/change-master-to.html 185 | */ 186 | public function withHeartbeatPeriod(float $heartbeatPeriod): self 187 | { 188 | $this->heartbeatPeriod = $heartbeatPeriod; 189 | 190 | return $this; 191 | } 192 | 193 | public function build(): Config 194 | { 195 | return new Config( 196 | $this->user, 197 | $this->host, 198 | $this->port, 199 | $this->password, 200 | $this->charset, 201 | $this->gtid, 202 | $this->mariaDbGtid, 203 | $this->slaveId, 204 | $this->binLogFileName, 205 | $this->binLogPosition, 206 | $this->eventsOnly, 207 | $this->eventsIgnore, 208 | $this->tablesOnly, 209 | $this->databasesOnly, 210 | $this->tableCacheSize, 211 | $this->custom, 212 | $this->heartbeatPeriod, 213 | $this->slaveUuid, 214 | $this->tablesRegex, 215 | $this->databasesRegex, 216 | ); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/MySQLReplication/Event/Event.php: -------------------------------------------------------------------------------- 1 | binLogSocketConnect->getResponse()); 39 | 40 | // check EOF_Packet -> https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_eof_packet.html 41 | if ($binaryDataReader->readUInt8() === self::EOF_HEADER_VALUE) { 42 | return; 43 | } 44 | 45 | $this->dispatch($this->makeEvent($binaryDataReader)); 46 | } 47 | 48 | private function makeEvent(BinaryDataReader $binaryDataReader): ?EventDTO 49 | { 50 | // decode all events data 51 | $eventInfo = $this->createEventInfo($binaryDataReader); 52 | 53 | // we always need these events to clean table maps and for BinLogCurrent class to keep track of binlog position 54 | // always parse table map event but propagate when needed (we need this for creating table cache) 55 | if ($eventInfo->type === ConstEventType::TABLE_MAP_EVENT->value) { 56 | return $this->rowEventFactory->makeRowEvent($binaryDataReader, $eventInfo) 57 | ->makeTableMapDTO(); 58 | } 59 | 60 | if ($eventInfo->type === ConstEventType::ROTATE_EVENT->value) { 61 | $this->cache->clear(); 62 | return (new RotateEvent($eventInfo, $binaryDataReader, $this->binLogServerInfo))->makeRotateEventDTO(); 63 | } 64 | 65 | if ($eventInfo->type === ConstEventType::GTID_LOG_EVENT->value) { 66 | return (new GtidEvent($eventInfo, $binaryDataReader, $this->binLogServerInfo))->makeGTIDLogDTO(); 67 | } 68 | 69 | if ($eventInfo->type === ConstEventType::HEARTBEAT_LOG_EVENT->value) { 70 | return new HeartbeatDTO($eventInfo); 71 | } 72 | 73 | if ($eventInfo->type === ConstEventType::MARIA_GTID_EVENT->value) { 74 | return (new MariaDbGtidEvent( 75 | $eventInfo, 76 | $binaryDataReader, 77 | $this->binLogServerInfo 78 | ))->makeMariaDbGTIDLogDTO(); 79 | } 80 | 81 | // check for ignore and permitted events 82 | if ($this->ignoreEvent($eventInfo->type)) { 83 | return null; 84 | } 85 | 86 | if (in_array( 87 | $eventInfo->type, 88 | [ConstEventType::UPDATE_ROWS_EVENT_V1->value, ConstEventType::UPDATE_ROWS_EVENT_V2->value], 89 | true 90 | )) { 91 | return $this->rowEventFactory->makeRowEvent($binaryDataReader, $eventInfo) 92 | ->makeUpdateRowsDTO(); 93 | } 94 | 95 | if (in_array( 96 | $eventInfo->type, 97 | [ConstEventType::WRITE_ROWS_EVENT_V1->value, ConstEventType::WRITE_ROWS_EVENT_V2->value], 98 | true 99 | )) { 100 | return $this->rowEventFactory->makeRowEvent($binaryDataReader, $eventInfo) 101 | ->makeWriteRowsDTO(); 102 | } 103 | 104 | if (in_array( 105 | $eventInfo->type, 106 | [ConstEventType::DELETE_ROWS_EVENT_V1->value, ConstEventType::DELETE_ROWS_EVENT_V2->value], 107 | true 108 | )) { 109 | return $this->rowEventFactory->makeRowEvent($binaryDataReader, $eventInfo) 110 | ->makeDeleteRowsDTO(); 111 | } 112 | 113 | if ($eventInfo->type === ConstEventType::XID_EVENT->value) { 114 | return (new XidEvent($eventInfo, $binaryDataReader, $this->binLogServerInfo))->makeXidDTO(); 115 | } 116 | 117 | if ($eventInfo->type === ConstEventType::QUERY_EVENT->value) { 118 | return $this->filterDummyMariaDbEvents( 119 | (new QueryEvent($eventInfo, $binaryDataReader, $this->binLogServerInfo))->makeQueryDTO() 120 | ); 121 | } 122 | 123 | // The Rows Query Log Event will be triggered with enabled MySQL Config `binlog_rows_query_log_events` 124 | if ($eventInfo->type === ConstEventType::ROWS_QUERY_LOG_EVENT->value) { 125 | return (new RowsQueryEvent($eventInfo, $binaryDataReader, $this->binLogServerInfo))->makeRowsQueryDTO(); 126 | } 127 | 128 | if ($eventInfo->type === ConstEventType::FORMAT_DESCRIPTION_EVENT->value) { 129 | return new FormatDescriptionEventDTO($eventInfo); 130 | } 131 | 132 | return null; 133 | } 134 | 135 | /** 136 | * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_replication_binlog_event.html#sect_protocol_replication_binlog_event_header 137 | */ 138 | private function createEventInfo(BinaryDataReader $binaryDataReader): EventInfo 139 | { 140 | return new EventInfo( 141 | $binaryDataReader->readInt32(), 142 | $binaryDataReader->readUInt8(), 143 | $binaryDataReader->readInt32(), 144 | $binaryDataReader->readInt32(), 145 | (string)$binaryDataReader->readInt32(), 146 | $binaryDataReader->readUInt16(), 147 | $this->binLogSocketConnect->getCheckSum(), 148 | $this->binLogSocketConnect->getBinLogCurrent() 149 | ); 150 | } 151 | 152 | private function filterDummyMariaDbEvents(QueryDTO $queryDTO): ?QueryDTO 153 | { 154 | if ($this->binLogServerInfo->isMariaDb() && str_contains($queryDTO->query, self::MARIADB_DUMMY_QUERY)) { 155 | return null; 156 | } 157 | 158 | return $queryDTO; 159 | } 160 | 161 | private function dispatch(?EventDTO $eventDTO): void 162 | { 163 | if ($eventDTO) { 164 | if ($this->ignoreEvent($eventDTO->getEventInfo()->type)) { 165 | return; 166 | } 167 | $this->eventDispatcher->dispatch($eventDTO, $eventDTO->getType()); 168 | } 169 | } 170 | 171 | private function ignoreEvent(int $type): bool 172 | { 173 | return !$this->config->checkEvent($type); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /tests/Unit/BinaryDataReader/BinaryDataReaderTest.php: -------------------------------------------------------------------------------- 1 | getBinaryRead(unpack('H*', $expected)[1])->read(52))); 20 | } 21 | 22 | public function testShouldReadCodedBinary(): void 23 | { 24 | self::assertSame(0, $this->getBinaryRead(pack('C', ''))->readCodedBinary()); 25 | self::assertNull($this->getBinaryRead(pack('C', BinaryDataReader::NULL_COLUMN))->readCodedBinary()); 26 | self::assertSame( 27 | 0, 28 | $this->getBinaryRead(pack('i', BinaryDataReader::UNSIGNED_SHORT_COLUMN))->readCodedBinary() 29 | ); 30 | self::assertSame( 31 | 0, 32 | $this->getBinaryRead(pack('i', BinaryDataReader::UNSIGNED_INT24_COLUMN))->readCodedBinary() 33 | ); 34 | } 35 | 36 | public function testShouldThrowErrorOnUnknownCodedBinary(): void 37 | { 38 | $this->expectException(BinaryDataReaderException::class); 39 | 40 | $this->getBinaryRead(pack('i', 255)) 41 | ->readCodedBinary(); 42 | } 43 | 44 | public static function dataProviderForUInt(): array 45 | { 46 | return [ 47 | [1, pack('c', 1), 1], 48 | [2, pack('v', 9999), 9999], 49 | [3, pack('CCC', 160, 190, 15), 1031840], 50 | [4, pack('I', 123123543), 123123543], 51 | [5, pack('CI', 71, 2570258120), 657986078791], 52 | [6, pack('v3', 2570258120, 2570258120, 2570258120), 7456176998088], 53 | [7, pack('CSI', 66, 7890, 2570258120), 43121775657013826], 54 | ]; 55 | } 56 | 57 | public function testShouldReadReadUInt64(): void 58 | { 59 | $this->assertSame( 60 | '18374686483949813760', 61 | $this->getBinaryRead(pack('VV', 4278190080, 4278190080)) 62 | ->readUInt64() 63 | ); 64 | } 65 | 66 | #[DataProvider('dataProviderForUInt')] 67 | public function testShouldReadUIntBySize(mixed $size, mixed $data, mixed $expected): void 68 | { 69 | self::assertSame($expected, $this->getBinaryRead($data)->readUIntBySize($size)); 70 | } 71 | 72 | public function testShouldThrowErrorOnReadUIntBySizeNotSupported(): void 73 | { 74 | $this->expectException(BinaryDataReaderException::class); 75 | 76 | $this->getBinaryRead('') 77 | ->readUIntBySize(32); 78 | } 79 | 80 | public static function dataProviderForBeInt(): array 81 | { 82 | return [ 83 | [1, pack('c', 4), 4], 84 | [2, pack('n', 9999), 9999], 85 | [3, pack('CCC', 160, 190, 15), -6242801], 86 | [4, pack('i', 123123543), 1471632903], 87 | [5, pack('NC', 71, 2570258120), 18376], 88 | ]; 89 | } 90 | 91 | #[DataProvider('dataProviderForBeInt')] public function testShouldReadIntBeBySize( 92 | int $size, 93 | string $data, 94 | int $expected 95 | ): void { 96 | self::assertSame($expected, $this->getBinaryRead($data)->readIntBeBySize($size)); 97 | } 98 | 99 | public function testShouldThrowErrorOnReadIntBeBySizeNotSupported(): void 100 | { 101 | $this->expectException(BinaryDataReaderException::class); 102 | 103 | $this->getBinaryRead('') 104 | ->readIntBeBySize(666); 105 | } 106 | 107 | public function testShouldReadInt16(): void 108 | { 109 | $expected = 1000; 110 | self::assertSame($expected, $this->getBinaryRead(pack('s', $expected))->readInt16()); 111 | } 112 | 113 | public function testShouldUnreadAdvance(): void 114 | { 115 | $binaryDataReader = $this->getBinaryRead('123'); 116 | 117 | self::assertEquals('123', $binaryDataReader->getBinaryData()); 118 | self::assertEquals(0, $binaryDataReader->getReadBytes()); 119 | 120 | $binaryDataReader->advance(2); 121 | 122 | self::assertEquals('3', $binaryDataReader->getBinaryData()); 123 | self::assertEquals(2, $binaryDataReader->getReadBytes()); 124 | 125 | $binaryDataReader->unread('12'); 126 | 127 | self::assertEquals('123', $binaryDataReader->getBinaryData()); 128 | self::assertEquals(0, $binaryDataReader->getReadBytes()); 129 | } 130 | 131 | public function testShouldReadInt24(): void 132 | { 133 | self::assertSame(-6513508, $this->getBinaryRead(pack('C3', -100, -100, -100))->readInt24()); 134 | } 135 | 136 | public function testShouldReadInt64(): void 137 | { 138 | self::assertSame('-72057589759737856', $this->getBinaryRead(pack('VV', 4278190080, 4278190080))->readInt64()); 139 | } 140 | 141 | public function testShouldReadLengthCodedPascalString(): void 142 | { 143 | $expected = 255; 144 | self::assertSame( 145 | $expected, 146 | hexdec(bin2hex($this->getBinaryRead(pack('cc', 1, $expected))->readLengthString(1))) 147 | ); 148 | } 149 | 150 | public function testShouldReadInt32(): void 151 | { 152 | $expected = 777333; 153 | self::assertSame($expected, $this->getBinaryRead(pack('i', $expected))->readInt32()); 154 | } 155 | 156 | public function testShouldReadFloat(): void 157 | { 158 | $expected = 0.001; 159 | // we need to add round as php have problem with precision in floats 160 | self::assertSame($expected, round($this->getBinaryRead(pack('f', $expected))->readFloat(), 3)); 161 | } 162 | 163 | public function testShouldReadDouble(): void 164 | { 165 | $expected = 1321312312.143567586; 166 | self::assertSame($expected, $this->getBinaryRead(pack('d', $expected))->readDouble()); 167 | } 168 | 169 | public function testShouldReadTableId(): void 170 | { 171 | self::assertSame( 172 | '7456176998088', 173 | $this->getBinaryRead(pack('v3', 2570258120, 2570258120, 2570258120)) 174 | ->readTableId() 175 | ); 176 | } 177 | 178 | public function testShouldCheckIsCompleted(): void 179 | { 180 | self::assertFalse($this->getBinaryRead('')->isComplete(1)); 181 | 182 | $r = $this->getBinaryRead(str_repeat('-', 30)); 183 | $r->advance(21); 184 | self::assertTrue($r->isComplete(1)); 185 | } 186 | 187 | public function testShouldPack64bit(): void 188 | { 189 | $expected = 9223372036854775807; 190 | self::assertSame((string)$expected, $this->getBinaryRead(BinaryDataReader::pack64bit($expected))->readInt64()); 191 | } 192 | 193 | public function testShouldGetBinaryDataLength(): void 194 | { 195 | self::assertSame(3, $this->getBinaryRead('foo')->getBinaryDataLength()); 196 | } 197 | 198 | private function getBinaryRead(string $data): BinaryDataReader 199 | { 200 | return new BinaryDataReader($data); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## v8.0.1 (2024-01-31) 4 | 5 | - Added: auto authorisation packet switching 6 | 7 | ## v8.0.0 (2024-01-29) 8 | 9 | - Change: drop support for < 8.2 10 | - Change: moved to enums, promoted properties 11 | - Added: logger for more socket info 12 | - Added: slave_uuid support (#99) 13 | - Change: EventInfo->id is now EventInfo->serverId (#83) 14 | - Change: config no longer static (#94) 15 | - Chore: typos in README/code 16 | - Chore: replace/remove old dead doc urls from code 17 | - Chore: changed variables to underscore 18 | - Added: support caching_sha2_password (#102) 19 | - Change: BinLogServerInfo static calls removed also added method getServerInfo to MySQLReplicationFactory 20 | - Change: type of bin log position is now string as it can be bigger then php can hande 2^64-1 (#84) 21 | 22 | ## v7.0.1 (2021-03-09) 23 | 24 | - Fixed negative number handling (#80) 25 | 26 | ## v7.0.0 (2021-01-24) 27 | 28 | - Change: added support doctrine/dbal to ^3.0 (#75)(#73) 29 | - Change: dropped support for <= 7.2 (#75) 30 | - Added: test cases for PHP 7.3 and 7.4 (#75) 31 | - Fixed: scale length in decimals values (#76) 32 | - Added: added tests for php 8.0 33 | 34 | ## v6.2.4 (2020-09-12) 35 | 36 | - Change: type from int to float for heartbeat period (#70) 37 | - Fixed: event dispatcher interface in Event.php #(69) 38 | 39 | ## v6.2.3 (2020-09-03) 40 | 41 | - Fixed: use provided event dispatcher (#67) 42 | 43 | ## v6.2.2 (2020-04-15) 44 | 45 | - Fixed: varchars table length should be read as unsigned int (#63) 46 | 47 | ## v6.2.1 (2020-03-24) 48 | - Fixed: maraidb bad binlog when parsing event (#62) 49 | - Added: BinLogServerInfo::getRevision() 50 | 51 | ## v6.2.0 (2020-02-18) 52 | - Added symfony 5 support #61 53 | - Removed support for symfony 2.3 54 | 55 | ## v6.1.0 (2020-02-12) 56 | - Added: more support for json data like JSON_REPLACE, JSON_SET, JSON_REMOVE 57 | 58 | ## v6.0.2 (2019-08-14) 59 | - Added: travis mariadb 10 and 10.1 tests 60 | 61 | ## v6.0.1 (2019-07-30) 62 | - Fixed: getDatetime2 not reads fsp when date is wrong (#54) 63 | 64 | ## v6.0.0 (2019-07-08) 65 | - Removed: support for lesser then php7 66 | - Added: strong and string types 67 | - Changed: ConfigFactory removed and method make form array moved to Config 68 | - Changed: MariaDbGtidLogDTO replaced getSequenceNumber with getMariaDbGtid 69 | - Fixed: Insert NULL in a boolean column returns no rows 70 | - Fixed: float problem about time field type 71 | - Fixed: column order 72 | - Changed: getFields and getMasterStatus returns no VO 73 | - Changed: Column to ColumnDTO and added ColumnDTOCollection 74 | - Changed: replaced getFields with getColumnDTOCollection in TableMap 75 | - Added: more compatibility for mysql 5.5, 5.6, 5.7, maria 10 and 8.0 76 | - Removed: makeConfigFromArray 77 | 78 | ## v5.0.6 (2019-02-05) 79 | - Fixed json with slash (#48) 80 | - Fixed disabling events that are needed (#46) 81 | - Changed to @inherit phpdoc in jsonSerialize methods 82 | - Removed unused exceptions from phpdoc 83 | - Changed moved wiki do readme 84 | - Changed added missing php extensions to composer.json 85 | 86 | ## v5.0.5 (2018-11-11) 87 | - Fixed support to receive more than 16MB + tests 88 | 89 | ## v5.0.4 (2018-08-10) 90 | - Added support to receive more than 16MB 91 | 92 | ## v5.0.3 (2018-08-07) 93 | - Added symfony 4.0 compatibility in composer 94 | 95 | ## v5.0.3 96 | - Added symfony 4.0 compatibility in composer 97 | 98 | ## v5.0.2 (2018-06-22) 99 | - Added checking for eof (#42) 100 | - Added support for column type 11 - TIME (mysql 5.5 only) (#41) 101 | 102 | ## v5.0.1 (2018-05-29) 103 | - Added tests now include php 7.2 and MariaDb 10.3 104 | - Added truncate table test (#37) 105 | - Added MariaDb events ids to const 106 | - Added filtering dummy events generated by MariaDB 107 | - Added missing throws in BasicTest 108 | 109 | ## v5.0.0 (2018-04-27) 110 | - Removed unused classes from code and merged some classes to one class 111 | - Added ability in MySQLReplicationFactory to provide implementations interfaces in constructor. This will give ability to replace default classes to your own 112 | - Added Config to MySQLReplicationFactory constructor (#35) 113 | - Changed register subscriber to accept interface of EventSubscriberInterface over EventSubscribers class (#36) 114 | - Changed moved exception messages to main exception class 115 | - Changed psr-2 "elseif " replaced to "else if" 116 | - Fixed 5.7 json column deserialization for null value + tests 117 | - Changed minor refactoring in classes 118 | 119 | ## v4.0.0 (2018-03-10) 120 | - Removed unused (probably?) classes ConfigService, BinaryDataReaderService 121 | - Changed Event class broke into smaller methods to be cleaner 122 | - Added some unit test 123 | - Added BinLogCurrent to keep current binlogFile, binlog position and gtid also added example how to resume script based on this data 124 | - Moved to php 5.6 sorry.. the future is now ;) 125 | 126 | ## v3.0.1 (2017-08-16) 127 | - Fixed in config filter_var validation if 0 given 128 | - Changed if bin log and bin log file not given then use master otherwise given data will be send to master 129 | - Fixed isCheckSum mysql returns string NONE not an empty array and mariaDbGtid fix (tx to @kobi97) 130 | - Added travis mysql 5.6 and 5.7 env 131 | - Removed mariaDB support for query event 132 | - Fixed clear table map cache after rotate event 133 | 134 | ## v3.0.0 (2017-07-14) 135 | - Added Cache interfaces for table info 136 | - Changed examples to use ConfigBuilder 137 | - Changed BinLogSocketConnect to separate sockets handling to another class + added interface for socket class 138 | - Changed tests namespace to MySQLReplication 139 | - Changed all exception messages moved to MySQLReplicationException 140 | - Added CHANGELOG.md 141 | - Simplify many classes and removed some of them 142 | - Added decorators for server version recognition 143 | - Changed if datetime not recognised will return null (0000-00-00 00:00:00 is invalid date) 144 | - Added 'custom' param to config if some custom params must be set in extended/implemented own classes 145 | - Added new tests 146 | - Changed Repository $schema to $database 147 | - Changed - YEAR = 0 will return null not 1900 148 | - Removed Exception from Columns class 149 | - Added format description event 150 | - Changed inserts to not existing tables/columns will be returned as WriteEvent with empty Fields (see BasicTest::shouldGetWriteEventDropTable) 151 | - Changed TABLE_MAP_EVENT will no longer appear after adding events to only/ignore configuration 152 | - Fixed events with dropped columns will return a proper columns amount 153 | - Changed configuration to static calls 154 | - Removed absolute method getConnection from repository 155 | - Added Heartbeat period and event support 156 | 157 | ## v2.2.0 (2017-03-10) 158 | - Removed foreign keys from events 159 | 160 | ## v2.1.3 (2016-11-26) 161 | - Documentation update 162 | - BinLogSocketConnect exception set as const 163 | - 'Dbname' removed from configuration as is deprecated 164 | - MySQLRepository and BinLogSocketConnect extracted to interfaces 165 | - Register slave use now hostname and port to be correct display in "SHOW SLAVE HOSTS" 166 | - Added foreign keys info to events 167 | 168 | ## v2.1.2 (2016-11-26) 169 | - Added json decoder 16/32 int support 170 | 171 | ## v2.1.1 (2016-11-19) 172 | - Fix for json decode 173 | - Table cache option moved to config 174 | - Strict variables 175 | 176 | ## v2.1.0 (2016-11-05) 177 | - Basic implementation of json binary encoder for mysql 5.7 178 | - Config now support ip and host setting 179 | - Connection_id correctly decoded 180 | - Events dispatcher now can work with 2.8 lib 181 | - Some code cleanup 182 | - Added new tests 183 | 184 | ## v2.0.2 (2016-08-29) 185 | - Added MariaDB compatibility 186 | - Code cleanup 187 | 188 | ## v2.0.1 (2016-08-28) 189 | - Added new field support TIMESTAMP=7 190 | - Query event fix 191 | - Added db charset to db connection 192 | 193 | ## v2.0.0 (2016-08-26) 194 | - Added MariaDb support 195 | - Added symphony event dispatcher 196 | - Added db charset to db connection 197 | - Removed support for php 5.4 198 | - Added slave register 199 | 200 | ## v1.0.3 (2016-07-16) 201 | - Added MariaDb gitid support (backport from 2.0.0-pre) 202 | 203 | ## v1.0.2 (2016-05-05) 204 | - Fixed handling not existing value in enum definitions 205 | 206 | ## v1.0.1 207 | - Fixed missing Config attr 208 | 209 | ## v1.0.0 210 | - Added php5.4 compatibility 211 | - Added new results set 212 | - Added benchmark results to readme 213 | - Added travis for tests 214 | -------------------------------------------------------------------------------- /tests/Unit/Config/ConfigTest.php: -------------------------------------------------------------------------------- 1 | 'foo', 21 | 'host' => '127.0.0.1', 22 | 'port' => 3308, 23 | 'password' => 'secret', 24 | 'charset' => 'utf8', 25 | 'gtid' => '9b1c8d18-2a76-11e5-a26b-000c2976f3f3:1-177592', 26 | 'slaveId' => 1, 27 | 'binLogFileName' => 'binfile1.bin', 28 | 'binLogPosition' => '999', 29 | 'eventsOnly' => [], 30 | 'eventsIgnore' => [], 31 | 'tablesOnly' => ['test_table'], 32 | 'databasesOnly' => ['test_database'], 33 | 'mariaDbGtid' => '123:123', 34 | 'tableCacheSize' => 777, 35 | 'custom' => [ 36 | [ 37 | 'random' => 'data', 38 | ], 39 | ], 40 | 'heartbeatPeriod' => 69, 41 | 'slaveUuid' => '6c27ed6d-7ee1-11e3-be39-6c626d957cff', 42 | ]; 43 | 44 | $config = new Config( 45 | $expected['user'], 46 | $expected['host'], 47 | $expected['port'], 48 | $expected['password'], 49 | $expected['charset'], 50 | $expected['gtid'], 51 | $expected['mariaDbGtid'], 52 | $expected['slaveId'], 53 | $expected['binLogFileName'], 54 | $expected['binLogPosition'], 55 | $expected['eventsOnly'], 56 | $expected['eventsIgnore'], 57 | $expected['tablesOnly'], 58 | $expected['databasesOnly'], 59 | $expected['tableCacheSize'], 60 | $expected['custom'], 61 | $expected['heartbeatPeriod'], 62 | $expected['slaveUuid'] 63 | ); 64 | 65 | self::assertSame($expected['user'], $config->user); 66 | self::assertSame($expected['host'], $config->host); 67 | self::assertSame($expected['port'], $config->port); 68 | self::assertSame($expected['password'], $config->password); 69 | self::assertSame($expected['charset'], $config->charset); 70 | self::assertSame($expected['gtid'], $config->gtid); 71 | self::assertSame($expected['slaveId'], $config->slaveId); 72 | self::assertSame($expected['binLogFileName'], $config->binLogFileName); 73 | self::assertSame($expected['binLogPosition'], $config->binLogPosition); 74 | self::assertSame($expected['eventsOnly'], $config->eventsOnly); 75 | self::assertSame($expected['eventsIgnore'], $config->eventsIgnore); 76 | self::assertSame($expected['tablesOnly'], $config->tablesOnly); 77 | self::assertSame($expected['mariaDbGtid'], $config->mariaDbGtid); 78 | self::assertSame($expected['tableCacheSize'], $config->tableCacheSize); 79 | self::assertSame($expected['custom'], $config->custom); 80 | self::assertSame($expected['heartbeatPeriod'], $config->heartbeatPeriod); 81 | self::assertSame($expected['databasesOnly'], $config->databasesOnly); 82 | self::assertSame($expected['slaveUuid'], $config->slaveUuid); 83 | 84 | $config->validate(); 85 | } 86 | 87 | public function testShouldCheckDataBasesOnly(): void 88 | { 89 | $config = (new ConfigBuilder())->withDatabasesOnly(['boo'])->build(); 90 | self::assertTrue($config->checkDataBasesOnly('foo')); 91 | 92 | $config = (new ConfigBuilder())->withDatabasesOnly(['foo'])->build(); 93 | self::assertFalse($config->checkDataBasesOnly('foo')); 94 | 95 | $config = (new ConfigBuilder())->withDatabasesOnly(['test'])->build(); 96 | self::assertFalse($config->checkDataBasesOnly('test')); 97 | 98 | $config = (new ConfigBuilder())->withDatabasesOnly(['foo'])->build(); 99 | self::assertTrue($config->checkDataBasesOnly('bar')); 100 | 101 | $config = (new ConfigBuilder())->withDatabasesRegex(['/^foo_.*/'])->build(); 102 | self::assertFalse($config->checkDataBasesOnly('foo_123')); 103 | } 104 | 105 | public function testShouldCheckTablesOnly(): void 106 | { 107 | $config = (new ConfigBuilder())->build(); 108 | self::assertFalse($config->checkTablesOnly('foo')); 109 | 110 | $config = (new ConfigBuilder())->withTablesOnly(['foo'])->build(); 111 | self::assertFalse($config->checkTablesOnly('foo')); 112 | 113 | $config = (new ConfigBuilder())->withTablesOnly(['test'])->build(); 114 | self::assertFalse($config->checkTablesOnly('test')); 115 | 116 | $config = (new ConfigBuilder())->withTablesOnly(['foo'])->build(); 117 | self::assertTrue($config->checkTablesOnly('bar')); 118 | 119 | $config = (new ConfigBuilder())->withTablesRegex(['/^foo_.*/'])->build(); 120 | self::assertFalse($config->checkTablesOnly('foo_123')); 121 | } 122 | 123 | public function testShouldCheckEvent(): void 124 | { 125 | $config = (new ConfigBuilder())->build(); 126 | self::assertTrue($config->checkEvent(1)); 127 | 128 | $config = (new ConfigBuilder())->withEventsOnly([2])->build(); 129 | self::assertTrue($config->checkEvent(2)); 130 | 131 | $config = (new ConfigBuilder())->withEventsOnly([3])->build(); 132 | self::assertFalse($config->checkEvent(4)); 133 | 134 | $config = (new ConfigBuilder())->withEventsIgnore([4])->build(); 135 | self::assertFalse($config->checkEvent(4)); 136 | } 137 | 138 | public static function shouldCheckHeartbeatPeriodProvider(): array 139 | { 140 | return [[0], [0.0], [0.001], [4294967], [2]]; 141 | } 142 | 143 | #[DataProvider('shouldCheckHeartbeatPeriodProvider')] public function testShouldCheckHeartbeatPeriod( 144 | int|float $heartbeatPeriod 145 | ): void { 146 | $config = (new ConfigBuilder())->withHeartbeatPeriod($heartbeatPeriod) 147 | ->build(); 148 | $config->validate(); 149 | 150 | self::assertEquals($heartbeatPeriod, $config->heartbeatPeriod); 151 | } 152 | 153 | public static function shouldValidateProvider(): array 154 | { 155 | return [ 156 | ['host', 'aaa', ConfigException::IP_ERROR_MESSAGE, ConfigException::IP_ERROR_CODE], 157 | ['port', -1, ConfigException::PORT_ERROR_MESSAGE, ConfigException::PORT_ERROR_CODE], 158 | ['slaveId', -1, ConfigException::SLAVE_ID_ERROR_MESSAGE, ConfigException::SLAVE_ID_ERROR_CODE], 159 | ['gtid', '-1', ConfigException::GTID_ERROR_MESSAGE, ConfigException::GTID_ERROR_CODE], 160 | [ 161 | 'binLogPosition', 162 | '-1', 163 | ConfigException::BIN_LOG_FILE_POSITION_ERROR_MESSAGE, 164 | ConfigException::BIN_LOG_FILE_POSITION_ERROR_CODE, 165 | ], 166 | [ 167 | 'tableCacheSize', 168 | -1, 169 | ConfigException::TABLE_CACHE_SIZE_ERROR_MESSAGE, 170 | ConfigException::TABLE_CACHE_SIZE_ERROR_CODE, 171 | ], 172 | [ 173 | 'heartbeatPeriod', 174 | 4294968, 175 | ConfigException::HEARTBEAT_PERIOD_ERROR_MESSAGE, 176 | ConfigException::HEARTBEAT_PERIOD_ERROR_CODE, 177 | ], 178 | [ 179 | 'heartbeatPeriod', 180 | -1, 181 | ConfigException::HEARTBEAT_PERIOD_ERROR_MESSAGE, 182 | ConfigException::HEARTBEAT_PERIOD_ERROR_CODE, 183 | ], 184 | ]; 185 | } 186 | 187 | #[DataProvider('shouldValidateProvider')] 188 | public function testShouldValidate( 189 | string $configKey, 190 | mixed $configValue, 191 | string $expectedMessage, 192 | int $expectedCode 193 | ): void { 194 | $this->expectException(ConfigException::class); 195 | $this->expectExceptionMessage($expectedMessage); 196 | $this->expectExceptionCode($expectedCode); 197 | 198 | /** @var Config $config */ 199 | $config = (new ConfigBuilder())->{'with' . strtoupper($configKey)}($configValue)->build(); 200 | $config->validate(); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/MySQLReplication/BinaryDataReader/BinaryDataReader.php: -------------------------------------------------------------------------------- 1 | > 0) & 0xFF, 51 | ($value >> 8) & 0xFF, 52 | ($value >> 16) & 0xFF, 53 | ($value >> 24) & 0xFF, 54 | ($value >> 32) & 0xFF, 55 | ($value >> 40) & 0xFF, 56 | ($value >> 48) & 0xFF, 57 | ($value >> 56) & 0xFF 58 | ); 59 | } 60 | 61 | public function advance(int $length): void 62 | { 63 | $this->read($length); 64 | } 65 | 66 | public function readInt16(): int 67 | { 68 | return self::unpack('s', $this->read(self::UNSIGNED_SHORT_LENGTH))[1]; 69 | } 70 | 71 | public function read(int $length): string 72 | { 73 | $return = substr($this->binaryData, 0, $length); 74 | $this->readBytes += $length; 75 | $this->binaryData = substr($this->binaryData, $length); 76 | 77 | return $return; 78 | } 79 | 80 | public function unread(string $data): void 81 | { 82 | $this->readBytes -= strlen($data); 83 | $this->binaryData = $data . $this->binaryData; 84 | } 85 | 86 | public function readCodedBinary(): ?int 87 | { 88 | $c = ord($this->read(self::UNSIGNED_CHAR_LENGTH)); 89 | if ($c === self::NULL_COLUMN) { 90 | return null; 91 | } 92 | if ($c < self::UNSIGNED_CHAR_COLUMN) { 93 | return $c; 94 | } 95 | if ($c === self::UNSIGNED_SHORT_COLUMN) { 96 | return $this->readUInt16(); 97 | } 98 | if ($c === self::UNSIGNED_INT24_COLUMN) { 99 | return $this->readUInt24(); 100 | } 101 | 102 | throw new BinaryDataReaderException('Column num ' . $c . ' not handled'); 103 | } 104 | 105 | public function readUInt16(): int 106 | { 107 | return self::unpack('v', $this->read(self::UNSIGNED_SHORT_LENGTH))[1]; 108 | } 109 | 110 | public function readUInt24(): int 111 | { 112 | $data = self::unpack('C3', $this->read(self::UNSIGNED_INT24_LENGTH)); 113 | 114 | return $data[1] + ($data[2] << 8) + ($data[3] << 16); 115 | } 116 | 117 | public function readUInt64(): string 118 | { 119 | return $this->unpackUInt64($this->read(self::UNSIGNED_INT64_LENGTH)); 120 | } 121 | 122 | public function unpackUInt64(string $binary): string 123 | { 124 | $data = self::unpack('V*', $binary); 125 | 126 | return bcadd((string)$data[1], bcmul((string)$data[2], bcpow('2', '32'))); 127 | } 128 | 129 | public function readInt24(): int 130 | { 131 | $data = self::unpack('C3', $this->read(self::UNSIGNED_INT24_LENGTH)); 132 | 133 | $res = $data[1] | ($data[2] << 8) | ($data[3] << 16); 134 | if ($res >= 0x800000) { 135 | $res -= 0x1000000; 136 | } 137 | 138 | return $res; 139 | } 140 | 141 | public function readInt64(): string 142 | { 143 | $data = self::unpack('V*', $this->read(self::UNSIGNED_INT64_LENGTH)); 144 | 145 | return bcadd((string)$data[1], (string)($data[2] << 32)); 146 | } 147 | 148 | public function readLengthString(int $size): string 149 | { 150 | return $this->read($this->readUIntBySize($size)); 151 | } 152 | 153 | public function readUIntBySize(int $size): int 154 | { 155 | if ($size === self::UNSIGNED_CHAR_LENGTH) { 156 | return $this->readUInt8(); 157 | } 158 | if ($size === self::UNSIGNED_SHORT_LENGTH) { 159 | return $this->readUInt16(); 160 | } 161 | if ($size === self::UNSIGNED_INT24_LENGTH) { 162 | return $this->readUInt24(); 163 | } 164 | if ($size === self::UNSIGNED_INT32_LENGTH) { 165 | return $this->readUInt32(); 166 | } 167 | if ($size === self::UNSIGNED_INT40_LENGTH) { 168 | return $this->readUInt40(); 169 | } 170 | if ($size === self::UNSIGNED_INT48_LENGTH) { 171 | return $this->readUInt48(); 172 | } 173 | if ($size === self::UNSIGNED_INT56_LENGTH) { 174 | return $this->readUInt56(); 175 | } 176 | 177 | throw new BinaryDataReaderException('$size ' . $size . ' not handled'); 178 | } 179 | 180 | public function readUInt8(): int 181 | { 182 | return self::unpack('C', $this->read(self::UNSIGNED_CHAR_LENGTH))[1]; 183 | } 184 | 185 | public function readUInt32(): int 186 | { 187 | return self::unpack('I', $this->read(self::UNSIGNED_INT32_LENGTH))[1]; 188 | } 189 | 190 | public function readUInt40(): int 191 | { 192 | $data1 = self::unpack('C', $this->read(self::UNSIGNED_CHAR_LENGTH))[1]; 193 | $data2 = self::unpack('I', $this->read(self::UNSIGNED_INT32_LENGTH))[1]; 194 | 195 | return $data1 + ($data2 << 8); 196 | } 197 | 198 | public function readUInt48(): int 199 | { 200 | $data = self::unpack('v3', $this->read(self::UNSIGNED_INT48_LENGTH)); 201 | 202 | return $data[1] + ($data[2] << 16) + ($data[3] << 32); 203 | } 204 | 205 | public function readUInt56(): int 206 | { 207 | $data1 = self::unpack('C', $this->read(self::UNSIGNED_CHAR_LENGTH))[1]; 208 | $data2 = self::unpack('S', $this->read(self::UNSIGNED_SHORT_LENGTH))[1]; 209 | $data3 = self::unpack('I', $this->read(self::UNSIGNED_INT32_LENGTH))[1]; 210 | 211 | return $data1 + ($data2 << 8) + ($data3 << 24); 212 | } 213 | 214 | public function readIntBeBySize(int $size): int 215 | { 216 | if ($size === self::UNSIGNED_CHAR_LENGTH) { 217 | return $this->readInt8(); 218 | } 219 | if ($size === self::UNSIGNED_SHORT_LENGTH) { 220 | return $this->readInt16Be(); 221 | } 222 | if ($size === self::UNSIGNED_INT24_LENGTH) { 223 | return $this->readInt24Be(); 224 | } 225 | if ($size === self::UNSIGNED_INT32_LENGTH) { 226 | return $this->readInt32Be(); 227 | } 228 | if ($size === self::UNSIGNED_INT40_LENGTH) { 229 | return $this->readInt40Be(); 230 | } 231 | 232 | throw new BinaryDataReaderException('$size ' . $size . ' not handled'); 233 | } 234 | 235 | public function readInt8(): int 236 | { 237 | $re = self::unpack('c', $this->read(self::UNSIGNED_CHAR_LENGTH))[1]; 238 | 239 | return $re >= 0x80 ? $re - 0x100 : $re; 240 | } 241 | 242 | public function readInt16Be(): int 243 | { 244 | $re = self::unpack('n', $this->read(self::UNSIGNED_SHORT_LENGTH))[1]; 245 | 246 | return $re >= 0x8000 ? $re - 0x10000 : $re; 247 | } 248 | 249 | public function readInt24Be(): int 250 | { 251 | $data = self::unpack('C3', $this->read(self::UNSIGNED_INT24_LENGTH)); 252 | $re = ($data[1] << 16) | ($data[2] << 8) | $data[3]; 253 | 254 | return $re >= 0x800000 ? $re - 0x1000000 : $re; 255 | } 256 | 257 | public function readInt32Be(): int 258 | { 259 | $re = self::unpack('N', $this->read(self::UNSIGNED_INT32_LENGTH))[1]; 260 | 261 | return $re >= 0x80000000 ? $re - 0x100000000 : $re; 262 | } 263 | 264 | public function readInt40Be(): int 265 | { 266 | $data1 = self::unpack('N', $this->read(self::UNSIGNED_INT32_LENGTH))[1]; 267 | $data2 = self::unpack('C', $this->read(self::UNSIGNED_CHAR_LENGTH))[1]; 268 | 269 | return $data2 + ($data1 << 8); 270 | } 271 | 272 | public function readInt32(): int 273 | { 274 | return self::unpack('i', $this->read(self::UNSIGNED_INT32_LENGTH))[1]; 275 | } 276 | 277 | public function readFloat(): float 278 | { 279 | return self::unpack('f', $this->read(self::UNSIGNED_FLOAT_LENGTH))[1]; 280 | } 281 | 282 | public function readDouble(): float 283 | { 284 | return self::unpack('d', $this->read(self::UNSIGNED_DOUBLE_LENGTH))[1]; 285 | } 286 | 287 | public function readTableId(): string 288 | { 289 | return (string)$this->unpackUInt64($this->read(self::UNSIGNED_INT48_LENGTH) . chr(0) . chr(0)); 290 | } 291 | 292 | public function isComplete(int $size): bool 293 | { 294 | return !($this->readBytes - 20 < $size); 295 | } 296 | 297 | public function getBinaryDataLength(): int 298 | { 299 | return strlen($this->binaryData); 300 | } 301 | 302 | public function getBinaryData(): string 303 | { 304 | return $this->binaryData; 305 | } 306 | 307 | public function getBinarySlice(int $binary, int $start, int $size, int $binaryLength): int 308 | { 309 | $binary >>= $binaryLength - ($start + $size); 310 | $mask = ((1 << $size) - 1); 311 | 312 | return $binary & $mask; 313 | } 314 | 315 | public function getReadBytes(): int 316 | { 317 | return $this->readBytes; 318 | } 319 | 320 | public static function unpack(string $format, string $string): array 321 | { 322 | $unpacked = unpack($format, $string); 323 | if ($unpacked) { 324 | return $unpacked; 325 | } 326 | return []; 327 | } 328 | 329 | public static function decodeNullLength(string $data, int &$offset = 0): string 330 | { 331 | $length = strpos($data, chr(0), $offset); 332 | if ($length === false) { 333 | return ''; 334 | } 335 | 336 | $length -= $offset; 337 | $result = substr($data, $offset, $length); 338 | $offset += $length + 1; 339 | 340 | return $result; 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/MySQLReplication/BinLog/BinLogSocketConnect.php: -------------------------------------------------------------------------------- 1 | binLogCurrent = new BinLogCurrent(); 36 | 37 | $this->socket->connectToStream($config->host, $config->port); 38 | 39 | $this->logger->debug('Connected to ' . $config->host . ':' . $config->port); 40 | 41 | $this->binLogServerInfo = BinLogServerInfo::make( 42 | $this->getResponse(false), 43 | $this->repository->getVersion() 44 | ); 45 | 46 | $this->logger->debug( 47 | 'Server version name: ' . $this->binLogServerInfo->versionName . ', revision: ' . $this->binLogServerInfo->versionRevision 48 | ); 49 | 50 | 51 | $this->authenticate($this->binLogServerInfo->authPlugin); 52 | $this->getBinlogStream(); 53 | } 54 | 55 | public function getBinLogServerInfo(): BinLogServerInfo 56 | { 57 | return $this->binLogServerInfo; 58 | } 59 | 60 | public function getResponse(bool $checkResponse = true): string 61 | { 62 | $header = $this->socket->readFromSocket(4); 63 | if ($header === '') { 64 | return ''; 65 | } 66 | $dataLength = BinaryDataReader::unpack('L', $header[0] . $header[1] . $header[2] . chr(0))[1]; 67 | $isMaxDataLength = $dataLength === $this->binaryDataMaxLength; 68 | 69 | $result = $this->socket->readFromSocket($dataLength); 70 | if ($checkResponse === true) { 71 | $this->isWriteSuccessful($result); 72 | } 73 | 74 | // https://dev.mysql.com/doc/internals/en/sending-more-than-16mbyte.html 75 | while ($isMaxDataLength) { 76 | $header = $this->socket->readFromSocket(4); 77 | if ($header === '') { 78 | return $result; 79 | } 80 | $dataLength = BinaryDataReader::unpack('L', $header[0] . $header[1] . $header[2] . chr(0))[1]; 81 | $isMaxDataLength = $dataLength === $this->binaryDataMaxLength; 82 | $next_result = $this->socket->readFromSocket($dataLength); 83 | $result .= $next_result; 84 | } 85 | 86 | return $result; 87 | } 88 | 89 | public function getBinLogCurrent(): BinLogCurrent 90 | { 91 | return $this->binLogCurrent; 92 | } 93 | 94 | public function getCheckSum(): bool 95 | { 96 | return $this->checkSum; 97 | } 98 | 99 | private function isWriteSuccessful(string $data): void 100 | { 101 | $head = ord($data[0]); 102 | if (!in_array($head, $this->packageOkHeader, true)) { 103 | $errorCode = BinaryDataReader::unpack('v', $data[1] . $data[2])[1]; 104 | $errorMessage = ''; 105 | $packetLength = strlen($data); 106 | for ($i = 9; $i < $packetLength; ++$i) { 107 | $errorMessage .= $data[$i]; 108 | } 109 | 110 | throw new BinLogException($errorMessage, $errorCode); 111 | } 112 | } 113 | 114 | private function authenticate(BinLogAuthPluginMode $authPlugin): void 115 | { 116 | $this->logger->debug( 117 | 'Trying to authenticate user: ' . $this->config->user . ' using ' . $authPlugin->value . ' default plugin' 118 | ); 119 | 120 | $data = pack('L', self::getCapabilities()); 121 | $data .= pack('L', $this->binaryDataMaxLength); 122 | $data .= chr(33); 123 | $data .= str_repeat(chr(0), 23); 124 | $data .= $this->config->user . chr(0); 125 | $auth = $this->getAuthData($authPlugin, $this->binLogServerInfo->salt); 126 | $data .= chr(strlen($auth)) . $auth; 127 | $data .= $authPlugin->value . chr(0); 128 | $str = pack('L', strlen($data)); 129 | $s = $str[0] . $str[1] . $str[2]; 130 | $data = $s . chr(1) . $data; 131 | 132 | $this->socket->writeToSocket($data); 133 | $response = $this->getResponse(); 134 | 135 | // Check for AUTH_SWITCH_PACKET 136 | if (isset($response[0]) && ord($response[0]) === self::AUTH_SWITCH_PACKET) { 137 | $this->switchAuth($response); 138 | } 139 | 140 | $this->logger->debug('User authenticated'); 141 | } 142 | 143 | private function getAuthData(?BinLogAuthPluginMode $authPlugin, string $salt): string 144 | { 145 | if ($authPlugin === BinLogAuthPluginMode::MysqlNativePassword) { 146 | return $this->authenticateMysqlNativePasswordPlugin($salt); 147 | } 148 | 149 | if ($authPlugin === BinLogAuthPluginMode::CachingSha2Password) { 150 | return $this->authenticateCachingSha2PasswordPlugin($salt); 151 | } 152 | 153 | return ''; 154 | } 155 | 156 | private function authenticateCachingSha2PasswordPlugin(string $salt): string 157 | { 158 | $hash1 = hash('sha256', $this->config->password, true); 159 | $hash2 = hash('sha256', $hash1, true); 160 | $hash3 = hash('sha256', $hash2 . $salt, true); 161 | return $hash1 ^ $hash3; 162 | } 163 | 164 | private function authenticateMysqlNativePasswordPlugin(string $salt): string 165 | { 166 | $hash1 = sha1($this->config->password, true); 167 | $hash2 = sha1($salt . sha1(sha1($this->config->password, true), true), true); 168 | return $hash1 ^ $hash2; 169 | } 170 | 171 | /** 172 | * https://dev.mysql.com/doc/dev/mysql-server/latest/group__group__cs__capabilities__flags.html 173 | * https://github.com/siddontang/mixer/blob/master/doc/protocol.txt 174 | */ 175 | private static function getCapabilities(): int 176 | { 177 | $noSchema = 1 << 4; 178 | $longPassword = 1; 179 | $longFlag = 1 << 2; 180 | $transactions = 1 << 13; 181 | $secureConnection = 1 << 15; 182 | $protocol41 = 1 << 9; 183 | $authPlugin = 1 << 19; 184 | 185 | return $longPassword | $longFlag | $transactions | $protocol41 | $secureConnection | $noSchema | $authPlugin; 186 | } 187 | 188 | private function getBinlogStream(): void 189 | { 190 | $this->checkSum = $this->repository->isCheckSum(); 191 | if ($this->checkSum) { 192 | $this->executeSQL('SET @master_binlog_checksum = @@global.binlog_checksum'); 193 | } 194 | 195 | 196 | if ($this->config->heartbeatPeriod > 0.00) { 197 | // master_heartbeat_period is in nanoseconds 198 | if (version_compare($this->repository->getVersion(), '8.4.0') >= 0) { 199 | $this->executeSQL('SET @source_heartbeat_period = ' . $this->config->heartbeatPeriod * 1000000000); 200 | } else { 201 | $this->executeSQL('SET @master_heartbeat_period = ' . $this->config->heartbeatPeriod * 1000000000); 202 | } 203 | 204 | $this->logger->debug('Heartbeat period set to ' . $this->config->heartbeatPeriod . ' seconds'); 205 | } 206 | 207 | if ($this->config->slaveUuid !== '') { 208 | $this->executeSQL( 209 | 'SET @slave_uuid = \'' . $this->config->slaveUuid . '\', @replica_uuid = \'' . $this->config->slaveUuid . '\'' 210 | ); 211 | 212 | $this->logger->debug('Salve uuid set to ' . $this->config->slaveUuid); 213 | } 214 | 215 | $this->registerSlave(); 216 | 217 | if ($this->config->mariaDbGtid !== '') { 218 | $this->setBinLogDumpMariaGtid(); 219 | } 220 | if ($this->config->gtid !== '') { 221 | $this->setBinLogDumpGtid(); 222 | } else { 223 | $this->setBinLogDump(); 224 | } 225 | } 226 | 227 | private function executeSQL(string $sql): void 228 | { 229 | $this->socket->writeToSocket(pack('LC', strlen($sql) + 1, 0x03) . $sql); 230 | $this->getResponse(); 231 | } 232 | 233 | /** 234 | * @see https://dev.mysql.com/doc/internals/en/com-register-slave.html 235 | */ 236 | private function registerSlave(): void 237 | { 238 | $host = (string)gethostname(); 239 | $hostLength = strlen($host); 240 | $userLength = strlen($this->config->user); 241 | $passLength = strlen($this->config->password); 242 | 243 | $data = pack('l', 18 + $hostLength + $userLength + $passLength); 244 | $data .= chr(self::COM_REGISTER_SLAVE); 245 | $data .= pack('V', $this->config->slaveId); 246 | $data .= pack('C', $hostLength); 247 | $data .= $host; 248 | $data .= pack('C', $userLength); 249 | $data .= $this->config->user; 250 | $data .= pack('C', $passLength); 251 | $data .= $this->config->password; 252 | $data .= pack('v', $this->config->port); 253 | $data .= pack('V', 0); 254 | $data .= pack('V', 0); 255 | 256 | $this->socket->writeToSocket($data); 257 | $this->getResponse(); 258 | 259 | $this->logger->debug('Slave registered with id ' . $this->config->slaveId); 260 | } 261 | 262 | private function setBinLogDumpMariaGtid(): void 263 | { 264 | $this->executeSQL('SET @mariadb_slave_capability = 4'); 265 | $this->executeSQL('SET @slave_connect_state = \'' . $this->config->mariaDbGtid . '\''); 266 | $this->executeSQL('SET @slave_gtid_strict_mode = 0'); 267 | $this->executeSQL('SET @slave_gtid_ignore_duplicates = 0'); 268 | 269 | $this->binLogCurrent->setMariaDbGtid($this->config->mariaDbGtid); 270 | 271 | $this->logger->debug('Set Maria GTID to start from: ' . $this->config->mariaDbGtid); 272 | } 273 | 274 | private function setBinLogDumpGtid(): void 275 | { 276 | $collection = GtidCollection::makeCollectionFromString($this->config->gtid); 277 | 278 | $data = pack('l', 26 + $collection->getEncodedLength()) . chr(self::COM_BINLOG_DUMP_GTID); 279 | $data .= pack('S', 0); 280 | $data .= pack('I', $this->config->slaveId); 281 | $data .= pack('I', 3); 282 | $data .= chr(0); 283 | $data .= chr(0); 284 | $data .= chr(0); 285 | $data .= BinaryDataReader::pack64bit(4); 286 | $data .= pack('I', $collection->getEncodedLength()); 287 | $data .= $collection->getEncoded(); 288 | 289 | $this->socket->writeToSocket($data); 290 | $this->getResponse(); 291 | 292 | $this->binLogCurrent->setGtid($this->config->gtid); 293 | 294 | $this->logger->debug('Set GTID to start from: ' . $this->config->gtid); 295 | } 296 | 297 | /** 298 | * 1 [12] COM_BINLOG_DUMP 299 | * 4 binlog-pos 300 | * 2 flags 301 | * 4 server-id 302 | * string[EOF] binlog-filename 303 | */ 304 | private function setBinLogDump(): void 305 | { 306 | $binFilePos = $this->config->binLogPosition; 307 | $binFileName = $this->config->binLogFileName; 308 | // if not set start from newest binlog 309 | if ($binFilePos === '' && $binFileName === '') { 310 | $masterStatusDTO = $this->repository->getMasterStatus(); 311 | $binFilePos = $masterStatusDTO->position; 312 | $binFileName = $masterStatusDTO->file; 313 | } 314 | 315 | $data = pack('i', strlen($binFileName) + 11) . chr(self::COM_BINLOG_DUMP); 316 | $data .= pack('I', $binFilePos); 317 | $data .= pack('v', 0); 318 | $data .= pack('I', $this->config->slaveId); 319 | $data .= $binFileName; 320 | 321 | $this->socket->writeToSocket($data); 322 | $this->getResponse(); 323 | 324 | $this->binLogCurrent->setBinLogPosition($binFilePos); 325 | $this->binLogCurrent->setBinFileName($binFileName); 326 | 327 | $this->logger->debug('Set binlog to start from: ' . $binFileName . ':' . $binFilePos); 328 | } 329 | 330 | private function switchAuth(string $response): void 331 | { 332 | // skip AUTH_SWITCH_PACKET byte 333 | $offset = 1; 334 | $authPluginSwitched = BinLogAuthPluginMode::make(BinaryDataReader::decodeNullLength($response, $offset)); 335 | $salt = BinaryDataReader::decodeNullLength($response, $offset); 336 | $auth = $this->getAuthData($authPluginSwitched, $salt); 337 | 338 | $this->logger->debug('Auth switch packet received, switching to ' . $authPluginSwitched->value); 339 | 340 | $this->socket->writeToSocket(pack('L', (strlen($auth)) | (3 << 24)) . $auth); 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/MySQLReplication/JsonBinaryDecoder/JsonBinaryDecoderService.php: -------------------------------------------------------------------------------- 1 | dataLength = $this->binaryDataReader->getBinaryDataLength(); 76 | } 77 | 78 | public static function makeJsonBinaryDecoder(string $data): self 79 | { 80 | return new self(new BinaryDataReader($data), new JsonBinaryDecoderFormatter()); 81 | } 82 | 83 | public function parseToString(): string 84 | { 85 | // Sometimes, we can insert a NULL JSON even we set the JSON field as NOT NULL. 86 | // If we meet this case, we can return a 'null' value. 87 | if ($this->binaryDataReader->getBinaryDataLength() === 0) { 88 | return 'null'; 89 | } 90 | $this->parseJson($this->binaryDataReader->readUInt8()); 91 | 92 | return $this->jsonBinaryDecoderFormatter->getJsonString(); 93 | } 94 | 95 | private function parseJson(int $type): void 96 | { 97 | $results = []; 98 | if ($type === self::SMALL_OBJECT) { 99 | $results[self::OBJECT] = $this->parseArrayOrObject(self::OBJECT, self::SMALL_OFFSET_SIZE); 100 | } elseif ($type === self::LARGE_OBJECT) { 101 | $results[self::OBJECT] = $this->parseArrayOrObject(self::OBJECT, self::LARGE_OFFSET_SIZE); 102 | } elseif ($type === self::SMALL_ARRAY) { 103 | $results[self::ARRAY] = $this->parseArrayOrObject(self::ARRAY, self::SMALL_OFFSET_SIZE); 104 | } elseif ($type === self::LARGE_ARRAY) { 105 | $results[self::ARRAY] = $this->parseArrayOrObject(self::ARRAY, self::LARGE_OFFSET_SIZE); 106 | } else { 107 | $results[self::SCALAR][] = [ 108 | 'name' => null, 109 | 'value' => $this->parseScalar($type), 110 | ]; 111 | } 112 | 113 | $this->parseToJson($results); 114 | } 115 | 116 | private function parseToJson(array $results): void 117 | { 118 | foreach ($results as $dataType => $entities) { 119 | if ($dataType === self::OBJECT) { 120 | $this->jsonBinaryDecoderFormatter->formatBeginObject(); 121 | } elseif ($dataType === self::ARRAY) { 122 | $this->jsonBinaryDecoderFormatter->formatBeginArray(); 123 | } 124 | 125 | foreach ($entities as $i => $entity) { 126 | if ($dataType === self::SCALAR) { 127 | if ($entity['value']->value === null) { 128 | $this->jsonBinaryDecoderFormatter->formatValue('null'); 129 | } elseif (is_bool($entity['value']->value)) { 130 | $this->jsonBinaryDecoderFormatter->formatValueBool($entity['value']->value); 131 | } else { 132 | $this->jsonBinaryDecoderFormatter->formatValue($entity['value']->value); 133 | } 134 | continue; 135 | } 136 | 137 | if ($i !== 0) { 138 | $this->jsonBinaryDecoderFormatter->formatNextEntry(); 139 | } 140 | 141 | if ($entity['name'] !== null) { 142 | $this->jsonBinaryDecoderFormatter->formatName($entity['name']); 143 | } 144 | $this->assignValues($entity['value']); 145 | } 146 | 147 | if ($dataType === self::OBJECT) { 148 | $this->jsonBinaryDecoderFormatter->formatEndObject(); 149 | } elseif ($dataType === self::ARRAY) { 150 | $this->jsonBinaryDecoderFormatter->formatEndArray(); 151 | } 152 | } 153 | } 154 | 155 | private function parseArrayOrObject(int $type, int $intSize): array 156 | { 157 | $large = $intSize === self::LARGE_OFFSET_SIZE; 158 | $offsetSize = self::offsetSize($large); 159 | if ($this->dataLength < 2 * $offsetSize) { 160 | throw new InvalidArgumentException('Document is not long enough to contain the two length fields'); 161 | } 162 | 163 | $elementCount = $this->binaryDataReader->readUIntBySize($intSize); 164 | $bytes = $this->binaryDataReader->readUIntBySize($intSize); 165 | 166 | if ($bytes > $this->dataLength) { 167 | throw new InvalidArgumentException( 168 | 'The value can\'t have more bytes than what\'s available in the data buffer.' 169 | ); 170 | } 171 | 172 | $keyEntrySize = self::keyEntrySize($large); 173 | $valueEntrySize = self::valueEntrySize($large); 174 | 175 | $headerSize = 2 * $offsetSize; 176 | 177 | if ($type === self::OBJECT) { 178 | $headerSize += $elementCount * $keyEntrySize; 179 | } 180 | $headerSize += $elementCount * $valueEntrySize; 181 | 182 | if ($headerSize > $bytes) { 183 | throw new InvalidArgumentException('Header is larger than the full size of the value.'); 184 | } 185 | 186 | $keyLengths = []; 187 | if ($type === self::OBJECT) { 188 | // Read each key-entry, consisting of the offset and length of each key ... 189 | for ($i = 0; $i !== $elementCount; ++$i) { 190 | $keyOffset = $this->binaryDataReader->readUIntBySize($intSize); 191 | $keyLengths[$i] = $this->binaryDataReader->readUInt16(); 192 | if ($keyOffset < $headerSize) { 193 | throw new InvalidArgumentException('Invalid key offset'); 194 | } 195 | } 196 | } 197 | 198 | $entries = []; 199 | for ($i = 0; $i !== $elementCount; ++$i) { 200 | $entries[$i] = $this->getOffsetOrInLinedValue($bytes, $intSize, $valueEntrySize); 201 | } 202 | 203 | $keys = []; 204 | if ($type === self::OBJECT) { 205 | for ($i = 0; $i !== $elementCount; ++$i) { 206 | $keys[$i] = $this->binaryDataReader->read($keyLengths[$i]); 207 | } 208 | } 209 | 210 | $results = []; 211 | for ($i = 0; $i !== $elementCount; ++$i) { 212 | $results[] = [ 213 | 'name' => $keys[$i] ?? null, 214 | 'value' => $entries[$i], 215 | ]; 216 | } 217 | 218 | return $results; 219 | } 220 | 221 | private static function offsetSize(bool $large): int 222 | { 223 | return $large ? self::LARGE_OFFSET_SIZE : self::SMALL_OFFSET_SIZE; 224 | } 225 | 226 | private static function keyEntrySize(bool $large): int 227 | { 228 | return $large ? self::KEY_ENTRY_SIZE_LARGE : self::KEY_ENTRY_SIZE_SMALL; 229 | } 230 | 231 | private static function valueEntrySize(bool $large): int 232 | { 233 | return $large ? self::VALUE_ENTRY_SIZE_LARGE : self::VALUE_ENTRY_SIZE_SMALL; 234 | } 235 | 236 | private function getOffsetOrInLinedValue(int $bytes, int $intSize, int $valueEntrySize): JsonBinaryDecoderValue 237 | { 238 | $type = $this->binaryDataReader->readUInt8(); 239 | 240 | if (self::isInLinedType($type, $intSize)) { 241 | $scalar = $this->parseScalar($type); 242 | 243 | // In binlog format, JSON arrays are fixed width elements, even though type value can be smaller. 244 | // In order to properly process this case, we need to move cursor to the next element, which is on position 1 + $valueEntrySize (1 is length of type) 245 | if ($type === self::UINT16 || $type === self::INT16) { 246 | $readNextBytes = $valueEntrySize - 2 - 1; 247 | $this->binaryDataReader->read($readNextBytes); 248 | } 249 | 250 | return $scalar; 251 | } 252 | 253 | $offset = $this->binaryDataReader->readUIntBySize($intSize); 254 | if ($offset > $bytes) { 255 | throw new LengthException( 256 | 'The offset for the value in the JSON binary document is ' . $offset . ', which is larger than the binary form of the JSON document (' . $bytes . ' bytes)' 257 | ); 258 | } 259 | 260 | return new JsonBinaryDecoderValue(false, null, $type, $offset); 261 | } 262 | 263 | private static function isInLinedType(int $type, int $intSize): bool 264 | { 265 | return match ($type) { 266 | self::LITERAL, self::INT16, self::UINT16 => true, 267 | self::INT32, self::UINT32 => $intSize === self::LARGE_OFFSET_SIZE, 268 | default => false, 269 | }; 270 | } 271 | 272 | private function parseScalar(int $type): JsonBinaryDecoderValue 273 | { 274 | if ($type === self::LITERAL) { 275 | $data = $this->readLiteral(); 276 | } elseif ($type === self::INT16) { 277 | $data = $this->binaryDataReader->readInt16(); 278 | } elseif ($type === self::INT32) { 279 | $data = ($this->binaryDataReader->readInt32()); 280 | } elseif ($type === self::INT64) { 281 | $data = $this->binaryDataReader->readInt64(); 282 | } elseif ($type === self::UINT16) { 283 | $data = ($this->binaryDataReader->readUInt16()); 284 | } elseif ($type === self::UINT64) { 285 | $data = ($this->binaryDataReader->readUInt64()); 286 | } elseif ($type === self::DOUBLE) { 287 | $data = ($this->binaryDataReader->readDouble()); 288 | } elseif ($type === self::STRING) { 289 | $data = ($this->binaryDataReader->read($this->readVariableInt())); 290 | } /** 291 | * else if (self::OPAQUE === $type) 292 | * { 293 | * 294 | * } 295 | */ 296 | else { 297 | throw new JsonBinaryDecoderException( 298 | JsonBinaryDecoderException::UNKNOWN_JSON_TYPE_MESSAGE . $type, 299 | JsonBinaryDecoderException::UNKNOWN_JSON_TYPE_CODE 300 | ); 301 | } 302 | 303 | return new JsonBinaryDecoderValue(true, $data, $type); 304 | } 305 | 306 | private function readLiteral(): ?bool 307 | { 308 | $literal = ord($this->binaryDataReader->read(BinaryDataReader::UNSIGNED_SHORT_LENGTH)); 309 | if ($literal === self::LITERAL_NULL) { 310 | return null; 311 | } 312 | if ($literal === self::LITERAL_TRUE) { 313 | return true; 314 | } 315 | if ($literal === self::LITERAL_FALSE) { 316 | return false; 317 | } 318 | 319 | return null; 320 | } 321 | 322 | private function readVariableInt(): int 323 | { 324 | $maxBytes = min($this->binaryDataReader->getBinaryDataLength(), 5); 325 | $len = 0; 326 | for ($i = 0; $i < $maxBytes; ++$i) { 327 | $size = $this->binaryDataReader->readUInt8(); 328 | // Get the next 7 bits of the length. 329 | $len |= ($size & 0x7f) << (7 * $i); 330 | if (($size & 0x80) === 0) { 331 | // This was the last byte. Return successfully. 332 | return $len; 333 | } 334 | } 335 | 336 | return $len; 337 | } 338 | 339 | private function assignValues(JsonBinaryDecoderValue $jsonBinaryDecoderValue): void 340 | { 341 | if ($jsonBinaryDecoderValue->isResolved === false) { 342 | $this->ensureOffset($jsonBinaryDecoderValue->offset); 343 | $this->parseJson($jsonBinaryDecoderValue->type); 344 | } elseif ($jsonBinaryDecoderValue->value === null) { 345 | $this->jsonBinaryDecoderFormatter->formatValueNull(); 346 | } elseif (is_bool($jsonBinaryDecoderValue->value)) { 347 | $this->jsonBinaryDecoderFormatter->formatValueBool($jsonBinaryDecoderValue->value); 348 | } elseif (is_numeric($jsonBinaryDecoderValue->value)) { 349 | $this->jsonBinaryDecoderFormatter->formatValueNumeric($jsonBinaryDecoderValue->value); 350 | } 351 | } 352 | 353 | private function ensureOffset(?int $ensureOffset): void 354 | { 355 | if ($ensureOffset === null) { 356 | return; 357 | } 358 | $pos = $this->binaryDataReader->getReadBytes(); 359 | if ($pos !== $ensureOffset) { 360 | if ($ensureOffset < $pos) { 361 | return; 362 | } 363 | $this->binaryDataReader->advance($ensureOffset + 1 - $pos); 364 | } 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | php-mysql-replication 2 | ========= 3 | [![Latest Stable Version](https://poser.pugx.org/krowinski/php-mysql-replication/v/stable)](https://packagist.org/packages/krowinski/php-mysql-replication) [![Total Downloads](https://poser.pugx.org/krowinski/php-mysql-replication/downloads)](https://packagist.org/packages/krowinski/php-mysql-replication) [![Latest Unstable Version](https://poser.pugx.org/krowinski/php-mysql-replication/v/unstable)](https://packagist.org/packages/krowinski/php-mysql-replication) 4 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/krowinski/php-mysql-replication/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/krowinski/php-mysql-replication/?branch=master) 5 | [![Code Coverage](https://scrutinizer-ci.com/g/krowinski/php-mysql-replication/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/krowinski/php-mysql-replication/?branch=master) 6 | 7 | Pure PHP Implementation of MySQL replication protocol. This allows you to receive event like insert, update, delete with their data and raw SQL queries. 8 | 9 | Based on a great work of creators:https://github.com/noplay/python-mysql-replication and https://github.com/fengxiangyun/mysql-replication 10 | 11 | Installation 12 | ========= 13 | 14 | In you project 15 | 16 | ```sh 17 | composer require krowinski/php-mysql-replication 18 | ``` 19 | 20 | or standalone 21 | 22 | ```sh 23 | git clone https://github.com/krowinski/php-mysql-replication.git 24 | 25 | composer install -o 26 | ``` 27 | 28 | Compatibility (based on integration tests) 29 | ========= 30 | PHP 31 | 32 | - php 8.2 33 | - php 8.3 34 | 35 | MYSQL 36 | - mysql 5.5 37 | - mysql 5.6 38 | - mysql 5.7 39 | - mysql 8.0 (mysql_native_password and caching_sha2_password supported) 40 | - mariadb 5.5 41 | - mariadb 10.0 42 | - mariadb 10.1 43 | - probably percona versions as is based on native mysql 44 | 45 | MySQL server settings 46 | ========= 47 | 48 | In your MySQL server configuration file you need to enable replication: 49 | 50 | [mysqld] 51 | server-id = 1 52 | log_bin = /var/log/mysql/mysql-bin.log 53 | expire_logs_days = 10 54 | max_binlog_size = 100M 55 | binlog-format = row #Very important if you want to receive write, update and delete row events 56 | 57 | 58 | Mysql replication events explained 59 | https://dev.mysql.com/doc/internals/en/event-meanings.html 60 | 61 | 62 | Mysql user privileges: 63 | ``` 64 | GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'user'@'host'; 65 | 66 | GRANT SELECT ON `dbName`.* TO 'user'@'host'; 67 | ``` 68 | 69 | Configuration 70 | ========= 71 | 72 | Use ConfigBuilder or ConfigFactory to create configuration. 73 | Available options: 74 | 75 | 'user' - your mysql user (mandatory) 76 | 77 | 'ip' or 'host' - your mysql host/ip (mandatory) 78 | 79 | 'password' - your mysql password (mandatory) 80 | 81 | 'port' - your mysql host port (default 3306) 82 | 83 | 'charset' - db connection charset (default utf8) 84 | 85 | 'gtid' - GTID marker(s) to start from (format 9b1c8d18-2a76-11e5-a26b-000c2976f3f3:1-177592) 86 | 87 | 'mariaDbGtid' - MariaDB GTID marker(s) to start from (format 1-1-3,0-1-88) 88 | 89 | 'slaveId' - script slave id for identification (default: 666) (SHOW SLAVE HOSTS) 90 | 91 | 'binLogFileName' - bin log file name to start from 92 | 93 | 'binLogPosition' - bin log position to start from 94 | 95 | 'eventsOnly' - array to listen on events (full list in [ConstEventType.php](https://github.com/krowinski/php-mysql-replication/blob/master/src/MySQLReplication/Definitions/ConstEventType.php) file) 96 | 97 | 'eventsIgnore' - array to ignore events (full list in [ConstEventType.php](https://github.com/krowinski/php-mysql-replication/blob/master/src/MySQLReplication/Definitions/ConstEventType.php) file) 98 | 99 | 'tablesOnly' - array to only listen on given tables (default all tables) 100 | 101 | 'databasesOnly' - array to only listen on given databases (default all databases) 102 | 103 | 'tableCacheSize' - some data are collected from information schema, this data is cached. 104 | 105 | 'custom' - if some params must be set in extended/implemented own classes 106 | 107 | 'heartbeatPeriod' - sets the interval in seconds between replication heartbeats. Whenever the master's binary log is updated with an event, the waiting period for the next heartbeat is reset. interval is a decimal value having the range 0 to 4294967 seconds and a resolution in milliseconds; the smallest nonzero value is 0.001. Heartbeats are sent by the master only if there are no unsent events in the binary log file for a period longer than interval. 108 | 109 | 'saveUuid' - sets slave uuid for identification (default: 0015d2b6-8a06-4e5e-8c07-206ef3fbd274) 110 | 111 | Similar projects 112 | ========= 113 | Ruby: https://github.com/y310/kodama 114 | 115 | Java: https://github.com/shyiko/mysql-binlog-connector-java 116 | 117 | GO: https://github.com/siddontang/go-mysql 118 | 119 | Python: https://github.com/noplay/python-mysql-replication 120 | 121 | .NET: https://github.com/rusuly/MySqlCdc 122 | 123 | Examples 124 | ========= 125 | 126 | All examples are available in the [examples directory](https://github.com/krowinski/php-mysql-replication/tree/master/example) 127 | 128 | This example will dump all replication events to the console: 129 | 130 | Remember to change config for your user, host and password. 131 | 132 | User should have replication privileges [ REPLICATION CLIENT, SELECT] 133 | 134 | ```sh 135 | php example/dump_events.php 136 | ``` 137 | 138 | For test SQL events: 139 | 140 | ```sql 141 | CREATE DATABASE php_mysql_replication; 142 | use php_mysql_replication; 143 | CREATE TABLE test4 (id int NOT NULL AUTO_INCREMENT, data VARCHAR(255), data2 VARCHAR(255), PRIMARY KEY(id)); 144 | INSERT INTO test4 (data,data2) VALUES ("Hello", "World"); 145 | UPDATE test4 SET data = "World", data2="Hello" WHERE id = 1; 146 | DELETE FROM test4 WHERE id = 1; 147 | ``` 148 | 149 | Output will be similar to this (depends on configuration for example GTID off/on): 150 | 151 | === Event format description === 152 | Date: 2017-07-06T13:31:11+00:00 153 | Log position: 0 154 | Event size: 116 155 | Memory usage 2.4 MB 156 | 157 | === Event gtid === 158 | Date: 2017-07-06T15:23:44+00:00 159 | Log position: 57803092 160 | Event size: 48 161 | Commit: true 162 | GTID NEXT: 3403c535-624f-11e7-9940-0800275713ee:13675 163 | Memory usage 2.42 MB 164 | 165 | === Event query === 166 | Date: 2017-07-06T15:23:44+00:00 167 | Log position: 57803237 168 | Event size: 145 169 | Database: php_mysql_replication 170 | Execution time: 0 171 | Query: CREATE DATABASE php_mysql_replication 172 | Memory usage 2.45 MB 173 | 174 | === Event gtid === 175 | Date: 2017-07-06T15:23:44+00:00 176 | Log position: 57803285 177 | Event size: 48 178 | Commit: true 179 | GTID NEXT: 3403c535-624f-11e7-9940-0800275713ee:13676 180 | Memory usage 2.45 MB 181 | 182 | === Event query === 183 | Date: 2017-07-06T15:23:44+00:00 184 | Log position: 57803500 185 | Event size: 215 186 | Database: php_mysql_replication 187 | Execution time: 0 188 | Query: CREATE TABLE test4 (id int NOT NULL AUTO_INCREMENT, data VARCHAR(255), data2 VARCHAR(255), PRIMARY KEY(id)) 189 | Memory usage 2.45 MB 190 | 191 | === Event gtid === 192 | Date: 2017-07-06T15:23:44+00:00 193 | Log position: 57803548 194 | Event size: 48 195 | Commit: true 196 | GTID NEXT: 3403c535-624f-11e7-9940-0800275713ee:13677 197 | Memory usage 2.45 MB 198 | 199 | === Event query === 200 | Date: 2017-07-06T15:23:44+00:00 201 | Log position: 57803637 202 | Event size: 89 203 | Database: php_mysql_replication 204 | Execution time: 0 205 | Query: BEGIN 206 | Memory usage 2.45 MB 207 | 208 | === Event tableMap === 209 | Date: 2017-07-06T15:23:44+00:00 210 | Log position: 57803708 211 | Event size: 71 212 | Table: test4 213 | Database: php_mysql_replication 214 | Table Id: 866 215 | Columns amount: 3 216 | Memory usage 2.71 MB 217 | 218 | === Event write === 219 | Date: 2017-07-06T15:23:44+00:00 220 | Log position: 57803762 221 | Event size: 54 222 | Table: test4 223 | Affected columns: 3 224 | Changed rows: 1 225 | Values: Array 226 | ( 227 | [0] => Array 228 | ( 229 | [id] => 1 230 | [data] => Hello 231 | [data2] => World 232 | ) 233 | 234 | ) 235 | 236 | Memory usage 2.74 MB 237 | 238 | === Event xid === 239 | Date: 2017-07-06T15:23:44+00:00 240 | Log position: 57803793 241 | Event size: 31 242 | Transaction ID: 662802 243 | Memory usage 2.75 MB 244 | 245 | === Event gtid === 246 | Date: 2017-07-06T15:23:44+00:00 247 | Log position: 57803841 248 | Event size: 48 249 | Commit: true 250 | GTID NEXT: 3403c535-624f-11e7-9940-0800275713ee:13678 251 | Memory usage 2.75 MB 252 | 253 | === Event query === 254 | Date: 2017-07-06T15:23:44+00:00 255 | Log position: 57803930 256 | Event size: 89 257 | Database: php_mysql_replication 258 | Execution time: 0 259 | Query: BEGIN 260 | Memory usage 2.76 MB 261 | 262 | === Event tableMap === 263 | Date: 2017-07-06T15:23:44+00:00 264 | Log position: 57804001 265 | Event size: 71 266 | Table: test4 267 | Database: php_mysql_replication 268 | Table Id: 866 269 | Columns amount: 3 270 | Memory usage 2.75 MB 271 | 272 | === Event update === 273 | Date: 2017-07-06T15:23:44+00:00 274 | Log position: 57804075 275 | Event size: 74 276 | Table: test4 277 | Affected columns: 3 278 | Changed rows: 1 279 | Values: Array 280 | ( 281 | [0] => Array 282 | ( 283 | [before] => Array 284 | ( 285 | [id] => 1 286 | [data] => Hello 287 | [data2] => World 288 | ) 289 | 290 | [after] => Array 291 | ( 292 | [id] => 1 293 | [data] => World 294 | [data2] => Hello 295 | ) 296 | 297 | ) 298 | 299 | ) 300 | 301 | Memory usage 2.76 MB 302 | 303 | === Event xid === 304 | Date: 2017-07-06T15:23:44+00:00 305 | Log position: 57804106 306 | Event size: 31 307 | Transaction ID: 662803 308 | Memory usage 2.76 MB 309 | 310 | === Event gtid === 311 | Date: 2017-07-06T15:23:44+00:00 312 | Log position: 57804154 313 | Event size: 48 314 | Commit: true 315 | GTID NEXT: 3403c535-624f-11e7-9940-0800275713ee:13679 316 | Memory usage 2.76 MB 317 | 318 | === Event query === 319 | Date: 2017-07-06T15:23:44+00:00 320 | Log position: 57804243 321 | Event size: 89 322 | Database: php_mysql_replication 323 | Execution time: 0 324 | Query: BEGIN 325 | Memory usage 2.76 MB 326 | 327 | === Event tableMap === 328 | Date: 2017-07-06T15:23:44+00:00 329 | Log position: 57804314 330 | Event size: 71 331 | Table: test4 332 | Database: php_mysql_replication 333 | Table Id: 866 334 | Columns amount: 3 335 | Memory usage 2.76 MB 336 | 337 | === Event delete === 338 | Date: 2017-07-06T15:23:44+00:00 339 | Log position: 57804368 340 | Event size: 54 341 | Table: test4 342 | Affected columns: 3 343 | Changed rows: 1 344 | Values: Array 345 | ( 346 | [0] => Array 347 | ( 348 | [id] => 1 349 | [data] => World 350 | [data2] => Hello 351 | ) 352 | 353 | ) 354 | 355 | Memory usage 2.77 MB 356 | 357 | === Event xid === 358 | Date: 2017-07-06T15:23:44+00:00 359 | Log position: 57804399 360 | Event size: 31 361 | Transaction ID: 662804 362 | Memory usage 2.77 MB 363 | 364 | 365 | 366 | Benchmarks 367 | ========= 368 | 369 | Tested on VM 370 | 371 | Debian 8.7 372 | PHP 5.6.30 373 | Percona 5.6.35 374 | 375 | ```sh 376 | inxi 377 | ``` 378 | 379 | CPU(s)~4 Single core Intel Core i5-2500Ks (-SMP-) clocked at 5901 Mhz Kernel~3.16.0-4-amd64 x86_64 Up~1 day Mem~1340.3/1996.9MB HDD~41.9GB(27.7% used) Procs~122 Client~Shell inxi~2.1.28 380 | 381 | ```sh 382 | php example/benchmark.php 383 | ``` 384 | Start insert data 385 | 7442 event by seconds (1000 total) 386 | 7679 event by seconds (2000 total) 387 | 7914 event by seconds (3000 total) 388 | 7904 event by seconds (4000 total) 389 | 7965 event by seconds (5000 total) 390 | 8006 event by seconds (6000 total) 391 | 8048 event by seconds (7000 total) 392 | 8038 event by seconds (8000 total) 393 | 8040 event by seconds (9000 total) 394 | 8055 event by seconds (10000 total) 395 | 8058 event by seconds (11000 total) 396 | 8071 event by seconds (12000 total) 397 | 398 | FAQ 399 | ========= 400 | 401 | 1. ### Why and when need php-mysql-replication ? 402 | 403 | Well first of all MYSQL don't give you async calls. You usually need to program this in your application (by event dispatching and adding to some queue system 404 | and if your db have many point of entry like web, backend other microservices its not always cheap to add processing to all of them. But using mysql replication 405 | protocol you can listen on write events and process then asynchronously (the best combo it's to add item to some queue system like rabbitmq, redis or kafka). 406 | Also in invalidate cache, search engine replication, real time analytics and audits. 407 | 408 | 2. ### It's awesome ! but what is the catch ? 409 | 410 | Well first of all you need to know that a lot of events may come through, like if you update 1 000 000 records in table "bar" and you need this one insert from 411 | your table "foo" Then all must be processed by script, and you need to wait for your data. This is normal and this how it's work. You can speed up 412 | using [config options](https://github.com/krowinski/php-mysql-replication#configuration). 413 | Also, if script crashes you need to save from time to time position form binlog (or gtid) to start from this position when you run this script again to avoid 414 | duplicates. 415 | 416 | 3. ### I need to process 1 000 000 records and its taking forever!! 417 | Like I mention in 1 point use queue system like rabbitmq, redis or kafka, they will give you ability to process data in multiple scripts. 418 | 419 | 4. ### I have a problem ? you script is missing something ! I have found a bug ! 420 | Create an [issue](https://github.com/krowinski/php-mysql-replication/issues) I will try to work on it in my free time :) 421 | 422 | 5. ### How much its give overhead to MYSQL server ? 423 | 424 | It work like any other MYSQL in slave mode and its giving same overhead. 425 | 426 | 6. ### Socket timeouts error 427 | 428 | To fix this best is to increase db configurations ```net_read_timeout``` and ```net_write_timeout``` to 3600. (tx Bijimon) 429 | 430 | 7. ### Partial updates fix 431 | 432 | Set in my.conf ```binlog_row_image=full``` to fix receiving only partial updates. 433 | 434 | 8. ### No replication events when connected to replica server 435 | Set in my.conf ```log_slave_updates=on``` to fix this (#71)(#66) 436 | 437 | 9. ### "Big" updates / inserts 438 | Default MYSQL setting generates one big blob of stream this require more RAM/CPU you can change this for smaller stream using 439 | variable ```binlog_row_event_max_size``` [https://dev.mysql.com/doc/refman/8.0/en/replication-options-binary-log.html#sysvar_binlog_row_event_max_size] to 440 | split into smaller chunks 441 | -------------------------------------------------------------------------------- /tests/Integration/BasicTest.php: -------------------------------------------------------------------------------- 1 | createAndInsertValue( 30 | 'CREATE TABLE test (id INT NOT NULL AUTO_INCREMENT, data VARCHAR (50) NOT NULL, PRIMARY KEY (id))', 31 | 'INSERT INTO test (data) VALUES(\'Hello World\')' 32 | ); 33 | 34 | $this->connection->executeStatement('DELETE FROM test WHERE id = 1'); 35 | 36 | self::assertInstanceOf(XidDTO::class, $this->getEvent()); 37 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 38 | self::assertInstanceOf(TableMapDTO::class, $this->getEvent()); 39 | 40 | /** @var DeleteRowsDTO $event */ 41 | $event = $this->getEvent(); 42 | self::assertInstanceOf(DeleteRowsDTO::class, $event); 43 | self::assertEquals(1, $event->values[0]['id']); 44 | self::assertEquals('Hello World', $event->values[0]['data']); 45 | } 46 | 47 | public function testShouldGetUpdateEvent(): void 48 | { 49 | $this->createAndInsertValue( 50 | 'CREATE TABLE test (id INT NOT NULL AUTO_INCREMENT, data VARCHAR (50) NOT NULL, PRIMARY KEY (id))', 51 | 'INSERT INTO test (data) VALUES(\'Hello\')' 52 | ); 53 | 54 | $this->connection->executeStatement('UPDATE test SET data = \'World\', id = 2 WHERE id = 1'); 55 | 56 | self::assertInstanceOf(XidDTO::class, $this->getEvent()); 57 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 58 | self::assertInstanceOf(TableMapDTO::class, $this->getEvent()); 59 | 60 | /** @var UpdateRowsDTO $event */ 61 | $event = $this->getEvent(); 62 | self::assertInstanceOf(UpdateRowsDTO::class, $event); 63 | self::assertEquals(1, $event->values[0]['before']['id']); 64 | self::assertEquals('Hello', $event->values[0]['before']['data']); 65 | self::assertEquals(2, $event->values[0]['after']['id']); 66 | self::assertEquals('World', $event->values[0]['after']['data']); 67 | } 68 | 69 | public function testShouldGetWriteEventDropTable(): void 70 | { 71 | $this->connection->executeStatement($createExpected = 'CREATE TABLE `test` (id INTEGER(11))'); 72 | $this->connection->executeStatement('INSERT INTO `test` VALUES (1)'); 73 | $this->connection->executeStatement($dropExpected = 'DROP TABLE `test`'); 74 | 75 | /** @var QueryDTO $event */ 76 | $event = $this->getEvent(); 77 | self::assertInstanceOf(QueryDTO::class, $event); 78 | self::assertEquals($createExpected, $event->query); 79 | 80 | /** @var QueryDTO $event */ 81 | $event = $this->getEvent(); 82 | self::assertInstanceOf(QueryDTO::class, $event); 83 | self::assertEquals('BEGIN', $event->query); 84 | 85 | /** @var TableMapDTO $event */ 86 | self::assertInstanceOf(TableMapDTO::class, $this->getEvent()); 87 | 88 | /** @var WriteRowsDTO $event */ 89 | $event = $this->getEvent(); 90 | self::assertInstanceOf(WriteRowsDTO::class, $event); 91 | self::assertEquals([], $event->values); 92 | self::assertEquals(0, $event->changedRows); 93 | 94 | self::assertInstanceOf(XidDTO::class, $this->getEvent()); 95 | 96 | /** @var QueryDTO $event */ 97 | $event = $this->getEvent(); 98 | self::assertInstanceOf(QueryDTO::class, $event); 99 | self::assertStringContainsString($dropExpected, $event->query); 100 | } 101 | 102 | public function testShouldGetQueryEventCreateTable(): void 103 | { 104 | $this->connection->executeStatement( 105 | $createExpected = 'CREATE TABLE test (id INT NOT NULL AUTO_INCREMENT, data VARCHAR (50) NOT NULL, PRIMARY KEY (id))' 106 | ); 107 | 108 | /** @var QueryDTO $event */ 109 | $event = $this->getEvent(); 110 | self::assertInstanceOf(QueryDTO::class, $event); 111 | self::assertEquals($createExpected, $event->query); 112 | } 113 | 114 | public function testShouldDropColumn(): void 115 | { 116 | $this->disconnect(); 117 | 118 | $this->configBuilder->withEventsOnly( 119 | [ConstEventType::WRITE_ROWS_EVENT_V1->value, ConstEventType::WRITE_ROWS_EVENT_V2->value] 120 | ); 121 | 122 | $this->connect(); 123 | 124 | $this->connection->executeStatement('CREATE TABLE test_drop_column (id INTEGER(11), data VARCHAR(50))'); 125 | $this->connection->executeStatement('INSERT INTO test_drop_column VALUES (1, \'A value\')'); 126 | $this->connection->executeStatement('ALTER TABLE test_drop_column DROP COLUMN data'); 127 | $this->connection->executeStatement('INSERT INTO test_drop_column VALUES (2)'); 128 | 129 | /** @var WriteRowsDTO $event */ 130 | $event = $this->getEvent(); 131 | self::assertInstanceOf(WriteRowsDTO::class, $event); 132 | self::assertEquals([ 133 | 'id' => 1, 134 | 'DROPPED_COLUMN_1' => null, 135 | ], $event->values[0]); 136 | 137 | $event = $this->getEvent(); 138 | self::assertInstanceOf(WriteRowsDTO::class, $event); 139 | self::assertEquals([ 140 | 'id' => 2, 141 | ], $event->values[0]); 142 | } 143 | 144 | public function testShouldFilterEvents(): void 145 | { 146 | $this->disconnect(); 147 | 148 | $this->configBuilder->withEventsOnly([ConstEventType::QUERY_EVENT->value]); 149 | 150 | $this->connect(); 151 | 152 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 153 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 154 | 155 | $this->connection->executeStatement( 156 | $createTableExpected = 'CREATE TABLE test (id INTEGER(11), data VARCHAR(50))' 157 | ); 158 | 159 | /** @var QueryDTO $event */ 160 | $event = $this->getEvent(); 161 | self::assertInstanceOf(QueryDTO::class, $event); 162 | self::assertEquals($createTableExpected, $event->query); 163 | } 164 | 165 | public function testShouldFilterTables(): void 166 | { 167 | $expectedTable = 'test_2'; 168 | $expectedValue = 'foobar'; 169 | 170 | $this->disconnect(); 171 | 172 | $this->configBuilder 173 | ->withEventsOnly( 174 | [ConstEventType::WRITE_ROWS_EVENT_V1->value, ConstEventType::WRITE_ROWS_EVENT_V2->value] 175 | )->withTablesOnly([$expectedTable]); 176 | 177 | $this->connect(); 178 | 179 | $this->connection->executeStatement( 180 | 'CREATE TABLE test_2 (id INT NOT NULL AUTO_INCREMENT, data VARCHAR (50) NOT NULL, PRIMARY KEY (id))' 181 | ); 182 | $this->connection->executeStatement( 183 | 'CREATE TABLE test_3 (id INT NOT NULL AUTO_INCREMENT, data VARCHAR (50) NOT NULL, PRIMARY KEY (id))' 184 | ); 185 | $this->connection->executeStatement( 186 | 'CREATE TABLE test_4 (id INT NOT NULL AUTO_INCREMENT, data VARCHAR (50) NOT NULL, PRIMARY KEY (id))' 187 | ); 188 | 189 | $this->connection->executeStatement('INSERT INTO test_4 (data) VALUES (\'foo\')'); 190 | $this->connection->executeStatement('INSERT INTO test_3 (data) VALUES (\'bar\')'); 191 | $this->connection->executeStatement('INSERT INTO test_2 (data) VALUES (\'' . $expectedValue . '\')'); 192 | 193 | $event = $this->getEvent(); 194 | self::assertInstanceOf(WriteRowsDTO::class, $event); 195 | self::assertEquals($expectedTable, $event->tableMap->table); 196 | self::assertEquals($expectedValue, $event->values[0]['data']); 197 | } 198 | 199 | public function testShouldTruncateTable(): void 200 | { 201 | $this->disconnect(); 202 | 203 | $this->configBuilder->withEventsOnly([ConstEventType::QUERY_EVENT->value]); 204 | 205 | $this->connect(); 206 | 207 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 208 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 209 | 210 | $this->connection->executeStatement('CREATE TABLE test_truncate_column (id INTEGER(11), data VARCHAR(50))'); 211 | $this->connection->executeStatement('INSERT INTO test_truncate_column VALUES (1, \'A value\')'); 212 | $this->connection->executeStatement('TRUNCATE TABLE test_truncate_column'); 213 | 214 | $event = $this->getEvent(); 215 | self::assertSame('CREATE TABLE test_truncate_column (id INTEGER(11), data VARCHAR(50))', $event->query); 216 | $event = $this->getEvent(); 217 | self::assertSame('BEGIN', $event->query); 218 | $event = $this->getEvent(); 219 | self::assertSame('TRUNCATE TABLE test_truncate_column', $event->query); 220 | } 221 | 222 | public function testShouldJsonSetPartialUpdateWithHoles(): void 223 | { 224 | if ($this->checkForVersion(5.7) || $this->mySQLReplicationFactory?->getServerInfo()->isMariaDb()) { 225 | self::markTestIncomplete('Only for mysql 5.7 or higher'); 226 | } 227 | 228 | $expected = '{"age":22,"addr":{"code":100,"detail":{"ab":"970785C8 - C299"}},"name":"Alice"}'; 229 | 230 | $create_query = 'CREATE TABLE t1 (j JSON)'; 231 | $insert_query = "INSERT INTO t1 VALUES ('" . $expected . "')"; 232 | 233 | $this->createAndInsertValue($create_query, $insert_query); 234 | 235 | $this->connection->executeQuery('UPDATE t1 SET j = JSON_SET(j, \'$.addr.detail.ab\', \'970785C8\')'); 236 | 237 | self::assertInstanceOf(XidDTO::class, $this->getEvent()); 238 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 239 | self::assertInstanceOf(TableMapDTO::class, $this->getEvent()); 240 | 241 | /** @var UpdateRowsDTO $event */ 242 | $event = $this->getEvent(); 243 | 244 | self::assertInstanceOf(UpdateRowsDTO::class, $event); 245 | self::assertEquals($expected, $event->values[0]['before']['j']); 246 | self::assertEquals( 247 | '{"age":22,"addr":{"code":100,"detail":{"ab":"970785C8"}},"name":"Alice"}', 248 | $event->values[0]['after']['j'] 249 | ); 250 | } 251 | 252 | public function testShouldJsonRemovePartialUpdateWithHoles(): void 253 | { 254 | if ($this->checkForVersion(5.7) || $this->mySQLReplicationFactory?->getServerInfo()->isMariaDb()) { 255 | self::markTestIncomplete('Only for mysql 5.7 or higher'); 256 | } 257 | 258 | $expected = '{"age":22,"addr":{"code":100,"detail":{"ab":"970785C8-C299"}},"name":"Alice"}'; 259 | 260 | $create_query = 'CREATE TABLE t1 (j JSON)'; 261 | $insert_query = "INSERT INTO t1 VALUES ('" . $expected . "')"; 262 | 263 | $this->createAndInsertValue($create_query, $insert_query); 264 | 265 | $this->connection->executeStatement('UPDATE t1 SET j = JSON_REMOVE(j, \'$.addr.detail.ab\')'); 266 | 267 | self::assertInstanceOf(XidDTO::class, $this->getEvent()); 268 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 269 | self::assertInstanceOf(TableMapDTO::class, $this->getEvent()); 270 | 271 | /** @var UpdateRowsDTO $event */ 272 | $event = $this->getEvent(); 273 | 274 | self::assertInstanceOf(UpdateRowsDTO::class, $event); 275 | self::assertEquals($expected, $event->values[0]['before']['j']); 276 | self::assertEquals( 277 | '{"age":22,"addr":{"code":100,"detail":{}},"name":"Alice"}', 278 | $event->values[0]['after']['j'] 279 | ); 280 | } 281 | 282 | public function testShouldJsonReplacePartialUpdateWithHoles(): void 283 | { 284 | if ($this->checkForVersion(5.7) || $this->mySQLReplicationFactory?->getServerInfo()->isMariaDb()) { 285 | self::markTestIncomplete('Only for mysql 5.7 or higher'); 286 | } 287 | 288 | $expected = '{"age":22,"addr":{"code":100,"detail":{"ab":"970785C8-C299"}},"name":"Alice"}'; 289 | 290 | $create_query = 'CREATE TABLE t1 (j JSON)'; 291 | $insert_query = "INSERT INTO t1 VALUES ('" . $expected . "')"; 292 | 293 | $this->createAndInsertValue($create_query, $insert_query); 294 | 295 | $this->connection->executeStatement('UPDATE t1 SET j = JSON_REPLACE(j, \'$.addr.detail.ab\', \'9707\')'); 296 | 297 | self::assertInstanceOf(XidDTO::class, $this->getEvent()); 298 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 299 | self::assertInstanceOf(TableMapDTO::class, $this->getEvent()); 300 | 301 | /** @var UpdateRowsDTO $event */ 302 | $event = $this->getEvent(); 303 | 304 | self::assertInstanceOf(UpdateRowsDTO::class, $event); 305 | self::assertEquals($expected, $event->values[0]['before']['j']); 306 | self::assertEquals( 307 | '{"age":22,"addr":{"code":100,"detail":{"ab":"9707"}},"name":"Alice"}', 308 | $event->values[0]['after']['j'] 309 | ); 310 | } 311 | 312 | public function testShouldRotateLog(): void 313 | { 314 | $this->connection->executeStatement('FLUSH LOGS'); 315 | 316 | self::assertInstanceOf(RotateDTO::class, $this->getEvent()); 317 | 318 | self::assertMatchesRegularExpression( 319 | '/^[a-z-]+\.[\d]+$/', 320 | $this->getEvent() 321 | ->getEventInfo() 322 | ->binLogCurrent 323 | ->getBinFileName() 324 | ); 325 | } 326 | 327 | public function testShouldUseProvidedEventDispatcher(): void 328 | { 329 | $this->disconnect(); 330 | 331 | $testEventSubscribers = new TestEventSubscribers($this); 332 | 333 | $eventDispatcher = new EventDispatcher(); 334 | $eventDispatcher->addSubscriber($testEventSubscribers); 335 | 336 | $this->connectWithProvidedEventDispatcher($eventDispatcher); 337 | 338 | $this->connection->executeStatement( 339 | $createExpected = 'CREATE TABLE test (id INT NOT NULL AUTO_INCREMENT, data VARCHAR (50) NOT NULL, PRIMARY KEY (id))' 340 | ); 341 | 342 | /** @var QueryDTO $event */ 343 | $event = $this->getEvent(); 344 | self::assertInstanceOf(QueryDTO::class, $event); 345 | self::assertEquals($createExpected, $event->query); 346 | } 347 | 348 | private function connectWithProvidedEventDispatcher(EventDispatcherInterface $eventDispatcher): void 349 | { 350 | $this->mySQLReplicationFactory = new MySQLReplicationFactory( 351 | $this->configBuilder->build(), 352 | null, 353 | null, 354 | $eventDispatcher 355 | ); 356 | 357 | $connection = $this->mySQLReplicationFactory->getDbConnection(); 358 | if ($connection === null) { 359 | throw new RuntimeException('Connection not initialized'); 360 | } 361 | 362 | $this->connection = $connection; 363 | $this->connection->executeStatement('SET SESSION time_zone = "UTC"'); 364 | $this->connection->executeStatement('DROP DATABASE IF EXISTS ' . $this->database); 365 | $this->connection->executeStatement('CREATE DATABASE ' . $this->database); 366 | $this->connection->executeStatement('USE ' . $this->database); 367 | $this->connection->executeStatement('SET SESSION sql_mode = \'\';'); 368 | 369 | if ($this->mySQLReplicationFactory->getServerInfo()->versionRevision >= 8 && $this->mySQLReplicationFactory->getServerInfo()->isGeneric()) { 370 | self::assertInstanceOf(RotateDTO::class, $this->getEvent()); 371 | } 372 | 373 | self::assertInstanceOf(FormatDescriptionEventDTO::class, $this->getEvent()); 374 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 375 | self::assertInstanceOf(QueryDTO::class, $this->getEvent()); 376 | } 377 | } 378 | --------------------------------------------------------------------------------