Skip to content

Commit fff1578

Browse files
author
hknokh2
committed
feat: add filtered query IN-clause field controls
- add ScriptObject.includedInClauseFields - add ScriptObject.excludedFromInClauseFields - keep denied lookup fields selected and exported - update schema, loader, planner, and unit tests
1 parent 034f72c commit fff1578

6 files changed

Lines changed: 190 additions & 0 deletions

File tree

schemas/export.schema.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,18 @@
323323
"type": "string"
324324
}
325325
},
326+
"includedInClauseFields": {
327+
"type": "array",
328+
"items": {
329+
"type": "string"
330+
}
331+
},
332+
"excludedFromInClauseFields": {
333+
"type": "array",
334+
"items": {
335+
"type": "string"
336+
}
337+
},
326338
"fieldMapping": {
327339
"type": "array",
328340
"items": {

src/modules/models/job/MigrationJobTask.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3974,6 +3974,7 @@ export default class MigrationJobTask implements ISFdmuRunCustomAddonTask {
39743974
if (
39753975
field.isSimpleReference &&
39763976
field.parentLookupObject &&
3977+
this._isFieldAllowedForInClause(field) &&
39773978
!excludedObjects.includes(field.referencedObjectType.toLowerCase())
39783979
) {
39793980
const parentTask = this.job.getTaskBySObjectName(field.parentLookupObject.name);
@@ -4025,6 +4026,25 @@ export default class MigrationJobTask implements ISFdmuRunCustomAddonTask {
40254026
return queries;
40264027
}
40274028

4029+
/**
4030+
* Checks whether a lookup field can drive filtered IN-clause source queries.
4031+
*
4032+
* @param field - Lookup field metadata.
4033+
* @returns True when field is allowed as a filtered-query pivot.
4034+
*/
4035+
private _isFieldAllowedForInClause(field: SFieldDescribe): boolean {
4036+
const normalize = (fieldName: string): string => fieldName.trim().toLowerCase();
4037+
const fieldNames = new Set([field.name, field.nameId].map(normalize).filter((fieldName) => fieldName.length > 0));
4038+
const included = new Set(this.scriptObject.includedInClauseFields.map(normalize));
4039+
const excluded = new Set(this.scriptObject.excludedFromInClauseFields.map(normalize));
4040+
const hasIncluded = included.size > 0;
4041+
const isIncluded = !hasIncluded || [...fieldNames].some((fieldName) => included.has(fieldName));
4042+
if (!isIncluded) {
4043+
return false;
4044+
}
4045+
return ![...fieldNames].some((fieldName) => excluded.has(fieldName));
4046+
}
4047+
40284048
/**
40294049
* Returns the base query string from preflight output.
40304050
*

src/modules/models/script/ScriptObject.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,16 @@ export default class ScriptObject {
363363
*/
364364
public excludedFromUpdateFields: string[] = [];
365365

366+
/**
367+
* Lookup fields explicitly allowed as filtered-query IN-clause pivots.
368+
*/
369+
public includedInClauseFields: string[] = [];
370+
371+
/**
372+
* Lookup fields excluded from filtered-query IN-clause pivots.
373+
*/
374+
public excludedFromInClauseFields: string[] = [];
375+
366376
/**
367377
* Runtime excluded fields from update operations.
368378
*/

src/modules/script/ScriptLoader.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,22 @@ export default class ScriptLoader {
414414
},
415415
(value) => this._normalizeStringArray(value)
416416
);
417+
this._assignArrayPropertyFromRaw<string>(
418+
rawObject,
419+
'includedInClauseFields',
420+
(value) => {
421+
object.includedInClauseFields = value as string[];
422+
},
423+
(value) => this._normalizeStringArray(value)
424+
);
425+
this._assignArrayPropertyFromRaw<string>(
426+
rawObject,
427+
'excludedFromInClauseFields',
428+
(value) => {
429+
object.excludedFromInClauseFields = value as string[];
430+
},
431+
(value) => this._normalizeStringArray(value)
432+
);
417433
const operation = this._resolveOperation(rawObject.operation);
418434
if (typeof operation !== 'undefined') {
419435
object.operation = operation;

test/modules/job/migration-job-task.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ type TaskFixtureType = {
3636
task: MigrationJobTask;
3737
};
3838

39+
type InClauseFixtureType = {
40+
childTask: MigrationJobTask;
41+
parentTask: MigrationJobTask;
42+
productTask: MigrationJobTask;
43+
relatedProductTask: MigrationJobTask;
44+
};
45+
3946
const createTempDir = (): string => fs.mkdtempSync(path.join(os.tmpdir(), 'sfdmu-task-'));
4047

4148
type FieldInitType = Partial<SFieldDescribe> & { name: string };
@@ -72,6 +79,70 @@ const createTaskFixture = (): TaskFixtureType => {
7279

7380
const createMap = (pairs: Array<[string, string]>): LookupIdMapType => new Map(pairs);
7481

82+
const hasFilteredInClause = (queries: string[], fieldName: string): boolean =>
83+
queries.some((query) => query.includes(`WHERE ${fieldName} IN (`) || query.includes(`AND ${fieldName} IN (`));
84+
85+
const createInClauseFixture = (): InClauseFixtureType => {
86+
const script = new Script();
87+
const job = new MigrationJob({ script });
88+
const parentObject = new ScriptObject('Parent_Offer__c');
89+
const productObject = new ScriptObject('Product__c');
90+
const relatedProductObject = new ScriptObject('Related_Product__c');
91+
const childObject = new ScriptObject('Offer_Line_Item__c');
92+
childObject.query = 'SELECT Id, Parent_Offer__c, Product__c, Related_Product__c FROM Offer_Line_Item__c';
93+
94+
const parentTask = new MigrationJobTask({ job, scriptObject: parentObject });
95+
const productTask = new MigrationJobTask({ job, scriptObject: productObject });
96+
const relatedProductTask = new MigrationJobTask({ job, scriptObject: relatedProductObject });
97+
const childTask = new MigrationJobTask({ job, scriptObject: childObject });
98+
99+
job.tasks = [childTask, parentTask, productTask, relatedProductTask];
100+
parentTask.sourceData.idRecordsMap.set('a01000000000001AAA', { Id: 'a01000000000001AAA' });
101+
productTask.sourceData.idRecordsMap.set('a02000000000001AAA', { Id: 'a02000000000001AAA' });
102+
relatedProductTask.sourceData.idRecordsMap.set('a03000000000001AAA', { Id: 'a03000000000001AAA' });
103+
104+
childTask.data.fieldsInQueryMap = new Map([
105+
[
106+
'Parent_Offer__c',
107+
new SFieldDescribe({
108+
name: 'Parent_Offer__c',
109+
objectName: childObject.name,
110+
type: 'reference',
111+
lookup: true,
112+
referencedObjectType: parentObject.name,
113+
parentLookupObject: parentObject,
114+
isDescribed: true,
115+
}),
116+
],
117+
[
118+
'Product__c',
119+
new SFieldDescribe({
120+
name: 'Product__c',
121+
objectName: childObject.name,
122+
type: 'reference',
123+
lookup: true,
124+
referencedObjectType: productObject.name,
125+
parentLookupObject: productObject,
126+
isDescribed: true,
127+
}),
128+
],
129+
[
130+
'Related_Product__c',
131+
new SFieldDescribe({
132+
name: 'Related_Product__c',
133+
objectName: childObject.name,
134+
type: 'reference',
135+
lookup: true,
136+
referencedObjectType: relatedProductObject.name,
137+
parentLookupObject: relatedProductObject,
138+
isDescribed: true,
139+
}),
140+
],
141+
]);
142+
143+
return { childTask, parentTask, productTask, relatedProductTask };
144+
};
145+
75146
describe('MigrationJobTask relationship external id matching', () => {
76147
it('links target records by nested relationship external id values', () => {
77148
const { task } = createTaskFixture();
@@ -97,6 +168,55 @@ describe('MigrationJobTask relationship external id matching', () => {
97168
});
98169
});
99170

171+
describe('MigrationJobTask filtered IN-clause fields', () => {
172+
it('uses every eligible lookup field when no IN-clause field filter is configured', () => {
173+
const { childTask } = createInClauseFixture();
174+
175+
const queries = childTask.createFilteredQueries('forwards', false);
176+
177+
assert.equal(queries.length, 3);
178+
assert.ok(hasFilteredInClause(queries, 'Parent_Offer__c'));
179+
assert.ok(hasFilteredInClause(queries, 'Product__c'));
180+
assert.ok(hasFilteredInClause(queries, 'Related_Product__c'));
181+
});
182+
183+
it('uses only included lookup fields as filtered IN-clause pivots', () => {
184+
const { childTask } = createInClauseFixture();
185+
childTask.scriptObject.includedInClauseFields = ['Parent_Offer__c'];
186+
187+
const queries = childTask.createFilteredQueries('forwards', false);
188+
189+
assert.equal(queries.length, 1);
190+
assert.ok(hasFilteredInClause(queries, 'Parent_Offer__c'));
191+
assert.ok(!hasFilteredInClause(queries, 'Product__c'));
192+
assert.ok(!hasFilteredInClause(queries, 'Related_Product__c'));
193+
});
194+
195+
it('skips excluded lookup fields as filtered IN-clause pivots', () => {
196+
const { childTask } = createInClauseFixture();
197+
childTask.scriptObject.excludedFromInClauseFields = ['Product__c', 'Related_Product__c'];
198+
199+
const queries = childTask.createFilteredQueries('forwards', false);
200+
201+
assert.equal(queries.length, 1);
202+
assert.ok(hasFilteredInClause(queries, 'Parent_Offer__c'));
203+
assert.ok(!hasFilteredInClause(queries, 'Product__c'));
204+
assert.ok(!hasFilteredInClause(queries, 'Related_Product__c'));
205+
});
206+
207+
it('subtracts excluded lookup fields from the IN-clause allow-list case-insensitively', () => {
208+
const { childTask } = createInClauseFixture();
209+
childTask.scriptObject.includedInClauseFields = ['parent_offer__c', 'PRODUCT__C'];
210+
childTask.scriptObject.excludedFromInClauseFields = ['product__c'];
211+
212+
const queries = childTask.createFilteredQueries('forwards', false);
213+
214+
assert.equal(queries.length, 1);
215+
assert.ok(hasFilteredInClause(queries, 'Parent_Offer__c'));
216+
assert.ok(!hasFilteredInClause(queries, 'Product__c'));
217+
});
218+
});
219+
100220
describe('MigrationJobTask field capability warnings', () => {
101221
it('warns only for explicit update query fields that are not updateable', () => {
102222
const originalLogger = Common.logger;

test/modules/script/script-loader.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ describe('ScriptLoader', () => {
7575
{
7676
name: 'Account',
7777
query: 'SELECT Id, Name FROM Account',
78+
includedInClauseFields: ['Parent_Offer__c', 'Product__c'],
79+
excludedFromInClauseFields: ['Related_Product__c'],
7880
fieldMapping: [
7981
{
8082
targetObject: 'Account',
@@ -108,6 +110,8 @@ describe('ScriptLoader', () => {
108110
assert.ok(script.objectSets[0].objects[0] instanceof ScriptObject);
109111
assert.ok(script.objectSets[0].objects[0].fieldMapping[0] instanceof ScriptMappingItem);
110112
assert.equal(script.objectSets[0].objects[0].name, '');
113+
assert.deepEqual(script.objectSets[0].objects[0].includedInClauseFields, ['Parent_Offer__c', 'Product__c']);
114+
assert.deepEqual(script.objectSets[0].objects[0].excludedFromInClauseFields, ['Related_Product__c']);
111115
assert.ok(script.orgs[0] instanceof ScriptOrg);
112116
assert.ok(script.workingJson);
113117
} finally {
@@ -264,6 +268,8 @@ describe('ScriptLoader', () => {
264268
master: null,
265269
beforeAddons: null,
266270
excludedFields: null,
271+
includedInClauseFields: null,
272+
excludedFromInClauseFields: null,
267273
},
268274
],
269275
};
@@ -305,6 +311,8 @@ describe('ScriptLoader', () => {
305311
assert.equal(object.master, null);
306312
assert.equal(object.beforeAddons, null);
307313
assert.equal(object.excludedFields, null);
314+
assert.equal(object.includedInClauseFields, null);
315+
assert.equal(object.excludedFromInClauseFields, null);
308316
} finally {
309317
Common.csvReadFileDelimiter = previousReadDelimiter;
310318
Common.csvWriteFileDelimiter = previousWriteDelimiter;
@@ -344,6 +352,8 @@ describe('ScriptLoader', () => {
344352
deleteOldData: undefined,
345353
beforeAddons: undefined,
346354
excludedFields: undefined,
355+
includedInClauseFields: undefined,
356+
excludedFromInClauseFields: undefined,
347357
},
348358
],
349359
} as unknown as ScriptPayloadType;
@@ -372,6 +382,8 @@ describe('ScriptLoader', () => {
372382
assert.equal(object.master, false);
373383
assert.deepEqual(object.beforeAddons, []);
374384
assert.deepEqual(object.excludedFields, []);
385+
assert.deepEqual(object.includedInClauseFields, []);
386+
assert.deepEqual(object.excludedFromInClauseFields, []);
375387
});
376388

377389
it('prepends flat objects to objectSets index 0 when both are present', async () => {

0 commit comments

Comments
 (0)