diff --git a/README.md b/README.md index 4b1ab6d..c60ae36 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,71 @@ $builder `> SELECT Id, Name, created_at FROM Account WHERE Name = 'Test' ORDER BY created_at DESC LIMIT 20` +## Running SOQL Queries + +You can execute SOQL directly, either from a builder or a raw string. + +```php +$builder = SalesforceApi::getQueryBuilder() + ->select(['Id', 'Name']) + ->from('Account'); + +// returns the first page of records (up to 2,000) +$records = $salesforceApi->executeQuery($builder); +// or with a raw SOQL string +$records = $salesforceApi->executeQueryRaw('SELECT Id, Name FROM Account'); +``` + +### queryAll (deleted and archived records) + +`queryAll` works exactly like `query` but also returns soft-deleted and archived records. + +```php +$records = $salesforceApi->executeQueryAll($builder); +// or +$records = $salesforceApi->executeQueryAllRaw('SELECT Id, Name FROM Account'); +``` + +### Pagination + +Salesforce returns query results in batches (up to 2,000 records per request). When more +records remain, the response includes `done => false` and a `nextRecordsUrl`. Note that +`recordsOnly()` strips this metadata, so do not enable it if you want to paginate manually. + +You can follow pagination yourself with `queryMore`, passing the `nextRecordsUrl` from the +previous response. This works for both `query` and `queryAll` (Salesforce returns a `/query/` +style `nextRecordsUrl` even for `queryAll` requests, and `queryMore` handles either). + +```php +$response = $salesforceApi->executeQueryRaw('SELECT Id, Name FROM Account'); + +while (($response['done'] ?? true) === false) { + $response = $salesforceApi->queryMore($response['nextRecordsUrl']); + // do something with $response['records'] +} +``` + +### Fetching every record at once + +If you just want the complete result set in one array, use `getAllRecords` (or +`getAllRecordsRaw`). It follows `nextRecordsUrl` pagination until Salesforce reports `done`, +collecting every batch for you. + +```php +// every matching record, across all pages +$records = $salesforceApi->getAllRecords($builder); +// or from a raw SOQL string +$records = $salesforceApi->getAllRecordsRaw('SELECT Id, Name FROM Account'); + +// include deleted/archived records via the queryAll endpoint +$records = $salesforceApi->getAllRecords($builder, true); +$records = $salesforceApi->getAllRecordsRaw('SELECT Id, Name FROM Account', true); +``` + +> Be mindful of memory when fetching very large result sets — every record is held in memory. +> For huge exports, prefer the Bulk API (see [Batch Jobs](#batch-jobs)). + + ## Testing Testing is done via PestPHP against a live Salesforce org. Tests use standard objects (Account, etc.) so no custom metadata deployment is needed. diff --git a/src/Requests/Query/QueryMore.php b/src/Requests/Query/QueryMore.php new file mode 100644 index 0000000..a18d81d --- /dev/null +++ b/src/Requests/Query/QueryMore.php @@ -0,0 +1,35 @@ +nextRecordsUrl = $nextRecordsUrl; + } + + public function resolveEndpoint(): string + { + // strip the /services/data/{version} prefix so saloon resolves against the base url + $endpoint = preg_replace('#^.*/services/data/v[0-9.]+#', '', $this->nextRecordsUrl); + + return '/'.ltrim($endpoint, '/'); + } +} diff --git a/src/SalesforceApi.php b/src/SalesforceApi.php index 41b99ba..57a78a9 100644 --- a/src/SalesforceApi.php +++ b/src/SalesforceApi.php @@ -17,6 +17,8 @@ use myoutdeskllc\SalesforcePhp\Requests\Organization\GetLimits; use myoutdeskllc\SalesforcePhp\Requests\Organization\GetSupportedApiVersions; use myoutdeskllc\SalesforcePhp\Requests\Query\ExecuteQuery; +use myoutdeskllc\SalesforcePhp\Requests\Query\ExecuteQueryAll; +use myoutdeskllc\SalesforcePhp\Requests\Query\QueryMore; use myoutdeskllc\SalesforcePhp\Requests\Query\Search; use myoutdeskllc\SalesforcePhp\Requests\SObjects\CreateRecord; use myoutdeskllc\SalesforcePhp\Requests\SObjects\CreateRecords; @@ -278,14 +280,26 @@ protected function unpackResponseIfNeeded(Response $response): mixed $inlineData = $response->json(); if (isset($inlineData['records']) && $this->recordsOnly) { - return array_map(function ($item) { - unset($item['attributes']); - - return $item; - }, $inlineData['records']); + return $this->stripAttributes($inlineData['records']); } - return $response->json(); + return $inlineData; + } + + /** + * Removes the salesforce 'attributes' metadata key from each record. + * + * @param array $records + * + * @return array + */ + protected function stripAttributes(array $records): array + { + return array_map(function ($item) { + unset($item['attributes']); + + return $item; + }, $records); } /** @@ -615,6 +629,100 @@ public function executeQueryRaw(string $rawQuery): array return $this->executeRequest($request); } + /** + * Executes a queryAll against salesforce, including deleted and archived records. + * + * @link https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_queryall.htm + * + * @param SoqlQueryBuilder $builder + * + * @return array + */ + public function executeQueryAll(SoqlQueryBuilder $builder): array + { + return $this->executeQueryAllRaw($builder->toSoql()); + } + + /** + * Directly execute SOQL against queryAll and get results, including deleted and archived records. + * + * @link https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_queryall.htm + * + * @param string $rawQuery + * + * @return array + */ + public function executeQueryAllRaw(string $rawQuery): array + { + $request = new ExecuteQueryAll($rawQuery); + + return $this->executeRequest($request); + } + + /** + * Executes a query and follows nextRecordsUrl pagination until salesforce reports done, + * returning the complete result set as a single array of records. + * + * @link https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_query.htm + * + * @param SoqlQueryBuilder $builder + * @param bool $includeDeleted use the queryAll endpoint to include deleted/archived records + * + * @return array + */ + public function getAllRecords(SoqlQueryBuilder $builder, bool $includeDeleted = false): array + { + return $this->getAllRecordsRaw($builder->toSoql(), $includeDeleted); + } + + /** + * Directly execute SOQL and follow nextRecordsUrl pagination until salesforce reports + * done, returning every record in a single array. + * + * Salesforce returns query results in batches (default 2,000 records). This walks every + * batch via queryMore so the caller receives the complete result set. + * + * @link https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_query.htm + * + * @param string $rawQuery + * @param bool $includeDeleted use the queryAll endpoint to include deleted/archived records + * + * @return array + */ + public function getAllRecordsRaw(string $rawQuery, bool $includeDeleted = false): array + { + $request = $includeDeleted ? new ExecuteQueryAll($rawQuery) : new ExecuteQuery($rawQuery); + $response = $this->executeRequestSync($request)->json(); + $records = $response['records'] ?? []; + + while (! ($response['done'] ?? true) && ! empty($response['nextRecordsUrl'])) { + $response = $this->executeRequestSync(new QueryMore($response['nextRecordsUrl']))->json(); + $records = array_merge($records, $response['records'] ?? []); + } + + return $this->recordsOnly ? $this->stripAttributes($records) : $records; + } + + /** + * Fetches the next batch of records from a paginated query or queryAll, using the + * nextRecordsUrl returned in the previous response. + * + * Note: when recordsOnly() is not enabled, the response contains 'done', + * 'totalSize', 'nextRecordsUrl' (if more remain) and 'records'. + * + * @link https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_query.htm + * + * @param string $nextRecordsUrl the nextRecordsUrl value from a prior query response + * + * @return array + */ + public function queryMore(string $nextRecordsUrl): array + { + $request = new QueryMore($nextRecordsUrl); + + return $this->executeRequest($request); + } + /** * Returns only one record found for the given sObject, based on its properties. * diff --git a/tests/QueryMoreTest.php b/tests/QueryMoreTest.php new file mode 100644 index 0000000..2baf677 --- /dev/null +++ b/tests/QueryMoreTest.php @@ -0,0 +1,105 @@ +resolveEndpoint())->toBe('/query/01gD0000002HU6KIAW-2000'); +}); + +test('QueryMore handles the nextRecordsUrl returned by queryAll', function () { + // per salesforce docs, queryAll returns a /query/ nextRecordsUrl, not /queryAll/ + // https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_queryall.htm + $request = new QueryMore('/services/data/v67.0/query/01gRO0000016PIAYA2-500'); + + expect($request->resolveEndpoint())->toBe('/query/01gRO0000016PIAYA2-500'); +}); + +test('QueryMore handles a fully qualified nextRecordsUrl', function () { + $request = new QueryMore('https://test.salesforce.com/services/data/v51.0/query/01gD0000002HU6KIAW-2000'); + + expect($request->resolveEndpoint())->toBe('/query/01gD0000002HU6KIAW-2000'); +}); + +test('getAllRecordsRaw follows pagination until done', function () { + $mockClient = new MockClient([ + MockResponse::make([ + 'totalSize' => 3, + 'done' => false, + 'nextRecordsUrl' => '/services/data/v51.0/query/01gD0000002HU6KIAW-2000', + 'records' => [ + ['attributes' => [], 'Id' => '001', 'Name' => 'Page 1 A'], + ['attributes' => [], 'Id' => '002', 'Name' => 'Page 1 B'], + ], + ], 200), + MockResponse::make([ + 'totalSize' => 3, + 'done' => true, + 'records' => [ + ['attributes' => [], 'Id' => '003', 'Name' => 'Page 2 A'], + ], + ], 200), + ]); + $api = getAPI($mockClient); + $results = $api->getAllRecordsRaw('SELECT Id, Name FROM Account'); + + $mockClient->assertSentCount(2); + expect($results)->toHaveCount(3); + expect($results[0])->not()->toHaveKey('attributes'); + expect($results[2])->toHaveKey('Name', 'Page 2 A'); +}); + +test('getAllRecordsRaw returns a single page when already done', function () { + $mockClient = new MockClient([ + MockResponse::make([ + 'totalSize' => 1, + 'done' => true, + 'records' => [ + ['attributes' => [], 'Id' => '001', 'Name' => 'Only Page'], + ], + ], 200), + ]); + $api = getAPI($mockClient); + $results = $api->getAllRecordsRaw('SELECT Id, Name FROM Account'); + + $mockClient->assertSentCount(1); + expect($results)->toHaveCount(1); +}); + +test('getAllRecordsRaw with includeDeleted uses the queryAll endpoint', function () { + $mockClient = new MockClient([ + MockResponse::make([ + 'totalSize' => 1, + 'done' => true, + 'records' => [['attributes' => [], 'Id' => '001']], + ], 200), + ]); + $api = getAPI($mockClient); + $api->getAllRecordsRaw('SELECT Id FROM Account', true); + + $mockClient->assertSent(function ($request) { + return $request->resolveEndpoint() === '/queryAll'; + }); +}); + +test('queryMore fetches the next batch of records', function () { + $mockClient = new MockClient([ + MockResponse::make([ + 'totalSize' => 4000, + 'done' => true, + 'records' => [ + ['attributes' => [], 'Id' => '001', 'Name' => 'Second Page'], + ], + ], 200), + ]); + $api = getAPI($mockClient); + $results = $api->queryMore('/services/data/v51.0/query/01gD0000002HU6KIAW-2000'); + + $mockClient->assertSent(function ($request) { + return $request->resolveEndpoint() === '/query/01gD0000002HU6KIAW-2000'; + }); + expect($results)->not()->toBeEmpty(); +});