Skip to content

Commit fb8fd5a

Browse files
committed
feat(imports): Preserve BBID and DB rows of entities during approval
Rather than collecting all properties of the pending entity data and creating a new entity (with new BBID), we simply create an entity header and a revision which link to the existing data. This is a complete rewrite using Kysely instead of Bookshelf/Knex. The final loading of the entity model (for search indexing) is out of scope for this function and has been moved into bookbrainz-site.
1 parent 05446cd commit fb8fd5a

File tree

3 files changed

+85
-85
lines changed

3 files changed

+85
-85
lines changed

src/func/imports/approve-import.ts

+81-84
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2018 Shivam Tripathi
2+
* Copyright (C) 2024 David Kellner
33
*
44
* This program is free software; you can redistribute it and/or modify
55
* it under the terms of the GNU General Public License as published by
@@ -16,94 +16,91 @@
1616
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
1717
*/
1818

19-
import * as _ from 'lodash';
20-
import {
21-
getAdditionalEntityProps, getEntityModelByType, getEntitySetMetadataByType
22-
} from '../entity';
19+
import type {EntityType} from '../../types/schema';
2320
import type {ImportMetadataWithSourceT} from '../../types/imports';
2421
import type {ORM} from '../..';
25-
import type {Transaction} from '../types';
26-
import {createNote} from '../note';
27-
import {deleteImport} from './delete-import';
28-
import {incrementEditorEditCountById} from '../editor';
22+
import {uncapitalize} from '../../util';
2923

3024

