jsforce
Version:
Salesforce API Library for JavaScript
1,326 lines (1,233 loc) • 36.6 kB
text/typescript
/**
* @file Manages query for records in Salesforce
* @author Shinichi Tomita <shinichi.tomita@gmail.com>
*/
import { EventEmitter } from 'events';
import { Logger, getLogger } from './util/logger';
import RecordStream, { Serializable } from './record-stream';
import Connection from './connection';
import { createSOQL } from './soql-builder';
import { QueryConfig as SOQLQueryConfig, SortDir } from './soql-builder';
import {
Record,
Optional,
Schema,
SObjectNames,
ChildRelationshipNames,
ChildRelationshipSObjectName,
FieldProjectionConfig,
FieldPathSpecifier,
FieldPathScopedProjection,
SObjectRecord,
SObjectInputRecord,
SObjectUpdateRecord,
SaveResult,
DateString,
SObjectChildRelationshipProp,
SObjectFieldNames,
} from './types';
import { Readable } from 'stream';
import SfDate from './date';
import { IngestJobV2Results } from './api/bulk2';
/**
*
*/
export type QueryField<
S extends Schema,
N extends SObjectNames<S>,
FP extends FieldPathSpecifier<S, N> = FieldPathSpecifier<S, N>
> = FP | FP[] | string | string[] | { [field: string]: number | boolean };
/**
*
*/
type CValue<T> = T extends DateString
? SfDate
: T extends string | number | boolean
? T
: never;
type CondOp<T> =
| ['$eq', CValue<T> | null]
| ['$ne', CValue<T> | null]
| ['$gt', CValue<T>]
| ['$gte', CValue<T>]
| ['$lt', CValue<T>]
| ['$lte', CValue<T>]
| ['$like', T extends string ? T : never]
| ['$nlike', T extends string ? T : never]
| ['$in', Array<CValue<T>>]
| ['$nin', Array<CValue<T>>]
| ['$includes', T extends string ? T[] : never]
| ['$excludes', T extends string ? T[] : never]
| ['$exists', boolean];
type CondValueObj<T, Op = CondOp<T>[0]> = Op extends CondOp<T>[0]
? Op extends string
? { [K in Op]: Extract<CondOp<T>, [Op, any]>[1] }
: never
: never;
type CondValue<T> = CValue<T> | Array<CValue<T>> | null | CondValueObj<T>;
type ConditionSet<R extends Record> = {
[K in keyof R]?: CondValue<R[K]>;
};
export type QueryCondition<S extends Schema, N extends SObjectNames<S>> =
| {
$or: Array<QueryCondition<S, N>>;
}
| {
$and: Array<QueryCondition<S, N>>;
}
| ConditionSet<SObjectRecord<S, N>>;
export type QuerySort<
S extends Schema,
N extends SObjectNames<S>,
R extends SObjectRecord<S, N> = SObjectRecord<S, N>
> =
| {
[K in keyof R]?: SortDir;
}
| Array<[keyof R, SortDir]>;
/**
*
*/
export type QueryConfig<
S extends Schema,
N extends SObjectNames<S>,
FP extends FieldPathSpecifier<S, N> = FieldPathSpecifier<S, N>
> = {
fields?: QueryField<S, N, FP>;
includes?: {
[CRN in ChildRelationshipNames<S, N>]?: QueryConfig<
S,
ChildRelationshipSObjectName<S, N, CRN>
>;
};
table?: string;
conditions?: QueryCondition<S, N>;
sort?: QuerySort<S, N>;
limit?: number;
offset?: number;
};
export type QueryOptions = {
headers: { [name: string]: string };
maxFetch: number;
autoFetch: boolean;
scanAll: boolean;
responseTarget: QueryResponseTarget;
};
export type QueryResult<R extends Record> = {
done: boolean;
totalSize: number;
records: R[];
nextRecordsUrl?: string;
};
export type QueryExplainResult = {
plans: Array<{
cardinality: number;
fields: string[];
leadingOperationType: 'Index' | 'Other' | 'Sharing' | 'TableScan';
notes: Array<{
description: string;
fields: string[];
tableEnumOrId: string;
}>;
relativeCost: number;
sobjectCardinality: number;
sobjectType: string;
}>;
};
const ResponseTargetValues = [
'QueryResult',
'Records',
'SingleRecord',
'Count',
] as const;
export type QueryResponseTarget = typeof ResponseTargetValues[number];
export const ResponseTargets: {
[K in QueryResponseTarget]: K;
} = ResponseTargetValues.reduce(
(values, target) => ({ ...values, [target]: target }),
{} as {
[K in QueryResponseTarget]: K;
},
);
export type QueryResponse<
R extends Record,
QRT extends QueryResponseTarget = QueryResponseTarget
> = QRT extends 'QueryResult'
? QueryResult<R>
: QRT extends 'Records'
? R[]
: QRT extends 'SingleRecord'
? R | null
: number; // QRT extends 'Count'
export type BulkApiVersion = 1 | 2;
export type QueryDestroyOptions = {
allowBulk?: boolean;
bulkThreshold?: number;
bulkApiVersion?: BulkApiVersion;
};
export type QueryUpdateOptions = {
allowBulk?: boolean;
bulkThreshold?: number;
bulkApiVersion?: BulkApiVersion;
/**
* Skip record template evaluation.
*/
skipRecordTemplateEval?: boolean;
};
/**
*
*/
const DEFAULT_BULK_THRESHOLD = 200;
const DEFAULT_BULK_API_VERSION = 1;
/**
* Query
*/
export class Query<
S extends Schema,
N extends SObjectNames<S>,
R extends Record = Record,
QRT extends QueryResponseTarget = QueryResponseTarget
> extends EventEmitter {
static _logger = getLogger('query');
_conn: Connection<S>;
_logger: Logger;
_soql: Optional<string>;
_locator: Optional<string>;
_config: SOQLQueryConfig = {};
_children: Array<SubQuery<S, N, R, QRT, any, any, any>> = [];
_options: QueryOptions;
_executed: boolean = false;
_finished: boolean = false;
_chaining: boolean = false;
_promise: Promise<QueryResponse<R, QRT>>;
_stream: Serializable<R>;
totalSize = 0;
totalFetched = 0;
records: R[] = [];
/**
*
*/
constructor(
conn: Connection<S>,
config: string | QueryConfig<S, N> | { locator: string },
options?: Partial<QueryOptions>,
) {
super();
this._conn = conn;
this._logger = conn._logLevel
? Query._logger.createInstance(conn._logLevel)
: Query._logger;
if (typeof config === 'string') {
this._soql = config;
this._logger.debug(`config is soql: ${config}`);
} else if (typeof (config as any).locator === 'string') {
const locator: string = (config as any).locator;
this._logger.debug(`config is locator: ${locator}`);
this._locator = locator.includes('/')
? this.urlToLocator(locator)
: locator;
} else {
this._logger.debug(`config is QueryConfig: ${JSON.stringify(config)}`);
const { fields, includes, sort, ..._config } = config as QueryConfig<
S,
N
>;
this._config = _config;
this.select(fields);
if (includes) {
this.includeChildren(includes);
}
if (sort) {
this.sort(sort);
}
}
this._options = {
headers: {},
maxFetch: 10000,
autoFetch: false,
scanAll: false,
responseTarget: 'QueryResult',
...(options || {}),
} as QueryOptions;
// promise instance
this._promise = new Promise((resolve, reject) => {
this.on('response', resolve);
this.on('error', reject);
});
this._stream = new Serializable();
this.on('record', (record) => this._stream.push(record));
this.on('end', () => this._stream.push(null));
this.on('error', (err) => {
try {
this._stream.emit('error', err);
} catch (e) {
// eslint-disable-line no-empty
}
});
}
/**
* Select fields to include in the returning result
*/
select<
R extends Record = Record,
FP extends FieldPathSpecifier<S, N> = FieldPathSpecifier<S, N>,
FPC extends FieldProjectionConfig = FieldPathScopedProjection<S, N, FP>,
R2 extends SObjectRecord<S, N, FPC, R> = SObjectRecord<S, N, FPC, R>
>(fields: QueryField<S, N, FP> = '*'): Query<S, N, R2, QRT> {
if (this._soql) {
throw Error(
'Cannot set select fields for the query which has already built SOQL.',
);
}
function toFieldArray(fields: QueryField<S, N, FP>): string[] {
return typeof fields === 'string'
? fields.split(/\s*,\s*/)
: Array.isArray(fields)
? (fields as Array<string | FP>)
.map(toFieldArray)
.reduce<string[]>((fs, f) => [...fs, ...f], [])
: Object.entries(fields as { [name: string]: QueryField<S, N, FP> })
.map(([f, v]) => {
if (typeof v === 'number' || typeof v === 'boolean') {
return v ? [f] : [];
} else {
return toFieldArray(v).map((p) => `${f}.${p}`);
}
})
.reduce<string[]>((fs, f) => [...fs, ...f], []);
}
if (fields) {
this._config.fields = toFieldArray(fields);
}
// force convert query record type without changing instance;
return (this as any) as Query<S, N, R2, QRT>;
}
/**
* Set query conditions to filter the result records
*/
where(conditions: QueryCondition<S, N> | string) {
if (this._soql) {
throw Error(
'Cannot set where conditions for the query which has already built SOQL.',
);
}
this._config.conditions = conditions;
return this;
}
/**
* Limit the returning result
*/
limit(limit: number) {
if (this._soql) {
throw Error(
'Cannot set limit for the query which has already built SOQL.',
);
}
this._config.limit = limit;
return this;
}
/**
* Skip records
*/
skip(offset: number) {
if (this._soql) {
throw Error(
'Cannot set skip/offset for the query which has already built SOQL.',
);
}
this._config.offset = offset;
return this;
}
/**
* Synonym of Query#skip()
*/
offset = this.skip;
/**
* Set query sort with direction
*/
sort(sort: QuerySort<S, N>|string): this;
sort(sort: SObjectFieldNames<S, N>|string, dir: SortDir): this;
sort(
sort: QuerySort<S, N> | SObjectFieldNames<S, N> | string,
dir?: SortDir,
) {
if (this._soql) {
throw Error(
'Cannot set sort for the query which has already built SOQL.',
);
}
if (typeof sort === 'string' && typeof dir !== 'undefined') {
this._config.sort = [[sort, dir]];
} else {
this._config.sort = sort as string | { [field: string]: SortDir };
}
return this;
}
/**
* Synonym of Query#sort()
*/
orderby: typeof Query.prototype.sort = this.sort;
/**
* Include child relationship query and move down to the child query context
*/
include<
CRN extends ChildRelationshipNames<S, N>,
CN extends ChildRelationshipSObjectName<S, N, CRN>,
CFP extends FieldPathSpecifier<S, CN> = FieldPathSpecifier<S, CN>,
CFPC extends FieldProjectionConfig = FieldPathScopedProjection<S, CN, CFP>,
CR extends Record = SObjectRecord<S, CN, CFPC>
>(
childRelName: CRN,
conditions?: Optional<QueryCondition<S, CN>>,
fields?: Optional<QueryField<S, CN, CFP>>,
options?: { limit?: number; offset?: number; sort?: QuerySort<S, CN> },
): SubQuery<S, N, R, QRT, CRN, CN, CR>;
include<
CRN extends ChildRelationshipNames<S, N>,
CN extends SObjectNames<S>,
CR extends Record = SObjectRecord<S, CN>
>(
childRelName: string,
conditions?: Optional<QueryCondition<S, CN>>,
fields?: Optional<QueryField<S, CN>>,
options?: { limit?: number; offset?: number; sort?: QuerySort<S, CN> },
): SubQuery<S, N, R, QRT, CRN, CN, CR>;
include<
CRN extends ChildRelationshipNames<S, N>,
CN extends ChildRelationshipSObjectName<S, N, CRN>,
CFP extends FieldPathSpecifier<S, CN> = FieldPathSpecifier<S, CN>,
CFPC extends FieldProjectionConfig = FieldPathScopedProjection<S, CN, CFP>,
CR extends Record = SObjectRecord<S, CN, CFPC>
>(
childRelName: CRN | string,
conditions?: Optional<QueryCondition<S, CN>>,
fields?: Optional<QueryField<S, CN, CFP>>,
options: { limit?: number; offset?: number; sort?: QuerySort<S, CN> } = {},
): SubQuery<S, N, R, QRT, CRN, CN, CR> {
if (this._soql) {
throw Error(
'Cannot include child relationship into the query which has already built SOQL.',
);
}
const childConfig: QueryConfig<S, CN, CFP> = {
fields: fields === null ? undefined : fields,
table: childRelName,
conditions: conditions === null ? undefined : conditions,
limit: options.limit,
offset: options.offset,
sort: options.sort,
};
// eslint-disable-next-line no-use-before-define
const childQuery = new SubQuery<S, N, R, QRT, CRN, CN, CR>(
this._conn,
childRelName as CRN,
childConfig,
this,
);
this._children.push(childQuery);
return childQuery;
}
/**
* Include child relationship queries, but not moving down to the children context
*/
includeChildren(
includes: {
[CRN in ChildRelationshipNames<S, N>]?: QueryConfig<
S,
ChildRelationshipSObjectName<S, N, CRN>
>;
},
) {
type CRN = ChildRelationshipNames<S, N>;
if (this._soql) {
throw Error(
'Cannot include child relationship into the query which has already built SOQL.',
);
}
for (const crname of Object.keys(includes) as CRN[]) {
const { conditions, fields, ...options } = includes[
crname
] as QueryConfig<S, ChildRelationshipSObjectName<S, N, CRN>>;
this.include(crname, conditions, fields, options);
}
return this;
}
/**
* Setting maxFetch query option
*/
maxFetch(maxFetch: number) {
this._options.maxFetch = maxFetch;
return this;
}
/**
* Switching auto fetch mode
*/
autoFetch(autoFetch: boolean) {
this._options.autoFetch = autoFetch;
return this;
}
/**
* Set flag to scan all records including deleted and archived.
*/
scanAll(scanAll: boolean) {
this._options.scanAll = scanAll;
return this;
}
/**
*
*/
setResponseTarget<QRT1 extends QueryResponseTarget>(
responseTarget: QRT1,
): Query<S, N, R, QRT1> {
if (responseTarget in ResponseTargets) {
this._options.responseTarget = responseTarget;
}
// force change query response target without changing instance
return (this as Query<S, N, R>) as Query<S, N, R, QRT1>;
}
/**
* Execute query and fetch records from server.
*/
execute<QRT1 extends QueryResponseTarget = QRT>(
options_: Partial<QueryOptions> & { responseTarget?: QRT1 } = {},
): Query<S, N, R, QRT1> {
if (this._executed) {
throw new Error('re-executing already executed query');
}
if (this._finished) {
throw new Error('executing already closed query');
}
const options = {
headers: options_.headers || this._options.headers,
responseTarget: options_.responseTarget || this._options.responseTarget,
autoFetch: options_.autoFetch || this._options.autoFetch,
maxFetch: options_.maxFetch || this._options.maxFetch,
scanAll: options_.scanAll || this._options.scanAll,
};
// collect fetched records in array
// only when response target is Records and
// either callback or chaining promises are available to this query.
this.once('fetch', () => {
if (
options.responseTarget === ResponseTargets.Records &&
this._chaining
) {
this._logger.debug('--- collecting all fetched records ---');
const records: Record[] = [];
const onRecord = (record: Record) => records.push(record);
this.on('record', onRecord);
this.once('end', () => {
this.removeListener('record', onRecord);
this.emit('response', records, this);
});
}
});
// flag to prevent re-execution
this._executed = true;
(async () => {
// start actual query
this._logger.debug('>>> Query start >>>');
try {
await this._execute(options);
this._logger.debug('*** Query finished ***');
} catch (error) {
this._logger.debug('--- Query error ---', error);
this.emit('error', error);
}
})();
// return Query instance for chaining
return (this as Query<S, N, R>) as Query<S, N, R, QRT1>;
}
/**
* Synonym of Query#execute()
*/
exec = this.execute;
/**
* Synonym of Query#execute()
*/
run = this.execute;
private locatorToUrl() {
return this._locator
? [this._conn._baseUrl(), '/query/', this._locator].join('')
: '';
}
private urlToLocator(url: string) {
return url.split('/').pop();
}
private constructResponse(
rawDone: boolean,
responseTarget: QueryResponseTarget[3],
): number;
private constructResponse(
rawDone: boolean,
responseTarget: QueryResponseTarget[2],
): R;
private constructResponse(
rawDone: boolean,
responseTarget: QueryResponseTarget[1],
): R[];
private constructResponse(
rawDone: boolean,
responseTarget: QueryResponseTarget[0],
): QueryResult<R>;
private constructResponse(
rawDone: boolean,
responseTarget: QueryResponseTarget,
): QueryResult<R> | R[] | number | R {
switch (responseTarget) {
case 'Count':
return this.totalSize;
case 'SingleRecord':
return this.records?.[0] ?? null;
case 'Records':
return this.records;
// QueryResult is default response target
default:
return {
...{
records: this.records,
totalSize: this.totalSize,
done: rawDone ?? true, // when no records, done is omitted
},
...(this._locator ? { nextRecordsUrl: this.locatorToUrl() } : {}),
};
}
}
/**
* @private
*/
async _execute(options: QueryOptions): Promise<QueryResponse<R>> {
const { headers, responseTarget, autoFetch, maxFetch, scanAll } = options;
this._logger.debug('execute with options', options);
let url;
if (this._locator) {
url = this.locatorToUrl();
} else {
const soql = await this.toSOQL();
this._logger.debug(`SOQL = ${soql}`);
url = [
this._conn._baseUrl(),
'/',
scanAll ? 'queryAll' : 'query',
'?q=',
encodeURIComponent(soql),
].join('');
}
const data = await this._conn.request<R>({ method: 'GET', url, headers });
this.emit('fetch');
this.totalSize = data.totalSize;
// If autoFetch is true, fetch all records for any subqueries
if (autoFetch && data.records.length > 0) {
const recordKeys = Object.keys(data.records[0]);
for (const record of data.records) {
for (const key of recordKeys) {
const field = record[key];
if (field && typeof field === 'object' && 'records' in field && 'nextRecordsUrl' in field) {
record[key] = {
...field,
records: await this._fetchAllSubqueryRecords(record, key, headers),
done: true,
nextRecordsUrl: undefined
};
}
}
}
}
this.records = this.records?.concat(
maxFetch - this.records.length > data.records.length
? data.records
: data.records.slice(0, maxFetch - this.records.length),
);
this._locator = data.nextRecordsUrl
? this.urlToLocator(data.nextRecordsUrl)
: undefined;
this._finished =
this._finished ||
data.done ||
!autoFetch ||
this.records.length === maxFetch ||
// this is what the response looks like when there are no results
(data.records.length === 0 && data.done === undefined);
// streaming record instances
const numRecords = data.records?.length ?? 0;
let totalFetched = this.totalFetched;
for (let i = 0; i < numRecords; i++) {
if (totalFetched >= maxFetch) {
this._finished = true;
break;
}
const record = data.records[i];
this.emit('record', record, totalFetched, this);
totalFetched += 1;
}
this.totalFetched = totalFetched;
if (this._finished) {
const response = this.constructResponse(data.done, responseTarget);
// only fire response event when it should be notified per fetch
if (responseTarget !== ResponseTargets.Records) {
this.emit('response', response, this);
}
this.emit('end');
return response;
} else {
return this._execute(options);
}
}
/**
* Obtain readable stream instance
*/
stream(type: 'record'): Serializable<R>;
stream(type: 'csv'): Readable;
stream(type: 'record' | 'csv' = 'csv') {
if (!this._finished && !this._executed) {
this.execute({ autoFetch: true });
}
return type === 'record' ? this._stream : this._stream.stream(type);
}
/**
* Pipe the queried records to another stream
* This is for backward compatibility; Query is not a record stream instance anymore in 2.0.
* If you want a record stream instance, use `Query#stream('record')`.
*/
pipe(stream: NodeJS.WritableStream) {
return this.stream('record').pipe(stream);
}
/**
* @protected
*/
async _expandFields(sobject_?: string): Promise<void> {
if (this._soql) {
throw new Error(
'Cannot expand fields for the query which has already built SOQL.',
);
}
const { fields = [], table = '' } = this._config;
const sobject = sobject_ || table;
this._logger.debug(
`_expandFields: sobject = ${sobject}, fields = ${fields.join(', ')}`,
);
const [efields] = await Promise.all([
this._expandAsteriskFields(sobject, fields),
...this._children.map(async (childQuery) => {
await childQuery._expandFields();
return [] as string[];
}),
]);
this._config.fields = efields;
this._config.includes = this._children
.map((cquery) => {
const cconfig = cquery._query._config;
return [cconfig.table, cconfig] as [string, SOQLQueryConfig];
})
.reduce<{ [name: string]: SOQLQueryConfig }>(
(includes, [ctable, cconfig]) => ({
...includes,
[ctable]: cconfig,
}),
{},
);
}
/**
*
*/
async _findRelationObject(relName: string): Promise<string> {
const table = this._config.table;
if (!table) {
throw new Error('No table information provided in the query');
}
this._logger.debug(
`finding table for relation "${relName}" in "${table}"...`,
);
const sobject = await this._conn.describe$(table);
const upperRname = relName.toUpperCase();
for (const cr of sobject.childRelationships) {
if (
(cr.relationshipName || '').toUpperCase() === upperRname &&
cr.childSObject
) {
return cr.childSObject;
}
}
throw new Error(`No child relationship found: ${relName}`);
}
/**
*
*/
async _expandAsteriskFields(
sobject: string,
fields: string[],
): Promise<string[]> {
const expandedFields = await Promise.all(
fields.map(async (field) => this._expandAsteriskField(sobject, field)),
);
return expandedFields.reduce(
(eflds: string[], flds: string[]): string[] => [...eflds, ...flds],
[],
);
}
/**
*
*/
async _expandAsteriskField(
sobject: string,
field: string,
): Promise<string[]> {
this._logger.debug(`expanding field "${field}" in "${sobject}"...`);
const fpath = field.split('.');
if (fpath[fpath.length - 1] === '*') {
const so = await this._conn.describe$(sobject);
this._logger.debug(`table ${sobject} has been described`);
if (fpath.length > 1) {
const rname = fpath.shift();
for (const f of so.fields) {
if (
f.relationshipName &&
rname &&
f.relationshipName.toUpperCase() === rname.toUpperCase()
) {
const rfield = f;
const referenceTo = rfield.referenceTo || [];
const rtable = referenceTo.length === 1 ? referenceTo[0] : 'Name';
const fpaths = await this._expandAsteriskField(
rtable,
fpath.join('.'),
);
return fpaths.map((fp) => `${rname}.${fp}`);
}
}
return [];
}
return so.fields.map((f) => f.name);
}
return [field];
}
/**
* Explain plan for executing query
*/
async explain() {
const soql = await this.toSOQL();
this._logger.debug(`SOQL = ${soql}`);
const url = `/query/?explain=${encodeURIComponent(soql)}`;
return this._conn.request<QueryExplainResult>(url);
}
/**
* Return SOQL expression for the query
*/
async toSOQL() {
if (this._soql) {
return this._soql;
}
await this._expandFields();
return createSOQL(this._config);
}
/**
* Promise/A+ interface
* http://promises-aplus.github.io/promises-spec/
*
* Delegate to deferred promise, return promise instance for query result
*/
then<U, V>(
onResolve?:
| ((qr: QueryResponse<R, QRT>) => U | Promise<U>)
| null
| undefined,
onReject?: ((err: Error) => V | Promise<V>) | null | undefined,
): Promise<U | V> {
this._chaining = true;
if (!this._finished && !this._executed) {
this.execute();
}
if (!this._promise) {
throw new Error(
'invalid state: promise is not set after query execution',
);
}
return this._promise.then(onResolve, onReject);
}
catch(
onReject: (
err: Error,
) => QueryResponse<R, QRT> | Promise<QueryResponse<R, QRT>>,
): Promise<QueryResponse<R, QRT>> {
return this.then(null, onReject);
}
promise(): Promise<QueryResponse<R, QRT>> {
// TODO(cristian): verify this is correct
return Promise.resolve((this as unknown) as QueryResponse<R, QRT>);
}
/**
* Bulk delete queried records
*/
destroy(options?: QueryDestroyOptions): Promise<SaveResult[]>;
destroy(type: N, options?: QueryDestroyOptions): Promise<SaveResult[]>;
destroy(type?: N | QueryDestroyOptions, options?: QueryDestroyOptions) {
if (typeof type === 'object' && type !== null) {
options = type;
type = undefined;
}
options = options || {};
const type_: Optional<N> = type || (this._config.table as Optional<N>);
if (!type_) {
throw new Error(
'SOQL based query needs SObject type information to bulk delete.',
);
}
// Set the threshold number to pass to bulk API
const thresholdNum =
options.allowBulk === false
? -1
: typeof options.bulkThreshold === 'number'
? options.bulkThreshold
: // determine threshold if the connection version supports SObject collection API or not
this._conn._ensureVersion(42)
? DEFAULT_BULK_THRESHOLD
: this._conn._maxRequest / 2;
const bulkApiVersion = options.bulkApiVersion ?? DEFAULT_BULK_API_VERSION;
return new Promise((resolve, reject) => {
const createBatch = () =>
this._conn
.sobject(type_)
.deleteBulk()
.on('response', resolve)
.on('error', reject);
let records: Record[] = [];
let batch: ReturnType<typeof createBatch> | null = null;
const handleRecord = (rec: Record) => {
if (!rec.Id) {
const err = new Error(
'Queried record does not include Salesforce record ID.',
);
this.emit('error', err);
return;
}
const record: Record = { Id: rec.Id };
if (batch) {
batch.write(record);
} else {
records.push(record);
if (
thresholdNum >= 0 &&
records.length > thresholdNum &&
bulkApiVersion === 1
) {
// Use bulk delete instead of SObject REST API
batch = createBatch();
for (const record of records) {
batch.write(record);
}
records = [];
}
}
};
const handleEnd = () => {
if (batch) {
batch.end();
} else {
const ids = records.map((record) => record.Id as string);
if (records.length > thresholdNum && bulkApiVersion === 2) {
this._conn.bulk2
.loadAndWaitForResults({
object: type_,
operation: 'delete',
input: records,
})
.then(
(allResults) =>
resolve(this.mapBulkV2ResultsToSaveResults(allResults)),
reject,
);
} else {
this._conn
.sobject(type_)
.destroy(ids, { allowRecursive: true })
.then(resolve, reject);
}
}
};
this.stream('record')
.on('data', handleRecord)
.on('end', handleEnd)
.on('error', reject);
});
}
/**
* Synonym of Query#destroy()
*/
delete = this.destroy;
/**
* Synonym of Query#destroy()
*/
del = this.destroy;
/**
* Bulk update queried records, using given mapping function/object
*/
update<UR extends SObjectInputRecord<S, N>>(
mapping: ((rec: R) => UR) | UR,
type: N,
options?: QueryUpdateOptions,
): Promise<SaveResult[]>;
update<UR extends SObjectInputRecord<S, N>>(
mapping: ((rec: R) => UR) | UR,
options?: QueryUpdateOptions,
): Promise<SaveResult[]>;
update<UR extends SObjectInputRecord<S, N>>(
mapping: ((rec: R) => UR) | UR,
type?: N | QueryUpdateOptions,
options?: QueryUpdateOptions,
) {
if (typeof type === 'object' && type !== null) {
options = type;
type = undefined;
}
options = options || {};
const type_: Optional<N> =
type || (this._config && (this._config.table as Optional<N>));
if (!type_) {
throw new Error(
'SOQL based query needs SObject type information to bulk update.',
);
}
const updateStream =
typeof mapping === 'function'
? RecordStream.map(mapping)
: RecordStream.recordMapStream(mapping, options.skipRecordTemplateEval);
// Set the threshold number to pass to bulk API
const thresholdNum =
options.allowBulk === false
? -1
: typeof options.bulkThreshold === 'number'
? options.bulkThreshold
: // determine threshold if the connection version supports SObject collection API or not
this._conn._ensureVersion(42)
? DEFAULT_BULK_THRESHOLD
: this._conn._maxRequest / 2;
const bulkApiVersion = options.bulkApiVersion ?? DEFAULT_BULK_API_VERSION;
return new Promise((resolve, reject) => {
const createBatch = () =>
this._conn
.sobject(type_)
.updateBulk()
.on('response', resolve)
.on('error', reject);
let records: Array<SObjectUpdateRecord<S, N>> = [];
let batch: ReturnType<typeof createBatch> | null = null;
const handleRecord = (record: Record) => {
if (batch) {
batch.write(record);
} else {
records.push(record as SObjectUpdateRecord<S, N>);
}
if (
thresholdNum >= 0 &&
records.length > thresholdNum &&
bulkApiVersion === 1
) {
// Use bulk update instead of SObject REST API
batch = createBatch();
for (const record of records) {
batch.write(record);
}
records = [];
}
};
const handleEnd = () => {
if (batch) {
batch.end();
} else {
if (records.length > thresholdNum && bulkApiVersion === 2) {
this._conn.bulk2
.loadAndWaitForResults({
object: type_,
operation: 'update',
input: records,
})
.then(
(allResults) =>
resolve(this.mapBulkV2ResultsToSaveResults(allResults)),
reject,
);
} else {
this._conn
.sobject(type_)
.update(records, { allowRecursive: true })
.then(resolve, reject);
}
}
};
this.stream('record')
.on('error', reject)
.pipe(updateStream)
.on('data', handleRecord)
.on('end', handleEnd)
.on('error', reject);
});
}
private mapBulkV2ResultsToSaveResults(
bulkJobAllResults: IngestJobV2Results<S>,
): SaveResult[] {
const successSaveResults: SaveResult[] = bulkJobAllResults.successfulResults.map(
(r) => {
const saveResult: SaveResult = {
id: r.sf__Id,
success: true,
errors: [],
};
return saveResult;
},
);
const failedSaveResults = bulkJobAllResults.failedResults.map((r) => {
const saveResult: SaveResult = {
success: false,
errors: [
{
errorCode: r.sf__Error,
message: r.sf__Error,
},
],
};
return saveResult;
});
return [...successSaveResults, ...failedSaveResults];
}
/**
* Fetches all records for a subquery field by following nextRecordsUrl
* @private
*/
private async _fetchAllSubqueryRecords(
record: Record,
fieldName: string,
headers: { [name: string]: string },
): Promise<Record[]> {
const subqueryField = record[fieldName] as QueryResult<Record>;
if (!subqueryField || !subqueryField.records) {
return [];
}
let allRecords = [...subqueryField.records];
let nextRecordsUrl = subqueryField.nextRecordsUrl;
while (nextRecordsUrl) {
// When following nextRecordsUrl for a subquery, we need to preserve the relationship context
// by using the full URL as-is rather than trying to reconstruct it
const data = await this._conn.request<QueryResult<Record>>({
method: 'GET',
url: nextRecordsUrl, // Use the full URL as provided by Salesforce
headers
});
allRecords = allRecords.concat(data.records);
nextRecordsUrl = data.nextRecordsUrl;
}
return allRecords;
}
}
/*--------------------------------------------*/
/**
* SubQuery object for representing child relationship query
*/
export class SubQuery<
S extends Schema,
PN extends SObjectNames<S>,
PR extends Record,
PQRT extends QueryResponseTarget,
CRN extends ChildRelationshipNames<S, PN> = ChildRelationshipNames<S, PN>,
CN extends SObjectNames<S> = ChildRelationshipSObjectName<S, PN, CRN>,
CR extends Record = Record
> {
_relName: CRN;
_query: Query<S, CN, CR>;
_parent: Query<S, PN, PR, PQRT>;
/**
*
*/
constructor(
conn: Connection<S>,
relName: CRN,
config: QueryConfig<S, CN>,
parent: Query<S, PN, PR, PQRT>,
) {
this._relName = relName;
this._query = new Query(conn, config);
this._parent = parent;
}
/**
*
*/
select<
R extends Record = Record,
FP extends FieldPathSpecifier<S, CN> = FieldPathSpecifier<S, CN>,
FPC extends FieldProjectionConfig = FieldPathScopedProjection<S, CN, FP>
>(
fields: QueryField<S, CN, FP>,
): SubQuery<S, PN, PR, PQRT, CRN, CN, SObjectRecord<S, CN, FPC, R>> {
// force convert query record type without changing instance
this._query = this._query.select(fields) as any;
return (this as any) as SubQuery<
S,
PN,
PR,
PQRT,
CRN,
CN,
SObjectRecord<S, CN, FPC, R>
>;
}
/**
*
*/
where(conditions: QueryCondition<S, CN> | string): this {
this._query = this._query.where(conditions);
return this;
}
/**
* Limit the returning result
*/
limit(limit: number) {
this._query = this._query.limit(limit);
return this;
}
/**
* Skip records
*/
skip(offset: number) {
this._query = this._query.skip(offset);
return this;
}
/**
* Synonym of SubQuery#skip()
*/
offset = this.skip;
/**
* Set query sort with direction
*/
sort(sort: QuerySort<S, CN>): this;
sort(sort: string| SObjectFieldNames<S, CN>, dir: SortDir): this;
sort(
sort: QuerySort<S, CN> | SObjectFieldNames<S, CN> | string,
dir?: SortDir,
) {
this._query = this._query.sort(sort as any, dir as SortDir);
return this;
}
/**
* Synonym of SubQuery#sort()
*/
orderby: typeof SubQuery.prototype.sort = this.sort;
/**
*
*/
async _expandFields() {
const sobject = await this._parent._findRelationObject(this._relName);
return this._query._expandFields(sobject);
}
/**
* Back the context to parent query object
*/
end<
CRP extends SObjectChildRelationshipProp<
CRN,
CR
> = SObjectChildRelationshipProp<CRN, CR>,
PR1 extends Record = PR & CRP
>(): Query<S, PN, PR1, PQRT> {
return (this._parent as any) as Query<S, PN, PR1, PQRT>;
}
}
export default Query;