fix(history-entry): remove composite primary keys

TypeORM promises to support composite primary keys,
but that does not work in reality.
This replaces the composite key used in the permission entities with
a single generated primary key and
a unique index on the relation columns.

See https://github.com/typeorm/typeorm/issues/8513

Signed-off-by: David Mehren <git@herrmehren.de>
This commit is contained in:
David Mehren 2022-09-18 18:59:10 +02:00
parent d1c3058655
commit a626ace4b9
7 changed files with 60 additions and 55 deletions

View file

@ -6,8 +6,9 @@
import { import {
Column, Column,
Entity, Entity,
Index,
ManyToOne, ManyToOne,
PrimaryColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
@ -15,28 +16,22 @@ import { Note } from '../notes/note.entity';
import { User } from '../users/user.entity'; import { User } from '../users/user.entity';
@Entity() @Entity()
@Index(['note', 'user'], { unique: true })
export class HistoryEntry { export class HistoryEntry {
/** @PrimaryGeneratedColumn()
* The `user` and `note` properties cannot be converted to promises id: number;
* (to lazy-load them), as TypeORM gets confused about lazy composite
* primary keys.
* See https://github.com/typeorm/typeorm/issues/6908
*/
@PrimaryColumn()
userId: string;
@ManyToOne((_) => User, (user) => user.historyEntries, { @ManyToOne((_) => User, (user) => user.historyEntries, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
orphanedRowAction: 'delete', // This ensures the whole row is deleted when the Entry stops being referenced
}) })
user: User; user: Promise<User>;
@PrimaryColumn()
noteId: string;
@ManyToOne((_) => Note, (note) => note.historyEntries, { @ManyToOne((_) => Note, (note) => note.historyEntries, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
orphanedRowAction: 'delete', // This ensures the whole row is deleted when the Entry stops being referenced
}) })
note: Note; note: Promise<Note>;
@Column() @Column()
pinStatus: boolean; pinStatus: boolean;
@ -56,8 +51,8 @@ export class HistoryEntry {
pinStatus = false, pinStatus = false,
): Omit<HistoryEntry, 'updatedAt'> { ): Omit<HistoryEntry, 'updatedAt'> {
const newHistoryEntry = new HistoryEntry(); const newHistoryEntry = new HistoryEntry();
newHistoryEntry.user = user; newHistoryEntry.user = Promise.resolve(user);
newHistoryEntry.note = note; newHistoryEntry.note = Promise.resolve(note);
newHistoryEntry.pinStatus = pinStatus; newHistoryEntry.pinStatus = pinStatus;
return newHistoryEntry; return newHistoryEntry;
} }

View file

@ -178,10 +178,12 @@ describe('HistoryService', () => {
Note.create(user, alias) as Note, Note.create(user, alias) as Note,
user, user,
); );
expect(await createHistoryEntry.note.aliases).toHaveLength(1); expect(await (await createHistoryEntry.note).aliases).toHaveLength(1);
expect((await createHistoryEntry.note.aliases)[0].name).toEqual(alias); expect((await (await createHistoryEntry.note).aliases)[0].name).toEqual(
expect(await createHistoryEntry.note.owner).toEqual(user); alias,
expect(createHistoryEntry.user).toEqual(user); );
expect(await (await createHistoryEntry.note).owner).toEqual(user);
expect(await createHistoryEntry.user).toEqual(user);
expect(createHistoryEntry.pinStatus).toEqual(false); expect(createHistoryEntry.pinStatus).toEqual(false);
}); });
@ -196,10 +198,12 @@ describe('HistoryService', () => {
Note.create(user, alias) as Note, Note.create(user, alias) as Note,
user, user,
); );
expect(await createHistoryEntry.note.aliases).toHaveLength(1); expect(await (await createHistoryEntry.note).aliases).toHaveLength(1);
expect((await createHistoryEntry.note.aliases)[0].name).toEqual(alias); expect((await (await createHistoryEntry.note).aliases)[0].name).toEqual(
expect(await createHistoryEntry.note.owner).toEqual(user); alias,
expect(createHistoryEntry.user).toEqual(user); );
expect(await (await createHistoryEntry.note).owner).toEqual(user);
expect(await createHistoryEntry.user).toEqual(user);
expect(createHistoryEntry.pinStatus).toEqual(false); expect(createHistoryEntry.pinStatus).toEqual(false);
expect(createHistoryEntry.updatedAt.getTime()).toBeGreaterThanOrEqual( expect(createHistoryEntry.updatedAt.getTime()).toBeGreaterThanOrEqual(
historyEntry.updatedAt.getTime(), historyEntry.updatedAt.getTime(),
@ -231,10 +235,12 @@ describe('HistoryService', () => {
pinStatus: true, pinStatus: true,
}, },
); );
expect(await updatedHistoryEntry.note.aliases).toHaveLength(1); expect(await (await updatedHistoryEntry.note).aliases).toHaveLength(1);
expect((await updatedHistoryEntry.note.aliases)[0].name).toEqual(alias); expect(
expect(await updatedHistoryEntry.note.owner).toEqual(user); (await (await updatedHistoryEntry.note).aliases)[0].name,
expect(updatedHistoryEntry.user).toEqual(user); ).toEqual(alias);
expect(await (await updatedHistoryEntry.note).owner).toEqual(user);
expect(await updatedHistoryEntry.user).toEqual(user);
expect(updatedHistoryEntry.pinStatus).toEqual(true); expect(updatedHistoryEntry.pinStatus).toEqual(true);
}); });
@ -357,13 +363,13 @@ describe('HistoryService', () => {
remove: jest remove: jest
.fn() .fn()
.mockImplementationOnce(async (entry: HistoryEntry) => { .mockImplementationOnce(async (entry: HistoryEntry) => {
expect(await entry.note.aliases).toHaveLength(1); expect(await (await entry.note).aliases).toHaveLength(1);
expect((await entry.note.aliases)[0].name).toEqual(alias); expect((await (await entry.note).aliases)[0].name).toEqual(alias);
expect(entry.pinStatus).toEqual(false); expect(entry.pinStatus).toEqual(false);
}), }),
save: jest.fn().mockImplementationOnce((entry: HistoryEntry) => { save: jest.fn().mockImplementationOnce(async (entry: HistoryEntry) => {
expect(entry.note.aliases).toEqual( expect((await entry.note).aliases).toEqual(
newlyCreatedHistoryEntry.note.aliases, (await newlyCreatedHistoryEntry.note).aliases,
); );
expect(entry.pinStatus).toEqual(newlyCreatedHistoryEntry.pinStatus); expect(entry.pinStatus).toEqual(newlyCreatedHistoryEntry.pinStatus);
expect(entry.updatedAt).toEqual(newlyCreatedHistoryEntry.updatedAt); expect(entry.updatedAt).toEqual(newlyCreatedHistoryEntry.updatedAt);

View file

@ -177,8 +177,8 @@ export class HistoryService {
return { return {
identifier: await getIdentifier(entry), identifier: await getIdentifier(entry),
lastVisitedAt: entry.updatedAt, lastVisitedAt: entry.updatedAt,
tags: await this.notesService.toTagList(entry.note), tags: await this.notesService.toTagList(await entry.note),
title: entry.note.title ?? '', title: (await entry.note).title ?? '',
pinStatus: entry.pinStatus, pinStatus: entry.pinStatus,
}; };
} }

View file

@ -7,13 +7,13 @@ import { getPrimaryAlias } from '../notes/utils';
import { HistoryEntry } from './history-entry.entity'; import { HistoryEntry } from './history-entry.entity';
export async function getIdentifier(entry: HistoryEntry): Promise<string> { export async function getIdentifier(entry: HistoryEntry): Promise<string> {
const aliases = await entry.note.aliases; const aliases = await (await entry.note).aliases;
if (!aliases || aliases.length === 0) { if (!aliases || aliases.length === 0) {
return entry.note.publicId; return (await entry.note).publicId;
} }
const primaryAlias = await getPrimaryAlias(entry.note); const primaryAlias = await getPrimaryAlias(await entry.note);
if (primaryAlias === undefined) { if (primaryAlias === undefined) {
return entry.note.publicId; return (await entry.note).publicId;
} }
return primaryAlias; return primaryAlias;
} }

View file

@ -313,7 +313,7 @@ describe('NotesService', () => {
expect(revisions).toHaveLength(1); expect(revisions).toHaveLength(1);
expect(revisions[0].content).toEqual(content); expect(revisions[0].content).toEqual(content);
expect(await newNote.historyEntries).toHaveLength(1); expect(await newNote.historyEntries).toHaveLength(1);
expect((await newNote.historyEntries)[0].user).toEqual(user); expect(await (await newNote.historyEntries)[0].user).toEqual(user);
expect(await newNote.userPermissions).toHaveLength(0); expect(await newNote.userPermissions).toHaveLength(0);
expect(await newNote.groupPermissions).toHaveLength(0); expect(await newNote.groupPermissions).toHaveLength(0);
expect(await newNote.tags).toHaveLength(0); expect(await newNote.tags).toHaveLength(0);
@ -338,7 +338,7 @@ describe('NotesService', () => {
expect(revisions).toHaveLength(1); expect(revisions).toHaveLength(1);
expect(revisions[0].content).toEqual(content); expect(revisions[0].content).toEqual(content);
expect(await newNote.historyEntries).toHaveLength(1); expect(await newNote.historyEntries).toHaveLength(1);
expect((await newNote.historyEntries)[0].user).toEqual(user); expect(await (await newNote.historyEntries)[0].user).toEqual(user);
expect(await newNote.userPermissions).toHaveLength(0); expect(await newNote.userPermissions).toHaveLength(0);
expect(await newNote.groupPermissions).toHaveLength(0); expect(await newNote.groupPermissions).toHaveLength(0);
expect(await newNote.tags).toHaveLength(0); expect(await newNote.tags).toHaveLength(0);

View file

@ -101,16 +101,16 @@ describe('History', () => {
.expect(201); .expect(201);
const userEntries = await testSetup.historyService.getEntriesByUser(user); const userEntries = await testSetup.historyService.getEntriesByUser(user);
expect(userEntries.length).toEqual(1); expect(userEntries.length).toEqual(1);
expect((await userEntries[0].note.aliases)[0].name).toEqual( expect((await (await userEntries[0].note).aliases)[0].name).toEqual(
(await note2.aliases)[0].name, (await note2.aliases)[0].name,
); );
expect((await userEntries[0].note.aliases)[0].primary).toEqual( expect((await (await userEntries[0].note).aliases)[0].primary).toEqual(
(await note2.aliases)[0].primary, (await note2.aliases)[0].primary,
); );
expect((await userEntries[0].note.aliases)[0].id).toEqual( expect((await (await userEntries[0].note).aliases)[0].id).toEqual(
(await note2.aliases)[0].id, (await note2.aliases)[0].id,
); );
expect(userEntries[0].user.username).toEqual(user.username); expect((await userEntries[0].user).username).toEqual(user.username);
expect(userEntries[0].pinStatus).toEqual(pinStatus); expect(userEntries[0].pinStatus).toEqual(pinStatus);
expect(userEntries[0].updatedAt).toEqual(lastVisited); expect(userEntries[0].updatedAt).toEqual(lastVisited);
}); });
@ -161,11 +161,13 @@ describe('History', () => {
user, user,
); );
expect(historyEntries).toHaveLength(1); expect(historyEntries).toHaveLength(1);
expect(await historyEntries[0].note.aliases).toEqual( expect(await (await historyEntries[0].note).aliases).toEqual(
await prevEntry.note.aliases, await (
await prevEntry.note
).aliases,
); );
expect(historyEntries[0].user.username).toEqual( expect((await historyEntries[0].user).username).toEqual(
prevEntry.user.username, (await prevEntry.user).username,
); );
expect(historyEntries[0].pinStatus).toEqual(prevEntry.pinStatus); expect(historyEntries[0].pinStatus).toEqual(prevEntry.pinStatus);
expect(historyEntries[0].updatedAt).toEqual(prevEntry.updatedAt); expect(historyEntries[0].updatedAt).toEqual(prevEntry.updatedAt);
@ -189,8 +191,9 @@ describe('History', () => {
user, user,
); );
expect(entry.pinStatus).toBeFalsy(); expect(entry.pinStatus).toBeFalsy();
const alias = (await entry.note.aliases).filter((alias) => alias.primary)[0] const alias = (await (await entry.note).aliases).filter(
.name; (alias) => alias.primary,
)[0].name;
await agent await agent
.put(`/api/private/me/history/${alias || 'null'}`) .put(`/api/private/me/history/${alias || 'null'}`)
.send({ pinStatus: true }) .send({ pinStatus: true })
@ -203,8 +206,9 @@ describe('History', () => {
it('DELETE /me/history/:note', async () => { it('DELETE /me/history/:note', async () => {
const entry = await historyService.updateHistoryEntryTimestamp(note2, user); const entry = await historyService.updateHistoryEntryTimestamp(note2, user);
const alias = (await entry.note.aliases).filter((alias) => alias.primary)[0] const alias = (await (await entry.note).aliases).filter(
.name; (alias) => alias.primary,
)[0].name;
const entry2 = await historyService.updateHistoryEntryTimestamp(note, user); const entry2 = await historyService.updateHistoryEntryTimestamp(note, user);
const entryDto = await historyService.toHistoryEntryDto(entry2); const entryDto = await historyService.toHistoryEntryDto(entry2);
await agent await agent

View file

@ -118,7 +118,7 @@ describe('Me', () => {
let theEntry: HistoryEntryDto; let theEntry: HistoryEntryDto;
for (const entry of history) { for (const entry of history) {
if ( if (
(await entry.note.aliases).find( (await (await entry.note).aliases).find(
(element) => element.name === noteName, (element) => element.name === noteName,
) )
) { ) {
@ -147,7 +147,7 @@ describe('Me', () => {
const history = await testSetup.historyService.getEntriesByUser(user); const history = await testSetup.historyService.getEntriesByUser(user);
for (const entry of history) { for (const entry of history) {
if ( if (
(await entry.note.aliases).find( (await (await entry.note).aliases).find(
(element) => element.name === noteName, (element) => element.name === noteName,
) )
) { ) {