Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
35 changes: 35 additions & 0 deletions src/Requests/Query/QueryMore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace myoutdeskllc\SalesforcePhp\Requests\Query;

use Saloon\Enums\Method;
use Saloon\Http\Request;

/**
* Fetches the next batch of records from a paginated query (query or queryAll)
* using the nextRecordsUrl returned by salesforce.
*
* Salesforce returns nextRecordsUrl as an absolute path, e.g.
* /services/data/v51.0/query/01gD0000002HU6KIAW-2000
*
* The connector base url already includes /services/data/{version}, and saloon
* does not allow absolute urls, so that prefix is stripped here.
*/
class QueryMore extends Request
{
protected Method $method = Method::GET;
protected string $nextRecordsUrl;

public function __construct(string $nextRecordsUrl)
{
$this->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, '/');
}
}
120 changes: 114 additions & 6 deletions src/SalesforceApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down
105 changes: 105 additions & 0 deletions tests/QueryMoreTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

use myoutdeskllc\SalesforcePhp\Requests\Query\QueryMore;
use Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\MockResponse;

test('QueryMore strips the services/data/version prefix from nextRecordsUrl', function () {
$request = new QueryMore('/services/data/v51.0/query/01gD0000002HU6KIAW-2000');

expect($request->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();
});
Loading