Skip to content

Commit 33ba5ac

Browse files
SIMSBIOHUB-847: Storing Feature Content Array (#332)
* Storing Feature Content Array * Lint files * Fix text/lint issue * Refactor for Sonarcloud * Rename columns per requirements * Lint files * Address feedback
1 parent 1161cae commit 33ba5ac

File tree

5 files changed

+409
-6
lines changed

5 files changed

+409
-6
lines changed

api/src/repositories/submission-repository.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1620,4 +1620,100 @@ describe('SubmissionRepository', () => {
16201620
expect(sqlStub).to.have.been.calledOnce;
16211621
});
16221622
});
1623+
1624+
describe('deleteSubmissionFeatureRelationships', () => {
1625+
afterEach(() => {
1626+
sinon.restore();
1627+
});
1628+
1629+
it('should delete relationship rows for features belonging to the submission', async () => {
1630+
const mockQueryResponse: QueryResult<never> = {
1631+
rowCount: 5,
1632+
rows: [],
1633+
command: '',
1634+
oid: 0,
1635+
fields: []
1636+
};
1637+
1638+
const sqlStub = sinon.stub().resolves(mockQueryResponse);
1639+
const mockDBConnection = getMockDBConnection({ sql: sqlStub });
1640+
1641+
const submissionRepository = new SubmissionRepository(mockDBConnection);
1642+
1643+
await submissionRepository.deleteSubmissionFeatureRelationships(1);
1644+
1645+
expect(sqlStub).to.have.been.calledOnce;
1646+
const calledSql = sqlStub.args[0][0];
1647+
expect(calledSql.text).to.include('submission_feature_feature');
1648+
expect(calledSql.text).to.include('submission_id');
1649+
});
1650+
});
1651+
1652+
describe('insertSubmissionFeatureRelationships', () => {
1653+
afterEach(() => {
1654+
sinon.restore();
1655+
});
1656+
1657+
it('should insert relationship pairs', async () => {
1658+
const sqlStub = sinon.stub().resolves({ rowCount: 2, rows: [] });
1659+
const mockDBConnection = getMockDBConnection({ sql: sqlStub });
1660+
1661+
const submissionRepository = new SubmissionRepository(mockDBConnection);
1662+
1663+
await submissionRepository.insertSubmissionFeatureRelationships([
1664+
{ source_feature_id: 1, target_feature_id: 2 },
1665+
{ source_feature_id: 1, target_feature_id: 3 }
1666+
]);
1667+
1668+
expect(sqlStub).to.have.been.calledOnce;
1669+
const calledSql = sqlStub.args[0][0];
1670+
expect(calledSql.text).to.include('submission_feature_feature');
1671+
expect(calledSql.text).to.include('ON CONFLICT');
1672+
});
1673+
1674+
it('should not execute SQL when pairs array is empty', async () => {
1675+
const sqlStub = sinon.stub().resolves({ rowCount: 0, rows: [] });
1676+
const mockDBConnection = getMockDBConnection({ sql: sqlStub });
1677+
1678+
const submissionRepository = new SubmissionRepository(mockDBConnection);
1679+
1680+
await submissionRepository.insertSubmissionFeatureRelationships([]);
1681+
1682+
expect(sqlStub).not.to.have.been.called;
1683+
});
1684+
});
1685+
1686+
describe('getRelatedSubmissionFeatureIds', () => {
1687+
afterEach(() => {
1688+
sinon.restore();
1689+
});
1690+
1691+
it('should return source and target IDs for a feature', async () => {
1692+
const mockParentResponse = {
1693+
rows: [{ source_feature_id: 10 }],
1694+
rowCount: 1
1695+
};
1696+
const mockChildResponse = {
1697+
rows: [{ target_feature_id: 2 }, { target_feature_id: 3 }],
1698+
rowCount: 2
1699+
};
1700+
1701+
const sqlStub = sinon
1702+
.stub()
1703+
.onFirstCall()
1704+
.resolves(mockParentResponse)
1705+
.onSecondCall()
1706+
.resolves(mockChildResponse);
1707+
1708+
const mockDBConnection = getMockDBConnection({ sql: sqlStub });
1709+
1710+
const submissionRepository = new SubmissionRepository(mockDBConnection);
1711+
1712+
const result = await submissionRepository.getRelatedSubmissionFeatureIds(1);
1713+
1714+
expect(result.sourceIds).to.deep.equal([10]);
1715+
expect(result.targetIds).to.deep.equal([2, 3]);
1716+
expect(sqlStub).to.have.been.calledTwice;
1717+
});
1718+
});
16231719
});

