diff --git a/.docker/replication-init.sql b/.docker/replication-init.sql new file mode 100644 index 0000000..4a0f793 --- /dev/null +++ b/.docker/replication-init.sql @@ -0,0 +1,7 @@ +-- MySQL/MariaDB Replication Setup for Integration Tests + +-- Create replication user +CREATE USER 'replication_test'@'%' IDENTIFIED BY 'replication_test'; +GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'replication_test'@'%'; + +FLUSH PRIVILEGES; \ No newline at end of file diff --git a/.github/workflows/split-and-push.yaml b/.github/workflows/split-and-push.yaml index 162d890..8f07c95 100644 --- a/.github/workflows/split-and-push.yaml +++ b/.github/workflows/split-and-push.yaml @@ -1,3 +1,4 @@ +name: Split & Push on: push: branches: @@ -11,6 +12,7 @@ jobs: project: - data-access-kit - data-access-kit-symfony + - data-access-kit-replication steps: - uses: actions/checkout@v4 with: @@ -24,5 +26,6 @@ jobs: ssh-private-key: | ${{ secrets.DATA_ACCESS_KIT_DEPLOY_KEY }} ${{ secrets.DATA_ACCESS_KIT_SYMFONY_DEPLOY_KEY }} + ${{ secrets.DATA_ACCESS_KIT_REPLICATION_DEPLOY_KEY }} - run: git subtree split --prefix=${{ matrix.project }} --branch project-branch - run: git push --force git@github.com:${{ github.repository_owner }}/${{ matrix.project }}.git project-branch:main diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a4f72b4..d8d50d0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,3 +1,4 @@ +name: Tests on: [push] jobs: test-unit: @@ -54,3 +55,94 @@ jobs: - run: composer test:database:env env: DATABASE_URL: ${{ matrix.database.database_url }} + + test-replication-rust-fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Check Rust formatting + working-directory: ./data-access-kit-replication + run: cargo fmt -- --check + + test-replication-unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Cache Rust dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + data-access-kit-replication/target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + - name: Install Composer dependencies + working-directory: ./data-access-kit-replication + run: composer install + - name: Run unit tests + working-directory: ./data-access-kit-replication + run: composer run test:unit + + test-replication-database: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + database: + - service: mysql + database_url: mysql://root@127.0.0.1:32016 + replication_database_url: mysql://replication_test:replication_test@127.0.0.1:32016 + - service: mariadb + database_url: mysql://root@127.0.0.1:35098 + replication_database_url: mysql://replication_test:replication_test@127.0.0.1:35098 + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Cache Rust dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + data-access-kit-replication/target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + - uses: adambirds/docker-compose-action@v1.5.0 + with: + compose-file: docker-compose.yaml + services: ${{ matrix.database.service }} + - name: Wait for container health + run: | + timeout 30s bash -c 'until docker inspect --format="{{.State.Health.Status}}" data-access-kit-src-${{ matrix.database.service }}-1 | grep -q "healthy"; do + echo "Waiting for container to be healthy..." + sleep 2 + done' + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + - name: Install Composer dependencies + working-directory: ./data-access-kit-replication + run: composer install + - name: Run database tests + working-directory: ./data-access-kit-replication + run: composer run test:database:env + env: + DATABASE_URL: ${{ matrix.database.database_url }} + REPLICATION_DATABASE_URL: ${{ matrix.database.replication_database_url }} diff --git a/README.md b/README.md index 591d9ba..5e0d0fe 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ - [DataAccessKit](https://github.com/jakubkulhan/data-access-kit#readme) - Persistence layer based on Doctrine\DBAL and repository generator. - [DataAccessKit\Symfony](https://github.com/jakubkulhan/data-access-kit-symfony#readme) - Integration with Symfony framework. +- [DataAccessKit\Replication](https://github.com/jakubkulhan/data-access-kit-replication#readme) - Real-time MySQL/MariaDB binary log replication stream for PHP. ## Contributing diff --git a/data-access-kit-replication/.cargo/config.toml b/data-access-kit-replication/.cargo/config.toml new file mode 100644 index 0000000..647fdf9 --- /dev/null +++ b/data-access-kit-replication/.cargo/config.toml @@ -0,0 +1,8 @@ +[target.'cfg(not(target_os = "windows"))'] +rustflags = ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"] + +[target.x86_64-pc-windows-msvc] +linker = "rust-lld" + +[target.i686-pc-windows-msvc] +linker = "rust-lld" \ No newline at end of file diff --git a/data-access-kit-replication/.gitignore b/data-access-kit-replication/.gitignore new file mode 100644 index 0000000..be4094c --- /dev/null +++ b/data-access-kit-replication/.gitignore @@ -0,0 +1,3 @@ +target/ +vendor/ +.phpunit.result.cache \ No newline at end of file diff --git a/data-access-kit-replication/CLAUDE.md b/data-access-kit-replication/CLAUDE.md new file mode 100644 index 0000000..d79c914 --- /dev/null +++ b/data-access-kit-replication/CLAUDE.md @@ -0,0 +1,160 @@ +# DataAccessKit Replication - Development Notes + +## Running Tests + +### Unit Tests +To run only unit tests (fast, no database required): +```bash +composer run test:unit +``` + +### Database Integration Tests +To run database tests: +```bash +composer run test:database:all +``` + +To run database tests against specific databases: +```bash +composer run test:database:mysql # MySQL on port 32016 +composer run test:database:mariadb # MariaDB on port 35098 +``` + +All test commands will: +1. Build the Rust extension (`cargo build`) +2. Load the extension via the local `php.ini` configuration +3. Run the specified PHPUnit test groups + +### Running All Tests +**The agent should always run both test suites to ensure complete validation:** +```bash +composer run test:unit # Run all unit tests (fast, no database) +composer run test:database:all # Run database tests against MySQL and MariaDB +``` + +### Test Groups +- **Unit tests** (`#[Group("unit")]`): Interface validation, event property tests - no database required +- **Database tests** (`#[Group("database")]`): Integration tests requiring DATABASE_URL environment variable + +The tests ensure: +- Extension builds correctly +- Interfaces load automatically on startup +- All interface definitions are valid +- Extension integrates properly with PHP 8.4 +- Database replication functionality works correctly + +## Test Writing Guidelines + +### Test Assertions Best Practices + +**For tests that don't need explicit assertions:** +```php +public function testSomeAction(): void +{ + // Use expectNotToPerformAssertions() at the start of the test + $this->expectNotToPerformAssertions(); + + // Test code that should complete without exceptions + $stream->setCheckpointer(null); +} +``` + +**Avoid using `addToAssertionCount()`** - it's an internal PHPUnit method and `expectNotToPerformAssertions()` is the proper public API. + +### Test Structure + +- **Unit tests**: Test individual components without external dependencies +- **Database tests**: Test full integration with real database connections +- Always clean up resources in `tearDown()` methods +- Use descriptive test method names that explain what is being tested +- **Do not add comments to PHP test files** - keep test code clean and minimal +- Group related assertions with clear, descriptive assertion messages + +## Rust Code Guidelines + +### Formatting Requirements + +**Always run `cargo fmt` after making changes to Rust code.** This ensures consistent formatting across the codebase. + +```bash +cargo fmt +``` + +The formatter will automatically: +- Organize imports alphabetically +- Apply consistent indentation +- Format code according to Rust style guidelines +- Ensure consistent spacing and line breaks + +**Important:** Run `cargo fmt` before committing any Rust code changes. + +## Rust Import Rules + +### Import Organization Guidelines + +When writing or refactoring Rust code in this project, follow these import rules: + +1. **Use statements always at the top of the file, never inside functions** + - All `use` statements must be placed at the top of the file after any comments or attributes + - Never place `use` statements inside functions, methods, or other code blocks + +2. **Types should be imported directly** + - Import types (structs, enums, traits) by their full path so they can be used directly + - Examples: + ```rust + use std::ffi::CString; + use ext_php_rs::types::Zval; + use ext_php_rs::zend::ClassEntry; + ``` + +3. **Functions should be used with module prefix** + - Import modules containing functions, then call functions with module prefix + - Examples: + ```rust + use std::{mem, ptr}; + + // Then call: + mem::zeroed() + ptr::null_mut() + ``` + +4. **Group related imports** + - Group imports from the same crate/module using braces + - Examples: + ```rust + use std::{mem, ptr}; + use std::collections::{HashMap, VecDeque}; + use ext_php_rs::zend::{self, ce}; + ``` + +### Examples + +**Good:** +```rust +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; +use ext_php_rs::zend::{ClassEntry, ZendType}; +use std::ffi::CString; +use std::{mem, ptr}; + +fn example() { + let interface_ce: ffi::zend_class_entry = mem::zeroed(); + let null_ptr = ptr::null_mut(); +} +``` + +**Bad:** +```rust +use ext_php_rs::prelude::*; + +fn example() { + use std::mem; // ❌ use inside function + use std::ptr; // ❌ use inside function + + let interface_ce = mem::zeroed(); +} +``` + +## Documentation + +The project specification is in `SPEC.md`. **Update SPEC.md when implementation diverges from the documented design** to keep documentation accurate and current. \ No newline at end of file diff --git a/data-access-kit-replication/Cargo.lock b/data-access-kit-replication/Cargo.lock new file mode 100644 index 0000000..d6a8076 --- /dev/null +++ b/data-access-kit-replication/Cargo.lock @@ -0,0 +1,3448 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19634d6336019ef220f09fd31168ce5c184b295cbf80345437cc36094ef223ca" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.60.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bigdecimal" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a22f228ab7a1b23027ccc6c350b72868017af7ea8356fbdf19f8d991c690013" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.106", +] + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "borsh" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "btoi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd6407f73a9b8b6162d8a2ef999fe6afd7cc15902ebf42c5cd296addf17e0ad" +dependencies = [ + "num-traits", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "bzip2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea8dcd42434048e4f7a304411d9273a411f647446c1234a65ce0554923f4cff" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "camino" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + +[[package]] +name = "cc" +version = "1.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5252b3d2648e5eedbc1a6f501e3c795e07025c1e93bbf8bbdd6eef7f447a6d54" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "data_access_kit_replication" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "ext-php-rs", + "mysql-binlog-connector-rust", + "mysql_async", + "rand", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.0", +] + +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "ext-php-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc03db60bfe6d05e80db431c1a61910ff549f88d4d6d5a7cc235a4b90734d4f" +dependencies = [ + "anyhow", + "bindgen", + "bitflags", + "cc", + "cfg-if", + "ext-php-rs-derive", + "native-tls", + "once_cell", + "parking_lot", + "skeptic", + "ureq", + "zip", +] + +[[package]] +name = "ext-php-rs-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ed494cb1c1bffa2fc1cd664216a223aa0155b73d176ac1935ac2f864fce8f" +dependencies = [ + "anyhow", + "convert_case", + "darling", + "lazy_static", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "libz-rs-sys", + "libz-sys", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "frunk" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28aef0f9aa070bce60767c12ba9cb41efeaf1a2bc6427f87b7d83f11239a16d7" +dependencies = [ + "frunk_core", + "frunk_derives", + "frunk_proc_macros", + "serde", +] + +[[package]] +name = "frunk_core" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476eeaa382e3462b84da5d6ba3da97b5786823c2d0d3a0d04ef088d073da225c" +dependencies = [ + "serde", +] + +[[package]] +name = "frunk_derives" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b4095fc99e1d858e5b8c7125d2638372ec85aa0fe6c807105cf10b0265ca6c" +dependencies = [ + "frunk_proc_macro_helpers", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "frunk_proc_macro_helpers" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1952b802269f2db12ab7c0bd328d0ae8feaabf19f352a7b0af7bb0c5693abfce" +dependencies = [ + "frunk_core", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "frunk_proc_macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3462f590fa236005bd7ca4847f81438bd6fe0febd4d04e11968d4c2e96437e78" +dependencies = [ + "frunk_core", + "frunk_proc_macro_helpers", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.5+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206a8042aec68fa4a62e8d3f7aa4ceb508177d9324faf261e1959e495b7a1921" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keyed_priority_queue" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d" +dependencies = [ + "indexmap", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libloading" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +dependencies = [ + "cfg-if", + "windows-targets 0.53.3", +] + +[[package]] +name = "liblzma" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10bf66f4598dc77ff96677c8e763655494f00ff9c1cf79e2eb5bb07bc31f807d" +dependencies = [ + "liblzma-sys", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libz-rs-sys" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" +dependencies = [ + "zlib-rs", +] + +[[package]] +name = "libz-sys" +version = "1.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +dependencies = [ + "value-bag", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "mysql-binlog-connector-rust" +version = "0.3.2" +source = "git+https://github.com/apecloud/mysql-binlog-connector-rust#ced82d26b94d668401957f700914057ed07d7984" +dependencies = [ + "async-recursion", + "async-std", + "base64 0.22.1", + "byteorder", + "dotenv", + "lazy_static", + "log", + "mysql_common 0.32.4", + "num_enum", + "percent-encoding", + "serde", + "serde_json", + "serial_test", + "sha1", + "sha2", + "thiserror", + "url", + "zstd 0.13.3", +] + +[[package]] +name = "mysql-common-derive" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63c3512cf11487168e0e9db7157801bf5273be13055a9cc95356dc9e0035e49c" +dependencies = [ + "darling", + "heck", + "num-bigint", + "proc-macro-crate", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.106", + "termcolor", + "thiserror", +] + +[[package]] +name = "mysql_async" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6750b17ce50f8f112ef1a8394121090d47c596b56a6a17569ca680a9626e2ef2" +dependencies = [ + "bytes", + "crossbeam", + "flate2", + "futures-core", + "futures-sink", + "futures-util", + "keyed_priority_queue", + "lazy_static", + "lru", + "mio 0.8.11", + "mysql_common 0.31.0", + "once_cell", + "pem", + "percent-encoding", + "pin-project", + "rand", + "serde", + "serde_json", + "socket2 0.5.10", + "thiserror", + "tokio", + "tokio-util", + "twox-hash", + "url", +] + +[[package]] +name = "mysql_common" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06f19e4cfa0ab5a76b627cec2d81331c49b034988eaf302c3bafeada684eadef" +dependencies = [ + "base64 0.21.7", + "bindgen", + "bitflags", + "btoi", + "byteorder", + "bytes", + "cc", + "cmake", + "crc32fast", + "flate2", + "lazy_static", + "num-bigint", + "num-traits", + "rand", + "regex", + "saturating", + "serde", + "serde_json", + "sha1", + "sha2", + "smallvec", + "subprocess", + "thiserror", + "uuid", + "zstd 0.12.4", +] + +[[package]] +name = "mysql_common" +version = "0.32.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478b0ff3f7d67b79da2b96f56f334431aef65e15ba4b29dd74a4236e29582bdc" +dependencies = [ + "base64 0.21.7", + "bigdecimal", + "bindgen", + "bitflags", + "bitvec", + "btoi", + "byteorder", + "bytes", + "cc", + "cmake", + "crc32fast", + "flate2", + "frunk", + "lazy_static", + "mysql-common-derive", + "num-bigint", + "num-traits", + "rand", + "regex", + "rust_decimal", + "saturating", + "serde", + "serde_json", + "sha1", + "sha2", + "smallvec", + "subprocess", + "thiserror", + "time", + "uuid", + "zstd 0.13.3", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64 0.22.1", + "serde", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polling" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppmd-rust" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c834641d8ad1b348c9ee86dec3b9840d805acd5f24daa5f90c788951a52ff59b" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.106", +] + +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "rkyv" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rust_decimal" +version = "1.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b203a6425500a03e0919c42d3c47caca51e79f1132046626d2c8871c5092035d" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "saturating" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" + +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skeptic" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +dependencies = [ + "bytecount", + "cargo_metadata", + "error-chain", + "glob", + "pulldown-cmark", + "tempfile", + "walkdir", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subprocess" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2e86926081dda636c546d8c5e641661049d7562a68f5488be4a1f7f66f6086" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.61.0", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "time" +version = "0.3.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio 1.0.4", + "pin-project-lite", + "slab", + "socket2 0.6.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "twox-hash" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" +dependencies = [ + "cfg-if", + "rand", + "static_assertions", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "ureq" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99ba1025f18a4a3fc3e9b48c868e9beb4f24f4b4b1a325bada26bd4119f46537" +dependencies = [ + "base64 0.22.1", + "der", + "flate2", + "log", + "native-tls", + "percent-encoding", + "rustls-pemfile", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b4531c118335662134346048ddb0e54cc86bd7e81866757873055f0e38f5d2" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "value-bag" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.5+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4494f6290a82f5fe584817a676a34b9d6763e8d9d18204009fb31dceca98fd4" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.0+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03fa2761397e5bd52002cd7e73110c71af2109aca4e521a9f40473fe685b0a24" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4ffd8df1c57e87c325000a3d6ef93db75279dc3a231125aac571650f22b12a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link 0.1.3", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.3.3", + "hmac", + "indexmap", + "liblzma", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", + "zeroize", + "zopfli", + "zstd 0.13.3", +] + +[[package]] +name = "zlib-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" + +[[package]] +name = "zopfli" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a27595e173641171fc74a1232b7b1c7a7cb6e18222c11e9dfb9888fa424c53c" +dependencies = [ + "zstd-safe 6.0.6", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe 7.2.4", +] + +[[package]] +name = "zstd-safe" +version = "6.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee98ffd0b48ee95e6c5168188e44a54550b1564d9d530ee21d5f0eaed1069581" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/data-access-kit-replication/Cargo.toml b/data-access-kit-replication/Cargo.toml new file mode 100644 index 0000000..bd384d6 --- /dev/null +++ b/data-access-kit-replication/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "data_access_kit_replication" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +ext-php-rs = "0.14.2" +mysql_async = { version = "0.33", default-features = false, features = ["minimal"] } +mysql-binlog-connector-rust = { git = "https://github.com/apecloud/mysql-binlog-connector-rust" } +tokio = { version = "1.0", default-features = false, features = ["rt", "net", "io-util"] } +url = "2.5" +rand = "0.8" +base64 = "0.22" +serde_json = "1.0" + +[profile.release] +strip = "debuginfo" diff --git a/data-access-kit-replication/README.md b/data-access-kit-replication/README.md new file mode 100644 index 0000000..79d148c --- /dev/null +++ b/data-access-kit-replication/README.md @@ -0,0 +1,286 @@ +# DataAccessKit\Replication + +> Real-time MySQL/MariaDB binary log replication stream for PHP + +## Quick start + +Start by creating a replication stream to capture database changes in real-time. + +```php +use DataAccessKit\Replication\Stream; + +// Connect to MySQL replication stream +$stream = new Stream('mysql://user:password@localhost:3306'); + +// Process events as they occur +foreach ($stream as $event) { + match ($event->type) { + 'INSERT' => handleInsert($event), + 'UPDATE' => handleUpdate($event), + 'DELETE' => handleDelete($event), + }; +} + +function handleInsert($event) { + echo "New record in {$event->schema}.{$event->table}\n"; + var_dump($event->after); // New row data +} + +function handleUpdate($event) { + echo "Updated record in {$event->schema}.{$event->table}\n"; + var_dump($event->before); // Old row data + var_dump($event->after); // New row data +} + +function handleDelete($event) { + echo "Deleted record from {$event->schema}.{$event->table}\n"; + var_dump($event->before); // Deleted row data +} +``` + +## Installation + +### Prerequisites + +- **PHP 8.4** or higher +- **Rust toolchain** (rustc, cargo) +- **cargo-php** for building PHP extensions + +Install cargo-php: + +```bash +cargo install cargo-php +``` + +### Build and Install Extension + +```bash +# Clone the repository +git clone https://github.com/jakubkulhan/data-access-kit-replication.git +cd data-access-kit-replication + +# Build the extension +cargo build --release + +# Install the extension using cargo-php +cargo php install --release --yes +``` + +### Remove Extension + +To uninstall the extension: + +```bash +cargo php remove --yes +``` + +## Usage + +### Stream + +Initialize a stream to connect to the MySQL replication log: + +```php +use DataAccessKit\Replication\Stream; + +// Create stream with connection URL +$stream = new Stream('mysql://user:password@localhost:3306'); + +// Start iterating over events +foreach ($stream as $event) { + // Process each replication event + echo "Event: {$event->type} on {$event->schema}.{$event->table}\n"; +} +``` + +Connection URL formats: + +```php +// MySQL/MariaDB (user only) +$url = 'mysql://user@localhost:3306'; + +// MySQL/MariaDB (user and password) +$url = 'mysql://user:password@localhost:3306'; + +// MySQL/MariaDB (explicitly specify server ID) +$url = 'mysql://user:password@localhost:3306?server_id=123'; +``` + +### Events + +The extension provides three types of events for database changes: + +#### InsertEvent + +```php +// Properties available on InsertEvent +$event->type; // 'INSERT' +$event->timestamp; // Unix timestamp +$event->checkpoint; // Replication checkpoint +$event->schema; // Database schema name +$event->table; // Table name +$event->after; // stdClass with new row data +``` + +#### UpdateEvent + +```php +// Properties available on UpdateEvent +$event->type; // 'UPDATE' +$event->timestamp; // Unix timestamp +$event->checkpoint; // Replication checkpoint +$event->schema; // Database schema name +$event->table; // Table name +$event->before; // stdClass with old row data +$event->after; // stdClass with new row data +``` + +#### DeleteEvent + +```php +// Properties available on DeleteEvent +$event->type; // 'DELETE' +$event->timestamp; // Unix timestamp +$event->checkpoint; // Replication checkpoint +$event->schema; // Database schema name +$event->table; // Table name +$event->before; // stdClass with deleted row data +``` + +### Filter + +Filter events to only process specific tables or event types: + +```php +use DataAccessKit\Replication\{Stream, StreamFilterInterface}; + +class TableFilter implements StreamFilterInterface { + public function __construct(private array $allowedTables) {} + + public function accept(string $type, string $schema, string $table): bool { + return in_array("$schema.$table", $this->allowedTables); + } +} + +$stream = new Stream('mysql://root@localhost:32016?server_id=100'); +$stream->setFilter(new TableFilter(['myapp.users', 'myapp.orders'])); + +foreach ($stream as $event) { + // Only receives events for users and orders tables + var_dump($event); +} +``` + +You can also filter by event type: + +```php +class EventTypeFilter implements StreamFilterInterface { + public function accept(string $type, string $schema, string $table): bool { + // Only process INSERT and UPDATE events + return in_array($type, ['INSERT', 'UPDATE']); + } +} +``` + +### Checkpointer + +Save and resume from specific positions in the binlog stream: + +```php +use DataAccessKit\Replication\{Stream, StreamCheckpointerInterface}; + +class FileCheckpointer implements StreamCheckpointerInterface { + public function __construct(private string $filename) {} + + public function loadLastCheckpoint(): ?string { + return file_exists($this->filename) ? file_get_contents($this->filename) : null; + } + + public function saveCheckpoint(string $checkpoint): void { + file_put_contents($this->filename, $checkpoint); + } +} + +$stream = new Stream('mysql://root@localhost:32016?server_id=100'); +$stream->setCheckpointer(new FileCheckpointer('/tmp/replication.checkpoint')); + +foreach ($stream as $event) { + // Process event... + // Checkpoint is automatically saved by the extension + var_dump($event); +} +``` + +For production systems, you'll probably want to use something like database-based checkpointing: + +```php +class DatabaseCheckpointer implements StreamCheckpointerInterface { + public function __construct(private PDO $pdo, private string $streamId) {} + + public function loadLastCheckpoint(): ?string { + $stmt = $this->pdo->prepare('SELECT checkpoint FROM stream_positions WHERE stream_id = ?'); + $stmt->execute([$this->streamId]); + return $stmt->fetchColumn() ?: null; + } + + public function saveCheckpoint(string $checkpoint): void { + $stmt = $this->pdo->prepare( + 'INSERT INTO stream_positions (stream_id, checkpoint, updated_at) VALUES (?, ?, NOW()) ' . + 'ON DUPLICATE KEY UPDATE checkpoint = VALUES(checkpoint), updated_at = NOW()' + ); + $stmt->execute([$this->streamId, $checkpoint]); + } +} +``` + +The extension supports two checkpoint formats: + +- **GTID format (MySQL only)**: `gtid:3E11FA47-71CA-11E1-9E33-C80AA9429562:23` +- **File/position format**: `file:mysql-bin.000123:45678` + +The extension automatically chooses the appropriate format based on server type and configuration. + +## Contributing + +This repository is part of the [DataAccessKit project](https://github.com/jakubkulhan/data-access-kit-src). Please open issues and pull requests in the main repository. + +### Local Development Setup + +For development, clone the source repository and install dependencies: + +```bash +composer install +``` + +Start databases for testing: + +```bash +# Start MySQL and MariaDB for testing +docker-compose up -d mysql mariadb +``` + +Build and test the extension: + +```bash +# Build extension for development +cargo build + +# Run unit tests (fast, no database required) +composer run test:unit + +# Run database integration tests (requires running databases) +composer run test:database:all + +# Run tests against specific databases +composer run test:database:mysql # MySQL on port 32016 +composer run test:database:mariadb # MariaDB on port 35098 +``` + +The test commands will: +1. Build the Rust extension (`cargo build`) +2. Load the extension via local PHP configuration +3. Run the specified PHPUnit test groups + +## License + +Licensed under MIT license. See [LICENSE](LICENSE) for details. diff --git a/data-access-kit-replication/composer.json b/data-access-kit-replication/composer.json new file mode 100644 index 0000000..4cc5994 --- /dev/null +++ b/data-access-kit-replication/composer.json @@ -0,0 +1,33 @@ +{ + "name": "data-access-kit/replication", + "description": "DataAccessKit Replication Extension", + "type": "php-ext", + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "autoload-dev": { + "psr-4": { + "DataAccessKit\\Replication\\Test\\": "test/" + } + }, + "scripts": { + "build": "cargo build", + "test:unit": [ + "@build", + "php -c php-$(uname -s | tr '[:upper:]' '[:lower:]').ini vendor/bin/phpunit --group unit" + ], + "test:database:env": [ + "@build", + "php -c php-$(uname -s | tr '[:upper:]' '[:lower:]').ini vendor/bin/phpunit --group database" + ], + "test:database:mysql": "DATABASE_URL=mysql://root@127.0.0.1:32016 REPLICATION_DATABASE_URL=mysql://replication_test:replication_test@127.0.0.1:32016 composer run test:database:env", + "test:database:mariadb": "DATABASE_URL=mysql://root@127.0.0.1:35098 REPLICATION_DATABASE_URL=mysql://replication_test:replication_test@127.0.0.1:35098 composer run test:database:env", + "test:database:all": [ + "@test:database:mysql", + "@test:database:mariadb" + ] + } +} \ No newline at end of file diff --git a/data-access-kit-replication/composer.lock b/data-access-kit-replication/composer.lock new file mode 100644 index 0000000..638b3f5 --- /dev/null +++ b/data-access-kit-replication/composer.lock @@ -0,0 +1,1777 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "2bfdd209e38e00f104571ef19bc3a1a7", + "packages": [], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.6.1", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" + }, + "time": "2025-08-13T20:13:15+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.11", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.4.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.0", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.2" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-08-27T14:37:49+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-27T05:02:59+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.38", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5bd0e4f64a2261b7ade7054c51547beaf2d99e43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5bd0e4f64a2261b7ade7054c51547beaf2d99e43", + "reference": "5bd0e4f64a2261b7ade7054c51547beaf2d99e43", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.11", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.2", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.0", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.38" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2025-09-11T10:34:07+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2025-08-10T08:07:46+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3", + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-12-05T09:17:50+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.2.3", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:36:25+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.4" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/data-access-kit-replication/php-darwin.ini b/data-access-kit-replication/php-darwin.ini new file mode 100644 index 0000000..d8b2180 --- /dev/null +++ b/data-access-kit-replication/php-darwin.ini @@ -0,0 +1,2 @@ +extension=./target/debug/libdata_access_kit_replication.dylib +log_errors = On diff --git a/data-access-kit-replication/php-linux.ini b/data-access-kit-replication/php-linux.ini new file mode 100644 index 0000000..b09d35d --- /dev/null +++ b/data-access-kit-replication/php-linux.ini @@ -0,0 +1,2 @@ +extension=./target/debug/libdata_access_kit_replication.so +log_errors = On \ No newline at end of file diff --git a/data-access-kit-replication/phpunit.xml b/data-access-kit-replication/phpunit.xml new file mode 100644 index 0000000..6bbdcd6 --- /dev/null +++ b/data-access-kit-replication/phpunit.xml @@ -0,0 +1,16 @@ + + + + + test + + + + + src + + + \ No newline at end of file diff --git a/data-access-kit-replication/src/checkpointer.rs b/data-access-kit-replication/src/checkpointer.rs new file mode 100644 index 0000000..2782533 --- /dev/null +++ b/data-access-kit-replication/src/checkpointer.rs @@ -0,0 +1,175 @@ +use ext_php_rs::ffi; +use ext_php_rs::flags::{ClassFlags, DataType}; +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; +use ext_php_rs::zend::{ClassEntry, ZendType}; +use std::ffi::CString; +use std::{mem, ptr}; + +// Global pointer to StreamCheckpointerInterface +static mut CHECKPOINTER_INTERFACE: *mut ClassEntry = ptr::null_mut(); + +// Unsafe function to register StreamCheckpointerInterface +pub unsafe fn register_checkpointer_interface() { + // Create and register StreamCheckpointerInterface + let mut interface_ce: ffi::zend_class_entry = mem::zeroed(); + + // Set the interface name + let name = CString::new("DataAccessKit\\Replication\\StreamCheckpointerInterface").unwrap(); + interface_ce.name = + ffi::ext_php_rs_zend_string_init(name.as_ptr(), name.as_bytes().len(), true); + + // Set interface flags + interface_ce.ce_flags = ClassFlags::Interface.bits(); + + // Create function entries for the interface methods + let mut functions: Vec = Vec::new(); + + // Create arginfo for loadLastCheckpoint method (no parameters, returns ?string) + let mut load_arg_infos: Vec = Vec::new(); + + // First element: metadata (return type ?string, 0 required args) + load_arg_infos.push(ffi::zend_internal_arg_info { + name: 0 as *const _, // required_num_args + type_: ZendType::empty_from_type(DataType::String, false, false, true) // nullable string + .unwrap_or_else(|| ZendType::empty(false, false)), + default_value: ptr::null(), + }); + + let load_method_name = CString::new("loadLastCheckpoint").unwrap(); + let load_num_args = (load_arg_infos.len() - 1) as u32; + let load_arg_info_ptr = Box::into_raw(load_arg_infos.into_boxed_slice()) as *const _; + + let load_method = ffi::zend_function_entry { + fname: load_method_name.as_ptr(), + handler: None, + arg_info: load_arg_info_ptr, + num_args: load_num_args, + flags: (ffi::ZEND_ACC_PUBLIC | ffi::ZEND_ACC_ABSTRACT) as u32, + doc_comment: ptr::null(), + frameless_function_infos: ptr::null(), + }; + functions.push(load_method); + + // Create arginfo for saveCheckpoint method (1 string parameter, returns void) + let mut save_arg_infos: Vec = Vec::new(); + + // First element: metadata (return type void, 1 required arg) + save_arg_infos.push(ffi::zend_internal_arg_info { + name: 1 as *const _, // required_num_args + type_: ZendType::empty_from_type(DataType::Void, false, false, false) + .unwrap_or_else(|| ZendType::empty(false, false)), + default_value: ptr::null(), + }); + + // Argument 1: $checkpoint (string) + let checkpoint_arg_name = CString::new("checkpoint").unwrap(); + save_arg_infos.push(ffi::zend_internal_arg_info { + name: checkpoint_arg_name.into_raw(), + type_: ZendType::empty_from_type(DataType::String, false, false, false) + .unwrap_or_else(|| ZendType::empty(false, false)), + default_value: ptr::null(), + }); + + let save_method_name = CString::new("saveCheckpoint").unwrap(); + let save_num_args = (save_arg_infos.len() - 1) as u32; + let save_arg_info_ptr = Box::into_raw(save_arg_infos.into_boxed_slice()) as *const _; + + let save_method = ffi::zend_function_entry { + fname: save_method_name.as_ptr(), + handler: None, + arg_info: save_arg_info_ptr, + num_args: save_num_args, + flags: (ffi::ZEND_ACC_PUBLIC | ffi::ZEND_ACC_ABSTRACT) as u32, + doc_comment: ptr::null(), + frameless_function_infos: ptr::null(), + }; + functions.push(save_method); + + // Add terminating entry + functions.push(ffi::zend_function_entry { + fname: ptr::null(), + handler: None, + arg_info: ptr::null(), + num_args: 0, + flags: 0, + doc_comment: ptr::null(), + frameless_function_infos: ptr::null(), + }); + + // Set the functions on the interface + interface_ce.info.internal.builtin_functions = functions.as_ptr(); + + // Register the interface + let registered = + ffi::zend_register_internal_class_ex(&mut interface_ce as *mut _, ptr::null_mut()); + + // Prevent the vectors and strings from being dropped + mem::forget(functions); + mem::forget(load_method_name); + mem::forget(save_method_name); + mem::forget(name); + + if registered.is_null() { + eprintln!("Failed to register StreamCheckpointerInterface"); + return; + } + + // Store the interface reference globally + CHECKPOINTER_INTERFACE = registered; +} + +/// Rust wrapper for PHP StreamCheckpointerInterface +/// Provides a clean abstraction over PHP checkpointer objects +#[derive(Debug)] +pub struct Checkpointer { + php_object: Zval, +} + +impl Checkpointer { + /// Create a new checkpointer wrapper from a PHP object + pub fn new(php_checkpointer: &Zval) -> PhpResult { + // Validate that the object implements the required interface + if !php_checkpointer.is_object() { + return Err(PhpException::default( + "Checkpointer must be an object implementing StreamCheckpointerInterface".into(), + ) + .into()); + } + + // Use shallow_clone to safely store the Zval reference + Ok(Checkpointer { + php_object: php_checkpointer.shallow_clone(), + }) + } + + /// Load the last checkpoint from the PHP checkpointer + /// Returns None if no checkpoint exists or if the method returns null + pub fn load_last_checkpoint(&self) -> PhpResult> { + // Call the loadLastCheckpoint() method on the PHP object + let result = self.php_object.try_call_method( + "loadLastCheckpoint", + Vec::<&dyn ext_php_rs::convert::IntoZvalDyn>::new(), + )?; + + if result.is_null() { + Ok(None) + } else if result.is_string() { + Ok(Some(result.string().unwrap_or_default().to_string())) + } else { + Err( + PhpException::default("loadLastCheckpoint() must return string or null".into()) + .into(), + ) + } + } + + /// Save a checkpoint using the PHP checkpointer + pub fn save_checkpoint(&self, checkpoint: &str) -> PhpResult<()> { + // Call the saveCheckpoint(string $checkpoint) method on the PHP object + let params: Vec<&dyn ext_php_rs::convert::IntoZvalDyn> = vec![&checkpoint]; + let _result = self.php_object.try_call_method("saveCheckpoint", params)?; + + Ok(()) + } +} diff --git a/data-access-kit-replication/src/events.rs b/data-access-kit-replication/src/events.rs new file mode 100644 index 0000000..b6ec50c --- /dev/null +++ b/data-access-kit-replication/src/events.rs @@ -0,0 +1,233 @@ +use ext_php_rs::convert::{FromZval, IntoZval}; +use ext_php_rs::error::Result; +use ext_php_rs::ffi; +use ext_php_rs::flags::{ClassFlags, DataType}; +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; +use ext_php_rs::zend::ClassEntry; +use std::ffi::CString; +use std::{mem, ptr}; + +// Global pointer to EventInterface +static mut EVENT_INTERFACE: *mut ClassEntry = ptr::null_mut(); + +// Function to get EventInterface CE +pub fn event_interface_ce() -> &'static ClassEntry { + unsafe { + EVENT_INTERFACE + .as_ref() + .expect("EventInterface not initialized") + } +} + +// Unsafe function to register EventInterface +pub unsafe fn register_event_interface() { + // Create and register EventInterface + let mut interface_ce: ffi::zend_class_entry = mem::zeroed(); + + // Set the interface name + let name = CString::new("DataAccessKit\\Replication\\EventInterface").unwrap(); + interface_ce.name = + ffi::ext_php_rs_zend_string_init(name.as_ptr(), name.as_bytes().len(), true); + + // Set interface flags + interface_ce.ce_flags = ClassFlags::Interface.bits(); + + // Register the interface + let registered = + ffi::zend_register_internal_class_ex(&mut interface_ce as *mut _, ptr::null_mut()); + + if registered.is_null() { + eprintln!("Failed to register EventInterface"); + ffi::ext_php_rs_zend_string_release(interface_ce.name); + return; + } + + // Store the EventInterface reference globally + EVENT_INTERFACE = registered; + + // Add constants to the interface + let insert_const = CString::new("INSERT").unwrap(); + let mut insert_val = Zval::new(); + insert_val.set_string("INSERT", true).unwrap(); + ffi::zend_declare_class_constant( + registered, + insert_const.as_ptr(), + insert_const.as_bytes().len(), + Box::into_raw(Box::new(insert_val)), + ); + + let update_const = CString::new("UPDATE").unwrap(); + let mut update_val = Zval::new(); + update_val.set_string("UPDATE", true).unwrap(); + ffi::zend_declare_class_constant( + registered, + update_const.as_ptr(), + update_const.as_bytes().len(), + Box::into_raw(Box::new(update_val)), + ); + + let delete_const = CString::new("DELETE").unwrap(); + let mut delete_val = Zval::new(); + delete_val.set_string("DELETE", true).unwrap(); + ffi::zend_declare_class_constant( + registered, + delete_const.as_ptr(), + delete_const.as_bytes().len(), + Box::into_raw(Box::new(delete_val)), + ); +} + +// Wrapper for Zval that implements Clone +pub struct Mixed(Zval); + +impl Clone for Mixed { + fn clone(&self) -> Self { + Mixed(self.0.shallow_clone()) + } +} + +impl Mixed { + pub fn new(val: &Zval) -> Self { + Mixed(val.shallow_clone()) + } +} + +impl IntoZval for Mixed { + const TYPE: DataType = DataType::Mixed; + const NULLABLE: bool = true; + + fn set_zval(self, zv: &mut Zval, _persistent: bool) -> Result<()> { + *zv = self.0; + Ok(()) + } +} + +impl<'a> FromZval<'a> for Mixed { + const TYPE: DataType = DataType::Mixed; + + fn from_zval(zval: &'a Zval) -> Option { + Some(Mixed(zval.shallow_clone())) + } +} + +#[php_class] +#[php(name = "DataAccessKit\\Replication\\InsertEvent")] +#[php(implements(ce = event_interface_ce, stub = "DataAccessKit\\Replication\\EventInterface"))] +pub struct InsertEvent { + #[php(prop, name = "type")] + r#type: String, + #[php(prop)] + timestamp: i64, + #[php(prop)] + checkpoint: String, + #[php(prop)] + schema: String, + #[php(prop)] + table: String, + #[php(prop)] + after: Mixed, +} + +#[php_impl] +impl InsertEvent { + pub fn __construct( + r#type: String, + timestamp: i64, + checkpoint: String, + schema: String, + table: String, + after: &Zval, + ) -> PhpResult { + Ok(InsertEvent { + r#type, + timestamp, + checkpoint, + schema, + table, + after: Mixed::new(after), + }) + } +} + +#[php_class] +#[php(name = "DataAccessKit\\Replication\\UpdateEvent")] +#[php(implements(ce = event_interface_ce, stub = "DataAccessKit\\Replication\\EventInterface"))] +pub struct UpdateEvent { + #[php(prop, name = "type")] + r#type: String, + #[php(prop)] + timestamp: i64, + #[php(prop)] + checkpoint: String, + #[php(prop)] + schema: String, + #[php(prop)] + table: String, + #[php(prop)] + before: Mixed, + #[php(prop)] + after: Mixed, +} + +#[php_impl] +impl UpdateEvent { + pub fn __construct( + r#type: String, + timestamp: i64, + checkpoint: String, + schema: String, + table: String, + before: &Zval, + after: &Zval, + ) -> PhpResult { + Ok(UpdateEvent { + r#type, + timestamp, + checkpoint, + schema, + table, + before: Mixed::new(before), + after: Mixed::new(after), + }) + } +} + +#[php_class] +#[php(name = "DataAccessKit\\Replication\\DeleteEvent")] +#[php(implements(ce = event_interface_ce, stub = "DataAccessKit\\Replication\\EventInterface"))] +pub struct DeleteEvent { + #[php(prop, name = "type")] + r#type: String, + #[php(prop)] + timestamp: i64, + #[php(prop)] + checkpoint: String, + #[php(prop)] + schema: String, + #[php(prop)] + table: String, + #[php(prop)] + before: Mixed, +} + +#[php_impl] +impl DeleteEvent { + pub fn __construct( + r#type: String, + timestamp: i64, + checkpoint: String, + schema: String, + table: String, + before: &Zval, + ) -> PhpResult { + Ok(DeleteEvent { + r#type, + timestamp, + checkpoint, + schema, + table, + before: Mixed::new(before), + }) + } +} diff --git a/data-access-kit-replication/src/filter.rs b/data-access-kit-replication/src/filter.rs new file mode 100644 index 0000000..bddeb5f --- /dev/null +++ b/data-access-kit-replication/src/filter.rs @@ -0,0 +1,151 @@ +use ext_php_rs::ffi; +use ext_php_rs::flags::{ClassFlags, DataType}; +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; +use ext_php_rs::zend::{ClassEntry, ZendType}; +use std::ffi::CString; +use std::{mem, ptr}; + +// Global pointer to StreamFilterInterface +static mut FILTER_INTERFACE: *mut ClassEntry = ptr::null_mut(); + +// Unsafe function to register StreamFilterInterface +pub unsafe fn register_filter_interface() { + // Create and register StreamFilterInterface + let mut interface_ce: ffi::zend_class_entry = mem::zeroed(); + + // Set the interface name + let name = CString::new("DataAccessKit\\Replication\\StreamFilterInterface").unwrap(); + interface_ce.name = + ffi::ext_php_rs_zend_string_init(name.as_ptr(), name.as_bytes().len(), true); + + // Set interface flags + interface_ce.ce_flags = ClassFlags::Interface.bits(); + + // Create function entries for the interface methods + let mut functions: Vec = Vec::new(); + + // Create arginfo for accept method + let mut arg_infos: Vec = Vec::new(); + + // First element: metadata (return type bool, 3 required args) + arg_infos.push(ffi::zend_internal_arg_info { + name: 3 as *const _, // required_num_args + type_: ZendType::empty_from_type(DataType::Bool, false, false, false) + .unwrap_or_else(|| ZendType::empty(false, false)), + default_value: ptr::null(), + }); + + // Argument 1: $type (string) + let type_arg_name = CString::new("type").unwrap(); + arg_infos.push(ffi::zend_internal_arg_info { + name: type_arg_name.into_raw(), + type_: ZendType::empty_from_type(DataType::String, false, false, false) + .unwrap_or_else(|| ZendType::empty(false, false)), + default_value: ptr::null(), + }); + + // Argument 2: $schema (string) + let schema_arg_name = CString::new("schema").unwrap(); + arg_infos.push(ffi::zend_internal_arg_info { + name: schema_arg_name.into_raw(), + type_: ZendType::empty_from_type(DataType::String, false, false, false) + .unwrap_or_else(|| ZendType::empty(false, false)), + default_value: ptr::null(), + }); + + // Argument 3: $table (string) + let table_arg_name = CString::new("table").unwrap(); + arg_infos.push(ffi::zend_internal_arg_info { + name: table_arg_name.into_raw(), + type_: ZendType::empty_from_type(DataType::String, false, false, false) + .unwrap_or_else(|| ZendType::empty(false, false)), + default_value: ptr::null(), + }); + + // Create the accept method + let accept_name = CString::new("accept").unwrap(); + let num_args = (arg_infos.len() - 1) as u32; // Subtract 1 for the metadata entry + let arg_info_ptr = Box::into_raw(arg_infos.into_boxed_slice()) as *const _; + + let accept_method = ffi::zend_function_entry { + fname: accept_name.as_ptr(), + handler: None, + arg_info: arg_info_ptr, + num_args, + flags: (ffi::ZEND_ACC_PUBLIC | ffi::ZEND_ACC_ABSTRACT) as u32, + doc_comment: ptr::null(), + frameless_function_infos: ptr::null(), + }; + functions.push(accept_method); + + // Add terminating entry + functions.push(ffi::zend_function_entry { + fname: ptr::null(), + handler: None, + arg_info: ptr::null(), + num_args: 0, + flags: 0, + doc_comment: ptr::null(), + frameless_function_infos: ptr::null(), + }); + + // Set the functions on the interface + interface_ce.info.internal.builtin_functions = functions.as_ptr(); + + // Register the interface + let registered = + ffi::zend_register_internal_class_ex(&mut interface_ce as *mut _, ptr::null_mut()); + + // Prevent the vectors and strings from being dropped + mem::forget(functions); + mem::forget(accept_name); + mem::forget(name); + + if registered.is_null() { + eprintln!("Failed to register StreamFilterInterface"); + return; + } + + // Store the interface reference globally + FILTER_INTERFACE = registered; +} + +/// Rust wrapper for PHP StreamFilterInterface +/// Provides a clean abstraction over PHP filter objects +#[derive(Debug)] +pub struct Filter { + php_object: Zval, +} + +impl Filter { + /// Create a new filter wrapper from a PHP object + pub fn new(php_filter: &Zval) -> PhpResult { + // Validate that the object implements the required interface + if !php_filter.is_object() { + return Err(PhpException::default( + "Filter must be an object implementing StreamFilterInterface".into(), + ) + .into()); + } + + // Use shallow_clone to safely store the Zval reference + Ok(Filter { + php_object: php_filter.shallow_clone(), + }) + } + + /// Call the accept method on the PHP filter object + /// Returns true if the event should be accepted, false if it should be filtered out + pub fn accept(&self, event_type: &str, schema: &str, table: &str) -> PhpResult { + // Call the accept(string $type, string $schema, string $table) method on the PHP object + let params: Vec<&dyn ext_php_rs::convert::IntoZvalDyn> = vec![&event_type, &schema, &table]; + let result = self.php_object.try_call_method("accept", params)?; + + if result.is_bool() { + Ok(result.bool().unwrap_or(false)) + } else { + Err(PhpException::default("accept() method must return boolean".into()).into()) + } + } +} diff --git a/data-access-kit-replication/src/lib.rs b/data-access-kit-replication/src/lib.rs new file mode 100644 index 0000000..2d6f7d4 --- /dev/null +++ b/data-access-kit-replication/src/lib.rs @@ -0,0 +1,35 @@ +use ext_php_rs::prelude::*; + +mod checkpointer; +mod events; +mod filter; +mod stream; + +use checkpointer::Checkpointer; +use events::{DeleteEvent, InsertEvent, UpdateEvent}; +use filter::Filter; +use stream::Stream; + +fn startup_function(_type: i32, _module_number: i32) -> i32 { + unsafe { + // Register EventInterface and its constants + events::register_event_interface(); + + // Register StreamCheckpointerInterface + checkpointer::register_checkpointer_interface(); + + // Register StreamFilterInterface + filter::register_filter_interface(); + } + 0 // SUCCESS +} + +#[php_module] +#[php(startup = startup_function)] +pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + module + .class::() + .class::() + .class::() + .class::() +} diff --git a/data-access-kit-replication/src/stream.rs b/data-access-kit-replication/src/stream.rs new file mode 100644 index 0000000..5365a6f --- /dev/null +++ b/data-access-kit-replication/src/stream.rs @@ -0,0 +1,109 @@ +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; +use ext_php_rs::zend::{self, ce}; +use url::Url; + +mod mysql; + +use mysql::MySQLStreamDriver; + +pub trait StreamDriver { + fn connect(&mut self) -> PhpResult<()>; + fn disconnect(&mut self) -> PhpResult<()>; + fn set_checkpointer(&mut self, checkpointer: &Zval) -> PhpResult<()>; + fn set_filter(&mut self, filter: &Zval) -> PhpResult<()>; + fn current(&self) -> PhpResult>; + fn key(&self) -> PhpResult; + fn next(&mut self) -> PhpResult<()>; + fn rewind(&mut self) -> PhpResult<()>; + fn valid(&self) -> PhpResult; +} + +#[php_class] +#[php(name = "DataAccessKit\\Replication\\Stream")] +#[php(implements(ce = ce::iterator, stub = "Iterator"))] +pub struct Stream { + driver: Box, +} + +impl Stream { + fn create_driver(connection_url: &str) -> Result, PhpException> { + match Url::parse(connection_url) { + Ok(url) => match url.scheme() { + "mysql" => { + let host = url.host_str().unwrap_or("localhost").to_string(); + + let port = url.port().unwrap_or(3306); + + let user = if url.username().is_empty() { + "root".to_string() + } else { + url.username().to_string() + }; + + let password = url.password().unwrap_or("").to_string(); + + let server_id = url + .query_pairs() + .find(|(key, _)| key == "server_id") + .and_then(|(_, value)| value.parse::().ok()); + + Ok(Box::new(MySQLStreamDriver::new( + host, port, user, password, server_id, + ))) + } + scheme => Err(PhpException::default( + format!("Unsupported protocol: {}", scheme).into(), + )), + }, + Err(e) => Err(PhpException::default( + format!("Invalid connection URL: {}", e).into(), + )), + } + } +} + +#[php_impl] +impl Stream { + pub fn __construct(connection_url: String) -> PhpResult { + let driver = Self::create_driver(&connection_url)?; + Ok(Stream { driver }) + } + + pub fn connect(&mut self) -> PhpResult<()> { + self.driver.connect() + } + + pub fn disconnect(&mut self) -> PhpResult<()> { + self.driver.disconnect() + } + + pub fn set_checkpointer(&mut self, checkpointer: &Zval) -> PhpResult<()> { + self.driver.set_checkpointer(checkpointer) + } + + pub fn set_filter(&mut self, filter: &Zval) -> PhpResult<()> { + self.driver.set_filter(filter) + } + + // Iterator interface methods + pub fn current(&self) -> PhpResult> { + self.driver.current() + } + + pub fn key(&self) -> PhpResult { + self.driver.key() + } + + pub fn next(&mut self) -> PhpResult<()> { + self.driver.next() + } + + pub fn rewind(&mut self) -> PhpResult<()> { + self.driver.rewind() + } + + pub fn valid(&self) -> PhpResult { + self.driver.valid() + } +} diff --git a/data-access-kit-replication/src/stream/mysql.rs b/data-access-kit-replication/src/stream/mysql.rs new file mode 100644 index 0000000..7a13dd7 --- /dev/null +++ b/data-access-kit-replication/src/stream/mysql.rs @@ -0,0 +1,1266 @@ +use super::StreamDriver; +use crate::{Checkpointer, Filter}; +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; +use ext_php_rs::zend; +use mysql_async::{OptsBuilder, Pool}; +use mysql_binlog_connector_rust::{ + binlog_client::BinlogClient, + binlog_stream::BinlogStream, + column::column_value::ColumnValue, + event::{ + event_data::EventData, event_header::EventHeader, row_event::RowEvent, + table_map::table_metadata::ColumnMetadata, table_map_event::TableMapEvent, + }, +}; +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::LazyLock; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::runtime::Runtime; + +macro_rules! with_runtime_block_on { + ($self:ident, $async_block:expr) => {{ + $self.ensure_runtime()?; + let runtime = $self.runtime.take().unwrap(); + let result = runtime.block_on($async_block); + $self.runtime = Some(runtime); + result + }}; +} + +static NEXT_SERVER_ID: LazyLock = LazyLock::new(|| { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32; + // Use lower 16 bits of timestamp + random component to avoid conflicts + AtomicU32::new((timestamp & 0xFFFF) + (rand::random::() as u32)) +}); + +pub struct MySQLStreamDriver { + host: String, + port: u16, + user: String, + password: String, + server_id: Option, + position: u64, + pool: Option, + binlog_client: Option, + binlog_stream: Option, + current_gtid: Option, + current_binlog_file: Option, + current_binlog_position: Option, + is_mariadb: bool, + use_gtid_checkpoints: bool, + current_event: Option, + event_queue: VecDeque, // Queue for buffering multi-row events + event_iterator_started: bool, + connected: bool, + table_map: HashMap, + checkpointer: Option, + filter: Option, + runtime: Option, +} + +impl MySQLStreamDriver { + pub fn new( + host: String, + port: u16, + user: String, + password: String, + server_id: Option, + ) -> Self { + MySQLStreamDriver { + host: host.clone(), + port, + user: user.clone(), + password, + server_id, + position: 0, + pool: None, + binlog_client: None, + binlog_stream: None, + current_gtid: None, + current_binlog_file: None, + current_binlog_position: None, + is_mariadb: false, + use_gtid_checkpoints: false, + current_event: None, + event_queue: VecDeque::new(), + event_iterator_started: false, + connected: false, + table_map: HashMap::new(), + checkpointer: None, + filter: None, + runtime: None, + } + } + + fn ensure_runtime(&mut self) -> PhpResult<()> { + if self.runtime.is_none() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + PhpException::default(format!("Failed to create Tokio runtime: {}", e).into()) + })?; + self.runtime = Some(rt); + } + Ok(()) + } + + async fn validate_mysql_config(&mut self, pool: &Pool) -> Result<(), String> { + let mut conn = pool + .get_conn() + .await + .map_err(|e| format!("Failed to get connection: {}", e))?; + + // Check binlog_format = ROW + let binlog_format: String = mysql_async::prelude::Queryable::query_first( + &mut conn, + "SHOW VARIABLES LIKE 'binlog_format'", + ) + .await + .map_err(|e| format!("Failed to query binlog_format: {}", e))? + .map(|row: (String, String)| row.1) + .unwrap_or_default(); + + if binlog_format.to_uppercase() != "ROW" { + return Err(format!("binlog_format must be ROW, got: {}", binlog_format)); + } + + // Check binlog_row_image = FULL + let binlog_row_image: String = mysql_async::prelude::Queryable::query_first( + &mut conn, + "SHOW VARIABLES LIKE 'binlog_row_image'", + ) + .await + .map_err(|e| format!("Failed to query binlog_row_image: {}", e))? + .map(|row: (String, String)| row.1) + .unwrap_or_default(); + + if binlog_row_image.to_uppercase() != "FULL" { + return Err(format!( + "binlog_row_image must be FULL, got: {}", + binlog_row_image + )); + } + + // Check binlog_row_metadata = FULL + let binlog_row_metadata: String = mysql_async::prelude::Queryable::query_first( + &mut conn, + "SHOW VARIABLES LIKE 'binlog_row_metadata'", + ) + .await + .map_err(|e| format!("Failed to query binlog_row_metadata: {}", e))? + .map(|row: (String, String)| row.1) + .unwrap_or_default(); + + if binlog_row_metadata.to_uppercase() != "FULL" { + return Err(format!( + "binlog_row_metadata must be FULL, got: {}", + binlog_row_metadata + )); + } + + // Detect database type by checking version + let version: String = + mysql_async::prelude::Queryable::query_first(&mut conn, "SELECT VERSION()") + .await + .map_err(|e| format!("Failed to query database version: {}", e))? + .unwrap_or_default(); + + self.is_mariadb = version.to_lowercase().contains("mariadb"); + + if !self.is_mariadb { + // MySQL - check GTID configuration + let gtid_mode: String = mysql_async::prelude::Queryable::query_first( + &mut conn, + "SHOW VARIABLES LIKE 'gtid_mode'", + ) + .await + .map_err(|e| format!("Failed to query gtid_mode: {}", e))? + .map(|row: (String, String)| row.1) + .unwrap_or_default(); + + if gtid_mode.to_uppercase() != "ON" { + return Err(format!("gtid_mode must be ON, got: {}", gtid_mode)); + } + + // MySQL with GTID enabled - use GTID checkpointing + self.use_gtid_checkpoints = true; + } else { + // MariaDB - always use binlog file/position checkpointing (per spec) + self.use_gtid_checkpoints = false; + } + + Ok(()) + } + + async fn get_current_gtid(&self, pool: &Pool) -> Result { + let mut conn = pool + .get_conn() + .await + .map_err(|e| format!("Failed to get connection for GTID: {}", e))?; + + // Use appropriate GTID variable based on database type + let gtid_query = if self.is_mariadb { + "SELECT @@global.gtid_current_pos" + } else { + "SELECT @@global.gtid_executed" + }; + + let gtid_position: String = + mysql_async::prelude::Queryable::query_first(&mut conn, gtid_query) + .await + .map_err(|e| format!("Failed to query GTID position: {}", e))? + .unwrap_or_default(); + + Ok(gtid_position) + } + + async fn get_current_binlog_position(&mut self, pool: &Pool) -> Result<(String, u64), String> { + let mut conn = pool + .get_conn() + .await + .map_err(|e| format!("Failed to get connection for binlog position: {}", e))?; + + // Get current binlog file and position using SHOW MASTER STATUS + // Handle both MySQL and MariaDB by extracting columns by name from the row + use mysql_async::prelude::*; + + let query = if self.is_mariadb { + "SHOW MASTER STATUS" + } else { + // MySQL 8.0+ uses SHOW BINARY LOG STATUS instead of SHOW MASTER STATUS + "SHOW BINARY LOG STATUS" + }; + + let result: Option = conn + .query_first(query) + .await + .map_err(|e| format!("Failed to query master status: {}", e))?; + + match result { + Some(row) => { + // Extract File and Position columns manually from the row + let file: String = row + .get("File") + .ok_or_else(|| "Missing File column in SHOW MASTER STATUS".to_string())?; + + // Handle position - try different types since MySQL/MariaDB might return different types + let position = if let Some(pos_u64) = row.get::("Position") { + // Position returned as u64 (MySQL) + pos_u64 + } else if let Some(pos_str) = row.get::("Position") { + // Position returned as string (MariaDB or other cases) + pos_str.parse::().map_err(|e| { + format!("Failed to parse binlog position '{}': {}", pos_str, e) + })? + } else { + return Err( + "Missing or invalid Position column in SHOW MASTER STATUS".to_string() + ); + }; + + self.current_binlog_file = Some(file.clone()); + self.current_binlog_position = Some(position); + Ok((file, position)) + } + None => Err("No master status available - is binary logging enabled?".to_string()), + } + } + + fn generate_checkpoint(&self, header: &EventHeader) -> String { + if self.use_gtid_checkpoints && !self.is_mariadb { + // MySQL with GTID - use "gtid:" prefix + if let Some(ref gtid) = self.current_gtid { + format!("gtid:{}", gtid) + } else { + // Fallback to file/position if GTID not available + self.generate_file_position_checkpoint(header) + } + } else { + // MariaDB or MySQL without GTID - use "file:" prefix + self.generate_file_position_checkpoint(header) + } + } + + fn generate_file_position_checkpoint(&self, header: &EventHeader) -> String { + if let Some(ref binlog_client) = self.binlog_client { + // Use the current binlog file and position from the client + format!( + "file:{}:{}", + binlog_client.binlog_filename, header.next_event_position + ) + } else if let (Some(ref file), Some(_pos)) = + (&self.current_binlog_file, &self.current_binlog_position) + { + // Use stored file and position from header + format!("file:{}:{}", file, header.next_event_position) + } else { + // Emergency fallback - use position from header + format!( + "file:binlog.{:06}:{}", + header.next_event_position / 1_000_000, + header.next_event_position + ) + } + } + + /// Save the current checkpoint using the configured checkpointer + fn save_current_checkpoint(&self, header: &EventHeader) -> PhpResult<()> { + if let Some(ref checkpointer) = self.checkpointer { + let checkpoint = self.generate_checkpoint(header); + checkpointer.save_checkpoint(&checkpoint)?; + } + // If no checkpointer is configured, silently continue + Ok(()) + } + + /// Load checkpoint from checkpointer if available and apply it + fn load_checkpoint_if_available(&mut self) -> PhpResult<()> { + if let Some(ref checkpointer) = self.checkpointer { + if let Some(checkpoint_str) = checkpointer.load_last_checkpoint()? { + self.apply_checkpoint(&checkpoint_str)?; + } + } + Ok(()) + } + + /// Parse and apply a checkpoint string to set the starting position + fn apply_checkpoint(&mut self, checkpoint: &str) -> PhpResult<()> { + if checkpoint.starts_with("gtid:") { + // GTID checkpoint format: "gtid:3E11FA47-71CA-11E1-9E33-C80AA9429562:23" + let gtid_str = &checkpoint[5..]; // Remove "gtid:" prefix + self.current_gtid = Some(gtid_str.to_string()); + + // When using GTID, we don't need specific binlog file/position + self.current_binlog_file = None; + self.current_binlog_position = None; + } else if checkpoint.starts_with("file:") { + // File/position checkpoint format: "file:mysql-bin.000123:45678" + let file_pos_str = &checkpoint[5..]; // Remove "file:" prefix + + if let Some(colon_pos) = file_pos_str.rfind(':') { + let filename = &file_pos_str[..colon_pos]; + let position_str = &file_pos_str[colon_pos + 1..]; + + match position_str.parse::() { + Ok(position) => { + self.current_binlog_file = Some(filename.to_string()); + self.current_binlog_position = Some(position); + + // Clear GTID when using file/position + self.current_gtid = None; + } + Err(e) => { + return Err(PhpException::default( + format!( + "Invalid binlog position in checkpoint '{}': {}", + checkpoint, e + ) + .into(), + ) + .into()); + } + } + } else { + return Err(PhpException::default( + format!("Invalid file checkpoint format: '{}'", checkpoint).into(), + ) + .into()); + } + } else { + return Err(PhpException::default( + format!( + "Invalid checkpoint format: '{}'. Must start with 'gtid:' or 'file:'", + checkpoint + ) + .into(), + ) + .into()); + } + + Ok(()) + } + + async fn initialize_binlog_client(&mut self) -> PhpResult<()> { + let connection_url = format!( + "mysql://{}:{}@{}:{}", + self.user, self.password, self.host, self.port + ); + + let mut binlog_client = if !self.is_mariadb && self.use_gtid_checkpoints { + // MySQL with GTID - use GTID mode + let gtid_set = self.current_gtid.clone().unwrap_or_default(); + BinlogClient { + url: connection_url, + binlog_filename: "".to_string(), + binlog_position: 4, + server_id: self + .server_id + .unwrap_or_else(|| NEXT_SERVER_ID.fetch_add(1, Ordering::Relaxed)) + as u64, + gtid_enabled: true, + gtid_set, + heartbeat_interval_secs: 30, + timeout_secs: 60, + } + } else { + // MariaDB (always) or MySQL without GTID - use binlog file/position + let binlog_file = self + .current_binlog_file + .clone() + .unwrap_or_else(|| String::new()); + let binlog_position = self.current_binlog_position.unwrap_or(4); + + BinlogClient { + url: connection_url, + binlog_filename: binlog_file, + binlog_position: binlog_position as u32, + server_id: self + .server_id + .unwrap_or_else(|| NEXT_SERVER_ID.fetch_add(1, Ordering::Relaxed)) + as u64, + gtid_enabled: false, + gtid_set: String::new(), // Explicitly empty for MariaDB + heartbeat_interval_secs: 30, + timeout_secs: 60, + } + }; + + // Connect to binlog stream + let binlog_stream = binlog_client.connect().await.map_err(|e| { + PhpException::default(format!("Failed to connect to binlog: {}", e).into()) + })?; + + self.binlog_stream = Some(binlog_stream); + self.binlog_client = Some(binlog_client); + Ok(()) + } + + fn fetch_next_event(&mut self) -> PhpResult<()> { + // Structure to hold event data for processing outside the async block + enum EventToProcess { + Insert(EventHeader, TableMapEvent, Vec), + Update(EventHeader, TableMapEvent, Vec<(RowEvent, RowEvent)>), + Delete(EventHeader, TableMapEvent, Vec), + } + + let mut events_to_process: Option = None; + + let async_result: PhpResult<()> = with_runtime_block_on!(self, async { + if let Some(ref mut stream) = self.binlog_stream { + loop { + // Read next event from binlog stream + let (header, data) = stream.read().await.map_err(|e| { + PhpException::default(format!("Failed to read binlog event: {}", e).into()) + })?; + + match data { + // Handle table map events to maintain column metadata + EventData::TableMap(table_map_event) => { + self.table_map + .insert(table_map_event.table_id, table_map_event.clone()); + // Continue to next event, don't return table map events to PHP + continue; + } + + // Handle row events that we want to convert to PHP events + EventData::WriteRows(write_rows_event) => { + if let Some(table_map) = self.table_map.get(&write_rows_event.table_id) + { + // Check filter before processing + if let Some(ref filter) = self.filter { + match filter.accept( + "INSERT", + &table_map.database_name, + &table_map.table_name, + ) { + Ok(false) => { + // Event is filtered out, skip it and continue to next event + continue; + } + Ok(true) => { + // Event is accepted, continue processing + } + Err(e) => { + // Filter error - log and skip this event + eprintln!("Filter error: {:?}", e); + continue; + } + } + } + + // Store event data for processing outside async block + events_to_process = Some(EventToProcess::Insert( + header, + table_map.clone(), + write_rows_event.rows, + )); + break; // Exit the loop to process events + } + // Skip if no table map found + continue; + } + + EventData::UpdateRows(update_rows_event) => { + if let Some(table_map) = self.table_map.get(&update_rows_event.table_id) + { + // Check filter before processing + if let Some(ref filter) = self.filter { + match filter.accept( + "UPDATE", + &table_map.database_name, + &table_map.table_name, + ) { + Ok(false) => { + // Event is filtered out, skip it and continue to next event + continue; + } + Ok(true) => { + // Event is accepted, continue processing + } + Err(e) => { + // Filter error - log and skip this event + eprintln!("Filter error: {:?}", e); + continue; + } + } + } + + // Store event data for processing outside async block + events_to_process = Some(EventToProcess::Update( + header, + table_map.clone(), + update_rows_event.rows, + )); + break; // Exit the loop to process events + } + // Skip if no table map found + continue; + } + + EventData::DeleteRows(delete_rows_event) => { + if let Some(table_map) = self.table_map.get(&delete_rows_event.table_id) + { + // Check filter before processing + if let Some(ref filter) = self.filter { + match filter.accept( + "DELETE", + &table_map.database_name, + &table_map.table_name, + ) { + Ok(false) => { + // Event is filtered out, skip it and continue to next event + continue; + } + Ok(true) => { + // Event is accepted, continue processing + } + Err(e) => { + // Filter error - log and skip this event + eprintln!("Filter error: {:?}", e); + continue; + } + } + } + + // Store event data for processing outside async block + events_to_process = Some(EventToProcess::Delete( + header, + table_map.clone(), + delete_rows_event.rows, + )); + break; // Exit the loop to process events + } + // Skip if no table map found + continue; + } + + // Skip all other event types + _ => { + continue; + } + } + } + } else { + // No binlog stream available + } + Ok(()) + }); + + async_result?; + + // Process events outside the async block to avoid borrow checker issues + if let Some(events) = events_to_process { + match events { + EventToProcess::Insert(header, table_map, rows) => { + for (_idx, row) in rows.iter().enumerate() { + match self.create_insert_event_from_binlog(&header, &table_map, row) { + Ok(event_obj) => { + self.event_queue.push_back(event_obj); + } + Err(e) => { + eprintln!("Failed to create event: {:?}", e); + } + } + } + if !self.event_queue.is_empty() { + self.save_current_checkpoint(&header)?; + } + } + EventToProcess::Update(header, table_map, rows) => { + for (_idx, (before_row, after_row)) in rows.iter().enumerate() { + match self.create_update_event_from_binlog( + &header, &table_map, before_row, after_row, + ) { + Ok(event_obj) => { + self.event_queue.push_back(event_obj); + } + Err(e) => { + eprintln!("Failed to create event: {:?}", e); + } + } + } + if !self.event_queue.is_empty() { + self.save_current_checkpoint(&header)?; + } + } + EventToProcess::Delete(header, table_map, rows) => { + for (_idx, row) in rows.iter().enumerate() { + match self.create_delete_event_from_binlog(&header, &table_map, row) { + Ok(event_obj) => { + self.event_queue.push_back(event_obj); + } + Err(e) => { + eprintln!("Failed to create event: {:?}", e); + } + } + } + if !self.event_queue.is_empty() { + self.save_current_checkpoint(&header)?; + } + } + } + } + + Ok(()) + } + + fn create_insert_event_from_binlog( + &self, + header: &EventHeader, + table_map: &TableMapEvent, + row: &RowEvent, + ) -> PhpResult { + let timestamp = header.timestamp as i64; + let checkpoint = self.generate_checkpoint(header); + + let after_data = self.create_data_object_from_row(table_map, row)?; + + self.create_event( + "DataAccessKit\\Replication\\InsertEvent", + "INSERT", + timestamp as i32, + &checkpoint, + &table_map.database_name, + &table_map.table_name, + None, + Some(after_data), + ) + .map(|opt| opt.unwrap()) + } + + fn create_update_event_from_binlog( + &self, + header: &EventHeader, + table_map: &TableMapEvent, + before_row: &RowEvent, + after_row: &RowEvent, + ) -> PhpResult { + let timestamp = header.timestamp as i64; + let checkpoint = self.generate_checkpoint(header); + + let before_data = self.create_data_object_from_row(table_map, before_row)?; + let after_data = self.create_data_object_from_row(table_map, after_row)?; + + self.create_event( + "DataAccessKit\\Replication\\UpdateEvent", + "UPDATE", + timestamp as i32, + &checkpoint, + &table_map.database_name, + &table_map.table_name, + Some(before_data), + Some(after_data), + ) + .map(|opt| opt.unwrap()) + } + + fn create_delete_event_from_binlog( + &self, + header: &EventHeader, + table_map: &TableMapEvent, + row: &RowEvent, + ) -> PhpResult { + let timestamp = header.timestamp as i64; + let checkpoint = self.generate_checkpoint(header); + + let before_data = self.create_data_object_from_row(table_map, row)?; + + self.create_event( + "DataAccessKit\\Replication\\DeleteEvent", + "DELETE", + timestamp as i32, + &checkpoint, + &table_map.database_name, + &table_map.table_name, + Some(before_data), + None, + ) + .map(|opt| opt.unwrap()) + } + + fn create_data_object_from_row( + &self, + table_map: &TableMapEvent, + row: &RowEvent, + ) -> PhpResult { + // Convert to stdClass object with proper column names + let stdclass_ce = zend::ClassEntry::try_find("stdClass") + .ok_or_else(|| PhpException::default("stdClass not found".into()))?; + + let mut obj = ext_php_rs::types::ZendObject::new(stdclass_ce); + + for (i, column_value) in row.column_values.iter().enumerate() { + // Get column name and metadata from table metadata - error if unavailable + let (column_name, column_metadata) = if let Some(ref table_metadata) = + table_map.table_metadata + { + if let Some(column_metadata) = table_metadata.columns.get(i) { + if let Some(ref name) = column_metadata.column_name { + (name.clone(), Some(column_metadata)) + } else { + return Err(PhpException::default( + format!( + "Column name not available for column index {} in table {}.{}", + i, table_map.database_name, table_map.table_name + ) + .into(), + ) + .into()); + } + } else { + return Err(PhpException::default( + format!( + "Column metadata not available for column index {} in table {}.{}", + i, table_map.database_name, table_map.table_name + ) + .into(), + ) + .into()); + } + } else { + return Err(PhpException::default( + format!("Table metadata not available for table {}.{} - ensure binlog_row_metadata=FULL", + table_map.database_name, table_map.table_name).into() + ).into()); + }; + + let prop_zval = self.convert_column_value_to_php(column_value, column_metadata)?; + obj.set_property(&column_name, prop_zval)?; + } + + let mut result = Zval::new(); + result.set_object(&mut *obj.into_raw()); + Ok(result) + } + + fn convert_column_value_to_php( + &self, + column_value: &ColumnValue, + column_metadata: Option<&ColumnMetadata>, + ) -> PhpResult { + let mut zval = Zval::new(); + + match column_value { + ColumnValue::None => { + zval.set_null(); + } + ColumnValue::Tiny(i) => zval.set_long(*i as i64), + ColumnValue::Short(i) => zval.set_long(*i as i64), + ColumnValue::Long(i) => zval.set_long(*i as i64), + ColumnValue::LongLong(i) => zval.set_long(*i), + ColumnValue::Float(f) => zval.set_double(*f as f64), + ColumnValue::Double(d) => zval.set_double(*d), + ColumnValue::Decimal(d) => zval.set_string(d, false)?, + ColumnValue::Date(date) => zval.set_string(date, false)?, + ColumnValue::DateTime(dt) => { + // Create DateTimeImmutable instance from datetime string + self.create_datetime_immutable(&mut zval, dt)?; + } + ColumnValue::Time(t) => zval.set_string(t, false)?, + ColumnValue::Timestamp(ts) => { + // Create DateTimeImmutable instance from timestamp microseconds + self.create_datetime_immutable_from_timestamp(&mut zval, *ts)?; + } + ColumnValue::Year(y) => zval.set_long(*y as i64), + ColumnValue::String(bytes) => { + // Convert Vec to string, assuming UTF-8 + if let Ok(s) = String::from_utf8(bytes.clone()) { + zval.set_string(&s, false)?; + } else { + // Fall back to base64 encoding for non-UTF-8 data + use base64::Engine; + let engine = base64::engine::general_purpose::STANDARD; + let encoded = engine.encode(bytes); + zval.set_string(&encoded, false)?; + } + } + ColumnValue::Blob(bytes) => { + // Set raw binary data directly to PHP + zval.set_binary(bytes.clone()); + } + ColumnValue::Json(bytes) => { + // Try to parse as JSON string and then parse to PHP objects/arrays + if let Ok(json_str) = mysql_binlog_connector_rust::column::json::json_binary::JsonBinary::parse_as_string(bytes) { + self.parse_json_to_php(&mut zval, &json_str)?; + } else { + // Fall back to base64 encoding + use base64::Engine; + let engine = base64::engine::general_purpose::STANDARD; + let encoded = engine.encode(bytes); + zval.set_string(&encoded, false)?; + } + } + ColumnValue::Bit(value) => { + zval.set_long(*value as i64); + } + ColumnValue::Set(value) => { + // Convert SET bitmask to array of string values using column metadata + if let Some(metadata) = column_metadata { + if let Some(ref set_values) = metadata.set_string_values { + let mut selected_values = Vec::new(); + let bitmask = *value as u64; + + // Check each bit position against set_string_values + for (i, set_val) in set_values.iter().enumerate() { + if (bitmask & (1u64 << i)) != 0 { + selected_values.push(set_val.clone()); + } + } + + // Create PHP array instead of comma-separated string + let zvals: Result, PhpException> = selected_values + .iter() + .map(|value| { + let mut element = Zval::new(); + element.set_string(value, false)?; + Ok(element) + }) + .collect(); + + match zvals { + Ok(array_zvals) => { + if let Err(_) = zval.set_array(array_zvals) { + // Fallback to comma-separated string on array error + let result_string = selected_values.join(","); + zval.set_string(&result_string, false)?; + } + } + Err(_) => { + // Fallback to comma-separated string on error + let result_string = selected_values.join(","); + zval.set_string(&result_string, false)?; + } + } + } else { + // Fallback to numeric value if no metadata + zval.set_long(*value as i64); + } + } else { + // Fallback to numeric value if no metadata + zval.set_long(*value as i64); + } + } + ColumnValue::Enum(value) => { + // Convert ENUM index to string value using column metadata + if let Some(metadata) = column_metadata { + if let Some(ref enum_values) = metadata.enum_string_values { + // ENUM values are 1-based, so subtract 1 for 0-based array access + let index = (*value as usize).saturating_sub(1); + if let Some(enum_val) = enum_values.get(index) { + zval.set_string(enum_val, false)?; + } else { + // Index out of bounds - fallback to numeric value + zval.set_long(*value as i64); + } + } else { + // Fallback to numeric value if no metadata + zval.set_long(*value as i64); + } + } else { + // Fallback to numeric value if no metadata + zval.set_long(*value as i64); + } + } + } + + Ok(zval) + } + + fn create_datetime_immutable(&self, zval: &mut Zval, datetime_str: &str) -> PhpResult<()> { + // Find DateTimeImmutable class + let datetime_ce = zend::ClassEntry::try_find("DateTimeImmutable") + .ok_or_else(|| PhpException::default("DateTimeImmutable class not found".into()))?; + + // Create DateTimeImmutable object + let datetime_obj = ext_php_rs::types::ZendObject::new(datetime_ce); + + // Call constructor with datetime string + let _result = datetime_obj.try_call_method("__construct", vec![&datetime_str])?; + + // Set the object in the zval + zval.set_object(&mut *datetime_obj.into_raw()); + Ok(()) + } + + fn create_datetime_immutable_from_timestamp( + &self, + zval: &mut Zval, + timestamp_micros: i64, + ) -> PhpResult<()> { + // Convert microseconds to seconds + let timestamp_seconds = timestamp_micros / 1_000_000; + + // Create a timestamp string in format that DateTimeImmutable constructor accepts + let timestamp_str = format!("@{}", timestamp_seconds); + + // Find DateTimeImmutable class + let datetime_ce = zend::ClassEntry::try_find("DateTimeImmutable") + .ok_or_else(|| PhpException::default("DateTimeImmutable class not found".into()))?; + + // Create DateTimeImmutable object with timestamp string + let datetime_obj = ext_php_rs::types::ZendObject::new(datetime_ce); + + // Call constructor with timestamp string (format: @1234567890) + let _result = datetime_obj.try_call_method("__construct", vec![×tamp_str])?; + + // Set the object in the zval + zval.set_object(&mut *datetime_obj.into_raw()); + Ok(()) + } + + fn parse_json_to_php(&self, zval: &mut Zval, json_str: &str) -> PhpResult<()> { + // Use serde_json to parse the JSON string to a Rust value first + match serde_json::from_str::(json_str) { + Ok(json_value) => { + self.json_value_to_zval(&mut *zval, &json_value)?; + Ok(()) + } + Err(_) => { + // If JSON parsing fails, fall back to setting as string + zval.set_string(json_str, false)?; + Ok(()) + } + } + } + + fn json_value_to_zval(&self, zval: &mut Zval, json_value: &serde_json::Value) -> PhpResult<()> { + match json_value { + serde_json::Value::Null => { + zval.set_null(); + } + serde_json::Value::Bool(b) => { + zval.set_bool(*b); + } + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + zval.set_long(i); + } else if let Some(f) = n.as_f64() { + zval.set_double(f); + } else { + // Fallback to string representation + zval.set_string(&n.to_string(), false)?; + } + } + serde_json::Value::String(s) => { + zval.set_string(s, false)?; + } + serde_json::Value::Array(arr) => { + let zvals: Result, PhpException> = arr + .iter() + .map(|item| { + let mut element = Zval::new(); + self.json_value_to_zval(&mut element, item)?; + Ok(element) + }) + .collect(); + + match zvals { + Ok(array_zvals) => { + if let Err(_) = zval.set_array(array_zvals) { + // Fallback to empty array on error + let _ = zval.set_array(Vec::::new()); + } + } + Err(_) => { + // Fallback to empty array on error + let _ = zval.set_array(Vec::::new()); + } + } + } + serde_json::Value::Object(obj) => { + // Create stdClass object + let stdclass_ce = zend::ClassEntry::try_find("stdClass") + .ok_or_else(|| PhpException::default("stdClass not found".into()))?; + let mut obj_zend = ext_php_rs::types::ZendObject::new(stdclass_ce); + + for (key, value) in obj { + let mut prop_zval = Zval::new(); + self.json_value_to_zval(&mut prop_zval, value)?; + obj_zend.set_property(key, prop_zval)?; + } + + zval.set_object(&mut *obj_zend.into_raw()); + } + } + Ok(()) + } + + fn create_event( + &self, + class_name: &str, + event_type: &str, + timestamp: i32, + checkpoint: &str, + schema: &str, + table: &str, + before_data: Option, + after_data: Option, + ) -> PhpResult> { + // Find the event class + let ce = zend::ClassEntry::try_find(class_name).ok_or_else(|| { + PhpException::default(format!("Class {} not found", class_name).into()) + })?; + + // Create new object instance + let obj = ext_php_rs::types::ZendObject::new(ce); + + // Prepare constructor parameters + let timestamp_i64 = timestamp as i64; + let mut params: Vec<&dyn ext_php_rs::convert::IntoZvalDyn> = + vec![&event_type, ×tamp_i64, &checkpoint, &schema, &table]; + + // Add objects to params in the correct order + if let Some(ref before) = before_data { + params.push(before); + } + if let Some(ref after) = after_data { + params.push(after); + } + + // Call constructor + let _result = obj.try_call_method("__construct", params)?; + + // Convert object to Zval + let mut event_zval = Zval::new(); + event_zval.set_object(&mut *obj.into_raw()); + Ok(Some(event_zval)) + } +} + +impl StreamDriver for MySQLStreamDriver { + fn connect(&mut self) -> PhpResult<()> { + if self.connected { + return Ok(()); + } + + with_runtime_block_on!(self, async { + // Build MySQL connection options + // For replication, we don't connect to a specific database + // The replication user needs REPLICATION SLAVE/CLIENT privileges, not database access + let opts = OptsBuilder::default() + .ip_or_hostname(&self.host) + .tcp_port(self.port) + .user(Some(&self.user)) + .pass(Some(&self.password)); + + // Create connection pool + let pool = Pool::new(opts); + + // Validate MySQL configuration (this also sets is_mariadb and use_gtid_checkpoints) + self.validate_mysql_config(&pool).await.map_err(|e| { + PhpException::default(format!("MySQL configuration invalid: {}", e).into()) + })?; + + // Get current GTID position for binlog streaming (only for MySQL with GTID) + // Only set if not already set by checkpoint + if self.use_gtid_checkpoints && !self.is_mariadb && self.current_gtid.is_none() { + let current_gtid = self.get_current_gtid(&pool).await.map_err(|e| { + PhpException::default(format!("Failed to get GTID: {}", e).into()) + })?; + self.current_gtid = Some(current_gtid); + } + + // Always get binlog file/position for checkpointing if not set by checkpoint + if self.current_binlog_file.is_none() || self.current_binlog_position.is_none() { + let (binlog_file, binlog_position) = + self.get_current_binlog_position(&pool).await.map_err(|e| { + PhpException::default( + format!("Failed to get binlog position: {}", e).into(), + ) + })?; + + // Store for checkpoint generation only if not already set + if self.current_binlog_file.is_none() { + self.current_binlog_file = Some(binlog_file); + } + if self.current_binlog_position.is_none() { + self.current_binlog_position = Some(binlog_position); + } + } + self.pool = Some(pool); + self.connected = true; + + Ok(()) + }) + } + + fn disconnect(&mut self) -> PhpResult<()> { + if !self.connected { + return Ok(()); + } + + self.pool = None; + self.binlog_client = None; + self.binlog_stream = None; + self.current_gtid = None; + self.current_binlog_file = None; + self.current_binlog_position = None; + self.is_mariadb = false; + self.use_gtid_checkpoints = false; + self.current_event = None; + self.event_queue.clear(); + self.event_iterator_started = false; + self.connected = false; + self.table_map.clear(); + self.checkpointer = None; + self.filter = None; + self.runtime = None; + + Ok(()) + } + + fn set_checkpointer(&mut self, checkpointer: &Zval) -> PhpResult<()> { + let wrapper = if checkpointer.is_null() { + None + } else { + Some(Checkpointer::new(checkpointer)?) + }; + + self.checkpointer = wrapper; + Ok(()) + } + + fn set_filter(&mut self, filter: &Zval) -> PhpResult<()> { + let wrapper = if filter.is_null() { + None + } else { + Some(Filter::new(filter)?) + }; + + self.filter = wrapper; + Ok(()) + } + + fn current(&self) -> PhpResult> { + if !self.connected || !self.event_iterator_started { + return Ok(None); + } + + // Return the current event if available + if let Some(ref event) = self.current_event { + // Create a new Zval and copy the content + let mut result = Zval::new(); + unsafe { + // Copy the zval content - this is a shallow copy + std::ptr::copy_nonoverlapping(event, &mut result, 1); + } + Ok(Some(result)) + } else { + // No current event - this is normal at the start or when no events are available + Ok(None) + } + } + + fn key(&self) -> PhpResult { + Ok(self.position as i32) + } + + fn next(&mut self) -> PhpResult<()> { + if !self.connected || !self.event_iterator_started { + return Err(PhpException::default("Stream not connected or not started".into()).into()); + } + + self.position += 1; + + // First check if we have events in the queue + if let Some(event) = self.event_queue.pop_front() { + self.current_event = Some(event); + } else { + // Queue is empty, fetch more events from binlog + self.fetch_next_event()?; + + // After fetching, pop the first event from queue + if let Some(event) = self.event_queue.pop_front() { + self.current_event = Some(event); + } else { + // No events available + self.current_event = None; + } + } + + Ok(()) + } + + fn rewind(&mut self) -> PhpResult<()> { + // Establish connection if not connected (as per spec) + if !self.connected { + self.connect()?; + } + + // Clear the event queue on rewind + self.event_queue.clear(); + self.current_event = None; + + // Load checkpoint BEFORE creating BinlogClient so it uses the checkpoint position + // instead of the current database position + self.load_checkpoint_if_available()?; + + // Initialize binlog client with checkpoint position (async) + with_runtime_block_on!(self, async { self.initialize_binlog_client().await })?; + + self.position = 0; + self.event_iterator_started = true; + + // Fetch the first batch of events + self.fetch_next_event()?; + + // Pop the first event from queue to current_event + if let Some(event) = self.event_queue.pop_front() { + self.current_event = Some(event); + } + + Ok(()) + } + + fn valid(&self) -> PhpResult { + let is_valid = + self.connected && self.event_iterator_started && self.current_event.is_some(); + Ok(is_valid) + } +} diff --git a/data-access-kit-replication/test/AbstractIntegrationTestCase.php b/data-access-kit-replication/test/AbstractIntegrationTestCase.php new file mode 100644 index 0000000..1fc7f57 --- /dev/null +++ b/data-access-kit-replication/test/AbstractIntegrationTestCase.php @@ -0,0 +1,180 @@ +dbConfig = [ + 'host' => $parsedUrl['host'] ?? 'localhost', + 'port' => $parsedUrl['port'] ?? 3306, + 'user' => $parsedUrl['user'] ?? 'root', + 'password' => $parsedUrl['pass'] ?? '', + ]; + + $replicationUrl = $_ENV['REPLICATION_DATABASE_URL'] ?? getenv('REPLICATION_DATABASE_URL'); + if ($replicationUrl) { + $replicationParsedUrl = parse_url($replicationUrl); + $this->replicationConfig = [ + 'host' => $replicationParsedUrl['host'] ?? $this->dbConfig['host'], + 'port' => $replicationParsedUrl['port'] ?? $this->dbConfig['port'], + 'user' => $replicationParsedUrl['user'] ?? 'replication_test', + 'password' => $replicationParsedUrl['pass'] ?? 'replication_test', + ]; + } else { + $this->replicationConfig = $this->dbConfig; + } + + try { + $this->pdo = new \PDO( + "mysql:host={$this->dbConfig['host']};port={$this->dbConfig['port']}", + $this->dbConfig['user'], + $this->dbConfig['password'] + ); + + $stmt = $this->pdo->query("SELECT @@GLOBAL.binlog_format"); + $this->originalBinlogFormat = $stmt->fetchColumn(); + + $stmt = $this->pdo->query("SELECT @@GLOBAL.binlog_row_image"); + $this->originalBinlogRowImage = $stmt->fetchColumn(); + + $stmt = $this->pdo->query("SELECT @@GLOBAL.binlog_row_metadata"); + $this->originalBinlogRowMetadata = $stmt->fetchColumn(); + + $stmt = $this->pdo->prepare("SET @@GLOBAL.binlog_format = ?"); + $stmt->execute(['ROW']); + + $stmt = $this->pdo->prepare("SET @@GLOBAL.binlog_row_image = ?"); + $stmt->execute(['FULL']); + + $stmt = $this->pdo->prepare("SET @@GLOBAL.binlog_row_metadata = ?"); + $stmt->execute(['FULL']); + + } catch (\Exception $e) { + $this->pdo = null; + } + } + + protected function tearDown(): void + { + if ($this->pdo === null) { + parent::tearDown(); + return; + } + + try { + if ($this->originalBinlogFormat !== null) { + $stmt = $this->pdo->prepare("SET @@GLOBAL.binlog_format = ?"); + $stmt->execute([$this->originalBinlogFormat]); + } + if ($this->originalBinlogRowImage !== null) { + $stmt = $this->pdo->prepare("SET @@GLOBAL.binlog_row_image = ?"); + $stmt->execute([$this->originalBinlogRowImage]); + } + if ($this->originalBinlogRowMetadata !== null) { + $stmt = $this->pdo->prepare("SET @@GLOBAL.binlog_row_metadata = ?"); + $stmt->execute([$this->originalBinlogRowMetadata]); + } + } catch (\Exception $e) { + } + + $this->pdo = null; + $this->dbConfig = null; + $this->replicationConfig = null; + + parent::tearDown(); + } + + protected function createConnectionUrl(array $params = []): string + { + if ($this->dbConfig === null) { + throw new \Exception('Database configuration not available'); + } + + $database = isset($params['database']) ? '/' . $params['database'] : ''; + unset($params['database']); + + $queryParams = array_merge(['server_id' => '100'], $params); + $queryString = http_build_query($queryParams); + + if (!empty($this->dbConfig['password'])) { + return sprintf( + 'mysql://%s:%s@%s:%d%s?%s', + $this->dbConfig['user'], + $this->dbConfig['password'], + $this->dbConfig['host'], + $this->dbConfig['port'], + $database, + $queryString + ); + } else { + return sprintf( + 'mysql://%s@%s:%d%s?%s', + $this->dbConfig['user'], + $this->dbConfig['host'], + $this->dbConfig['port'], + $database, + $queryString + ); + } + } + + protected function createReplicationConnectionUrl(array $params = []): string + { + if ($this->replicationConfig === null) { + throw new \Exception('Replication configuration not available'); + } + + $database = isset($params['database']) ? '/' . $params['database'] : ''; + unset($params['database']); + + $queryParams = array_merge(['server_id' => '100'], $params); + $queryString = http_build_query($queryParams); + + if (!empty($this->replicationConfig['password'])) { + return sprintf( + 'mysql://%s:%s@%s:%d%s?%s', + $this->replicationConfig['user'], + $this->replicationConfig['password'], + $this->replicationConfig['host'], + $this->replicationConfig['port'], + $database, + $queryString + ); + } else { + return sprintf( + 'mysql://%s@%s:%d%s?%s', + $this->replicationConfig['user'], + $this->replicationConfig['host'], + $this->replicationConfig['port'], + $database, + $queryString + ); + } + } + + protected function requireDatabase(): void + { + if ($this->pdo === null) { + $this->markTestSkipped('DATABASE_URL environment variable is required'); + } + } +} \ No newline at end of file diff --git a/data-access-kit-replication/test/DeleteEventTest.php b/data-access-kit-replication/test/DeleteEventTest.php new file mode 100644 index 0000000..51a9d2e --- /dev/null +++ b/data-access-kit-replication/test/DeleteEventTest.php @@ -0,0 +1,44 @@ +assertTrue(class_exists(DeleteEvent::class)); + } + + public function testClassImplementsInterface(): void + { + $this->assertTrue(is_subclass_of(DeleteEvent::class, EventInterface::class)); + } + + public function testCanConstructClassWithProperties(): void + { + $beforeData = (object)['id' => 1, 'name' => 'John', 'email' => 'john@example.com']; + $timestamp = time(); + + $event = new DeleteEvent( + EventInterface::DELETE, + $timestamp, + 'checkpoint789', + 'mydb', + 'users', + $beforeData + ); + + $this->assertEquals(EventInterface::DELETE, $event->type); + $this->assertEquals($timestamp, $event->timestamp); + $this->assertEquals('checkpoint789', $event->checkpoint); + $this->assertEquals('mydb', $event->schema); + $this->assertEquals('users', $event->table); + $this->assertEquals($beforeData, $event->before); + } +} \ No newline at end of file diff --git a/data-access-kit-replication/test/EventInterfaceTest.php b/data-access-kit-replication/test/EventInterfaceTest.php new file mode 100644 index 0000000..6ac3ae3 --- /dev/null +++ b/data-access-kit-replication/test/EventInterfaceTest.php @@ -0,0 +1,23 @@ +assertTrue(interface_exists(EventInterface::class)); + } + + public function testEventInterfaceConstants(): void + { + $this->assertEquals('INSERT', EventInterface::INSERT); + $this->assertEquals('UPDATE', EventInterface::UPDATE); + $this->assertEquals('DELETE', EventInterface::DELETE); + } +} \ No newline at end of file diff --git a/data-access-kit-replication/test/InsertEventTest.php b/data-access-kit-replication/test/InsertEventTest.php new file mode 100644 index 0000000..689890c --- /dev/null +++ b/data-access-kit-replication/test/InsertEventTest.php @@ -0,0 +1,45 @@ +assertTrue(class_exists(InsertEvent::class)); + } + + public function testClassImplementsInterface(): void + { + $this->assertTrue(is_subclass_of(InsertEvent::class, EventInterface::class)); + } + + public function testCanConstructClassWithProperties(): void + { + $afterData = (object)['id' => 1, 'name' => 'John', 'email' => 'john@example.com']; + $timestamp = time(); + + $event = new InsertEvent( + EventInterface::INSERT, + $timestamp, + 'checkpoint123', + 'mydb', + 'users', + $afterData + ); + + $this->assertEquals(EventInterface::INSERT, $event->type); + $this->assertEquals($timestamp, $event->timestamp); + $this->assertEquals('checkpoint123', $event->checkpoint); + $this->assertEquals('mydb', $event->schema); + $this->assertEquals('users', $event->table); + $this->assertEquals($afterData, $event->after); + } + +} \ No newline at end of file diff --git a/data-access-kit-replication/test/StreamCheckpointerIntegrationTest.php b/data-access-kit-replication/test/StreamCheckpointerIntegrationTest.php new file mode 100644 index 0000000..b281256 --- /dev/null +++ b/data-access-kit-replication/test/StreamCheckpointerIntegrationTest.php @@ -0,0 +1,145 @@ +pdo) { + try { + $this->pdo->exec("DROP DATABASE IF EXISTS `test_checkpointer_db`"); + } catch (\Exception $e) { + } + } + + parent::tearDown(); + } + + public function testNullCheckpointer(): void + { + $this->expectNotToPerformAssertions(); + $this->requireDatabase(); + + $stream = new Stream($this->createReplicationConnectionUrl()); + + $stream->setCheckpointer(null); + } + + public function testInvalidCheckpointer(): void + { + $this->requireDatabase(); + + $stream = new Stream($this->createReplicationConnectionUrl()); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Checkpointer must be an object implementing StreamCheckpointerInterface'); + + $stream->setCheckpointer("not an object"); + } + + public function testCheckpointerSaveCheckpoint(): void + { + $this->requireDatabase(); + + $checkpointer = new class implements StreamCheckpointerInterface { + public array $loadCalls = []; + public array $saveCalls = []; + + public function loadLastCheckpoint(): ?string { + $this->loadCalls[] = microtime(true); + return null; + } + + public function saveCheckpoint(string $checkpoint): void { + $this->saveCalls[] = [ + 'checkpoint' => $checkpoint, + 'timestamp' => microtime(true) + ]; + } + }; + + $stream = null; + + try { + $this->pdo->exec("CREATE DATABASE IF NOT EXISTS `test_checkpointer_db`"); + $this->pdo->exec("USE `test_checkpointer_db`"); + + $testPdo = new \PDO( + "mysql:host={$this->dbConfig['host']};port={$this->dbConfig['port']};dbname=test_checkpointer_db", + $this->dbConfig['user'], + $this->dbConfig['password'] + ); + $testPdo->exec(" + CREATE TABLE IF NOT EXISTS `test_checkpoint_table` ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "); + + $stream = new Stream($this->createReplicationConnectionUrl(['database' => 'test_checkpointer_db'])); + $stream->setCheckpointer($checkpointer); + + $stream->connect(); + + $testPdo->exec(" + INSERT INTO `test_checkpoint_table` (name, email) VALUES + ('Test User', 'test@example.com') + "); + + $stream->rewind(); + + $this->assertNotEmpty($checkpointer->loadCalls, 'loadLastCheckpoint should be called during rewind'); + + $this->assertTrue($stream->valid(), 'Stream should be valid after rewind'); + + $insertEvent = $stream->current(); + $this->assertInstanceOf(EventInterface::class, $insertEvent, 'Should receive an event'); + $this->assertInstanceOf(InsertEvent::class, $insertEvent, 'Should be an InsertEvent'); + $this->assertEquals(EventInterface::INSERT, $insertEvent->type, 'Event type should be INSERT'); + $this->assertEquals('test_checkpointer_db', $insertEvent->schema, 'Schema should match'); + $this->assertEquals('test_checkpoint_table', $insertEvent->table, 'Table should match'); + + $this->assertIsObject($insertEvent->after, 'InsertEvent should have after data'); + $this->assertEquals('Test User', $insertEvent->after->name, 'Name should match inserted value'); + $this->assertEquals('test@example.com', $insertEvent->after->email, 'Email should match inserted value'); + + $this->assertNotEmpty($checkpointer->saveCalls, 'saveCheckpoint should be called during event processing'); + + $latestSave = end($checkpointer->saveCalls); + $this->assertIsArray($latestSave, 'Save call should be recorded'); + $this->assertArrayHasKey('checkpoint', $latestSave, 'Save call should have checkpoint'); + + $savedCheckpoint = $latestSave['checkpoint']; + $this->assertIsString($savedCheckpoint, 'Checkpoint should be a string'); + + $this->assertTrue( + str_starts_with($savedCheckpoint, 'gtid:') || str_starts_with($savedCheckpoint, 'file:'), + 'Checkpoint should start with "gtid:" or "file:" prefix. Got: ' . $savedCheckpoint + ); + + $this->assertEquals( + $savedCheckpoint, + $insertEvent->checkpoint, + 'Saved checkpoint should match the checkpoint in the InsertEvent' + ); + + } finally { + if ($stream !== null) { + try { + $stream->disconnect(); + } catch (\Exception $e) { + } + } + } + } +} \ No newline at end of file diff --git a/data-access-kit-replication/test/StreamCheckpointerInterfaceTest.php b/data-access-kit-replication/test/StreamCheckpointerInterfaceTest.php new file mode 100644 index 0000000..f136136 --- /dev/null +++ b/data-access-kit-replication/test/StreamCheckpointerInterfaceTest.php @@ -0,0 +1,30 @@ +assertTrue(interface_exists(StreamCheckpointerInterface::class)); + } + + public function testStreamCheckpointerInterfaceHasRequiredMethods(): void + { + $reflection = new \ReflectionClass(StreamCheckpointerInterface::class); + + $this->assertTrue($reflection->hasMethod('loadLastCheckpoint')); + $this->assertTrue($reflection->hasMethod('saveCheckpoint')); + + $loadMethod = $reflection->getMethod('loadLastCheckpoint'); + $this->assertEquals('?string', (string)$loadMethod->getReturnType()); + + $saveMethod = $reflection->getMethod('saveCheckpoint'); + $this->assertEquals('void', (string)$saveMethod->getReturnType()); + } +} \ No newline at end of file diff --git a/data-access-kit-replication/test/StreamFilterIntegrationTest.php b/data-access-kit-replication/test/StreamFilterIntegrationTest.php new file mode 100644 index 0000000..651e097 --- /dev/null +++ b/data-access-kit-replication/test/StreamFilterIntegrationTest.php @@ -0,0 +1,160 @@ +pdo) { + try { + $this->pdo->exec("DROP DATABASE IF EXISTS `test_filter_db`"); + } catch (\Exception $e) { + } + } + + parent::tearDown(); + } + + public function testNullFilter(): void + { + $this->expectNotToPerformAssertions(); + $this->requireDatabase(); + + $stream = new Stream($this->createReplicationConnectionUrl()); + + $stream->setFilter(null); + } + + public function testInvalidFilter(): void + { + $this->requireDatabase(); + + $stream = new Stream($this->createReplicationConnectionUrl()); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Filter must be an object implementing StreamFilterInterface'); + + $stream->setFilter("not an object"); + } + + public function testFilterAcceptAndReject(): void + { + $this->requireDatabase(); + + $filter = new class implements StreamFilterInterface { + public array $acceptCalls = []; + + public function accept(string $type, string $schema, string $table): bool { + $this->acceptCalls[] = [ + 'type' => $type, + 'schema' => $schema, + 'table' => $table, + 'timestamp' => microtime(true) + ]; + + // Accept only events from 'allowed_table', reject 'filtered_table' + return $table === 'allowed_table'; + } + }; + + $stream = null; + + try { + $this->pdo->exec("CREATE DATABASE IF NOT EXISTS `test_filter_db`"); + $this->pdo->exec("USE `test_filter_db`"); + + $testPdo = new \PDO( + "mysql:host={$this->dbConfig['host']};port={$this->dbConfig['port']};dbname=test_filter_db", + $this->dbConfig['user'], + $this->dbConfig['password'] + ); + + // Create two tables: one that should be filtered out, one that should be allowed + $testPdo->exec(" + CREATE TABLE IF NOT EXISTS `filtered_table` ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "); + + $testPdo->exec(" + CREATE TABLE IF NOT EXISTS `allowed_table` ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "); + + $stream = new Stream($this->createReplicationConnectionUrl(['database' => 'test_filter_db'])); + $stream->setFilter($filter); + + $stream->connect(); + + // Insert into the filtered table first (this should be filtered out) + $testPdo->exec(" + INSERT INTO `filtered_table` (name) VALUES + ('Filtered User') + "); + + // Insert into the allowed table (this should pass through) + $testPdo->exec(" + INSERT INTO `allowed_table` (name, email) VALUES + ('Allowed User', 'allowed@example.com') + "); + + $stream->rewind(); + + $this->assertTrue($stream->valid(), 'Stream should be valid after rewind'); + + $insertEvent = $stream->current(); + $this->assertInstanceOf(EventInterface::class, $insertEvent, 'Should receive an event'); + $this->assertInstanceOf(InsertEvent::class, $insertEvent, 'Should be an InsertEvent'); + $this->assertEquals(EventInterface::INSERT, $insertEvent->type, 'Event type should be INSERT'); + $this->assertEquals('test_filter_db', $insertEvent->schema, 'Schema should match'); + $this->assertEquals('allowed_table', $insertEvent->table, 'Table should be the allowed table, not the filtered one'); + + $this->assertIsObject($insertEvent->after, 'InsertEvent should have after data'); + $this->assertEquals('Allowed User', $insertEvent->after->name, 'Name should match inserted value'); + $this->assertEquals('allowed@example.com', $insertEvent->after->email, 'Email should match inserted value'); + + // Verify that the filter was called for both tables + $this->assertNotEmpty($filter->acceptCalls, 'Filter accept method should be called'); + + // Filter should have been called at least once (possibly multiple times due to table map events) + $foundFilteredCall = false; + $foundAllowedCall = false; + + foreach ($filter->acceptCalls as $call) { + if ($call['table'] === 'filtered_table') { + $foundFilteredCall = true; + } + if ($call['table'] === 'allowed_table') { + $foundAllowedCall = true; + } + } + + $this->assertTrue($foundAllowedCall, 'Filter should have been called for allowed_table'); + + // Verify that we only received the allowed event, not the filtered one + // The fact that we got 'allowed_table' and not 'filtered_table' proves the filter worked + + } finally { + if ($stream !== null) { + try { + $stream->disconnect(); + } catch (\Exception $e) { + } + } + } + } +} \ No newline at end of file diff --git a/data-access-kit-replication/test/StreamFilterInterfaceTest.php b/data-access-kit-replication/test/StreamFilterInterfaceTest.php new file mode 100644 index 0000000..6df199c --- /dev/null +++ b/data-access-kit-replication/test/StreamFilterInterfaceTest.php @@ -0,0 +1,27 @@ +assertTrue(interface_exists(StreamFilterInterface::class)); + } + + public function testStreamFilterInterfaceHasRequiredMethods(): void + { + $reflection = new \ReflectionClass(StreamFilterInterface::class); + + $this->assertTrue($reflection->hasMethod('accept')); + + $acceptMethod = $reflection->getMethod('accept'); + $this->assertEquals('bool', (string)$acceptMethod->getReturnType()); + $this->assertCount(3, $acceptMethod->getParameters()); + } +} \ No newline at end of file diff --git a/data-access-kit-replication/test/StreamIntegrationTest.php b/data-access-kit-replication/test/StreamIntegrationTest.php new file mode 100644 index 0000000..9182ce8 --- /dev/null +++ b/data-access-kit-replication/test/StreamIntegrationTest.php @@ -0,0 +1,560 @@ +requireDatabase(); + + $stream = null; + + try { + // Set up test database using existing PDO connection + $this->pdo->exec("CREATE DATABASE IF NOT EXISTS `test_replication_db`"); + $this->pdo->exec("USE `test_replication_db`"); + + // Set up test table + $testPdo = new \PDO( + "mysql:host={$this->dbConfig['host']};port={$this->dbConfig['port']};dbname=test_replication_db", + $this->dbConfig['user'], + $this->dbConfig['password'] + ); + $testPdo->exec(" + CREATE TABLE IF NOT EXISTS `test_users` ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "); + + // Create stream with replication user connection for binlog streaming + $stream = new Stream($this->createReplicationConnectionUrl(['database' => 'test_replication_db'])); + $this->assertInstanceOf(Stream::class, $stream); + + // Test 1: Connect to database + $stream->connect(); + + // Test 2: Insert test data to generate INSERT event + $testPdo->exec(" + INSERT INTO `test_users` (name, email) VALUES + ('John Doe', 'john@example.com') + "); + + // Test 3: Test iterator interface - call rewind to start + $stream->rewind(); + $this->assertTrue($stream->valid()); // Should be valid after rewind + + // Test 4: Test INSERT event + $key = $stream->key(); + $this->assertEquals(0, $key); // First event at position 0 + + $insertEvent = $stream->current(); + $this->assertInstanceOf(EventInterface::class, $insertEvent); + $this->assertInstanceOf(InsertEvent::class, $insertEvent); + $this->assertEquals(EventInterface::INSERT, $insertEvent->type); + $this->assertEquals('test_replication_db', $insertEvent->schema); + $this->assertEquals('test_users', $insertEvent->table); + $this->assertIsObject($insertEvent->after); + $this->assertEquals('John Doe', $insertEvent->after->name); + $this->assertEquals('john@example.com', $insertEvent->after->email); + + // Test 5: Update the row to generate UPDATE event + $testPdo->exec(" + UPDATE `test_users` + SET name = 'John Smith', email = 'johnsmith@example.com' + WHERE email = 'john@example.com' + "); + + // Test 6: Move to next event and test UPDATE event + $stream->next(); + $this->assertTrue($stream->valid()); // Should still be valid + + $updateKey = $stream->key(); + $this->assertEquals(1, $updateKey); // Second event at position 1 + + $updateEvent = $stream->current(); + $this->assertInstanceOf(EventInterface::class, $updateEvent); + $this->assertInstanceOf(UpdateEvent::class, $updateEvent); + $this->assertEquals(EventInterface::UPDATE, $updateEvent->type); + $this->assertEquals('test_replication_db', $updateEvent->schema); + $this->assertEquals('test_users', $updateEvent->table); + $this->assertIsObject($updateEvent->before); + $this->assertIsObject($updateEvent->after); + $this->assertEquals('John Doe', $updateEvent->before->name); + $this->assertEquals('john@example.com', $updateEvent->before->email); + $this->assertEquals('John Smith', $updateEvent->after->name); + $this->assertEquals('johnsmith@example.com', $updateEvent->after->email); + // Test 7: Delete the row to generate DELETE event + $testPdo->exec(" + DELETE FROM `test_users` + WHERE email = 'johnsmith@example.com' + "); + + // Test 8: Move to next event and test DELETE event + $stream->next(); + $this->assertTrue($stream->valid()); // Should still be valid + + $deleteKey = $stream->key(); + $this->assertEquals(2, $deleteKey); // Third event at position 2 + + $deleteEvent = $stream->current(); + $this->assertInstanceOf(EventInterface::class, $deleteEvent); + $this->assertInstanceOf(DeleteEvent::class, $deleteEvent); + $this->assertEquals(EventInterface::DELETE, $deleteEvent->type); + $this->assertEquals('test_replication_db', $deleteEvent->schema); + $this->assertEquals('test_users', $deleteEvent->table); + $this->assertIsObject($deleteEvent->before); + $this->assertEquals('John Smith', $deleteEvent->before->name); + $this->assertEquals('johnsmith@example.com', $deleteEvent->before->email); + + // Note: We don't test moving past available events because binlog streams + // are designed to wait for new events indefinitely, not to "end" + + // Test 9: Test disconnect + $stream->disconnect(); + + // Test 10: After disconnect, valid should return false + $this->assertFalse($stream->valid()); + + // Test 11: Calling iterator methods after disconnect should return false + $this->assertFalse($stream->valid()); + + } finally { + // Cleanup: disconnect stream if created + if ($stream !== null) { + try { + $stream->disconnect(); + } catch (Exception $e) { + // Ignore disconnect errors in cleanup + } + } + + // Cleanup: drop test table + try { + $cleanupPdo = new \PDO( + "mysql:host={$this->dbConfig['host']};port={$this->dbConfig['port']};dbname=test_replication_db", + $this->dbConfig['user'], + $this->dbConfig['password'] + ); + $cleanupPdo->exec("DROP TABLE IF EXISTS `test_users`"); + } catch (Exception $e) { + // Ignore cleanup errors + } + + // Cleanup: drop test database + try { + $this->pdo->exec("DROP DATABASE IF EXISTS `test_replication_db`"); + } catch (Exception $e) { + // Ignore cleanup errors + } + } + } + + + public function testMysqlConfigurationValidationBinlogFormatFailure(): void + { + $this->requireDatabase(); + + // Set invalid binlog_format globally + $stmt = $this->pdo->prepare("SET @@GLOBAL.binlog_format = ?"); + $stmt->execute(['STATEMENT']); + + $stream = new Stream($this->createReplicationConnectionUrl()); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/binlog_format must be ROW/i'); + $stream->connect(); + } + + public function testMysqlConfigurationValidationBinlogRowImageFailure(): void + { + $this->requireDatabase(); + + // Set invalid binlog_row_image globally + $stmt = $this->pdo->prepare("SET @@GLOBAL.binlog_row_image = ?"); + $stmt->execute(['MINIMAL']); + + $stream = new Stream($this->createReplicationConnectionUrl()); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/binlog_row_image must be FULL/i'); + $stream->connect(); + } + + public function testMysqlConfigurationValidationBinlogRowMetadataFailure(): void + { + $this->requireDatabase(); + + // Set invalid binlog_row_metadata globally + $stmt = $this->pdo->prepare("SET @@GLOBAL.binlog_row_metadata = ?"); + $stmt->execute(['MINIMAL']); + + $stream = new Stream($this->createReplicationConnectionUrl()); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/binlog_row_metadata must be FULL/i'); + $stream->connect(); + } + + public function testMysqlConfigurationValidationGtidModeFailure(): void + { + $this->requireDatabase(); + + // Detect database type + $stmt = $this->pdo->query("SELECT VERSION()"); + $version = $stmt->fetchColumn(); + $isMariaDB = stripos($version, 'mariadb') !== false; + + if ($isMariaDB) { + $this->markTestSkipped('This test is for MySQL only'); + } + + // MySQL GTID test + $stmt = $this->pdo->query("SELECT @@GLOBAL.gtid_mode"); + $originalGtidMode = $stmt->fetchColumn(); + + try { + // MySQL requires stepping GTID mode: ON -> ON_PERMISSIVE -> OFF_PERMISSIVE -> OFF + if ($originalGtidMode === 'ON') { + $this->pdo->exec("SET @@GLOBAL.gtid_mode = 'ON_PERMISSIVE'"); + $this->pdo->exec("SET @@GLOBAL.gtid_mode = 'OFF_PERMISSIVE'"); + $this->pdo->exec("SET @@GLOBAL.gtid_mode = 'OFF'"); + } + + $stream = new Stream($this->createReplicationConnectionUrl()); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/gtid_mode must be ON/i'); + $stream->connect(); + + } finally { + // Restore original GTID mode by stepping back up + if ($originalGtidMode === 'ON') { + try { + $this->pdo->exec("SET @@GLOBAL.gtid_mode = 'OFF_PERMISSIVE'"); + $this->pdo->exec("SET @@GLOBAL.gtid_mode = 'ON_PERMISSIVE'"); + $this->pdo->exec("SET @@GLOBAL.gtid_mode = 'ON'"); + } catch (Exception $e) { + // Ignore errors in cleanup + } + } + } + } + + public static function dataTypeProvider(): array + { + return [ + // Integer Types + ['TINYINT', 127, 127, null], + ['TINYINT UNSIGNED', 255, -1, null], // MySQL binlog represents max unsigned as -1 + ['SMALLINT', 32767, 32767, null], + ['SMALLINT UNSIGNED', 65535, -1, null], // MySQL binlog represents max unsigned as -1 + ['MEDIUMINT', 8388607, 8388607, null], + ['MEDIUMINT UNSIGNED', 16777215, -1, null], // MySQL binlog represents max unsigned as -1 + ['INT', 2147483647, 2147483647, null], + ['INT UNSIGNED', 4294967295, -1, null], // MySQL binlog represents max unsigned as -1 + ['BIGINT', '9223372036854775807', '9223372036854775807', null], + ['BIGINT UNSIGNED', '18446744073709551615', -1, null], // MySQL binlog represents max unsigned as -1 + ['BIT(8)', 'b\'11111111\'', 255, null], + ['BIT(1)', 'b\'1\'', 1, null], + + // Fixed-Point Types + ['DECIMAL(10,2)', '123.45', '123.45', null], + ['DECIMAL(5,0)', '12345', '12345', null], + ['NUMERIC(8,3)', '12345.678', '12345.678', null], + + // Floating-Point Types + ['FLOAT', 123.456, 123.456, null], + ['DOUBLE', 123.456789, 123.456789, null], + + // Character Types + ['CHAR(10)', '\'Hello\'', 'Hello', null], + ['VARCHAR(50)', '\'Variable length\'', 'Variable length', null], + ['BINARY(5)', 'X\'48656c6c6f\'', 'Hello', null], // Use hex notation for binary data + ['VARBINARY(10)', 'X\'48656c6c6f\'', 'Hello', null], // Use hex notation for binary data + + // Text Types - now returned as UTF-8 strings + ['TINYTEXT', '\'Tiny text\'', 'Tiny text', null], + ['TEXT', '\'Regular text content\'', 'Regular text content', null], + ['MEDIUMTEXT', '\'Medium text content\'', 'Medium text content', null], + ['LONGTEXT', '\'Long text content\'', 'Long text content', null], + + // Binary Large Object Types - now returned as raw binary data + ['TINYBLOB', 'X\'48656c6c6f\'', 'Hello', null], // "Hello" from hex + ['BLOB', 'X\'48656c6c6f20576f726c64\'', 'Hello World', null], // "Hello World" from hex + ['MEDIUMBLOB', 'X\'48656c6c6f204d656469756d\'', 'Hello Medium', null], // "Hello Medium" from hex + ['LONGBLOB', 'X\'48656c6c6f204c6f6e67\'', 'Hello Long', null], // "Hello Long" from hex + + // Special String Types - now return actual string values with fix-enum-set-metadata branch + ['ENUM(\'red\',\'green\',\'blue\')', '\'red\'', 'red', null], // ENUM returns actual string value + ['SET(\'read\',\'write\',\'execute\')', '\'read,write\'', ['read', 'write'], null], // SET returns array of strings + + // Date and Time Data Types + ['DATE', '\'2024-01-15\'', '2024-01-15', null], + ['TIME', '\'14:30:45\'', '14:30:45.000000', null], // TIME includes microseconds + ['DATETIME', '\'2024-01-15 14:30:45\'', new \DateTimeImmutable('2024-01-15 14:30:45.000000'), null], // DATETIME as DateTimeImmutable + ['TIMESTAMP', '\'2024-01-15 14:30:45\'', new \DateTimeImmutable('2024-01-15 14:30:45'), null], // TIMESTAMP as DateTimeImmutable + ['YEAR', '2024', '2024', null], + + // JSON Data Type - MySQL returns parsed stdClass objects + ['JSON', '\'{"key": "value", "number": 42}\'', (object)['key' => 'value', 'number' => 42], 'mysql'], + // JSON Data Type - MariaDB returns JSON string (not parsed) + ['JSON', '\'{"key": "value", "number": 42}\'', '{"key": "value", "number": 42}', 'mariadb'], + + // NULL values for various types + ['VARCHAR(50)', 'NULL', null, null], + ['INT', 'NULL', null, null], + ['DATE', 'NULL', null, null], + ['JSON', 'NULL', null, null], + + // Zero and empty values + ['INT', '0', 0, null], + ['VARCHAR(50)', '\'\'', '', null], + ['TEXT', '\'\'', '', null], + + // Negative numbers + ['TINYINT', '-128', -128, null], + ['SMALLINT', '-32768', -32768, null], + ['MEDIUMINT', '-8388608', -8388608, null], + ['INT', '-2147483648', -2147483648, null], + ['BIGINT', '-9223372036854775808', '-9223372036854775808', null], + ['DECIMAL(10,2)', '-123.45', '-123.45', null], + ['FLOAT', '-123.456', -123.456, null], + ['DOUBLE', '-123.456789', -123.456789, null], + ]; + } + + #[DataProvider('dataTypeProvider')] + public function testDataTypeConversion(string $columnType, $insertValue, $expectedPhpValue, ?string $databaseFlavor): void + { + $this->requireDatabase(); + + // Skip test if database flavor doesn't match + if ($databaseFlavor !== null) { + $stmt = $this->pdo->query("SELECT VERSION()"); + $version = $stmt->fetchColumn(); + $isMariaDB = stripos($version, 'mariadb') !== false; + + if (($databaseFlavor === 'mariadb' && !$isMariaDB) || + ($databaseFlavor === 'mysql' && $isMariaDB)) { + $this->markTestSkipped("Test only runs on {$databaseFlavor}"); + } + } + + $stream = null; + $testTableName = 'test_data_types_' . md5($columnType . serialize($insertValue)); + + try { + $this->pdo->exec("CREATE DATABASE IF NOT EXISTS `test_replication_db`"); + $this->pdo->exec("USE `test_replication_db`"); + + $testPdo = new \PDO( + "mysql:host={$this->dbConfig['host']};port={$this->dbConfig['port']};dbname=test_replication_db", + $this->dbConfig['user'], + $this->dbConfig['password'] + ); + + $createTableSql = "CREATE TABLE IF NOT EXISTS `{$testTableName}` ( + id INT AUTO_INCREMENT PRIMARY KEY, + test_column {$columnType} + )"; + $testPdo->exec($createTableSql); + + $stream = new Stream($this->createReplicationConnectionUrl(['database' => 'test_replication_db'])); + $stream->connect(); + + if ($insertValue === 'NULL') { + $insertSql = "INSERT INTO `{$testTableName}` (test_column) VALUES (NULL)"; + } else { + $insertSql = "INSERT INTO `{$testTableName}` (test_column) VALUES ({$insertValue})"; + } + $testPdo->exec($insertSql); + + $stream->rewind(); + $this->assertTrue($stream->valid()); + + $insertEvent = $stream->current(); + $this->assertInstanceOf(InsertEvent::class, $insertEvent); + $this->assertEquals('test_replication_db', $insertEvent->schema); + $this->assertEquals($testTableName, $insertEvent->table); + $this->assertIsObject($insertEvent->after); + + // Use expected value as-is since database flavor filtering handles different expectations + $actualExpectedValue = $expectedPhpValue; + + if ($actualExpectedValue === null) { + $this->assertNull($insertEvent->after->test_column); + } elseif (is_float($actualExpectedValue)) { + $this->assertEqualsWithDelta($actualExpectedValue, $insertEvent->after->test_column, 0.001); + } elseif ($actualExpectedValue instanceof \DateTimeImmutable) { + $this->assertInstanceOf(\DateTimeImmutable::class, $insertEvent->after->test_column); + $this->assertEquals($actualExpectedValue->getTimestamp(), $insertEvent->after->test_column->getTimestamp()); + // Additional checks for DateTimeImmutable + $this->assertEquals($actualExpectedValue->format('Y-m-d H:i:s'), $insertEvent->after->test_column->format('Y-m-d H:i:s')); + } elseif (is_object($actualExpectedValue) && get_class($actualExpectedValue) === 'stdClass') { + $this->assertInstanceOf(\stdClass::class, $insertEvent->after->test_column); + $this->assertEquals($actualExpectedValue, $insertEvent->after->test_column); + } else { + $this->assertEquals($actualExpectedValue, $insertEvent->after->test_column); + } + + } finally { + if ($stream !== null) { + try { + $stream->disconnect(); + } catch (Exception $e) { + } + } + + try { + $cleanupPdo = new \PDO( + "mysql:host={$this->dbConfig['host']};port={$this->dbConfig['port']};dbname=test_replication_db", + $this->dbConfig['user'], + $this->dbConfig['password'] + ); + $cleanupPdo->exec("DROP TABLE IF EXISTS `{$testTableName}`"); + } catch (Exception $e) { + } + + try { + $this->pdo->exec("DROP DATABASE IF EXISTS `test_replication_db`"); + } catch (Exception $e) { + } + } + } + + public function testBulkOperationsStreamFlow(): void + { + $this->requireDatabase(); + + $stream = null; + + try { + $this->pdo->exec("CREATE DATABASE IF NOT EXISTS `test_replication_db`"); + $this->pdo->exec("USE `test_replication_db`"); + $this->pdo->exec(" + CREATE TABLE IF NOT EXISTS `test_bulk_users` ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(100) NOT NULL, + status VARCHAR(20) DEFAULT 'active' + ) + "); + + $stream = new Stream($this->createReplicationConnectionUrl(['database' => 'test_replication_db'])); + $stream->connect(); + + // Batch insert 10 rows in a single statement + $this->pdo->exec(" + INSERT INTO `test_bulk_users` (name, email) VALUES + ('User 1', 'user1@example.com'), + ('User 2', 'user2@example.com'), + ('User 3', 'user3@example.com'), + ('User 4', 'user4@example.com'), + ('User 5', 'user5@example.com'), + ('User 6', 'user6@example.com'), + ('User 7', 'user7@example.com'), + ('User 8', 'user8@example.com'), + ('User 9', 'user9@example.com'), + ('User 10', 'user10@example.com') + "); + + // Update all 10 rows + $this->pdo->exec(" + UPDATE `test_bulk_users` + SET status = 'updated', name = CONCAT(name, ' - Updated') + WHERE status = 'active' + "); + + // Delete all 10 rows + $this->pdo->exec("DELETE FROM `test_bulk_users` WHERE status = 'updated'"); + + $eventCount = 0; + $insertEventCount = 0; + $updateEventCount = 0; + $deleteEventCount = 0; + + // Process all 30 events from bulk operations (10 batch inserts + 10 bulk updates + 10 bulk deletes) + foreach ($stream as $event) { + $this->assertInstanceOf(EventInterface::class, $event); + $this->assertEquals('test_replication_db', $event->schema); + $this->assertEquals('test_bulk_users', $event->table); + + if ($event->type === EventInterface::INSERT) { + $insertEventCount++; + $this->assertInstanceOf(InsertEvent::class, $event); + $this->assertIsObject($event->after); + $this->assertStringContainsString('User ', $event->after->name); + $this->assertStringContainsString('@example.com', $event->after->email); + $this->assertEquals('active', $event->after->status); + } elseif ($event->type === EventInterface::UPDATE) { + $updateEventCount++; + $this->assertInstanceOf(UpdateEvent::class, $event); + $this->assertIsObject($event->before); + $this->assertIsObject($event->after); + $this->assertEquals('active', $event->before->status); + $this->assertEquals('updated', $event->after->status); + $this->assertStringContainsString(' - Updated', $event->after->name); + } elseif ($event->type === EventInterface::DELETE) { + $deleteEventCount++; + $this->assertInstanceOf(DeleteEvent::class, $event); + $this->assertIsObject($event->before); + $this->assertEquals('updated', $event->before->status); + $this->assertStringContainsString(' - Updated', $event->before->name); + } + + $eventCount++; + + if ($eventCount >= 30) { + break; + } + } + + // Verify we processed exactly 30 events: 10 inserts, 10 updates, 10 deletes + $this->assertEquals(30, $eventCount); + $this->assertEquals(10, $insertEventCount); + $this->assertEquals(10, $updateEventCount); + $this->assertEquals(10, $deleteEventCount); + + $stream->disconnect(); + $this->assertFalse($stream->valid()); + + } finally { + if ($stream !== null) { + try { + $stream->disconnect(); + } catch (Exception $e) { + // Ignore disconnect errors in cleanup + } + } + + try { + $this->pdo->exec("USE `test_replication_db`"); + $this->pdo->exec("DROP TABLE IF EXISTS `test_bulk_users`"); + } catch (Exception $e) { + // Ignore cleanup errors + } + + try { + $this->pdo->exec("DROP DATABASE IF EXISTS `test_replication_db`"); + } catch (Exception $e) { + // Ignore cleanup errors + } + } + } + + +} \ No newline at end of file diff --git a/data-access-kit-replication/test/StreamInterfaceTest.php b/data-access-kit-replication/test/StreamInterfaceTest.php new file mode 100644 index 0000000..49125f2 --- /dev/null +++ b/data-access-kit-replication/test/StreamInterfaceTest.php @@ -0,0 +1,130 @@ +assertTrue(class_exists(Stream::class)); + } + + public function testStreamConstructor(): void + { + $connectionUrl = 'mysql://user:password@localhost:3306?server_id=100'; + $stream = new Stream($connectionUrl); + + $this->assertInstanceOf(Stream::class, $stream); + } + + public function testStreamImplementsIterator(): void + { + $connectionUrl = 'mysql://user:password@localhost:3306?server_id=100'; + $stream = new Stream($connectionUrl); + + // Test that Stream class implements Iterator interface + $this->assertInstanceOf(\Iterator::class, $stream); + } + + public function testStreamHasConnectMethod(): void + { + $connectionUrl = 'mysql://user:password@localhost:3306?server_id=100'; + $stream = new Stream($connectionUrl); + + $this->assertTrue(method_exists($stream, 'connect'), 'Stream should have connect() method'); + } + + public function testStreamHasDisconnectMethod(): void + { + $connectionUrl = 'mysql://user:password@localhost:3306?server_id=100'; + $stream = new Stream($connectionUrl); + + $this->assertTrue(method_exists($stream, 'disconnect'), 'Stream should have disconnect() method'); + } + + public function testStreamSetCheckpointer(): void + { + $this->expectNotToPerformAssertions(); + + $connectionUrl = 'mysql://user:password@localhost:3306?server_id=100'; + $stream = new Stream($connectionUrl); + + $checkpointer = new class implements StreamCheckpointerInterface { + public function loadLastCheckpoint(): ?string { + return null; + } + + public function saveCheckpoint(string $checkpoint): void { + // Mock implementation + } + }; + + // Should not throw an exception + $stream->setCheckpointer($checkpointer); + } + + public function testStreamSetFilter(): void + { + $this->expectNotToPerformAssertions(); + + $connectionUrl = 'mysql://user:password@localhost:3306?server_id=100'; + $stream = new Stream($connectionUrl); + + $filter = new class implements StreamFilterInterface { + public function accept(string $type, string $schema, string $table): bool { + return true; + } + }; + + // Should not throw an exception + $stream->setFilter($filter); + } + + public function testIteratorHasKeyMethod(): void + { + $connectionUrl = 'mysql://user:password@localhost:3306?server_id=100'; + $stream = new Stream($connectionUrl); + + $this->assertTrue(method_exists($stream, 'key'), 'Stream should have key() method'); + } + + public function testIteratorHasCurrentMethod(): void + { + $connectionUrl = 'mysql://user:password@localhost:3306?server_id=100'; + $stream = new Stream($connectionUrl); + + $this->assertTrue(method_exists($stream, 'current'), 'Stream should have current() method'); + } + + public function testIteratorHasNextMethod(): void + { + $connectionUrl = 'mysql://user:password@localhost:3306?server_id=100'; + $stream = new Stream($connectionUrl); + + $this->assertTrue(method_exists($stream, 'next'), 'Stream should have next() method'); + } + + public function testIteratorHasRewindMethod(): void + { + $connectionUrl = 'mysql://user:password@localhost:3306?server_id=100'; + $stream = new Stream($connectionUrl); + + $this->assertTrue(method_exists($stream, 'rewind'), 'Stream should have rewind() method'); + } + + public function testIteratorHasValidMethod(): void + { + $connectionUrl = 'mysql://user:password@localhost:3306?server_id=100'; + $stream = new Stream($connectionUrl); + + $this->assertTrue(method_exists($stream, 'valid'), 'Stream should have valid() method'); + } +} \ No newline at end of file diff --git a/data-access-kit-replication/test/UpdateEventTest.php b/data-access-kit-replication/test/UpdateEventTest.php new file mode 100644 index 0000000..ff1fbd3 --- /dev/null +++ b/data-access-kit-replication/test/UpdateEventTest.php @@ -0,0 +1,47 @@ +assertTrue(class_exists(UpdateEvent::class)); + } + + public function testClassImplementsInterface(): void + { + $this->assertTrue(is_subclass_of(UpdateEvent::class, EventInterface::class)); + } + + public function testCanConstructClassWithProperties(): void + { + $beforeData = (object)['id' => 1, 'name' => 'John', 'email' => 'john@example.com']; + $afterData = (object)['id' => 1, 'name' => 'Jane', 'email' => 'jane@example.com']; + $timestamp = time(); + + $event = new UpdateEvent( + EventInterface::UPDATE, + $timestamp, + 'checkpoint456', + 'mydb', + 'users', + $beforeData, + $afterData + ); + + $this->assertEquals(EventInterface::UPDATE, $event->type); + $this->assertEquals($timestamp, $event->timestamp); + $this->assertEquals('checkpoint456', $event->checkpoint); + $this->assertEquals('mydb', $event->schema); + $this->assertEquals('users', $event->table); + $this->assertEquals($beforeData, $event->before); + $this->assertEquals($afterData, $event->after); + } +} \ No newline at end of file diff --git a/data-access-kit-replication/test/bootstrap.php b/data-access-kit-replication/test/bootstrap.php new file mode 100644 index 0000000..751c3d0 --- /dev/null +++ b/data-access-kit-replication/test/bootstrap.php @@ -0,0 +1,12 @@ +