31-
interface approveEntityPropsType {
32-
orm: ORM,
33-
transacting: Transaction,
25+
export async function approveImport({editorId, importEntity, orm}: {
26+
editorId: number,
3427
importEntity: any,
35-
editorId: string
36-
}
37-
38-
export async function approveImport(
39-
{orm, transacting, importEntity, editorId}: approveEntityPropsType
40-
): Promise<Record<string, unknown>> {
41-
const {bbid: pendingEntityBbid, type: entityType, disambiguationId, aliasSet,
42-
identifierSetId, annotationId} = importEntity;
28+
orm: ORM,
29+
}) {
30+
const {bbid, type, annotationId} = importEntity;
4331
const metadata: ImportMetadataWithSourceT = importEntity.importMetadata;
44-
const {id: aliasSetId} = aliasSet;
45-
46-
const {Annotation, Entity, Revision} = orm;
47-
48-
// Increase user edit count
49-
const editorUpdatePromise =
50-
incrementEditorEditCountById(orm, editorId, transacting);
51-
52-
// Create a new revision record
53-
const revision = await new Revision({
54-
authorId: editorId
55-
}).save(null, {transacting});
56-
const revisionId = revision.get('id');
57-
58-
if (annotationId) {
59-
// Set revision of our annotation which is NULL for imports
60-
await new Annotation({id: annotationId})
61-
.save({lastRevisionId: revisionId}, {transacting});
62-
}
63-
64-
const note = `Approved from automatically imported record of ${metadata.source}`;
65-
// Create a new note promise
66-
const notePromise = createNote(orm, note, editorId, revision, transacting);
67-
68-
// Get additional props
69-
const additionalProps = getAdditionalEntityProps(importEntity, entityType);
70-
71-
// Collect the entity sets from the importEntity
72-
const entitySetMetadata = getEntitySetMetadataByType(entityType);
73-
const entitySets = entitySetMetadata.reduce(
74-
(set, {entityIdField}) =>
75-
_.assign(set, {[entityIdField]: importEntity[entityIdField]})
76-
, {}
77-
);
78-
79-
await Promise.all([notePromise, editorUpdatePromise]);
80-
81-
const newEntity = await new Entity({type: entityType})
82-
.save(null, {transacting});
83-
const acceptedEntityBbid = newEntity.get('bbid');
84-
const propsToSet = _.extend({
85-
aliasSetId,
86-
annotationId,
87-
bbid: acceptedEntityBbid,
88-
disambiguationId,
89-
identifierSetId,
90-
revisionId
91-
}, entitySets, additionalProps);
92-
93-
const Model = getEntityModelByType(orm, entityType);
94-
95-
const entityModel = await new Model(propsToSet)
96-
.save(null, {
97-
method: 'insert',
98-
transacting
99-
});
100-
101-
const entity = await entityModel.refresh({
102-
transacting,
103-
withRelated: ['defaultAlias']
32+
const entityType = uncapitalize(type as EntityType);
33+
34+
await orm.kysely.transaction().execute(async (trx) => {
35+
const pendingUpdates = [
36+
// Mark the pending entity as accepted
37+
trx.updateTable('entity')
38+
.set('isImport', false)
39+
.where((eb) => eb.and({bbid, isImport: true}))
40+
.executeTakeFirst(),
41+
// Indicate approval of the entity by setting the accepted BBID
42+
trx.updateTable('importMetadata')
43+
.set('acceptedEntityBbid', bbid)
44+
.where('pendingEntityBbid', '=', bbid)
45+
.executeTakeFirst(),
46+
// Increment revision count of the active editor
47+
trx.updateTable('editor')
48+
.set((eb) => ({
49+
revisionsApplied: eb('revisionsApplied', '+', 1),
50+
totalRevisions: eb('totalRevisions', '+', 1),
51+
}))
52+
.where('id', '=', editorId)
53+
.executeTakeFirst(),
54+
];
55+
56+
// Create a new revision and an entity header
57+
const revision = await trx.insertInto('revision')
58+
.values({authorId: editorId})
59+
.returning('id')
60+
.executeTakeFirstOrThrow();
61+
await trx.insertInto(`${entityType}Header`)
62+
.values({bbid})
63+
.executeTakeFirstOrThrow();
64+
65+
// Create initial entity revision using the entity data from the import
66+
await trx.insertInto(`${entityType}Revision`)
67+
.values((eb) => ({
68+
bbid,
69+
dataId: eb.selectFrom(`${entityType}ImportHeader`)
70+
.select('dataId')
71+
.where('bbid', '=', bbid),
72+
id: revision.id
73+
}))
74+
.executeTakeFirstOrThrow();
75+
76+
// Update the entity header with the revision, doing this earlier causes a FK constraint violation
77+
pendingUpdates.push(trx.updateTable(`${entityType}Header`)
78+
.set('masterRevisionId', revision.id)
79+
.where('bbid', '=', bbid)
80+
.executeTakeFirst());
81+
82+
if (annotationId) {
83+
// Set revision of our annotation which is NULL for pending imports
84+
pendingUpdates.push(trx.updateTable('annotation')
85+
.set('lastRevisionId', revision.id)
86+
.where('id', '=', annotationId)
87+
.executeTakeFirst());
88+
}
89+
90+
// Create edit note
91+
await trx.insertInto('note')
92+
.values({
93+
authorId: editorId,
94+
content: `Approved automatically imported record ${metadata.externalIdentifier} from ${metadata.source}`,
95+
revisionId: revision.id,
96+
})
97+
.executeTakeFirstOrThrow();
98+
99+
return Promise.all(pendingUpdates.map(async (update) => {
100+
const {numUpdatedRows} = await update;
101+
if (Number(numUpdatedRows) !== 1) {
102+
throw new Error(`Failed to approve import of ${bbid}`);
103+
}
104+
}));
104105
});
105-
106-
await deleteImport(transacting, pendingEntityBbid, acceptedEntityBbid);
107-
108-
return entity;
109106
}

src/func/imports/delete-import.ts

-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import {camelToSnake, snakeToCamel} from '../../util';
2121
import type {Transaction} from '../types';
2222

2323

24-
// TODO: Don't call this function on approval, we want to reuse BBID and data of approved imports!
2524
export async function deleteImport(
2625
transacting: Transaction, importId: string, entityId?: string | null | undefined
2726
) {

src/util.ts

+4
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ export function camelToSnake<S, C extends object>(attrs: C) {
7979
{} as S);
8080
}
8181

82+
export function uncapitalize<T extends string>(word: T): Uncapitalize<T> {
83+
return word.replace(/^./, (first) => first.toLowerCase()) as Uncapitalize<T>;
84+
}
85+
8286
export class EntityTypeError extends Error {
8387
constructor(message: string) {
8488
super(message);

0 commit comments

Comments
 (0)