api/src/repositories/submission-repository.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,92 @@ export class SubmissionRepository extends BaseRepository {
480480
await this.connection.sql(sqlStatement);
481481
}
482482

483+
/**
484+
* Delete all submission feature relationships for features belonging to a submission.
485+
* Used for idempotency - allows job retries to start fresh.
486+
*
487+
* @param {number} submissionId The submission ID.
488+
* @return {Promise<void>}
489+
* @memberof SubmissionRepository
490+
*/
491+
async deleteSubmissionFeatureRelationships(submissionId: number): Promise<void> {
492+
const sqlStatement = SQL`
493+
DELETE FROM submission_feature_feature
494+
WHERE source_feature_id IN (
495+
SELECT submission_feature_id FROM submission_feature WHERE submission_id = ${submissionId}
496+
)
497+
OR target_feature_id IN (
498+
SELECT submission_feature_id FROM submission_feature WHERE submission_id = ${submissionId}
499+
);
500+
`;
501+
502+
await this.connection.sql(sqlStatement);
503+
}
504+
505+
/**
506+
* Insert submission feature relationships (source-target from content array).
507+
* Uses ON CONFLICT DO NOTHING to avoid failures on duplicate pairs.
508+
*
509+
* @param {Array<{ source_feature_id: number; target_feature_id: number }>} pairs The pairs to insert.
510+
* @return {Promise<void>}
511+
* @memberof SubmissionRepository
512+
*/
513+
async insertSubmissionFeatureRelationships(
514+
pairs: Array<{ source_feature_id: number; target_feature_id: number }>
515+
): Promise<void> {
516+
if (pairs.length === 0) {
517+
return;
518+
}
519+
520+
const sourceIds = pairs.map((p) => p.source_feature_id);
521+
const targetIds = pairs.map((p) => p.target_feature_id);
522+
523+
const sqlStatement = SQL`
524+
INSERT INTO submission_feature_feature (source_feature_id, target_feature_id)
525+
SELECT source_id, target_id FROM unnest(
526+
${sourceIds}::integer[],
527+
${targetIds}::integer[]
528+
) AS t(source_id, target_id)
529+
ON CONFLICT (source_feature_id, target_feature_id) DO NOTHING;
530+
`;
531+
532+
await this.connection.sql(sqlStatement);
533+
}
534+
535+
/**
536+
* Get all related submission feature IDs for a given feature (sources and targets).
537+
* sourceIds = features that reference this one; targetIds = features this one references.
538+
*
539+
* @param {number} submissionFeatureId The submission feature ID.
540+
* @return {Promise<{ sourceIds: number[]; targetIds: number[] }>}
541+
* @memberof SubmissionRepository
542+
*/
543+
async getRelatedSubmissionFeatureIds(
544+
submissionFeatureId: number
545+
): Promise<{ sourceIds: number[]; targetIds: number[] }> {
546+
const parentSqlStatement = SQL`
547+
SELECT source_feature_id
548+
FROM submission_feature_feature
549+
WHERE target_feature_id = ${submissionFeatureId};
550+
`;
551+
552+
const childSqlStatement = SQL`
553+
SELECT target_feature_id
554+
FROM submission_feature_feature
555+
WHERE source_feature_id = ${submissionFeatureId};
556+
`;
557+
558+
const [parentResult, childResult] = await Promise.all([
559+
this.connection.sql<{ source_feature_id: number }>(parentSqlStatement),
560+
this.connection.sql<{ target_feature_id: number }>(childSqlStatement)
561+
]);
562+
563+
return {
564+
sourceIds: parentResult.rows.map((r) => r.source_feature_id),
565+
targetIds: childResult.rows.map((r) => r.target_feature_id)
566+
};
567+
}
568+
483569
/**
484570
* Get feature type id by name.
485571
*

api/src/services/feature-ingestion-service.test.ts

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -670,13 +670,20 @@ describe('FeatureIngestionService', () => {
670670
.resolves(mockFeatureTypeWithProperties);
671671

672672
const deleteStub = sinon.stub(SubmissionRepository.prototype, 'deleteSubmissionFeatures').resolves();
673+
const deleteRelationshipsStub = sinon
674+
.stub(SubmissionRepository.prototype, 'deleteSubmissionFeatureRelationships')
675+
.resolves();
673676

674677
const insertStub = sinon.stub(SubmissionRepository.prototype, 'insertSubmissionFeatureRecord');
675678
insertStub.onFirstCall().resolves({ submission_feature_id: 100 });
676679
insertStub.onSecondCall().resolves({ submission_feature_id: 101 });
677680

678681
const updateParentStub = sinon.stub(SubmissionRepository.prototype, 'updateSubmissionFeatureParent').resolves();
679682

683+
const insertRelationshipsStub = sinon
684+
.stub(SubmissionRepository.prototype, 'insertSubmissionFeatureRelationships')
685+
.resolves();
686+
680687
const features: IFlattenedBlock[] = [
681688
createValidFeature({ id: 'uuid-1', content: ['uuid-2'] }),
682689
createValidFeature({ id: 'uuid-2', parent: 'uuid-1', content: [] })
@@ -687,8 +694,10 @@ describe('FeatureIngestionService', () => {
687694
expect(result.valid).to.be.true;
688695
expect(result.errors).to.have.length(0);
689696
expect(deleteStub).to.have.been.calledOnceWith(1);
697+
expect(deleteRelationshipsStub).to.have.been.calledOnceWith(1);
690698
expect(insertStub).to.have.been.calledTwice;
691699
expect(updateParentStub).to.have.been.calledOnceWith(101, 100);
700+
expect(insertRelationshipsStub).to.have.been.calledOnceWith([{ source_feature_id: 100, target_feature_id: 101 }]);
692701
});
693702

694703
it('should return all errors when validation fails', async () => {
@@ -1037,7 +1046,7 @@ describe('FeatureIngestionService', () => {
10371046
expect(insertStub.secondCall.args[4]).to.deep.equal(propsMinimal);
10381047
});
10391048

1040-
it('should call deleteSubmissionFeatures before inserting', async () => {
1049+
it('should call deleteSubmissionFeatures and deleteSubmissionFeatureRelationships before inserting', async () => {
10411050
const mockDBConnection = getMockDBConnection();
10421051
const service = new FeatureIngestionService(mockDBConnection);
10431052

@@ -1051,6 +1060,12 @@ describe('FeatureIngestionService', () => {
10511060
callOrder.push('delete');
10521061
});
10531062

1063+
const deleteRelationshipsStub = sinon
1064+
.stub(SubmissionRepository.prototype, 'deleteSubmissionFeatureRelationships')
1065+
.callsFake(async () => {
1066+
callOrder.push('deleteRelationships');
1067+
});
1068+
10541069
const insertStub = sinon
10551070
.stub(SubmissionRepository.prototype, 'insertSubmissionFeatureRecord')
10561071
.callsFake(async () => {
@@ -1059,14 +1074,89 @@ describe('FeatureIngestionService', () => {
10591074
});
10601075

10611076
sinon.stub(SubmissionRepository.prototype, 'updateSubmissionFeatureParent').resolves();
1077+
sinon.stub(SubmissionRepository.prototype, 'insertSubmissionFeatureRelationships').resolves();
10621078

10631079
const features: IFlattenedBlock[] = [createValidFeature()];
10641080

10651081
await service.ingestFeatures(1, features);
10661082

10671083
expect(callOrder[0]).to.equal('delete');
1068-
expect(callOrder[1]).to.equal('insert');
1069-
expect(deleteStub).to.have.been.calledBefore(insertStub);
1084+
expect(callOrder[1]).to.equal('deleteRelationships');
1085+
expect(callOrder[2]).to.equal('insert');
1086+
expect(deleteStub).to.have.been.calledOnceWith(1);
1087+
expect(deleteRelationshipsStub).to.have.been.calledOnceWith(1);
1088+
expect(insertStub).to.have.been.calledOnce;
1089+
});
1090+
1091+
it('should not call insertSubmissionFeatureRelationships when all features have empty content', async () => {
1092+
const mockDBConnection = getMockDBConnection();
1093+
const service = new FeatureIngestionService(mockDBConnection);
1094+
1095+
sinon
1096+
.stub(ValidationRepository.prototype, 'getFeatureTypeWithProperties')
1097+
.resolves(mockFeatureTypeWithProperties);
1098+
1099+
sinon.stub(SubmissionRepository.prototype, 'deleteSubmissionFeatures').resolves();
1100+
sinon.stub(SubmissionRepository.prototype, 'deleteSubmissionFeatureRelationships').resolves();
1101+
1102+
const insertStub = sinon
1103+
.stub(SubmissionRepository.prototype, 'insertSubmissionFeatureRecord')
1104+
.resolves({ submission_feature_id: 1 });
1105+
1106+
sinon.stub(SubmissionRepository.prototype, 'updateSubmissionFeatureParent').resolves();
1107+
1108+
const insertRelationshipsStub = sinon
1109+
.stub(SubmissionRepository.prototype, 'insertSubmissionFeatureRelationships')
1110+
.resolves();
1111+
1112+
const features: IFlattenedBlock[] = [
1113+
createValidFeature({ id: 'uuid-1', content: [], parent: null }),
1114+
createValidFeature({ id: 'uuid-2', content: [], parent: 'uuid-1' })
1115+
];
1116+
1117+
await service.ingestFeatures(1, features);
1118+
1119+
expect(insertStub).to.have.been.calledTwice;
1120+
expect(insertRelationshipsStub).to.not.have.been.called;
1121+
});
1122+
1123+
it('should insert relationship rows for parent with multiple children', async () => {
1124+
const mockDBConnection = getMockDBConnection();
1125+
const service = new FeatureIngestionService(mockDBConnection);
1126+
1127+
sinon
1128+
.stub(ValidationRepository.prototype, 'getFeatureTypeWithProperties')
1129+
.resolves(mockFeatureTypeWithProperties);
1130+
1131+
sinon.stub(SubmissionRepository.prototype, 'deleteSubmissionFeatures').resolves();
1132+
sinon.stub(SubmissionRepository.prototype, 'deleteSubmissionFeatureRelationships').resolves();
1133+
1134+
const insertStub = sinon.stub(SubmissionRepository.prototype, 'insertSubmissionFeatureRecord');
1135+
insertStub.onFirstCall().resolves({ submission_feature_id: 10 });
1136+
insertStub.onSecondCall().resolves({ submission_feature_id: 20 });
1137+
insertStub.onThirdCall().resolves({ submission_feature_id: 30 });
1138+
insertStub.onCall(3).resolves({ submission_feature_id: 40 });
1139+
1140+
sinon.stub(SubmissionRepository.prototype, 'updateSubmissionFeatureParent').resolves();
1141+
1142+
const insertRelationshipsStub = sinon
1143+
.stub(SubmissionRepository.prototype, 'insertSubmissionFeatureRelationships')
1144+
.resolves();
1145+
1146+
const features: IFlattenedBlock[] = [
1147+
createValidFeature({ id: 'parent', parent: null, content: ['child-1', 'child-2', 'child-3'] }),
1148+
createValidFeature({ id: 'child-1', parent: 'parent', content: [] }),
1149+
createValidFeature({ id: 'child-2', parent: 'parent', content: [] }),
1150+
createValidFeature({ id: 'child-3', parent: 'parent', content: [] })
1151+
];
1152+
1153+
await service.ingestFeatures(1, features);
1154+
1155+
expect(insertRelationshipsStub).to.have.been.calledOnceWith([
1156+
{ source_feature_id: 10, target_feature_id: 20 },
1157+
{ source_feature_id: 10, target_feature_id: 30 },
1158+
{ source_feature_id: 10, target_feature_id: 40 }
1159+
]);
10701160
});
10711161

10721162
it('should insert all features before updating any parent references', async () => {

0 commit comments

Comments
 (0)