mirror of
https://github.com/outline/outline.git
synced 2026-05-07 02:20:56 -05:00
Migrate Backlink model to Relationship (#9370)
* Migrate Backlink model to generic Relationship model - Create new Relationship model with type field to support different relationship types - Add database migration to create relationships table and migrate existing backlinks - Update Backlink model to delegate to Relationship model for backward compatibility - Update BacklinksProcessor to use Relationship model with backlink type - Update API routes to use new Relationship model - Update test files to use Relationship model - Maintain backward compatibility through database view and model delegation Fixes #9366 * Update migration to rename table instead of creating new one - Rename existing backlinks table to relationships instead of creating new table - Add type column with default value to existing table - Update existing rows to have type='backlink' - Avoid expensive data migration by keeping existing data in place - Maintain backward compatibility with database view - Update rollback to reverse table rename and column addition This approach is much more efficient for large datasets as it avoids copying millions of rows. * Remove unnecessary UPDATE statement from migration The UPDATE statement is not needed since defaultValue automatically applies to existing rows when adding a column with a default value. Thanks @tommoor for catching this! * Wrap up migration in transaction - Wrap all migration operations in a transaction for atomicity - Add transaction parameter to all queryInterface calls - Follow the same pattern as other migrations in the codebase - Ensures all operations succeed or fail together * Remove Backlink class entirely and use Relationship everywhere - Delete server/models/Backlink.ts - Remove Backlink export from server/models/index.ts - Remove Backlink import and association from Document model - All functionality now uses Relationship model with RelationshipType.Backlink - Maintains same API through Relationship model methods - Cleaner architecture with single relationship model * Update documents.test.ts to use RelationshipType enum instead of string - Import RelationshipType from Relationship model - Replace type: "backlink" with type: RelationshipType.Backlink - Improves type safety and consistency with enum usage * Address code review feedback - Add transaction wrapper to migration down method for safer rollback - Remove unused findByTypeForUser method from Relationship model - Method wasn't used and won't work for all relationship types (e.g., user mentions) - Clean up code structure and improve safety * Restore imports --------- Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com> Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up (queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||
// Rename the existing backlinks table to relationships
|
||||
await queryInterface.renameTable("backlinks", "relationships", { transaction });
|
||||
|
||||
// Add the type column with default value
|
||||
await queryInterface.addColumn("relationships", "type", {
|
||||
type: Sequelize.ENUM('backlink'),
|
||||
allowNull: false,
|
||||
defaultValue: 'backlink',
|
||||
}, { transaction });
|
||||
|
||||
// Add new indexes for performance (the old indexes on documentId and reverseDocumentId should still exist)
|
||||
await queryInterface.addIndex("relationships", ["type"], { transaction });
|
||||
await queryInterface.addIndex("relationships", ["documentId", "type"], { transaction });
|
||||
|
||||
// Create a view for backward compatibility
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE VIEW backlinks AS
|
||||
SELECT id, "userId", "documentId", "reverseDocumentId", "createdAt", "updatedAt"
|
||||
FROM relationships
|
||||
WHERE type = 'backlink';
|
||||
`, { transaction });
|
||||
});
|
||||
},
|
||||
|
||||
async down (queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||
// Drop the view
|
||||
await queryInterface.sequelize.query('DROP VIEW IF EXISTS backlinks;', { transaction });
|
||||
|
||||
// Remove the type-specific indexes
|
||||
await queryInterface.removeIndex("relationships", ["type"], { transaction });
|
||||
await queryInterface.removeIndex("relationships", ["documentId", "type"], { transaction });
|
||||
|
||||
// Remove the type column
|
||||
await queryInterface.removeColumn("relationships", "type", { transaction });
|
||||
|
||||
// Drop the enum type
|
||||
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_relationships_type";', { transaction });
|
||||
|
||||
// Rename the table back to backlinks
|
||||
await queryInterface.renameTable("relationships", "backlinks", { transaction });
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -51,13 +51,13 @@ import slugify from "@shared/utils/slugify";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import { generateUrlId } from "@server/utils/url";
|
||||
import Backlink from "./Backlink";
|
||||
import Collection from "./Collection";
|
||||
import FileOperation from "./FileOperation";
|
||||
import Group from "./Group";
|
||||
import GroupMembership from "./GroupMembership";
|
||||
import GroupUser from "./GroupUser";
|
||||
import Import from "./Import";
|
||||
import Relationship from "./Relationship";
|
||||
import Revision from "./Revision";
|
||||
import Star from "./Star";
|
||||
import Team from "./Team";
|
||||
@@ -617,8 +617,8 @@ class Document extends ArchivableModel<
|
||||
@HasMany(() => Revision)
|
||||
revisions: Revision[];
|
||||
|
||||
@HasMany(() => Backlink)
|
||||
backlinks: Backlink[];
|
||||
@HasMany(() => Relationship)
|
||||
relationships: Relationship[];
|
||||
|
||||
@HasMany(() => Star)
|
||||
starred: Star[];
|
||||
|
||||
@@ -11,11 +11,15 @@ import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
@Table({ tableName: "backlinks", modelName: "backlink" })
|
||||
export enum RelationshipType {
|
||||
Backlink = "backlink",
|
||||
}
|
||||
|
||||
@Table({ tableName: "relationships", modelName: "relationship" })
|
||||
@Fix
|
||||
class Backlink extends IdModel<
|
||||
InferAttributes<Backlink>,
|
||||
Partial<InferCreationAttributes<Backlink>>
|
||||
class Relationship extends IdModel<
|
||||
InferAttributes<Relationship>,
|
||||
Partial<InferCreationAttributes<Relationship>>
|
||||
> {
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
@@ -38,6 +42,13 @@ class Backlink extends IdModel<
|
||||
@Column(DataType.UUID)
|
||||
reverseDocumentId: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.ENUM(...Object.values(RelationshipType)),
|
||||
allowNull: false,
|
||||
defaultValue: RelationshipType.Backlink,
|
||||
})
|
||||
type: RelationshipType;
|
||||
|
||||
/**
|
||||
* Find all backlinks for a document that the user has access to
|
||||
*
|
||||
@@ -48,15 +59,16 @@ class Backlink extends IdModel<
|
||||
documentId: string,
|
||||
user: User
|
||||
) {
|
||||
const backlinks = await this.findAll({
|
||||
const relationships = await this.findAll({
|
||||
attributes: ["reverseDocumentId"],
|
||||
where: {
|
||||
documentId,
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
});
|
||||
|
||||
const documents = await Document.findByIds(
|
||||
backlinks.map((backlink) => backlink.reverseDocumentId),
|
||||
relationships.map((relationship) => relationship.reverseDocumentId),
|
||||
{ userId: user.id }
|
||||
);
|
||||
|
||||
@@ -64,4 +76,4 @@ class Backlink extends IdModel<
|
||||
}
|
||||
}
|
||||
|
||||
export default Backlink;
|
||||
export default Relationship;
|
||||
@@ -4,7 +4,7 @@ export { default as Attachment } from "./Attachment";
|
||||
|
||||
export { default as AuthenticationProvider } from "./AuthenticationProvider";
|
||||
|
||||
export { default as Backlink } from "./Backlink";
|
||||
export { default as Relationship } from "./Relationship";
|
||||
|
||||
export { default as Collection } from "./Collection";
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { parser } from "@server/editor";
|
||||
import { Backlink } from "@server/models";
|
||||
import { Relationship } from "@server/models";
|
||||
import { RelationshipType } from "@server/models/Relationship";
|
||||
import { buildDocument } from "@server/test/factories";
|
||||
|
||||
import BacklinksProcessor from "./BacklinksProcessor";
|
||||
|
||||
const ip = "127.0.0.1";
|
||||
@@ -22,9 +24,10 @@ describe("documents.publish", () => {
|
||||
data: { title: document.title },
|
||||
ip,
|
||||
});
|
||||
const backlinks = await Backlink.findAll({
|
||||
const backlinks = await Relationship.findAll({
|
||||
where: {
|
||||
reverseDocumentId: document.id,
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
});
|
||||
expect(backlinks.length).toBe(1);
|
||||
@@ -52,9 +55,10 @@ describe("documents.publish", () => {
|
||||
data: { title: document.title },
|
||||
ip,
|
||||
});
|
||||
const backlinks = await Backlink.findAll({
|
||||
const backlinks = await Relationship.findAll({
|
||||
where: {
|
||||
reverseDocumentId: document.id,
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
});
|
||||
expect(backlinks.length).toBe(0);
|
||||
@@ -79,9 +83,10 @@ describe("documents.update", () => {
|
||||
data: { title: document.title, autosave: false, done: true },
|
||||
ip,
|
||||
});
|
||||
const backlinks = await Backlink.findAll({
|
||||
const backlinks = await Relationship.findAll({
|
||||
where: {
|
||||
reverseDocumentId: document.id,
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
});
|
||||
expect(backlinks.length).toBe(1);
|
||||
@@ -109,9 +114,10 @@ describe("documents.update", () => {
|
||||
data: { title: document.title, autosave: false, done: true },
|
||||
ip,
|
||||
});
|
||||
const backlinks = await Backlink.findAll({
|
||||
const backlinks = await Relationship.findAll({
|
||||
where: {
|
||||
reverseDocumentId: document.id,
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
});
|
||||
expect(backlinks.length).toBe(1);
|
||||
@@ -136,9 +142,10 @@ describe("documents.update", () => {
|
||||
data: { title: document.title, autosave: false, done: true },
|
||||
ip,
|
||||
});
|
||||
const backlinks = await Backlink.findAll({
|
||||
const backlinks = await Relationship.findAll({
|
||||
where: {
|
||||
reverseDocumentId: document.id,
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
});
|
||||
expect(backlinks.length).toBe(1);
|
||||
@@ -182,9 +189,10 @@ describe("documents.update", () => {
|
||||
data: { title: document.title, autosave: false, done: true },
|
||||
ip,
|
||||
});
|
||||
const backlinks = await Backlink.findAll({
|
||||
const backlinks = await Relationship.findAll({
|
||||
where: {
|
||||
reverseDocumentId: document.id,
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
});
|
||||
expect(backlinks.length).toBe(1);
|
||||
@@ -222,9 +230,10 @@ describe("documents.delete", () => {
|
||||
data: { title: document.title },
|
||||
ip,
|
||||
});
|
||||
const backlinks = await Backlink.findAll({
|
||||
const backlinks = await Relationship.findAll({
|
||||
where: {
|
||||
reverseDocumentId: document.id,
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
});
|
||||
expect(backlinks.length).toBe(0);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Op } from "sequelize";
|
||||
import { Document, Backlink } from "@server/models";
|
||||
import { Document, Relationship } from "@server/models";
|
||||
import { RelationshipType } from "@server/models/Relationship";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { Event, DocumentEvent, RevisionEvent } from "@server/types";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
@@ -27,13 +28,15 @@ export default class BacklinksProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
await Backlink.findOrCreate({
|
||||
await Relationship.findOrCreate({
|
||||
where: {
|
||||
documentId: linkedDocument.id,
|
||||
reverseDocumentId: event.documentId,
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
defaults: {
|
||||
userId: document.lastModifiedById,
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
});
|
||||
})
|
||||
@@ -64,13 +67,15 @@ export default class BacklinksProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
await Backlink.findOrCreate({
|
||||
await Relationship.findOrCreate({
|
||||
where: {
|
||||
documentId: linkedDocument.id,
|
||||
reverseDocumentId: event.documentId,
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
defaults: {
|
||||
userId: document.lastModifiedById,
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
});
|
||||
linkedDocumentIds.push(linkedDocument.id);
|
||||
@@ -78,19 +83,20 @@ export default class BacklinksProcessor extends BaseProcessor {
|
||||
);
|
||||
|
||||
// delete any backlinks that no longer exist
|
||||
await Backlink.destroy({
|
||||
await Relationship.destroy({
|
||||
where: {
|
||||
documentId: {
|
||||
[Op.notIn]: linkedDocumentIds,
|
||||
},
|
||||
reverseDocumentId: event.documentId,
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "documents.delete": {
|
||||
await Backlink.destroy({
|
||||
await Relationship.destroy({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{
|
||||
@@ -100,6 +106,7 @@ export default class BacklinksProcessor extends BaseProcessor {
|
||||
documentId: event.documentId,
|
||||
},
|
||||
],
|
||||
type: RelationshipType.Backlink,
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -13,13 +13,14 @@ import {
|
||||
Document,
|
||||
View,
|
||||
Revision,
|
||||
Backlink,
|
||||
UserMembership,
|
||||
SearchQuery,
|
||||
Event,
|
||||
User,
|
||||
GroupMembership,
|
||||
Relationship,
|
||||
} from "@server/models";
|
||||
import { RelationshipType } from "@server/models/Relationship";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import {
|
||||
buildShare,
|
||||
@@ -1033,8 +1034,9 @@ describe("#documents.list", () => {
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await Backlink.create({
|
||||
await Relationship.create({
|
||||
reverseDocumentId: anotherDoc.id,
|
||||
type: RelationshipType.Backlink,
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import {
|
||||
Attachment,
|
||||
Backlink,
|
||||
Relationship,
|
||||
Collection,
|
||||
Document,
|
||||
Event,
|
||||
@@ -209,7 +209,7 @@ router.post(
|
||||
}
|
||||
|
||||
if (backlinkDocumentId) {
|
||||
const sourceDocumentIds = await Backlink.findSourceDocumentIdsForUser(
|
||||
const sourceDocumentIds = await Relationship.findSourceDocumentIdsForUser(
|
||||
backlinkDocumentId,
|
||||
user
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user