├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── composer.json ├── docker ├── docker-compose.yml ├── docker-entrypoint-initdb.d │ └── init.sh └── pg_hba_new.conf ├── example ├── Connection.php ├── ListenNotify.php ├── Prepare.php ├── SequentialQueries.php ├── SimpleQuery.php ├── bootstrap.php └── db.sql ├── phpunit.xml ├── src └── PgAsync │ ├── Client.php │ ├── Column.php │ ├── Command │ ├── Bind.php │ ├── CancelRequest.php │ ├── Close.php │ ├── CommandInterface.php │ ├── CommandTrait.php │ ├── Describe.php │ ├── Execute.php │ ├── Parse.php │ ├── PasswordMessage.php │ ├── Query.php │ ├── SSLRequest.php │ ├── SaslInitialResponse.php │ ├── SaslResponse.php │ ├── StartupMessage.php │ ├── Sync.php │ └── Terminate.php │ ├── Connection.php │ ├── ErrorException.php │ ├── Message │ ├── Authentication.php │ ├── BackendKeyData.php │ ├── CommandComplete.php │ ├── CopyInResponse.php │ ├── CopyOutResponse.php │ ├── DataRow.php │ ├── Discard.php │ ├── EmptyQueryResponse.php │ ├── ErrorResponse.php │ ├── Message.php │ ├── NoticeResponse.php │ ├── NotificationResponse.php │ ├── ParameterStatus.php │ ├── ParseComplete.php │ ├── ParserInterface.php │ ├── ParserTrait.php │ ├── ReadyForQuery.php │ └── RowDescription.php │ └── ScramSha256.php └── tests ├── Integration ├── BoolTest.php ├── ClientTest.php ├── ConnectionTest.php ├── Md5PasswordTest.php ├── NullPasswordTest.php ├── ScramSha256PasswordTest.php ├── SimpleQueryTest.php └── TestCase.php ├── TestCase.php ├── Unit ├── ClientTest.php ├── ConnectionTest.php └── Message │ ├── MessageTest.php │ ├── ParseTest.php │ ├── SSLRequestTest.php │ └── StartupMessageTest.php ├── bootstrap.php └── test_db.sql /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | on: 3 | push: 4 | branches: 5 | - 'master' 6 | - 'refs/heads/[0-9]+.[0-9]+.[0-9]+' 7 | pull_request: 8 | jobs: 9 | supported-versions-matrix: 10 | name: Supported Versions Matrix 11 | runs-on: ubuntu-latest 12 | outputs: 13 | version: ${{ steps.supported-versions-matrix.outputs.version }} 14 | steps: 15 | - uses: actions/checkout@v1 16 | - id: supported-versions-matrix 17 | uses: WyriHaximus/github-action-composer-php-versions-in-range@v1 18 | tests: 19 | services: 20 | postgres: 21 | image: postgres:${{ matrix.postgres }} 22 | env: 23 | POSTGRES_PASSWORD: postgres 24 | POSTGRES_INITDB_ARGS: --auth-host=md5 25 | # Set health checks to wait until postgres has started 26 | options: >- 27 | --health-cmd pg_isready 28 | --health-interval 10s 29 | --health-timeout 5s 30 | --health-retries 5 31 | ports: 32 | - 5432:5432 33 | name: Testing on PHP ${{ matrix.php }} with ${{ matrix.composer }} dependency preference against Postgres ${{ matrix.postgres }} 34 | strategy: 35 | fail-fast: false 36 | matrix: 37 | php: ${{ fromJson(needs.supported-versions-matrix.outputs.version) }} 38 | postgres: [12, 13, 14, 15] 39 | composer: [lowest, locked, highest] 40 | needs: 41 | - supported-versions-matrix 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v3 45 | - run: | 46 | PGPASSWORD=postgres psql -h localhost -U postgres -c "CREATE USER pgasync" 47 | PGPASSWORD=postgres psql -h localhost -U postgres -c "ALTER ROLE pgasync PASSWORD 'pgasync'" 48 | PGPASSWORD=postgres psql -h localhost -U postgres -c "CREATE USER pgasyncpw" 49 | PGPASSWORD=postgres psql -h localhost -U postgres -c "ALTER ROLE pgasyncpw PASSWORD 'example_password'" 50 | PGPASSWORD=postgres psql -h localhost -U postgres -c "CREATE USER scram_user" 51 | PGPASSWORD=postgres psql -h localhost -U postgres -c "SET password_encryption='scram-sha-256';ALTER ROLE scram_user PASSWORD 'scram_password'" 52 | PGPASSWORD=postgres psql -h localhost -U postgres -c "CREATE DATABASE pgasync_test OWNER pgasync" 53 | PGPASSWORD=pgasync psql -h localhost -U pgasync -f tests/test_db.sql pgasync_test 54 | # PGPASSWORD=postgres cat tests/test_db.sql | xargs -I % psql -h localhost -U postgres -c "%" 55 | - uses: shivammathur/setup-php@v2 56 | with: 57 | php-version: ${{ matrix.php }} 58 | coverage: xdebug 59 | - uses: ramsey/composer-install@v2 60 | with: 61 | dependency-versions: ${{ matrix.composer }} 62 | # - run: vendor/bin/phpunit --testdox 63 | - run: vendor/bin/phpunit 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /vendor 3 | /docker/database 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | sudo: required 3 | 4 | php: 5 | - 7.2 6 | - 7.3 7 | - 7.4 8 | 9 | services: 10 | - docker 11 | 12 | install: 13 | - composer install 14 | 15 | before_script: 16 | - docker-compose -f docker/docker-compose.yml up -d 17 | - sh docker/waitForPostgres.sh 18 | 19 | script: 20 | - vendor/bin/phpunit --testdox 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.0.3 2 | 3 | ### Bug Fixes 4 | 5 | - Fixed recursion issue 6 | 7 | # 1.0.2 8 | 9 | ### Bug Fixes 10 | 11 | - Fixed #19. Passwords should be null terminated strings. 12 | 13 | # 1.0.1 14 | 15 | ### Bug Fixes 16 | 17 | - Fixed disconnect endless loop when CONNECTION_BAD #17 #18 18 | 19 | # 1.0.0 20 | 21 | ### Bug Fixes 22 | 23 | - Fixed `auto_disconnect` not actually disconnecting #14 #15 24 | 25 | ### Features 26 | 27 | - Added 0.5.* as supported version for `react/socket-client` #13 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/voryx/PgAsync.svg?branch=master)](https://travis-ci.org/voryx/PgAsync) 2 | # PgAsync 3 | Asynchronous Reactive Postgres Library for PHP (Non-blocking) 4 | 5 | ## What it is 6 | This is an asynchronous Postgres library for PHP. Observables are returned by the query 7 | methods allowing asynchronous row-by-row data handling (and other Rx operators on the data) 8 | See [Rx.PHP](https://github.com/asm89/Rx.PHP). Network and event processing is handled by 9 | [ReactPHP](http://reactphp.org/). 10 | 11 | This is a pure PHP implementation (you don't need Postgres extensions to use it). 12 | 13 | ## Example - Simple Query 14 | ```php 15 | 16 | $client = new PgAsync\Client([ 17 | "host" => "127.0.0.1", 18 | "port" => "5432", 19 | "user" => "matt", 20 | "database" => "matt" 21 | ]); 22 | 23 | $client->query('SELECT * FROM channel')->subscribe( 24 | function ($row) { 25 | var_dump($row); 26 | }, 27 | function ($e) { 28 | echo "Failed.\n"; 29 | }, 30 | function () { 31 | echo "Complete.\n"; 32 | } 33 | ); 34 | 35 | 36 | ``` 37 | 38 | ## Example - parameterized query 39 | ```php 40 | 41 | $client = new PgAsync\Client([ 42 | "host" => "127.0.0.1", 43 | "port" => "5432", 44 | "user" => "matt", 45 | "database" => "matt", 46 | "auto_disconnect" => true //This option will force the client to disconnect as soon as it completes. The connection will not be returned to the connection pool. 47 | 48 | ]); 49 | 50 | $client->executeStatement('SELECT * FROM channel WHERE id = $1', ['5']) 51 | ->subscribe( 52 | function ($row) { 53 | var_dump($row); 54 | }, 55 | function ($e) { 56 | echo "Failed.\n"; 57 | }, 58 | function () { 59 | echo "Complete.\n"; 60 | } 61 | ); 62 | 63 | ``` 64 | 65 | ## Example - LISTEN/NOTIFY 66 | ```php 67 | $client = new PgAsync\Client([ 68 | "host" => "127.0.0.1", 69 | "port" => "5432", 70 | "user" => "matt", 71 | "database" => "matt" 72 | ]); 73 | 74 | $client->listen('some_channel') 75 | ->subscribe(function (\PgAsync\Message\NotificationResponse $message) { 76 | echo $message->getChannelName() . ': ' . $message->getPayload() . "\n"; 77 | }); 78 | 79 | $client->query("NOTIFY some_channel, 'Hello World'")->subscribe(); 80 | ``` 81 | 82 | ## Install 83 | With [composer](https://getcomposer.org/) install into you project with: 84 | 85 | Install pgasync: 86 | ```composer require voryx/pgasync``` 87 | 88 | ## What it can do 89 | - Run queries (CREATE, UPDATE, INSERT, SELECT, DELETE) 90 | - Queue commands 91 | - Return results asynchronously (using Observables - you get data one row at a time as it comes from the db server) 92 | - Prepared statements (as parameterized queries) 93 | - Connection pooling (basic pooling) 94 | 95 | ## What it can't quite do yet 96 | - Transactions (Actually though, just grab a connection and you can run your transaction on that single connection) 97 | 98 | ## What's next 99 | - Add more testing 100 | - Transactions 101 | - Take over the world 102 | 103 | ## Keep in mind 104 | 105 | This is an asynchronous library. If you begin 3 queries (subscribe to their observable): 106 | ```php 107 | $client->query("SELECT * FROM table1")->subscribe(...); 108 | $client->query("SELECT * FROM table2")->subscribe(...); 109 | $client->query("SELECT * FROM table3")->subscribe(...); 110 | ``` 111 | It will start all of them almost simultaneously (and you will begin receiving rows on 112 | all 3 before any of them have completed). This can be great if you want to run 113 | 3 queries at the same time, but if you have some queries that need information 114 | that was modified by other statements, this can cause a race condition: 115 | ```php 116 | $client->query("INSERT INTO invoices(inv_no, customer_id, amount) VALUES('1234A', 1, 35.75)")->subscribe(...); 117 | $client->query("SELECT SUM(amount) AS balance FROM invoices WHERE customer_id = 1")->subscribe(...); 118 | ``` 119 | In the above situation, your balance may or may not include the invoice inserted 120 | on the first line. 121 | 122 | You can avoid this by using the Rx concat* operator to only start up the second observable 123 | after the first has completed: 124 | ```php 125 | $insert = $client->query("INSERT INTO invoices(inv_no, customer_id, amount) VALUES('1234A', 1, 35.75)"); 126 | $select = $client->query("SELECT SUM(amount) AS balance FROM invoices WHERE customer_id = 1"); 127 | 128 | $insert 129 | ->concat($select) 130 | ->subscribe(...); 131 | ``` 132 | 133 | ## Testing 134 | 135 | We use docker to run a postgresql instance for testing. To run locally, 136 | just install docker and run the following command from the project root: 137 | ```bash 138 | docker-compose -f docker/docker-compose.yml up -d 139 | ``` 140 | If you need to reset the database, just stop the docker instance and delete 141 | the `docker/database` directory. Restart the docker with the above command and it will 142 | initialize the database again. 143 | 144 | The tests do not change the ending structure of the database, so you should not 145 | normally need to do this. 146 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "voryx/pgasync", 3 | "type": "library", 4 | "description": "Async Reactive Postgres Driver for PHP (Non-blocking)", 5 | "keywords": [ 6 | "async", 7 | "postgresql", 8 | "postgres", 9 | "pgsql", 10 | "react", 11 | "reactive", 12 | "driver", 13 | "rx.php" 14 | ], 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Matt Bonneau", "email": "matt@bonneau.net", "role": "Developer" 19 | }, 20 | 21 | { 22 | "name": "David Dan", "email": "davidwdan@gmail.com", "role": "Developer" 23 | } 24 | ], 25 | "autoload": { 26 | "psr-4": { 27 | "PgAsync\\": "src/PgAsync/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "PgAsync\\Tests\\": "tests/" 33 | } 34 | }, 35 | "require": { 36 | "php": ">=7.0.0", 37 | "voryx/event-loop": "^3.0 || ^2.0.2", 38 | "reactivex/rxphp": "^2.0", 39 | "react/socket": "^1.0 || ^0.8 || ^0.7", 40 | "evenement/evenement": "^2.0 | ^3.0" 41 | }, 42 | "require-dev": { 43 | "phpunit/phpunit": ">=8.5.23 || ^6.5.5", 44 | "react/dns": "^1.0" 45 | }, 46 | "scripts": { 47 | "docker-up": "cd docker && docker-compose up -d", 48 | "docker-down": "cd docker && docker-compose down" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | pgasync-postgres: 5 | container_name: pgasync-postgres 6 | image: postgres:11 7 | environment: 8 | - PGDATA=/database 9 | - POSTGRES_PASSWORD=some_password 10 | - POSTGRES_INITDB_ARGS=--auth-host=md5 11 | - TZ=America/New_York 12 | volumes: 13 | - .:/app 14 | - ./database:/database 15 | - ./../tests/test_db.sql:/app/test_db.sql 16 | - ./docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d 17 | ports: 18 | - "5432:5432" 19 | 20 | configs: 21 | pg_hba: 22 | file: pg_hba_new.conf 23 | 24 | -------------------------------------------------------------------------------- /docker/docker-entrypoint-initdb.d/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | 4 | echo "Running as $USER in $PWD" 5 | 6 | createuser -U postgres --createdb pgasync 7 | createuser -U postgres --createdb pgasyncpw 8 | createuser -U postgres --createdb scram_user 9 | psql -U postgres -c "ALTER ROLE pgasyncpw PASSWORD 'example_password'" 10 | psql -U postgres -c "SET password_encryption='scram-sha-256'; ALTER ROLE scram_user PASSWORD 'scram_password'" 11 | 12 | cd /app 13 | cp pg_hba_new.conf database/pg_hba.conf 14 | 15 | createdb -U pgasync pgasync_test 16 | 17 | psql -U pgasync -f test_db.sql pgasync_test 18 | 19 | 20 | -------------------------------------------------------------------------------- /docker/pg_hba_new.conf: -------------------------------------------------------------------------------- 1 | # TYPE DATABASE USER ADDRESS METHOD 2 | 3 | # "local" is for Unix domain socket connections only 4 | local all all trust 5 | # IPv4 local connections: 6 | host all all 127.0.0.1/32 trust 7 | # IPv6 local connections: 8 | host all all ::1/128 trust 9 | # Allow replication connections from localhost, by a user with the 10 | # replication privilege. 11 | local replication all trust 12 | host replication all 127.0.0.1/32 trust 13 | host replication all ::1/128 trust 14 | 15 | host all pgasync all trust 16 | host all all all md5 17 | -------------------------------------------------------------------------------- /example/Connection.php: -------------------------------------------------------------------------------- 1 | "127.0.0.1", 7 | "port" => "5432", 8 | "user" => "matt", 9 | "database" => "matt" 10 | ]); 11 | 12 | $jsonObserverFactory = function () { 13 | return new \Rx\Observer\CallbackObserver( 14 | function ($row) { 15 | echo json_encode($row) . "\n"; 16 | }, 17 | function ($err) { 18 | echo "ERROR: " . json_encode($err) . "\n"; 19 | }, 20 | function () { 21 | echo "Complete.\n"; 22 | } 23 | ); 24 | }; 25 | 26 | $channels = $conn->query("SELECT * FROM channel"); 27 | 28 | $channels->subscribe($jsonObserverFactory()); 29 | 30 | $channelsWithParamQ = $conn->executeStatement("SELECT * FROM channel WHERE id % 3 = $1", ['0']); 31 | 32 | $channelsWithParamQ->subscribe($jsonObserverFactory()); 33 | 34 | $channels->subscribe($jsonObserverFactory()); 35 | $channelsWithParamQ->subscribe($jsonObserverFactory()); 36 | -------------------------------------------------------------------------------- /example/ListenNotify.php: -------------------------------------------------------------------------------- 1 | '127.0.0.1', 7 | 'port' => '5432', 8 | 'user' => 'matt', 9 | 'database' => 'matt', 10 | ]); 11 | 12 | $client->listen('some_channel') 13 | ->subscribe(function (\PgAsync\Message\NotificationResponse $message) { 14 | echo $message->getChannelName() . ': ' . $message->getPayload() . "\n"; 15 | }); 16 | 17 | \Rx\Observable::timer(1000) 18 | ->flatMapTo($client->query("NOTIFY some_channel, 'Hello World'")) 19 | ->subscribe(); 20 | 21 | -------------------------------------------------------------------------------- /example/Prepare.php: -------------------------------------------------------------------------------- 1 | "127.0.0.1", 7 | "port" => "5432", 8 | "user" => "matt", 9 | "database" => "matt" 10 | ]); 11 | 12 | $jsonObserverFactory = function () { 13 | return new \Rx\Observer\CallbackObserver( 14 | function ($row) { 15 | echo json_encode($row) . "\n"; 16 | }, 17 | function ($err) { 18 | echo "ERROR: " . $err . "\n"; 19 | }, 20 | function () { 21 | echo "Complete."; 22 | } 23 | ); 24 | }; 25 | 26 | $statement = $client->executeStatement("SELECT * FROM channel WHERE id = $1", ['2', '3']); 27 | 28 | $statement->subscribe($jsonObserverFactory()); 29 | 30 | $insertStatement = $client->executeStatement("InSErT INTO channel(name, description) VALUES($1, $2)", ['The name', null]); 31 | 32 | $insertStatement->subscribe($jsonObserverFactory()); 33 | 34 | $statement->subscribe($jsonObserverFactory()); 35 | -------------------------------------------------------------------------------- /example/SequentialQueries.php: -------------------------------------------------------------------------------- 1 | "matt", 6 | "database" => "matt" 7 | ]); 8 | 9 | $insert = $client->query("INSERT INTO channel(name, description) VALUES('SQ', 'SQ Insert')"); 10 | 11 | $select = $client->executeStatement("SELECT * FROM channel WHERE name = $1", ['SQ']); 12 | 13 | $insert 14 | ->concat($select) 15 | ->count() 16 | ->subscribe(new \Rx\Observer\CallbackObserver( 17 | function ($x) { 18 | echo json_encode($x) . "\n"; 19 | } 20 | )); 21 | -------------------------------------------------------------------------------- /example/SimpleQuery.php: -------------------------------------------------------------------------------- 1 | "127.0.0.1", 6 | "port" => "5432", 7 | "user" => "matt", 8 | "database" => "matt" 9 | ]); 10 | 11 | $insert = $client->query("INSERT INTO channel(name, description) VALUES('Test Name', 'This was inserted using the PgAsync thing')"); 12 | 13 | $insert->subscribe(new \Rx\Observer\CallbackObserver( 14 | function ($row) { 15 | echo "Row on insert?\n"; 16 | var_dump($row); 17 | }, 18 | function ($e) { 19 | echo "Failed.\n"; 20 | }, 21 | function () { 22 | echo "INSERT Complete.\n"; 23 | } 24 | )); 25 | 26 | $select = $client->query('SELECT * FROM channel'); 27 | 28 | $select->subscribe(new \Rx\Observer\CallbackObserver( 29 | function ($row) { 30 | var_dump($row); 31 | }, 32 | function ($e) { 33 | echo "Failed.\n"; 34 | }, 35 | function () { 36 | echo "SELECT complete.\n"; 37 | } 38 | )); 39 | 40 | $timerCount = 0; 41 | 42 | \EventLoop\addPeriodicTimer(1, function ($timer) use ($client, $select, &$timerCount) { 43 | echo "There are " . $client->getConnectionCount() . " connections. ($timerCount)\n"; 44 | if ($timerCount < 3) { 45 | $select->subscribe(new \Rx\Observer\CallbackObserver( 46 | function ($row) { 47 | var_dump($row); 48 | }, 49 | function ($e) { 50 | echo "Failed.\n"; 51 | }, 52 | function () { 53 | echo "SELECT complete.\n"; 54 | } 55 | )); 56 | } 57 | $timerCount++; 58 | }); 59 | -------------------------------------------------------------------------------- /example/bootstrap.php: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | tests/ 16 | 17 | 18 | 19 | 20 | src 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/PgAsync/Client.php: -------------------------------------------------------------------------------- 1 | loop = $loop ?: \EventLoop\getLoop(); 44 | $this->connector = $connector; 45 | 46 | if (isset($parameters['auto_disconnect'])) { 47 | $this->autoDisconnect = $parameters['auto_disconnect']; 48 | } 49 | 50 | if (isset($parameters['max_connections'])) { 51 | if (!is_int($parameters['max_connections'])) { 52 | throw new \InvalidArgumentException('`max_connections` must an be integer greater than zero.'); 53 | } 54 | $this->maxConnections = $parameters['max_connections']; 55 | unset($parameters['max_connections']); 56 | if ($this->maxConnections < 1) { 57 | throw new \InvalidArgumentException('`max_connections` must be greater than zero.'); 58 | } 59 | } 60 | 61 | $this->parameters = $parameters; 62 | } 63 | 64 | public function query($s) 65 | { 66 | return Observable::defer(function () use ($s) { 67 | $conn = $this->getLeastBusyConnection(); 68 | 69 | return $conn->query($s); 70 | }); 71 | } 72 | 73 | public function executeStatement(string $queryString, array $parameters = []) 74 | { 75 | return Observable::defer(function () use ($queryString, $parameters) { 76 | $conn = $this->getLeastBusyConnection(); 77 | 78 | return $conn->executeStatement($queryString, $parameters); 79 | }); 80 | } 81 | 82 | private function getLeastBusyConnection(): Connection 83 | { 84 | if (count($this->connections) === 0) { 85 | // try to spin up another connection to return 86 | $conn = $this->createNewConnection(); 87 | if ($conn === null) { 88 | throw new \Exception('There are no connections. Cannot find least busy one and could not create a new one.'); 89 | } 90 | 91 | return $conn; 92 | } 93 | 94 | $min = $this->connections[0]; 95 | 96 | foreach ($this->connections as $connection) { 97 | // if this connection is idle - just return it 98 | if ($connection->getBacklogLength() === 0 && $connection->getState() === Connection::STATE_READY) { 99 | return $connection; 100 | } 101 | 102 | if ($min->getBacklogLength() > $connection->getBacklogLength()) { 103 | $min = $connection; 104 | } 105 | } 106 | 107 | if (count($this->connections) < $this->maxConnections) { 108 | return $this->createNewConnection(); 109 | } 110 | 111 | return $min; 112 | } 113 | 114 | public function getIdleConnection(): Connection 115 | { 116 | // we want to get the first available one 117 | // this will keep the connections at the front the busiest 118 | // and then we can add an idle timer to the connections 119 | foreach ($this->connections as $connection) { 120 | // need to figure out different states (in trans etc.) 121 | if ($connection->getState() === Connection::STATE_READY) { 122 | return $connection; 123 | } 124 | } 125 | 126 | if (count($this->connections) >= $this->maxConnections) { 127 | return null; 128 | } 129 | 130 | return $this->createNewConnection(); 131 | } 132 | 133 | private function createNewConnection() 134 | { 135 | // no idle connections were found - spin up new one 136 | $connection = new Connection($this->parameters, $this->loop, $this->connector); 137 | if ($this->autoDisconnect) { 138 | return $connection; 139 | } 140 | 141 | $this->connections[] = $connection; 142 | 143 | $connection->on('close', function () use ($connection) { 144 | $this->connections = array_values(array_filter($this->connections, function ($c) use ($connection) { 145 | return $connection !== $c; 146 | })); 147 | }); 148 | 149 | return $connection; 150 | } 151 | 152 | public function getConnectionCount(): int 153 | { 154 | return count($this->connections); 155 | } 156 | 157 | /** 158 | * This is here temporarily so that the tests can disconnect 159 | * Will be setup better/more gracefully at some point hopefully 160 | * 161 | * @deprecated 162 | */ 163 | public function closeNow() 164 | { 165 | foreach ($this->connections as $connection) { 166 | $connection->disconnect(); 167 | } 168 | } 169 | 170 | public function listen(string $channel): Observable 171 | { 172 | if (isset($this->listeners[$channel])) { 173 | return $this->listeners[$channel]; 174 | } 175 | 176 | $unlisten = function () use ($channel) { 177 | $this->listenConnection->query('UNLISTEN ' . $channel)->subscribe(); 178 | 179 | unset($this->listeners[$channel]); 180 | 181 | if (empty($this->listeners)) { 182 | $this->listenConnection->disconnect(); 183 | $this->listenConnection = null; 184 | } 185 | }; 186 | 187 | $this->listeners[$channel] = Observable::defer(function () use ($channel) { 188 | if ($this->listenConnection === null) { 189 | $this->listenConnection = $this->createNewConnection(); 190 | } 191 | 192 | if ($this->listenConnection === null) { 193 | throw new \Exception('Could not get new connection to listen on.'); 194 | } 195 | 196 | return $this->listenConnection->query('LISTEN ' . $channel) 197 | ->merge($this->listenConnection->notifications()) 198 | ->filter(function (NotificationResponse $message) use ($channel) { 199 | return $message->getChannelName() === $channel; 200 | }); 201 | }) 202 | ->finally($unlisten) 203 | ->share(); 204 | 205 | return $this->listeners[$channel]; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/PgAsync/Column.php: -------------------------------------------------------------------------------- 1 | params = $params; 18 | $this->statementName = $statementName; 19 | } 20 | 21 | public function encodedMessage(): string 22 | { 23 | $message = $this->portalName . "\0"; 24 | $message .= $this->statementName . "\0"; 25 | 26 | // next is the number of format codes - we say zero because we are just going to use text 27 | $message .= Message::int16(0); 28 | 29 | // // this would be where the param codes are added 30 | // $message = Message::int16(count($this->params)); 31 | // for ($i = 0; $i < count($this->params); $i++) { 32 | // // we are only going to use strings for right now 33 | // $message .= 34 | // } 35 | 36 | // parameter values 37 | $message .= Message::int16(count($this->params)); 38 | for ($i = 0; $i < count($this->params); $i++) { 39 | if ($this->params[$i] === null) { 40 | // null is a special case that just has a length of -1 41 | $message .= Message::int32(-1); 42 | continue; 43 | } 44 | if ($this->params[$i] === false) { 45 | $this->params[$i] = 'FALSE'; 46 | } 47 | $message .= Message::int32(strlen($this->params[$i])) . $this->params[$i]; 48 | } 49 | 50 | // result column format codes - we aren't using these right now 51 | $message .= Message::int16(0); 52 | 53 | return 'B' . Message::prependLengthInt32($message); 54 | } 55 | 56 | public function shouldWaitForComplete(): bool 57 | { 58 | return false; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/PgAsync/Command/CancelRequest.php: -------------------------------------------------------------------------------- 1 | pid = $pid; 17 | $this->key = $key; 18 | } 19 | 20 | public function encodedMessage(): string 21 | { 22 | $len = '00000010'; 23 | $requestCode = '04d2162e'; 24 | 25 | return hex2bin($len . $requestCode) . Message::int32($this->pid) . Message::int32($this->key); 26 | } 27 | 28 | public function shouldWaitForComplete(): bool 29 | { 30 | return false; 31 | } 32 | } -------------------------------------------------------------------------------- /src/PgAsync/Command/Close.php: -------------------------------------------------------------------------------- 1 | statementName = $statementName; 16 | } 17 | 18 | public function encodedMessage(): string 19 | { 20 | return 'C' . Message::prependLengthInt32('S' . $this->statementName . "\0"); 21 | } 22 | 23 | public function shouldWaitForComplete(): bool 24 | { 25 | return false; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/PgAsync/Command/CommandInterface.php: -------------------------------------------------------------------------------- 1 | active = false; 17 | if (!$this->observer instanceof ObserverInterface) { 18 | throw new \Exception('Observer not set on command.'); 19 | } 20 | $this->observer->onCompleted(); 21 | } 22 | 23 | public function error(\Throwable $exception) 24 | { 25 | $this->active = false; 26 | if (!$this->observer instanceof ObserverInterface) { 27 | throw $exception; 28 | } 29 | $this->observer->onError($exception); 30 | } 31 | 32 | public function next($value) { 33 | if (!$this->active) { 34 | return; 35 | } 36 | if (!$this->observer instanceof ObserverInterface) { 37 | throw new \Exception('Observer not set on command.'); 38 | } 39 | $this->observer->onNext($value); 40 | } 41 | 42 | public function isActive(): bool 43 | { 44 | return $this->active; 45 | } 46 | 47 | public function cancel() 48 | { 49 | $this->active = false; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/PgAsync/Command/Describe.php: -------------------------------------------------------------------------------- 1 | name = $name; 18 | $this->portalOrStatement = 'P'; 19 | } 20 | 21 | public function encodedMessage(): string 22 | { 23 | return 'D' . Message::prependLengthInt32("{$this->portalOrStatement}$this->name\0"); 24 | } 25 | 26 | public function shouldWaitForComplete(): bool 27 | { 28 | return false; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/PgAsync/Command/Execute.php: -------------------------------------------------------------------------------- 1 | portalName = $portalName; 16 | } 17 | 18 | public function encodedMessage(): string 19 | { 20 | return 'E' . Message::prependLengthInt32($this->portalName . "\0" 21 | . Message::int32(0)); // max rows - 0 is unlimited; 22 | } 23 | 24 | public function shouldWaitForComplete(): bool 25 | { 26 | return false; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/PgAsync/Command/Parse.php: -------------------------------------------------------------------------------- 1 | name = $name; 21 | $this->queryString = $queryString; 22 | } 23 | 24 | // there is mechanisms to pre-describe types - we aren't getting into that 25 | 26 | public function encodedMessage(): string 27 | { 28 | return 'P' . Message::prependLengthInt32( 29 | $this->name . "\0" . 30 | $this->queryString . "\0" . 31 | "\0\0" 32 | 33 | ); 34 | } 35 | 36 | public function shouldWaitForComplete(): bool 37 | { 38 | return false; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/PgAsync/Command/PasswordMessage.php: -------------------------------------------------------------------------------- 1 | password = $password; 16 | } 17 | 18 | public function encodedMessage(): string 19 | { 20 | 21 | return 'p' . Message::prependLengthInt32($this->password . "\x00"); 22 | } 23 | 24 | public function shouldWaitForComplete(): bool 25 | { 26 | return false; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/PgAsync/Command/Query.php: -------------------------------------------------------------------------------- 1 | queryString = $queryString; 17 | 18 | $this->observer = $observer; 19 | } 20 | 21 | public function encodedMessage(): string 22 | { 23 | return 'Q' . Message::prependLengthInt32($this->queryString . "\0"); 24 | } 25 | 26 | public function shouldWaitForComplete(): bool 27 | { 28 | return true; 29 | } 30 | 31 | public function getQueryString(): string 32 | { 33 | return $this->queryString; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/PgAsync/Command/SSLRequest.php: -------------------------------------------------------------------------------- 1 | scramSha265 = $scramSha265; 22 | } 23 | 24 | public function encodedMessage(): string 25 | { 26 | $mechanism = self::SCRAM_SHA_256 . "\0"; 27 | $clientFirstMessage = $this->scramSha265->getClientFirstMessage(); 28 | 29 | $message = "p"; 30 | $messageLength = strlen($mechanism) + strlen($clientFirstMessage) + 8; 31 | $message .= pack("N", $messageLength) . $mechanism; 32 | $message .= pack("N", strlen($clientFirstMessage)) . $clientFirstMessage; 33 | 34 | return $message; 35 | } 36 | 37 | public function shouldWaitForComplete(): bool 38 | { 39 | return false; 40 | } 41 | } -------------------------------------------------------------------------------- /src/PgAsync/Command/SaslResponse.php: -------------------------------------------------------------------------------- 1 | scramSha265 = $scramSha265; 20 | } 21 | 22 | public function encodedMessage(): string 23 | { 24 | $clientFinalMessage = $this->createClientFinalMessage(); 25 | $messageLength = strlen($clientFinalMessage) + 4; 26 | 27 | return 'p' . pack('N', $messageLength) . $clientFinalMessage; 28 | } 29 | 30 | public function shouldWaitForComplete(): bool 31 | { 32 | return false; 33 | } 34 | 35 | private function createClientFinalMessage(): string 36 | { 37 | return $this->scramSha265->getClientFirstMessageWithoutProof() . ',p=' . base64_encode($this->scramSha265->getClientProof()); 38 | } 39 | } -------------------------------------------------------------------------------- /src/PgAsync/Command/StartupMessage.php: -------------------------------------------------------------------------------- 1 | parameters; 38 | } 39 | 40 | /** 41 | * @param array $parameters 42 | */ 43 | public function setParameters(array $parameters) 44 | { 45 | $this->parameters = $parameters; 46 | } 47 | 48 | public function encodedMessage(): string 49 | { 50 | $msg = ""; 51 | 52 | $msg .= Message::int32($this->protocolVersion); 53 | 54 | foreach ($this->parameters as $k => $v) { 55 | $msg .= $k . "\0" . $v . "\0"; 56 | } 57 | 58 | $msg .= "\0"; 59 | 60 | return Message::prependLengthInt32($msg); 61 | } 62 | 63 | public function shouldWaitForComplete(): bool 64 | { 65 | return false; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/PgAsync/Command/Sync.php: -------------------------------------------------------------------------------- 1 | description = $description; 16 | $this->observer = $observer; 17 | } 18 | 19 | public function encodedMessage(): string 20 | { 21 | return "S\0\0\0\x04"; 22 | } 23 | 24 | public function shouldWaitForComplete(): bool 25 | { 26 | return true; 27 | } 28 | 29 | public function getDescription(): string 30 | { 31 | return $this->description; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/PgAsync/Command/Terminate.php: -------------------------------------------------------------------------------- 1 | password = $parameters['password']; 158 | unset($parameters['password']); 159 | } 160 | 161 | if (isset($parameters['auto_disconnect'])) { 162 | $this->auto_disconnect = $parameters['auto_disconnect']; 163 | unset($parameters['auto_disconnect']); 164 | } 165 | 166 | if (!isset($parameters['application_name'])) { 167 | $parameters['application_name'] = 'pgasync'; 168 | } 169 | 170 | $this->loop = $loop; 171 | $this->commandQueue = []; 172 | $this->queryState = static::STATE_BUSY; 173 | $this->queryType = static::QUERY_SIMPLE; 174 | $this->connStatus = static::CONNECTION_NEEDED; 175 | $this->socket = $connector ?: new Connector($loop); 176 | $this->uri = 'tcp://' . $parameters['host'] . ':' . $parameters['port']; 177 | $this->notificationSubject = new Subject(); 178 | $this->cancelPending = false; 179 | $this->cancelRequested = false; 180 | 181 | $this->parameters = $parameters; 182 | $this->scramSha256 = new ScramSha256($parameters['user'], $this->password ?: ''); 183 | } 184 | 185 | private function start() 186 | { 187 | if ($this->connStatus !== static::CONNECTION_NEEDED) { 188 | throw new \Exception('Connection not in startable state'); 189 | } 190 | 191 | $this->connStatus = static::CONNECTION_STARTED; 192 | 193 | $this->socket->connect($this->uri)->then( 194 | function (DuplexStreamInterface $stream) { 195 | $this->stream = $stream; 196 | $this->connStatus = static::CONNECTION_MADE; 197 | 198 | $stream->on('close', [$this, 'onClose']); 199 | 200 | $stream->on('data', [$this, 'onData']); 201 | 202 | // $ssl = new SSLRequest(); 203 | // $stream->write($ssl->encodedMessage()); 204 | 205 | $startupParameters = $this->parameters; 206 | unset($startupParameters['host'], $startupParameters['port']); 207 | 208 | $startup = new StartupMessage(); 209 | $startup->setParameters($startupParameters); 210 | $stream->write($startup->encodedMessage()); 211 | }, 212 | function ($e) { 213 | // connection error 214 | $this->failAllCommandsWith($e); 215 | $this->connStatus = static::CONNECTION_BAD; 216 | $this->emit('error', [$e]); 217 | } 218 | ); 219 | } 220 | 221 | public function getState() 222 | { 223 | return $this->queryState; 224 | } 225 | 226 | public function getBacklogLength() : int 227 | { 228 | return array_reduce( 229 | $this->commandQueue, 230 | function ($a, CommandInterface $command) { 231 | if ($command instanceof Query || $command instanceof Sync) { 232 | $a++; 233 | } 234 | return $a; 235 | }, 236 | 0); 237 | } 238 | 239 | public function onData($data) 240 | { 241 | while (strlen($data) > 0) { 242 | $data = $this->processData($data); 243 | } 244 | 245 | // We should only cancel if we have drained the input buffer (as much as we can see) 246 | // and there is still a pending query that needs to be canceled 247 | if ($this->cancelRequested) { 248 | $this->cancelRequest(); 249 | } 250 | } 251 | 252 | private function processData($data) 253 | { 254 | if ($this->currentMessage) { 255 | $overflow = $this->currentMessage->parseData($data); 256 | // json_encode can slow things down here 257 | //$this->debug("onData: " . json_encode($overflow) . ""); 258 | if ($overflow === false) { 259 | // there was not enough data to complete the message 260 | // leave this as the currentParser 261 | return ''; 262 | } 263 | 264 | $this->handleMessage($this->currentMessage); 265 | 266 | $this->currentMessage = null; 267 | 268 | return $overflow; 269 | } 270 | 271 | if (strlen($data) == 0) { 272 | return ''; 273 | } 274 | 275 | $type = $data[0]; 276 | 277 | $message = Message::createMessageFromIdentifier($type, [ 278 | 'SCRAM_SHA_256' => $this->scramSha256 279 | ]); 280 | if ($message !== false) { 281 | $this->currentMessage = $message; 282 | return $data; 283 | } 284 | 285 | // if (in_array($type, ['R', 'S', 'D', 'K', '2', '3', 'C', 'd', 'c', 'G', 'H', 'W', 'D', 'I', 'E', 'V', 'n', 'N', 'A', 't', '1', 's', 'Z', 'T'])) { 286 | // $this->currentParser = [$this, 'parse1PlusLenMessage']; 287 | // call_user_func($this->currentParser, $data); 288 | // } else { 289 | // echo "Unhandled message \"".$type."\""; 290 | // } 291 | } 292 | 293 | public function onClose() 294 | { 295 | $this->connStatus = static::CONNECTION_CLOSED; 296 | $this->emit('close'); 297 | } 298 | 299 | public function getConnectionStatus() 300 | { 301 | return $this->connStatus; 302 | } 303 | 304 | public function handleMessage($message) 305 | { 306 | $this->debug('Handling ' . get_class($message)); 307 | if ($message instanceof DataRow) { 308 | $this->handleDataRow($message); 309 | } elseif ($message instanceof Authentication) { 310 | $this->handleAuthentication($message); 311 | } elseif ($message instanceof BackendKeyData) { 312 | $this->handleBackendKeyData($message); 313 | } elseif ($message instanceof CommandComplete) { 314 | $this->handleCommandComplete($message); 315 | } elseif ($message instanceof CopyInResponse) { 316 | $this->handleCopyInResponse($message); 317 | } elseif ($message instanceof CopyOutResponse) { 318 | $this->handleCopyOutResponse($message); 319 | } elseif ($message instanceof EmptyQueryResponse) { 320 | $this->handleEmptyQueryResponse($message); 321 | } elseif ($message instanceof ErrorResponse) { 322 | $this->handleErrorResponse($message); 323 | } elseif ($message instanceof NoticeResponse) { 324 | $this->handleNoticeResponse($message); 325 | } elseif ($message instanceof ParameterStatus) { 326 | $this->handleParameterStatus($message); 327 | } elseif ($message instanceof ParseComplete) { 328 | $this->handleParseComplete($message); 329 | } elseif ($message instanceof ReadyForQuery) { 330 | $this->handleReadyForQuery($message); 331 | } elseif ($message instanceof RowDescription) { 332 | $this->handleRowDescription($message); 333 | } elseif ($message instanceof NotificationResponse) { 334 | $this->handleNotificationResponse($message); 335 | } 336 | } 337 | 338 | private function handleNotificationResponse(NotificationResponse $message) 339 | { 340 | $this->notificationSubject->onNext($message); 341 | } 342 | 343 | private function handleDataRow(DataRow $dataRow) 344 | { 345 | if ($this->queryState === $this::STATE_BUSY && $this->currentCommand instanceof CommandInterface) { 346 | if (count($dataRow->getColumnValues()) !== count($this->columnNames)) { 347 | throw new \Exception('Expected ' . count($this->columnNames) . ' data values got ' . count($dataRow->getColumnValues())); 348 | } 349 | $row = array_combine($this->columnNames, $dataRow->getColumnValues()); 350 | 351 | // this should be broken out into a "data-mapper" type thing 352 | // where objects can be added to allow formatting data as it is 353 | // processed according to the type 354 | foreach ($this->columns as $column) { 355 | if ($column->typeOid === 16) { // bool 356 | if ($row[$column->name] === null) { 357 | continue; 358 | } 359 | if ($row[$column->name] === 'f') { 360 | $row[$column->name] = false; 361 | continue; 362 | } 363 | 364 | $row[$column->name] = true; 365 | } 366 | } 367 | 368 | $this->currentCommand->next($row); 369 | } 370 | } 371 | 372 | private function handleAuthentication(Authentication $message) 373 | { 374 | $this->lastError = 'Unhandled authentication message: ' . $message->getAuthCode(); 375 | if ($message->getAuthCode() === $message::AUTH_CLEARTEXT_PASSWORD || 376 | $message->getAuthCode() === $message::AUTH_MD5_PASSWORD 377 | ) { 378 | if ($this->password === null) { 379 | $this->lastError = 'Server asked for password, but none was configured.'; 380 | } else { 381 | $passwordToSend = $this->password; 382 | if ($message->getAuthCode() === $message::AUTH_MD5_PASSWORD) { 383 | $salt = $message->getSalt(); 384 | $passwordToSend = 'md5' . 385 | md5(md5($this->password . $this->parameters['user']) . $salt); 386 | } 387 | $passwordMessage = new PasswordMessage($passwordToSend); 388 | $this->stream->write($passwordMessage->encodedMessage()); 389 | 390 | return; 391 | } 392 | } 393 | if ($message->getAuthCode() === $message::AUTH_OK) { 394 | $this->connStatus = $this::CONNECTION_AUTH_OK; 395 | 396 | return; 397 | } 398 | 399 | if ($message->getAuthCode() === $message::AUTH_SCRAM) { 400 | $saslInitialResponse = new SaslInitialResponse($this->scramSha256); 401 | $this->stream->write($saslInitialResponse->encodedMessage()); 402 | 403 | return; 404 | } 405 | 406 | if ($message->getAuthCode() === $message::AUTH_SCRAM_CONTINUE) { 407 | $saslResponse = new SaslResponse($this->scramSha256); 408 | $this->stream->write($saslResponse->encodedMessage()); 409 | 410 | return; 411 | } 412 | 413 | if ($message->getAuthCode() === $message::AUTH_SCRAM_FIN) { 414 | if ($this->scramSha256->verify()) { 415 | return; 416 | } 417 | 418 | $this->lastError = 'Invalid server signature sent by server on SCRAM FIN stage'; 419 | } 420 | 421 | $this->connStatus = $this::CONNECTION_BAD; 422 | $this->failAllCommandsWith(new \Exception($this->lastError)); 423 | $this->emit('error', [new \Exception($this->lastError)]); 424 | $this->disconnect(); 425 | } 426 | 427 | private function handleBackendKeyData(BackendKeyData $message) 428 | { 429 | $this->backendKeyData = $message; 430 | } 431 | 432 | private function handleCommandComplete(CommandComplete $message) 433 | { 434 | if ($this->currentCommand instanceof CommandInterface) { 435 | $command = $this->currentCommand; 436 | $this->currentCommand = null; 437 | $command->complete(); 438 | 439 | // if we have requested a cancel for this query 440 | // but we have received the command complete before we 441 | // had a chance to start canceling - then never mind 442 | $this->cancelRequested = false; 443 | } 444 | $this->debug('Command complete.'); 445 | } 446 | 447 | private function handleCopyInResponse(CopyInResponse $message) 448 | { 449 | } 450 | 451 | private function handleCopyOutResponse(CopyOutResponse $message) 452 | { 453 | } 454 | 455 | private function handleEmptyQueryResponse(EmptyQueryResponse $message) 456 | { 457 | } 458 | 459 | private function handleErrorResponse(ErrorResponse $message) 460 | { 461 | $this->lastError = $message; 462 | if ($message->getSeverity() === 'FATAL') { 463 | $this->connStatus = $this::CONNECTION_BAD; 464 | // notify any waiting commands 465 | $this->processQueue(); 466 | } 467 | if ($this->connStatus === $this::CONNECTION_MADE) { 468 | $this->connStatus = $this::CONNECTION_BAD; 469 | // notify any waiting commands 470 | $this->processQueue(); 471 | } 472 | if ($this->currentCommand !== null) { 473 | $extraInfo = null; 474 | if ($this->currentCommand instanceof Sync) { 475 | $extraInfo = [ 476 | 'query_string' => $this->currentCommand->getDescription() 477 | ]; 478 | } elseif ($this->currentCommand instanceof Query) { 479 | $extraInfo = [ 480 | 'query_string' => $this->currentCommand->getQueryString() 481 | ]; 482 | } 483 | $this->currentCommand->error(new ErrorException($message, $extraInfo)); 484 | $this->currentCommand = null; 485 | } 486 | } 487 | 488 | private function handleNoticeResponse(NoticeResponse $message) 489 | { 490 | } 491 | 492 | private function handleParameterStatus(ParameterStatus $message) 493 | { 494 | $this->debug($message->getParameterName() . ': ' . $message->getParameterValue()); 495 | } 496 | 497 | private function handleParseComplete(ParseComplete $message) 498 | { 499 | } 500 | 501 | private function handleReadyForQuery(ReadyForQuery $message) 502 | { 503 | $this->connStatus = $this::CONNECTION_OK; 504 | $this->queryState = $this::STATE_READY; 505 | $this->currentCommand = null; 506 | $this->processQueue(); 507 | } 508 | 509 | private function handleRowDescription(RowDescription $message) 510 | { 511 | $this->addColumns($message->getColumns()); 512 | } 513 | 514 | private function failAllCommandsWith(\Throwable $e = null) 515 | { 516 | $e = $e ?: new \Exception('unknown error'); 517 | 518 | $this->notificationSubject->onError($e); 519 | 520 | while (count($this->commandQueue) > 0) { 521 | $c = array_shift($this->commandQueue); 522 | if ($c instanceof CommandInterface) { 523 | $c->error($e); 524 | } 525 | } 526 | } 527 | 528 | public function processQueue() 529 | { 530 | if ($this->cancelPending) { 531 | $this->debug("Not processing queue because there is a cancellation pending."); 532 | return; 533 | } 534 | 535 | if (count($this->commandQueue) === 0 && $this->queryState === static::STATE_READY && $this->auto_disconnect) { 536 | $this->commandQueue[] = new Terminate(); 537 | } 538 | 539 | if (count($this->commandQueue) === 0) { 540 | return; 541 | } 542 | 543 | if ($this->connStatus === $this::CONNECTION_BAD) { 544 | $this->failAllCommandsWith(new \Exception('Bad connection: ' . $this->lastError)); 545 | if ($this->stream) { 546 | $this->stream->end(); 547 | $this->stream = null; 548 | } 549 | return; 550 | } 551 | 552 | while (count($this->commandQueue) > 0 && $this->queryState === static::STATE_READY) { 553 | /** @var CommandInterface $c */ 554 | $c = array_shift($this->commandQueue); 555 | if (!$c->isActive()) { 556 | continue; 557 | } 558 | $this->debug('Sending ' . get_class($c)); 559 | if ($c instanceof Query) { 560 | $this->debug('Sending simple query: ' . $c->getQueryString()); 561 | } 562 | $this->stream->write($c->encodedMessage()); 563 | if ($c instanceof Terminate) { 564 | $this->stream->end(); 565 | } 566 | if ($c->shouldWaitForComplete()) { 567 | $this->queryState = $this::STATE_BUSY; 568 | if ($c instanceof Query) { 569 | $this->queryType = $this::QUERY_SIMPLE; 570 | } elseif ($c instanceof Sync) { 571 | $this->queryType = $this::QUERY_EXTENDED; 572 | } 573 | 574 | $this->currentCommand = $c; 575 | 576 | return; 577 | } 578 | } 579 | } 580 | 581 | public function query($query): Observable 582 | { 583 | return new AnonymousObservable( 584 | function (ObserverInterface $observer, SchedulerInterface $scheduler = null) use ($query) { 585 | if ($this->connStatus === $this::CONNECTION_NEEDED) { 586 | $this->start(); 587 | } 588 | if ($this->connStatus === $this::CONNECTION_BAD) { 589 | $observer->onError(new \Exception('Connection failed')); 590 | return new EmptyDisposable(); 591 | } 592 | 593 | $q = new Query($query, $observer); 594 | $this->commandQueue[] = $q; 595 | 596 | $this->processQueue(); 597 | 598 | return new CallbackDisposable(function () use ($q) { 599 | if ($this->currentCommand === $q && $q->isActive()) { 600 | $this->cancelRequested = true; 601 | } 602 | $q->cancel(); 603 | }); 604 | } 605 | ); 606 | 607 | } 608 | 609 | public function executeStatement(string $queryString, array $parameters = []): Observable 610 | { 611 | /** 612 | * http://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/interfaces/libpq/fe-exec.c;h=828f18e1110119efc3bf99ecf16d98ce306458ea;hb=6bcce25801c3fcb219e0d92198889ec88c74e2ff#l1381 613 | * 614 | * Should make this return a Statement object 615 | * 616 | * To use prepared statements, looks like we need to: 617 | * - Parse (if needed?) (P) 618 | * - Bind (B) 619 | * - Parameter Stuff 620 | * - Describe portal (D) 621 | * - Execute (E) 622 | * - Sync (S) 623 | * 624 | * Expect back 625 | * - Parse Complete (1) 626 | * - Bind Complete (2) 627 | * - Row Description (T) 628 | * - Row Data (D) 0..n 629 | * - Command Complete (C) 630 | * - Ready for Query (Z) 631 | */ 632 | 633 | return new AnonymousObservable( 634 | function (ObserverInterface $observer, SchedulerInterface $scheduler = null) use ($queryString, $parameters) { 635 | if ($this->connStatus === $this::CONNECTION_NEEDED) { 636 | $this->start(); 637 | } 638 | if ($this->connStatus === $this::CONNECTION_BAD) { 639 | $observer->onError(new \Exception('Connetion failed')); 640 | return new EmptyDisposable(); 641 | } 642 | 643 | $name = 'somestatement'; 644 | 645 | /** @var CommandInterface[] $commandGroup */ 646 | $commandGroup = []; 647 | $close = new Close($name); 648 | $commandGroup[] = $close; 649 | 650 | $prepare = new Parse($name, $queryString); 651 | $commandGroup[] = $prepare; 652 | 653 | $bind = new Bind($parameters, $name); 654 | $commandGroup[] = $bind; 655 | 656 | $describe = new Describe(); 657 | $commandGroup[] = $describe; 658 | 659 | $execute = new Execute(); 660 | $commandGroup[] = $execute; 661 | 662 | $sync = new Sync($queryString, $observer); 663 | $commandGroup[] = $sync; 664 | 665 | $this->commandQueue = array_merge($this->commandQueue, $commandGroup); 666 | 667 | $this->processQueue(); 668 | 669 | return new CallbackDisposable(function () use ($sync, $commandGroup) { 670 | if ($this->currentCommand === $sync && $sync->isActive()) { 671 | $this->cancelRequested = true; 672 | $sync->cancel(); 673 | 674 | // no point in canceling the other commands because they are out the door 675 | return; 676 | } 677 | foreach ($commandGroup as $command) { 678 | $command->cancel(); 679 | } 680 | }); 681 | } 682 | ); 683 | } 684 | 685 | /** 686 | * Add Column information (from T) 687 | * 688 | * @param $columns 689 | */ 690 | private function addColumns($columns) 691 | { 692 | $this->columns = $columns; 693 | $this->columnNames = array_map(function ($column) { 694 | return $column->name; 695 | }, $this->columns); 696 | } 697 | 698 | private function debug($string) 699 | { 700 | //echo "DEBUG: " . $string . "\n"; 701 | } 702 | 703 | /** 704 | * https://www.postgresql.org/docs/9.2/static/protocol-flow.html#AEN95792 705 | */ 706 | public function disconnect() 707 | { 708 | $this->commandQueue[] = new Terminate(); 709 | $this->processQueue(); 710 | } 711 | 712 | private function cancelRequest() 713 | { 714 | $this->cancelRequested = false; 715 | if ($this->queryState !== self::STATE_BUSY) { 716 | $this->debug("Not canceling because there is nothing to cancel."); 717 | return; 718 | } 719 | if ($this->currentCommand !== null) { 720 | $this->cancelPending = true; 721 | $this->socket->connect($this->uri)->then(function (DuplexStreamInterface $conn) { 722 | $cancelRequest = new CancelRequest($this->backendKeyData->getPid(), $this->backendKeyData->getKey()); 723 | $conn->on('close', function () { 724 | $this->cancelPending = false; 725 | $this->processQueue(); 726 | }); 727 | $conn->end($cancelRequest->encodedMessage()); 728 | }, function (\Throwable $e) { 729 | $this->cancelPending = false; 730 | $this->processQueue(); 731 | $this->debug("Error connecting for cancellation... " . $e->getMessage() . "\n"); 732 | }); 733 | } 734 | } 735 | 736 | public function notifications() { 737 | return $this->notificationSubject->asObservable(); 738 | } 739 | } 740 | -------------------------------------------------------------------------------- /src/PgAsync/ErrorException.php: -------------------------------------------------------------------------------- 1 | errorResponse = $errorResponse; 15 | $this->message = $this->errorResponse->__toString(); 16 | $this->extraInfo = $extraInfo; 17 | 18 | if (is_array($extraInfo) && isset($extraInfo['query_string'])) { 19 | $this->message .= ' while executing "' . $extraInfo['query_string'] . '"'; 20 | } 21 | } 22 | 23 | public function getErrorResponse(): ErrorResponse 24 | { 25 | return $this->errorResponse; 26 | } 27 | 28 | public function getExtraInfo(): array 29 | { 30 | return $this->extraInfo; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/PgAsync/Message/Authentication.php: -------------------------------------------------------------------------------- 1 | scramSha256 = $scramSha265; 35 | } 36 | 37 | /** 38 | * @inheritDoc 39 | * @throws \InvalidArgumentException 40 | */ 41 | public function parseMessage(string $rawMessage) 42 | { 43 | $authCode = unpack("N", substr($rawMessage, 5, 4))[1]; 44 | switch ($authCode) { 45 | case $this::AUTH_OK: 46 | break; // AuthenticationOk 47 | case $this::AUTH_KERBEROS_V_5: 48 | break; // AuthenticationKerberosV5 49 | case $this::AUTH_CLEARTEXT_PASSWORD: 50 | break; // AuthenticationCleartextPassword 51 | case $this::AUTH_MD5_PASSWORD: 52 | if (strlen($rawMessage) !== 13) { 53 | throw new \InvalidArgumentException('Invalid raw message length for MD5 authentication message.'); 54 | } 55 | 56 | $this->salt = substr($rawMessage, 9, 4); 57 | 58 | break; // AuthenticationMD5Password 59 | case $this::AUTH_SCM_CREDENTIAL: 60 | break; // AuthenticationSCMCredential 61 | case $this::AUTH_GSS: 62 | break; // AuthenticationGSS 63 | case $this::AUTH_GSS_CONTINUE: 64 | break; // AuthenticationGSSContinue 65 | case $this::AUTH_SSPI: 66 | break; // AuthenticationSSPI 67 | case $this::AUTH_SCRAM: 68 | $this->scramSha256->beginFirstClientMessageStage(); 69 | break; 70 | case $this::AUTH_SCRAM_CONTINUE: 71 | $content = $this->getContent($rawMessage); 72 | $parts = explode(',', $content); 73 | $this->scramSha256->beginFinalClientMessageStage( 74 | substr($parts[0], 2), 75 | substr($parts[1], 2), 76 | (int) substr($parts[2], 2) 77 | ); 78 | 79 | break; 80 | case $this::AUTH_SCRAM_FIN: 81 | $content = $this->getContent($rawMessage); 82 | $this->scramSha256->beginVerificationStage(substr($content, 2)); 83 | break; 84 | } 85 | 86 | $this->authCode = $authCode; 87 | } 88 | 89 | public static function getMessageIdentifier(): string 90 | { 91 | return 'R'; 92 | } 93 | 94 | public function getAuthCode(): int 95 | { 96 | return $this->authCode; 97 | } 98 | 99 | public function getSalt(): string 100 | { 101 | if ($this->getAuthCode() !== $this::AUTH_MD5_PASSWORD) { 102 | throw new \Exception('getSalt called on non-md5 authentication message'); 103 | } 104 | 105 | return $this->salt; 106 | } 107 | 108 | private function getContent(string $rawMessage): string 109 | { 110 | $messageLength = unpack('N', substr($rawMessage, 1, 4))[1]; 111 | return substr($rawMessage, 9, $messageLength - 8); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/PgAsync/Message/BackendKeyData.php: -------------------------------------------------------------------------------- 1 | pid = unpack('N', substr($rawMessage, 5, 4))[1]; 20 | $this->key = unpack('N', substr($rawMessage, 9, 4))[1]; 21 | } 22 | 23 | /** 24 | * @inheritDoc 25 | */ 26 | public static function getMessageIdentifier(): string 27 | { 28 | return 'K'; 29 | } 30 | 31 | public function getPid() : int 32 | { 33 | return $this->pid; 34 | } 35 | 36 | public function getKey() : int 37 | { 38 | return $this->key; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/PgAsync/Message/CommandComplete.php: -------------------------------------------------------------------------------- 1 | rows = $parts[1]; 24 | if ($parts[1] == 1 && $parts[2] != 0) { 25 | $this->oid = $parts[2]; 26 | } 27 | break; 28 | case 'DELETE': 29 | case 'UPDATE': 30 | case 'SELECT': 31 | case 'MOVE': 32 | case 'FETCH': 33 | case 'COPY': 34 | $this->rows = $parts[1]; 35 | break; 36 | } 37 | 38 | $this->tag = $parts[0]; 39 | } 40 | } 41 | 42 | /** 43 | * @inheritDoc 44 | */ 45 | public static function getMessageIdentifier(): string 46 | { 47 | return 'C'; 48 | } 49 | 50 | /** 51 | * @return mixed 52 | */ 53 | public function getTag() 54 | { 55 | return $this->tag; 56 | } 57 | 58 | public function getOid(): int 59 | { 60 | return $this->oid; 61 | } 62 | 63 | public function getRows(): int 64 | { 65 | return $this->rows; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/PgAsync/Message/CopyInResponse.php: -------------------------------------------------------------------------------- 1 | columnValues = []; 24 | $columnStart = 7; 25 | for ($i = 0; $i < $columnCount; $i++) { 26 | if ($len < $columnStart + 4) { 27 | throw new \UnderflowException; 28 | } 29 | $columnLen = unpack('N', substr($rawMessage, $columnStart, 4))[1]; 30 | if ($columnLen == 4294967295) { 31 | $columnLen = 0; 32 | $this->columnValues[] = null; 33 | } else { 34 | if ($len < $columnStart + 4 + $columnLen) { 35 | throw new \UnderflowException; 36 | } 37 | $this->columnValues[] = substr($rawMessage, $columnStart + 4, $columnLen); 38 | } 39 | $columnStart += 4 + $columnLen; 40 | } 41 | 42 | if ($len !== $columnStart) { 43 | //echo "Warning, there was some straggling info in the data row..."; 44 | } 45 | } 46 | 47 | /** 48 | * @inheritDoc 49 | */ 50 | public static function getMessageIdentifier(): string 51 | { 52 | return 'D'; 53 | } 54 | 55 | public function getColumnValues(): array 56 | { 57 | return $this->columnValues; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/PgAsync/Message/Discard.php: -------------------------------------------------------------------------------- 1 | errorMessages[] = [ 43 | 'type' => $fieldType, 44 | 'message' => $msg 45 | ]; 46 | } 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | public static function getMessageIdentifier(): string 53 | { 54 | return 'E'; 55 | } 56 | 57 | /** 58 | * @return array 59 | */ 60 | public function getErrorMessages() 61 | { 62 | return $this->errorMessages; 63 | } 64 | 65 | public function getSeverity(): string 66 | { 67 | $severity = $this->getErrorMessagesOfType('S'); 68 | 69 | return count($severity) > 0 ? array_pop($severity) : null; 70 | } 71 | 72 | public function getMessage() 73 | { 74 | $message = $this->getErrorMessagesOfType('M'); 75 | 76 | return count($message) > 0 ? array_pop($message) : null; 77 | } 78 | 79 | private function getErrorMessagesOfType($type):array 80 | { 81 | return array_map(function ($x) { 82 | return $x['message']; 83 | }, array_filter($this->getErrorMessages(), function ($x) use ($type) { 84 | return $x['type'] === $type; 85 | })); 86 | } 87 | 88 | /** 89 | * @inheritDoc 90 | */ 91 | public function __toString() 92 | { 93 | return $this->getSeverity() . ': ' . $this->getMessage(); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/PgAsync/Message/Message.php: -------------------------------------------------------------------------------- 1 | noticeMessages[] = [ 28 | 'type' => $fieldType, 29 | 'message' => $msg 30 | ]; 31 | } 32 | } 33 | 34 | /** 35 | * @inheritDoc 36 | */ 37 | public static function getMessageIdentifier(): string 38 | { 39 | return 'N'; 40 | } 41 | 42 | public function getNoticeMessages(): array 43 | { 44 | return $this->noticeMessages; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/PgAsync/Message/NotificationResponse.php: -------------------------------------------------------------------------------- 1 | notifyingProcessId = unpack('N', substr($rawMessage, $currentPos, 4))[1]; 32 | $currentPos += 4; 33 | 34 | $rawPayload = substr($rawMessage, $currentPos); 35 | $parts = explode("\0", $rawPayload); 36 | 37 | if (count($parts) !== 3) { 38 | throw new \UnderflowException('Wrong number of notification parts in payload'); 39 | } 40 | 41 | $this->channelName = $parts[0]; 42 | $this->payload = $parts[1]; 43 | } 44 | 45 | /** 46 | * @return string 47 | */ 48 | public function getPayload(): string 49 | { 50 | return $this->payload; 51 | } 52 | 53 | /** 54 | * @return int 55 | */ 56 | public function getNotifyingProcessId(): int 57 | { 58 | return $this->notifyingProcessId; 59 | } 60 | 61 | /** 62 | * @return string 63 | */ 64 | public function getChannelName(): string 65 | { 66 | return $this->channelName; 67 | } 68 | 69 | /** 70 | * @inheritDoc 71 | */ 72 | public static function getMessageIdentifier(): string 73 | { 74 | return 'A'; 75 | } 76 | 77 | public function getNoticeMessages(): array 78 | { 79 | return $this->noticeMessages; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/PgAsync/Message/ParameterStatus.php: -------------------------------------------------------------------------------- 1 | parameterName = $paramParts[0]; 21 | $this->parameterValue = $paramParts[1]; 22 | } 23 | 24 | /** 25 | * @inheritDoc 26 | */ 27 | public static function getMessageIdentifier(): string 28 | { 29 | return 'S'; 30 | } 31 | 32 | public function getParameterName(): string 33 | { 34 | return $this->parameterName; 35 | } 36 | 37 | public function getParameterValue(): string 38 | { 39 | return $this->parameterValue; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/PgAsync/Message/ParseComplete.php: -------------------------------------------------------------------------------- 1 | currentMsg .= $data; 13 | 14 | $len = strlen($this->currentMsg); 15 | if ($len >= 5) { 16 | $this->msgLen = unpack('N', substr($this->currentMsg, 1, 4))[1]; 17 | if ($this->msgLen > 0 && $len > $this->msgLen) { 18 | $theMessage = substr($this->currentMsg, 0, $this->msgLen + 1); 19 | 20 | $this->parseMessage($theMessage); 21 | 22 | if ($len > $this->msgLen + 1) { 23 | return substr($this->currentMsg, $this->msgLen + 1); 24 | } 25 | 26 | return ""; 27 | } 28 | } 29 | 30 | return false; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/PgAsync/Message/ReadyForQuery.php: -------------------------------------------------------------------------------- 1 | backendTransactionStatus = $rawMessage[5]; 22 | if (!in_array($this->backendTransactionStatus, ['I', 'T', 'E'], true)) { 23 | throw new \Exception; 24 | } 25 | } 26 | 27 | /** 28 | * @inheritDoc 29 | */ 30 | public static function getMessageIdentifier(): string 31 | { 32 | return 'Z'; 33 | } 34 | 35 | /** 36 | * @return mixed 37 | */ 38 | public function getBackendTransactionStatus() 39 | { 40 | return $this->backendTransactionStatus; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/PgAsync/Message/RowDescription.php: -------------------------------------------------------------------------------- 1 | columns = []; 29 | for ($i = 0; $i < $columnCount; $i++) { 30 | $column = new Column(); 31 | 32 | $strEnd = strpos($rawMessage, "\0", $columnStart); 33 | if ($strEnd === false) { 34 | throw new \InvalidArgumentException; 35 | } 36 | 37 | $column->name = substr($rawMessage, $columnStart, $strEnd - $columnStart); 38 | $pos = $strEnd + 1; 39 | $column->tableOid = unpack('N', substr($rawMessage, $pos, 4))[1]; 40 | $pos += 4; 41 | $column->attrNo = unpack('n', substr($rawMessage, $pos, 2))[1]; 42 | $pos += 2; 43 | $column->typeOid = unpack('N', substr($rawMessage, $pos, 4))[1]; 44 | $pos += 4; 45 | $column->dataSize = unpack('n', substr($rawMessage, $pos, 2))[1]; 46 | $pos += 2; 47 | $column->typeModifier = unpack('N', substr($rawMessage, $pos, 4))[1]; 48 | $pos += 4; 49 | $column->formatCode = unpack('n', substr($rawMessage, $pos, 2))[1]; 50 | $pos += 2; 51 | $this->columns[] = $column; 52 | $columnStart = $pos; 53 | } 54 | } 55 | 56 | /** 57 | * @inheritDoc 58 | */ 59 | public static function getMessageIdentifier(): string 60 | { 61 | return 'T'; 62 | } 63 | 64 | /** 65 | * @return \PgAsync\Column[] 66 | */ 67 | public function getColumns() 68 | { 69 | return $this->columns; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/PgAsync/ScramSha256.php: -------------------------------------------------------------------------------- 1 | user = $user; 56 | $this->password = $password; 57 | } 58 | 59 | public function beginFirstClientMessageStage() 60 | { 61 | $length = strlen(self::CHARACTERS); 62 | 63 | for ($i = 0; $i < self::CLIENT_NONCE_LENGTH; $i++) { 64 | $this->clientNonce .= substr(self::CHARACTERS, random_int(0, $length), 1); 65 | } 66 | 67 | $this->currentStage = self::STAGE_FIRST_MESSAGE; 68 | } 69 | 70 | public function beginFinalClientMessageStage(string $nonce, string $salt, int $iteration) 71 | { 72 | $this->nonce = $nonce; 73 | $this->salt = $salt; 74 | $this->iteration = $iteration; 75 | 76 | $this->currentStage = self::STAGE_FINAL_MESSAGE; 77 | } 78 | 79 | public function beginVerificationStage(string $verification) 80 | { 81 | $this->verification = $verification; 82 | 83 | $this->currentStage = self::STAGE_VERIFICATION; 84 | } 85 | 86 | public function verify(): bool 87 | { 88 | $this->checkStage(self::STAGE_VERIFICATION); 89 | 90 | $serverKey = hash_hmac("sha256", "Server Key", $this->getSaltedPassword(), true); 91 | $serverSignature = hash_hmac('sha256', $this->getAuthMessage(), $serverKey, true); 92 | 93 | return $serverSignature === base64_decode($this->verification); 94 | } 95 | 96 | public function getClientFirstMessageWithoutProof(): string 97 | { 98 | if (null === $this->clientFirstMessageWithoutProof) { 99 | $this->clientFirstMessageWithoutProof = sprintf( 100 | 'c=%s,r=%s', 101 | base64_encode('n,,'), 102 | $this->nonce 103 | ); 104 | } 105 | 106 | return $this->clientFirstMessageWithoutProof; 107 | } 108 | 109 | public function getSaltedPassword(): string 110 | { 111 | $this->checkStage(self::STAGE_FINAL_MESSAGE); 112 | 113 | if (null === $this->saltedPassword) { 114 | $this->saltedPassword = hash_pbkdf2( 115 | "sha256", 116 | $this->password, 117 | base64_decode($this->salt), 118 | $this->iteration, 119 | 32, 120 | true 121 | ); 122 | } 123 | 124 | return $this->saltedPassword; 125 | } 126 | 127 | public function getClientKey(): string 128 | { 129 | $this->checkStage(self::STAGE_FINAL_MESSAGE); 130 | 131 | if (null === $this->clientKey) { 132 | $this->clientKey = hash_hmac("sha256", "Client Key", $this->getSaltedPassword(), true); 133 | } 134 | 135 | return $this->clientKey; 136 | } 137 | 138 | public function getStoredKey(): string 139 | { 140 | $this->checkStage(self::STAGE_FINAL_MESSAGE); 141 | 142 | if (null === $this->storedKey) { 143 | $this->storedKey = hash("sha256", $this->getClientKey(), true); 144 | } 145 | 146 | return $this->storedKey; 147 | } 148 | 149 | public function getClientFirstMessageBare(): string 150 | { 151 | $this->checkStage(self::STAGE_FIRST_MESSAGE); 152 | 153 | return sprintf( 154 | 'n=%s,r=%s', 155 | $this->user, 156 | $this->clientNonce 157 | ); 158 | } 159 | 160 | public function getClientFirstMessage(): string 161 | { 162 | $this->checkStage(self::STAGE_FIRST_MESSAGE); 163 | 164 | return sprintf('n,,%s', $this->getClientFirstMessageBare()); 165 | } 166 | 167 | public function getAuthMessage(): string 168 | { 169 | $this->checkStage(self::STAGE_FINAL_MESSAGE); 170 | 171 | if (null === $this->authMessage) { 172 | $clientFirstMessageBare = $this->getClientFirstMessageBare(); 173 | $serverFirstMessage = sprintf( 174 | 'r=%s,s=%s,i=%s', 175 | $this->nonce, 176 | $this->salt, 177 | $this->iteration 178 | ); 179 | 180 | $this->authMessage = implode(',', [ 181 | $clientFirstMessageBare, 182 | $serverFirstMessage, 183 | $this->getClientFirstMessageWithoutProof() 184 | ]); 185 | } 186 | 187 | return $this->authMessage; 188 | } 189 | 190 | public function getClientProof(): string 191 | { 192 | $this->checkStage(self::STAGE_FINAL_MESSAGE); 193 | 194 | $clientKey = $this->getClientKey(); 195 | $storedKey = $this->getStoredKey(); 196 | $authMessage = $this->getAuthMessage(); 197 | $clientSignature = hash_hmac("sha256", $authMessage, $storedKey, true); 198 | 199 | return $clientKey ^ $clientSignature; 200 | } 201 | 202 | private function checkStage(int $stage) 203 | { 204 | if ($this->currentStage < $stage) { 205 | throw new \LogicException('Invalid Stage of SCRAM authorization'); 206 | } 207 | } 208 | } -------------------------------------------------------------------------------- /tests/Integration/BoolTest.php: -------------------------------------------------------------------------------- 1 | $this::getDbUser(), "password" => $this::getDbUser(), "database" => $this::getDbName()]); 14 | 15 | $count = $client->query("SELECT * FROM thing"); 16 | 17 | $trueCount = 0; 18 | $falseCount = 0; 19 | $nullCount = 0; 20 | $completes = false; 21 | $count->subscribe(new CallbackObserver( 22 | function ($x) use (&$trueCount, &$falseCount, &$nullCount) { 23 | if ($x['thing_in_stock'] === true) { 24 | $trueCount++; 25 | } 26 | if ($x['thing_in_stock'] === false) { 27 | $falseCount++; 28 | } 29 | if ($x['thing_in_stock'] === null) { 30 | $nullCount++; 31 | } 32 | }, 33 | function ($e) use ($client) { 34 | $client->closeNow(); 35 | $this->cancelCurrentTimeoutTimer(); 36 | throw $e; 37 | }, 38 | function () use (&$completes, $client) { 39 | $completes = true; 40 | $client->closeNow(); 41 | $this->cancelCurrentTimeoutTimer(); 42 | } 43 | )); 44 | 45 | $this->runLoopWithTimeout(2); 46 | 47 | $client->closeNow(); 48 | 49 | $this->assertTrue($completes); 50 | $this->assertEquals(1, $trueCount); 51 | $this->assertEquals(1, $falseCount); 52 | $this->assertEquals(1, $nullCount); 53 | } 54 | 55 | /** 56 | * see https://github.com/voryx/PgAsync/issues/10 57 | */ 58 | public function testBoolParam() 59 | { 60 | $client = new Client(["user" => $this::getDbUser(), "password" => $this::getDbUser(), "database" => $this::getDbName()]); 61 | 62 | $args = [false, 1]; 63 | 64 | $upd = 'UPDATE test_bool_param SET b = $1 WHERE id = $2 RETURNING *'; 65 | 66 | $completes = false; 67 | $client->executeStatement($upd, $args)->subscribe( 68 | new \Rx\Observer\CallbackObserver( 69 | function ($row) { 70 | $this->assertEquals($row, [ 'id' => '1', 'b' => false]); 71 | }, 72 | function ($e) use ($client) { 73 | $client->closeNow(); 74 | $this->cancelCurrentTimeoutTimer(); 75 | throw $e; 76 | }, 77 | function () use (&$completes, $client) { 78 | $completes = true; 79 | $client->closeNow(); 80 | $this->cancelCurrentTimeoutTimer(); 81 | } 82 | ) 83 | ); 84 | 85 | $this->runLoopWithTimeout(2); 86 | 87 | $this->assertTrue($completes); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/Integration/ClientTest.php: -------------------------------------------------------------------------------- 1 | $this->getDbUser(), "password" => $this::getDbUser(), "database" => $this::getDbName()], $this->getLoop()); 15 | 16 | $hello = null; 17 | 18 | $client->executeStatement("SELECT 'Hello' AS Hello")->subscribe(new CallbackObserver( 19 | function ($x) use (&$hello) { 20 | $this->assertNull($hello); 21 | $hello = $x; 22 | }, 23 | function ($e) { 24 | $this->fail("Got an error"); 25 | $this->cancelCurrentTimeoutTimer(); 26 | $this->stopLoop(); 27 | }, 28 | function () { 29 | $this->cancelCurrentTimeoutTimer(); 30 | // We should wait here for a moment for the connection state to return to ready 31 | $this->getLoop()->addTimer(0.1, function () { 32 | $this->stopLoop(); 33 | }); 34 | } 35 | )); 36 | 37 | $this->runLoopWithTimeout(2); 38 | 39 | $this->assertEquals(1, $client->getConnectionCount()); 40 | $this->assertEquals([ 'hello' => 'Hello' ], $hello); 41 | 42 | $conn = $client->getIdleConnection(); 43 | $this->assertEquals(1, $client->getConnectionCount()); 44 | 45 | $hello = null; 46 | 47 | $client->executeStatement("SELECT 'Hello' AS Hello")->subscribe(new CallbackObserver( 48 | function ($x) use (&$hello) { 49 | $this->assertNull($hello); 50 | $hello = $x; 51 | }, 52 | function ($e) { 53 | $this->cancelCurrentTimeoutTimer(); 54 | $this->stopLoop(); 55 | $this->fail("Got an error"); 56 | }, 57 | function () { 58 | $this->cancelCurrentTimeoutTimer(); 59 | // We should wait here for a moment for the connection state to return to ready 60 | $this->stopLoop(); 61 | } 62 | )); 63 | 64 | $this->runLoopWithTimeout(2); 65 | 66 | $this->assertEquals(1, $client->getConnectionCount()); 67 | $this->assertEquals([ 'hello' => 'Hello' ], $hello); 68 | 69 | $connNew = $client->getIdleConnection(); 70 | 71 | $this->assertSame($conn, $connNew); 72 | 73 | $this->assertEquals(1, $client->getConnectionCount()); 74 | 75 | $client->closeNow(); 76 | 77 | $this->getLoop()->run(); // run the loop to allow the connection to disconnect 78 | } 79 | 80 | public function testAutoDisconnect() 81 | { 82 | $client = new Client([ 83 | "user" => $this->getDbUser(), 84 | "password" => $this::getDbUser(), 85 | "database" => $this::getDbName(), 86 | "auto_disconnect" => true 87 | ], $this->getLoop()); 88 | 89 | $hello = null; 90 | 91 | $client->executeStatement("SELECT 'Hello' AS Hello")->subscribe(new CallbackObserver( 92 | function ($x) use (&$hello) { 93 | $this->assertNull($hello); 94 | $hello = $x; 95 | }, 96 | function ($e) { 97 | $this->fail("Got an error"); 98 | $this->cancelCurrentTimeoutTimer(); 99 | $this->stopLoop(); 100 | }, 101 | function () { 102 | // wait a bit for things to close down 103 | $this->getLoop()->addTimer(0.1, function () { 104 | $this->cancelCurrentTimeoutTimer(); 105 | }); 106 | } 107 | )); 108 | 109 | $this->runLoopWithTimeout(2); 110 | 111 | $this->assertEquals(0, $client->getConnectionCount()); 112 | $this->assertEquals([ 'hello' => 'Hello' ], $hello); 113 | } 114 | 115 | public function testSendingTwoQueriesRepeatedlyOnlyCreatesTwoConnections() 116 | { 117 | $client = new Client([ 118 | "user" => $this->getDbUser(), 119 | "password" => $this::getDbUser(), 120 | "database" => $this::getDbName(), 121 | ], $this->getLoop()); 122 | 123 | $value = null; 124 | 125 | $testQuery = $client->query("SELECT pg_sleep(0.1)")->mapTo(1) 126 | ->merge($client->query("SELECT pg_sleep(0.2)")->mapTo(2)) 127 | ->concat(Observable::timer(1000)->flatMapTo(Observable::empty())) 128 | ->concat($client->query("SELECT pg_sleep(0.1)")->mapTo(3) 129 | ->merge($client->query("SELECT pg_sleep(0.2)")->mapTo(4))) 130 | ->concat(Observable::timer(1000)->flatMapTo(Observable::empty())) 131 | ->concat($client->query("SELECT pg_sleep(0.1)")->mapTo(5) 132 | ->merge($client->query("SELECT pg_sleep(0.2)")->mapTo(6))) 133 | ->toArray(); 134 | 135 | $testQuery->subscribe(new \Rx\Observer\CallbackObserver( 136 | function ($results) use (&$value) { 137 | $value = $results; 138 | }, 139 | function (\Throwable $e) use (&$error) { 140 | $this->fail('Error while testing'); 141 | $this->stopLoop(); 142 | }, 143 | function () { 144 | $this->stopLoop(); 145 | } 146 | )); 147 | 148 | $this->runLoopWithTimeout(4); 149 | 150 | $this->assertEquals([1,2,3,4,5,6], $value); 151 | $this->assertEquals(2, $client->getConnectionCount()); 152 | 153 | $client->closeNow(); 154 | $this->getLoop()->run(); 155 | } 156 | 157 | public function testMaxConnections() 158 | { 159 | $client = new Client([ 160 | "user" => $this->getDbUser(), 161 | "password" => $this::getDbUser(), 162 | "database" => $this::getDbName(), 163 | "max_connections" => 3 164 | ], $this->getLoop()); 165 | 166 | $value = null; 167 | 168 | $testQuery = $client->query("SELECT pg_sleep(0.1)")->mapTo(1) 169 | ->merge($client->query("SELECT pg_sleep(0.2)")->mapTo(2)) 170 | ->merge($client->query("SELECT pg_sleep(0.3)")->mapTo(3)) 171 | ->merge($client->query("SELECT pg_sleep(0.4)")->mapTo(4)) 172 | ->merge($client->query("SELECT pg_sleep(0.5)")->mapTo(5)) 173 | ->merge($client->query("SELECT pg_sleep(0.6)")->mapTo(6)) 174 | ->toArray(); 175 | 176 | $testQuery->subscribe(new \Rx\Observer\CallbackObserver( 177 | function ($results) use (&$value) { 178 | $value = $results; 179 | }, 180 | function (\Throwable $e) use (&$error) { 181 | $this->fail('Error while testing' . $e->getMessage()); 182 | $this->stopLoop(); 183 | }, 184 | function () { 185 | $this->stopLoop(); 186 | } 187 | )); 188 | 189 | $this->runLoopWithTimeout(4); 190 | 191 | $this->assertEquals([1,2,3,4,5,6], $value); 192 | $this->assertEquals(3, $client->getConnectionCount()); 193 | 194 | $client->closeNow(); 195 | $this->getLoop()->run(); 196 | } 197 | 198 | public function testListen() 199 | { 200 | $client = new Client([ 201 | "user" => $this->getDbUser(), 202 | "password" => $this::getDbUser(), 203 | "database" => $this::getDbName(), 204 | ], $this->getLoop()); 205 | 206 | $testQuery = $client->listen('some_channel') 207 | ->merge($client->listen('some_channel')->take(1)) 208 | ->take(3) 209 | ->concat($client->listen('some_channel')->take(1)); 210 | 211 | $values = []; 212 | 213 | $testQuery->subscribe( 214 | function (NotificationResponse $results) use (&$values) { 215 | $values[] = $results->getPayload(); 216 | }, 217 | function (\Throwable $e) use (&$error) { 218 | $this->fail('Error while testing: ' . $e->getMessage()); 219 | $this->stopLoop(); 220 | }, 221 | function () { 222 | $this->stopLoop(); 223 | } 224 | ); 225 | 226 | Observable::interval(300) 227 | ->take(3) 228 | ->flatMap(function ($x) use ($client) { 229 | return $client->executeStatement("NOTIFY some_channel, 'Hello" . $x . "'"); 230 | }) 231 | ->subscribe(); 232 | 233 | $this->runLoopWithTimeout(4); 234 | 235 | $this->assertEquals(['Hello0', 'Hello0', 'Hello1', 'Hello2'], $values); 236 | 237 | $client->closeNow(); 238 | $this->getLoop()->run(); 239 | } 240 | } -------------------------------------------------------------------------------- /tests/Integration/ConnectionTest.php: -------------------------------------------------------------------------------- 1 | $this->getDbUser(), 17 | "password" => $this::getDbUser(), 18 | "database" => $this::getDbName(), 19 | "auto_disconnect" => true 20 | ], $this->getLoop()); 21 | 22 | $hello = null; 23 | 24 | $conn->query("SELECT 'Hello' AS hello")->subscribe(new CallbackObserver( 25 | function ($x) use (&$hello) { 26 | $this->assertNull($hello); 27 | $hello = $x['hello']; 28 | }, 29 | function (\Exception $e) { 30 | $this->stopLoop(); 31 | $this->fail(); 32 | }, 33 | function () { 34 | $this->stopLoop(); 35 | } 36 | )); 37 | 38 | $this->runLoopWithTimeout(2); 39 | 40 | $this->assertEquals('Hello', $hello); 41 | $this->assertEquals(Connection::CONNECTION_CLOSED, $conn->getConnectionStatus()); 42 | } 43 | 44 | public function testConnectionDisconnectAfterSuccessfulStatement() 45 | { 46 | $conn = new Connection([ 47 | "user" => $this->getDbUser(), 48 | "password" => $this::getDbUser(), 49 | "database" => $this::getDbName(), 50 | "auto_disconnect" => true 51 | ], $this->getLoop()); 52 | 53 | $hello = null; 54 | 55 | $conn->executeStatement("SELECT 'Hello' AS hello", [])->subscribe(new CallbackObserver( 56 | function ($x) use (&$hello) { 57 | $this->assertNull($hello); 58 | $hello = $x['hello']; 59 | }, 60 | function (\Exception $e) { 61 | $this->fail(); 62 | $this->stopLoop(); 63 | }, 64 | function () { 65 | $this->stopLoop(); 66 | } 67 | )); 68 | 69 | $this->runLoopWithTimeout(2); 70 | 71 | $this->assertEquals('Hello', $hello); 72 | $this->assertEquals(Connection::CONNECTION_CLOSED, $conn->getConnectionStatus()); 73 | } 74 | 75 | public function testConnectionDisconnectAfterFailedQuery() 76 | { 77 | $conn = new Connection([ 78 | "user" => $this->getDbUser(), 79 | "password" => $this::getDbUser(), 80 | "database" => $this::getDbName(), 81 | "auto_disconnect" => true 82 | ], $this->getLoop()); 83 | 84 | $hello = null; 85 | 86 | $conn->query("Some bad query")->subscribe(new CallbackObserver( 87 | function ($x) { 88 | echo "next\n"; 89 | $this->fail('Should not get any items'); 90 | }, 91 | function (\Exception $e) use (&$hello) { 92 | $hello = "Hello"; 93 | $this->stopLoop(); 94 | }, 95 | function () { 96 | echo "complete\n"; 97 | $this->fail('Should not complete'); 98 | } 99 | )); 100 | 101 | $this->runLoopWithTimeout(2); 102 | 103 | $this->assertEquals('Hello', $hello); 104 | $this->assertEquals(Connection::CONNECTION_CLOSED, $conn->getConnectionStatus()); 105 | } 106 | 107 | public function testInvalidHostName() 108 | { 109 | $conn = new Connection([ 110 | "host" => 'host.invalid', 111 | "user" => $this->getDbUser(), 112 | "password" => $this::getDbUser(), 113 | "database" => $this::getDbName(), 114 | "auto_disconnect" => true 115 | ], $this->getLoop()); 116 | 117 | $testQuery = $conn->query("SELECT 1"); 118 | 119 | $error = null; 120 | 121 | $testQuery->subscribe(new \Rx\Observer\CallbackObserver( 122 | function ($row) { 123 | $this->fail('Did not expect onNext to be called.'); 124 | }, 125 | function (\Throwable $e) use (&$error) { 126 | $error = $e; 127 | $this->stopLoop(); 128 | }, 129 | function () { 130 | $this->fail('Did not expect onNext to be called.'); 131 | } 132 | )); 133 | 134 | $this->runLoopWithTimeout(2); 135 | 136 | // At some point, DNS was returning RecordNotFoundException 137 | // as long as we are getting an Exception here, we should be good 138 | $this->assertInstanceOf(\Exception::class, $error); 139 | 140 | // looks like this behavior changed with newer versions of react libs 141 | // $this->assertInstanceOf(RecordNotFoundException::class, $error->getPrevious()); 142 | } 143 | 144 | public function testSendingTwoQueriesWithoutWaitingNoAutoDisconnect() 145 | { 146 | $conn = new Connection([ 147 | "user" => $this->getDbUser(), 148 | "password" => $this::getDbUser(), 149 | "database" => $this::getDbName() 150 | ], $this->getLoop()); 151 | 152 | $testQuery = $conn->query("SELECT pg_sleep(0.1)")->mapTo(1) 153 | ->merge($conn->query("SELECT pg_sleep(0.2)")->mapTo(2)) 154 | ->toArray(); 155 | 156 | $value = null; 157 | 158 | $testQuery->subscribe(new \Rx\Observer\CallbackObserver( 159 | function ($results) use (&$value) { 160 | $value = $results; 161 | }, 162 | function (\Throwable $e) use (&$error) { 163 | $this->fail('Error while testing'); 164 | $this->stopLoop(); 165 | }, 166 | function () { 167 | $this->stopLoop(); 168 | } 169 | )); 170 | 171 | $this->runLoopWithTimeout(2); 172 | 173 | $this->assertEquals([1,2], $value); 174 | 175 | $conn->disconnect(); 176 | $this->getLoop()->run(); 177 | } 178 | 179 | public function testSendingTwoQueriesWithoutWaitingAutoDisconnect() 180 | { 181 | $conn = new Connection([ 182 | "user" => $this->getDbUser(), 183 | "password" => $this::getDbUser(), 184 | "database" => $this::getDbName(), 185 | "auto_disconnect" => true 186 | ], $this->getLoop()); 187 | 188 | $testQuery = $conn->query("SELECT pg_sleep(0.1)")->mapTo(1) 189 | ->merge($conn->query("SELECT pg_sleep(0.2)")->mapTo(2)) 190 | ->toArray(); 191 | 192 | $value = null; 193 | 194 | $testQuery->subscribe(new \Rx\Observer\CallbackObserver( 195 | function ($results) use (&$value) { 196 | $value = $results; 197 | }, 198 | function (\Throwable $e) use (&$error) { 199 | $this->fail('Error while testing'); 200 | $this->stopLoop(); 201 | }, 202 | function () { 203 | $this->stopLoop(); 204 | } 205 | )); 206 | 207 | $this->runLoopWithTimeout(2); 208 | 209 | $this->assertEquals([1,2], $value); 210 | 211 | $this->getLoop()->run(); 212 | } 213 | 214 | public function testCancellationUsingDispose() 215 | { 216 | $this->markTestSkipped('We have disabled cancellation for the time being.'); 217 | $conn = new Connection([ 218 | "user" => $this->getDbUser(), 219 | "password" => $this::getDbUser(), 220 | "database" => $this::getDbName(), 221 | "auto_disconnect" => true 222 | ], $this->getLoop()); 223 | 224 | $disposed = false; 225 | 226 | $testQuery = $conn->query("SELECT pg_sleep(10)") 227 | ->mapTo(1) 228 | ->finally(function () use (&$disposed) { 229 | $disposed = true; 230 | }); 231 | 232 | //$disposable = $testQuery->takeUntil(Observable::timer(500))->subscribe( 233 | $disposable = $testQuery->subscribe( 234 | function ($results) { 235 | $this->stopLoop(); 236 | $this->fail('Expected no value'); 237 | }, 238 | function (\Throwable $e) { 239 | $this->stopLoop(); 240 | $this->fail('Expected no error'); 241 | }, 242 | function () { 243 | $this->stopLoop(); 244 | $this->fail('Expected no completion'); 245 | } 246 | ); 247 | 248 | $this->getLoop()->addTimer(500, function () use ($disposable) { 249 | $disposable->dispose(); 250 | }); 251 | 252 | $this->runLoopWithTimeout(2); 253 | 254 | $this->assertTrue($disposed); 255 | } 256 | 257 | public function testCancellationUsingInternalFunctions() 258 | { 259 | $conn = new Connection([ 260 | "user" => $this->getDbUser(), 261 | "password" => $this::getDbUser(), 262 | "database" => $this::getDbName() 263 | ], $this->getLoop()); 264 | 265 | $testQuery = $conn->query("SELECT pg_sleep(10)")->mapTo(1); 266 | 267 | $error = null; 268 | 269 | $testQuery->subscribe( 270 | function ($results) { 271 | $this->fail('Expected no value'); 272 | $this->stopLoop(); 273 | }, 274 | function (\Throwable $e) use (&$error) { 275 | $error = $e; 276 | $this->stopLoop(); 277 | }, 278 | function () { 279 | $this->fail('Expected no completion'); 280 | $this->stopLoop(); 281 | } 282 | ); 283 | 284 | $this->getLoop()->addTimer(0.5, function () use ($conn) { 285 | $r = new \ReflectionClass($conn); 286 | $m = $r->getMethod('cancelRequest'); 287 | $m->setAccessible(true); 288 | $m->invoke($conn); 289 | $m->setAccessible(false); 290 | }); 291 | 292 | $this->runLoopWithTimeout(2); 293 | 294 | $this->assertInstanceOf(ErrorException::class, $error); 295 | $this->assertStringStartsWith('ERROR: canceling statement due to user request while executing', $error->getMessage()); 296 | 297 | $conn->disconnect(); 298 | $this->getLoop()->run(); 299 | } 300 | 301 | public function testCancellationOfNonActiveQuery() 302 | { 303 | $conn = new Connection([ 304 | "user" => $this->getDbUser(), 305 | "password" => $this::getDbUser(), 306 | "database" => $this::getDbName() 307 | ], $this->getLoop()); 308 | 309 | $testQuery = $conn->query("SELECT pg_sleep(1)")->mapTo(1) 310 | ->merge($conn->query('SELECT pg_sleep(1)') 311 | ->mapTo(2) 312 | ->takeUntil(Observable::timer(250)) 313 | ) 314 | ->merge($conn->query('SELECT pg_sleep(1)')->mapTo(3)) 315 | ->toArray(); 316 | 317 | $value = null; 318 | 319 | $testQuery->subscribe( 320 | function ($results) use (&$value) { 321 | $value = $results; 322 | $this->stopLoop(); 323 | }, 324 | function (\Throwable $e) { 325 | $this->fail('Expected no error' . $e->getMessage()); 326 | $this->stopLoop(); 327 | }, 328 | function () { 329 | $this->stopLoop(); 330 | } 331 | ); 332 | 333 | $this->runLoopWithTimeout(15); 334 | 335 | $this->assertEquals([1,3], $value); 336 | 337 | $conn->disconnect(); 338 | $this->getLoop()->run(); 339 | } 340 | 341 | public function testCancellationWithImmediateQueryQueuedUp() { 342 | $conn = new Connection([ 343 | "user" => $this->getDbUser(), 344 | "password" => $this::getDbUser(), 345 | "database" => $this::getDbName() 346 | ], $this->getLoop()); 347 | 348 | $q1 = $conn->query("SELECT * FROM generate_series(1,4)"); 349 | $q2 = $conn->query("SELECT pg_sleep(10)"); 350 | 351 | $testQuery = $q1->merge($q2)->take(1); 352 | 353 | $value = null; 354 | 355 | $testQuery->subscribe( 356 | function ($results) use (&$value) { 357 | $value = $results; 358 | $this->stopLoop(); 359 | }, 360 | function (\Throwable $e) { 361 | $this->fail('Expected no error' . $e->getMessage()); 362 | $this->stopLoop(); 363 | }, 364 | function () { 365 | $this->stopLoop(); 366 | } 367 | ); 368 | 369 | $this->runLoopWithTimeout(15); 370 | 371 | $this->assertEquals(['generate_series' => '1'], $value); 372 | 373 | $conn->disconnect(); 374 | $this->getLoop()->run(); 375 | } 376 | 377 | public function testArrayInParameters() { 378 | $conn = new Connection([ 379 | "user" => $this->getDbUser(), 380 | "password" => $this::getDbUser(), 381 | "database" => $this::getDbName() 382 | ], $this->getLoop()); 383 | 384 | $testQuery = $conn->executeStatement("SELECT * FROM generate_series(1,4) WHERE generate_series = ANY($1)", ['{2, 3}']); 385 | 386 | $value = []; 387 | 388 | $testQuery->subscribe( 389 | function ($results) use (&$value) { 390 | $value[] = $results; 391 | $this->stopLoop(); 392 | }, 393 | function (\Throwable $e) { 394 | $this->fail('Expected no error' . $e->getMessage()); 395 | $this->stopLoop(); 396 | }, 397 | function () { 398 | $this->stopLoop(); 399 | } 400 | ); 401 | 402 | $this->runLoopWithTimeout(15); 403 | 404 | $this->assertEquals([['generate_series' => 2], ['generate_series' => 3]], $value); 405 | 406 | $conn->disconnect(); 407 | $this->getLoop()->run(); 408 | } 409 | } -------------------------------------------------------------------------------- /tests/Integration/Md5PasswordTest.php: -------------------------------------------------------------------------------- 1 | "pgasyncpw", 14 | "database" => $this->getDbName(), 15 | "auto_disconnect" => true, 16 | "password" => "example_password" 17 | ], $this->getLoop()); 18 | 19 | $hello = null; 20 | 21 | $client->query("SELECT 'Hello' AS hello") 22 | ->subscribe(new CallbackObserver( 23 | function ($x) use (&$hello) { 24 | $this->assertNull($hello); 25 | $hello = $x['hello']; 26 | }, 27 | function ($e) { 28 | $this->fail('Unexpected error'); 29 | }, 30 | function () { 31 | $this->getLoop()->addTimer(0.1, function () { 32 | $this->stopLoop(); 33 | }); 34 | } 35 | )); 36 | 37 | $this->runLoopWithTimeout(2); 38 | 39 | $this->assertEquals('Hello', $hello); 40 | } 41 | } -------------------------------------------------------------------------------- /tests/Integration/NullPasswordTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('Not using null password anymore. Maybe should setup tests to twst this again.'); 13 | 14 | $client = new Client([ 15 | "user" => $this::getDbUser(), 16 | "database" => $this::getDbName(), 17 | "password" => null 18 | ]); 19 | 20 | $count = $client->query("SELECT count(*) AS the_count FROM thing"); 21 | 22 | $theCount = -1; 23 | 24 | $count->subscribe(new CallbackObserver( 25 | function ($x) use (&$theCount) { 26 | $this->assertTrue($theCount == -1); 27 | $theCount = $x["the_count"]; 28 | }, 29 | function ($e) use ($client) { 30 | $client->closeNow(); 31 | $this->cancelCurrentTimeoutTimer(); 32 | $this->fail("onError"); 33 | }, 34 | function () use ($client) { 35 | $client->closeNow(); 36 | $this->cancelCurrentTimeoutTimer(); 37 | } 38 | )); 39 | 40 | $this->runLoopWithTimeout(2); 41 | 42 | $this->assertEquals(3, $theCount); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Integration/ScramSha256PasswordTest.php: -------------------------------------------------------------------------------- 1 | 'scram_user', 15 | "database" => $this->getDbName(), 16 | "auto_disconnect" => true, 17 | "password" => "scram_password" 18 | ], $this->getLoop()); 19 | 20 | $hello = null; 21 | 22 | $client->query("SELECT 'Hello' AS hello") 23 | ->subscribe(new CallbackObserver( 24 | function ($x) use (&$hello) { 25 | $this->assertNull($hello); 26 | $hello = $x['hello']; 27 | }, 28 | function ($e) { 29 | $this->fail('Unexpected error ' . $e); 30 | }, 31 | function () { 32 | $this->getLoop()->addTimer(0.1, function () { 33 | $this->stopLoop(); 34 | }); 35 | } 36 | )); 37 | 38 | $this->runLoopWithTimeout(2); 39 | 40 | $this->assertEquals('Hello', $hello); 41 | } 42 | } -------------------------------------------------------------------------------- /tests/Integration/SimpleQueryTest.php: -------------------------------------------------------------------------------- 1 | $this::getDbUser(), "password" => $this::getDbUser(), "database" => $this::getDbName()]); 13 | 14 | $count = $client->query("SELECT count(*) AS the_count FROM thing"); 15 | 16 | $theCount = -1; 17 | 18 | $count->subscribe(new CallbackObserver( 19 | function ($x) use (&$theCount) { 20 | $this->assertTrue($theCount == -1); 21 | $theCount = $x["the_count"]; 22 | }, 23 | function ($e) use ($client) { 24 | $client->closeNow(); 25 | $this->cancelCurrentTimeoutTimer(); 26 | $this->fail("onError"); 27 | }, 28 | function () use ($client) { 29 | $client->closeNow(); 30 | $this->cancelCurrentTimeoutTimer(); 31 | } 32 | )); 33 | 34 | $this->runLoopWithTimeout(2); 35 | 36 | $this->assertEquals(3, $theCount); 37 | } 38 | 39 | public function testSimpleQueryNoResult() 40 | { 41 | $client = new Client(["user" => $this->getDbUser(), "password" => $this::getDbUser(), "database" => $this->getDbName()], $this->getLoop()); 42 | 43 | $count = $client->query("SELECT count(*) AS the_count FROM thing WHERE thing_type = 'non-thing'"); 44 | 45 | $theCount = -1; 46 | 47 | $count->subscribe(new CallbackObserver( 48 | function ($x) use (&$theCount) { 49 | $this->assertTrue($theCount == -1); // make sure we only run once 50 | $theCount = $x["the_count"]; 51 | }, 52 | function ($e) use ($client) { 53 | $client->closeNow(); 54 | $this->cancelCurrentTimeoutTimer(); 55 | $this->fail("onError"); 56 | }, 57 | function () use ($client) { 58 | $client->closeNow(); 59 | $this->cancelCurrentTimeoutTimer(); 60 | } 61 | )); 62 | 63 | $this->runLoopWithTimeout(2); 64 | 65 | $this->assertEquals(0, $theCount); 66 | } 67 | 68 | public function testSimpleQueryError() 69 | { 70 | $client = new Client(["user" => $this->getDbUser(), "password" => $this::getDbUser(), "database" => $this::getDbName()], $this->getLoop()); 71 | 72 | $count = $client->query("SELECT count(*) abcdef AS the_count FROM thing WHERE thing_type = 'non-thing'"); 73 | 74 | $theCount = -1; 75 | 76 | $count->subscribe(new CallbackObserver( 77 | function ($x) use ($client) { 78 | $client->closeNow(); 79 | $this->cancelCurrentTimeoutTimer(); 80 | $this->fail("Should not get result"); 81 | }, 82 | function ($e) use ($client) { 83 | $client->closeNow(); 84 | $this->cancelCurrentTimeoutTimer(); 85 | }, 86 | function () use ($client) { 87 | $client->closeNow(); 88 | $this->cancelCurrentTimeoutTimer(); 89 | $this->fail("Should not complete"); 90 | } 91 | )); 92 | 93 | $this->runLoopWithTimeout(2); 94 | 95 | $this->assertEquals(-1, $theCount); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/Integration/TestCase.php: -------------------------------------------------------------------------------- 1 | addTimer(0.1, function () { 34 | static::getLoop()->stop(); 35 | }); 36 | } 37 | 38 | public static function cancelCurrentTimeoutTimer() 39 | { 40 | if (static::$timeoutTimer !== null) { 41 | static::getLoop()->cancelTimer(static::$timeoutTimer); 42 | static::$timeoutTimer = null; 43 | } 44 | } 45 | 46 | public static function runLoopWithTimeout($seconds) 47 | { 48 | $loop = static::getLoop(); 49 | 50 | static::cancelCurrentTimeoutTimer(); 51 | 52 | static::$timeoutTimer = $loop->addTimer($seconds, function ($timer) use ($seconds) { 53 | static::stopLoop(); 54 | static::$timeoutTimer = null; 55 | 56 | throw new \Exception("Test timed out after " . $seconds . " seconds."); 57 | }); 58 | 59 | $loop->run(); 60 | 61 | static::cancelCurrentTimeoutTimer(); 62 | } 63 | 64 | /** 65 | * @return string 66 | */ 67 | public static function getDbUser() 68 | { 69 | return self::$dbUser; 70 | } 71 | 72 | /** 73 | * @param string $dbUser 74 | */ 75 | public static function setDbUser($dbUser) 76 | { 77 | self::$dbUser = $dbUser; 78 | } 79 | 80 | public static function getDbName() 81 | { 82 | return self::DBNAME; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/Unit/ClientTest.php: -------------------------------------------------------------------------------- 1 | createMock(ExecutorInterface::class); 16 | 17 | $deferred = new Deferred(); 18 | 19 | $executor 20 | ->method('query') 21 | ->willReturn($deferred->promise()); 22 | 23 | $resolver = new Resolver($executor); 24 | 25 | $conn = new Client([ 26 | "database" => $this->getDbName(), 27 | "user" => $this->getDbUser(), 28 | "host" => 'somenonexistenthost.' 29 | ], $this->getLoop(), new Connector($this->getLoop(), ['dns' => $resolver])); 30 | 31 | $exception = null; 32 | 33 | $conn->query("SELECT now() as something") 34 | ->subscribe( 35 | null, 36 | function (Exception $e) use (&$exception) { 37 | $exception = $e; 38 | $this->cancelCurrentTimeoutTimer(); 39 | } 40 | ); 41 | 42 | $this->getLoop()->addTimer(0.01, function () use ($deferred) { 43 | $deferred->reject(new React\Dns\RecordNotFoundException()); 44 | }); 45 | 46 | $this->runLoopWithTimeout(5); 47 | 48 | $this->assertInstanceOf(Exception::class, $exception); 49 | } 50 | 51 | public function testFailedDNSLookupEarlyRejection() 52 | { 53 | $executor = $this->createMock(ExecutorInterface::class); 54 | 55 | $executor 56 | ->method('query') 57 | ->willReturn(new RejectedPromise(new React\Dns\RecordNotFoundException())); 58 | 59 | $resolver = new Resolver($executor); 60 | 61 | $conn = new Client([ 62 | "database" => $this->getDbName(), 63 | "user" => $this->getDbUser(), 64 | "host" => 'somenonexistenthost.' 65 | ], $this->getLoop(), new Connector($this->getLoop(), ['dns' => $resolver])); 66 | 67 | $exception = null; 68 | 69 | $conn->query("SELECT now() as something") 70 | ->subscribe( 71 | null, 72 | function (Exception $e) use (&$exception) { 73 | $exception = $e; 74 | } 75 | ); 76 | 77 | $this->assertInstanceOf(Exception::class, $exception); 78 | } 79 | } -------------------------------------------------------------------------------- /tests/Unit/ConnectionTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 11 | $conn = new Connection(['something' => ''], $this->getLoop()); 12 | } 13 | 14 | public function testNoUserThrows() 15 | { 16 | $this->expectException(\InvalidArgumentException::class); 17 | $conn = new Connection(["database" => "some_database"], $this->getLoop()); 18 | } 19 | 20 | public function testNoDatabaseThrows() 21 | { 22 | $this->expectException(\InvalidArgumentException::class); 23 | $conn = new Connection(["user" => "some_user"], $this->getLoop()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Unit/Message/MessageTest.php: -------------------------------------------------------------------------------- 1 | assertEquals("\x04\xd2\x16\x2f", \PgAsync\Message\Message::int32(80877103)); 10 | $this->assertEquals("\x00\x00\x00\x00", \PgAsync\Message\Message::int32(0)); 11 | } 12 | 13 | public function testNotificationResponse() 14 | { 15 | $rawNotificationMessage = hex2bin('41000000190000040c686572650048656c6c6f20746865726500'); 16 | 17 | 18 | $notificationResponse = \PgAsync\Message\Message::createMessageFromIdentifier($rawNotificationMessage[0], []); 19 | $this->assertInstanceOf(\PgAsync\Message\NotificationResponse::class, $notificationResponse); 20 | /** @var \PgAsync\Message\NotificationResponse */ 21 | $notificationResponse->parseData($rawNotificationMessage); 22 | 23 | $this->assertEquals('Hello there', $notificationResponse->getPayload()); 24 | $this->assertEquals('here', $notificationResponse->getChannelName()); 25 | $this->assertEquals(1036, $notificationResponse->getNotifyingProcessId()); 26 | 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Unit/Message/ParseTest.php: -------------------------------------------------------------------------------- 1 | assertEquals("P\00\00\00\x33Hello\0SELECT * FROM some_table WHERE id = $1\0\0\0", $prepared->encodedMessage()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/Unit/Message/SSLRequestTest.php: -------------------------------------------------------------------------------- 1 | assertEquals("\x00\x00\x00\x08\x04\xd2\x16\x2f", $ssl->encodedMessage()); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/Unit/Message/StartupMessageTest.php: -------------------------------------------------------------------------------- 1 | setParameters([ 12 | "user" => "zxcv", 13 | "database" => "asdf", 14 | ]); 15 | 16 | // len 24 + 4 byte proto ver 17 | $this->assertEquals("\x00\x00\x00\x21\x00\x03\x00\x00user\0zxcv\0database\0asdf\0\0", $m->encodedMessage()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |