Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions shared/database/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,9 @@ export const schedule = wxyc_schema.table('schedule', {
day: smallint('day').notNull(),
start_time: time('start_time').notNull(),
show_duration: smallint('show_duration').notNull(), // In 15-minute blocs
specialty_id: integer('specialty_id').references(() => specialty_shows.id), //null for regular shows
assigned_dj_id: varchar('assigned_dj_id', { length: 255 }).references(() => user.id),
assigned_dj_id2: varchar('assigned_dj_id2', { length: 255 }).references(() => user.id),
specialty_id: integer('specialty_id').references(() => specialty_shows.id, { onDelete: 'set null' }), //null for regular shows
assigned_dj_id: varchar('assigned_dj_id', { length: 255 }).references(() => user.id, { onDelete: 'set null' }),
assigned_dj_id2: varchar('assigned_dj_id2', { length: 255 }).references(() => user.id, { onDelete: 'set null' }),
});

//SELECT date_trunc('week', current_timestamp + timestamp '${n} weeks') + interval '${schedule.day} days' + ${schedule.time}
Expand All @@ -190,7 +190,7 @@ export const shift_covers = wxyc_schema.table('shift_covers', {
.references(() => schedule.id)
.notNull(),
shift_timestamp: timestamp('shift_timestamp').notNull(), //Timestamp to expire cover requests
cover_dj_id: varchar('cover_dj_id', { length: 255 }).references(() => user.id),
cover_dj_id: varchar('cover_dj_id', { length: 255 }).references(() => user.id, { onDelete: 'set null' }),
covered: boolean('covered').default(false),
});

Expand Down Expand Up @@ -279,7 +279,7 @@ export const rotation = wxyc_schema.table(
{
id: serial('id').primaryKey(), //need to create an entry w/ id 0 for items not currently on rotation and items from outside the station
album_id: integer('album_id')
.references(() => library.id)
.references(() => library.id, { onDelete: 'cascade' })
.notNull(),
rotation_bin: freqEnum('rotation_bin').notNull(),
add_date: date('add_date').defaultNow().notNull(),
Expand All @@ -296,9 +296,9 @@ export type NewFSEntry = InferInsertModel<typeof flowsheet>;
export type FSEntry = InferSelectModel<typeof flowsheet>;
export const flowsheet = wxyc_schema.table('flowsheet', {
id: serial('id').primaryKey(),
show_id: integer('show_id').references(() => shows.id),
album_id: integer('album_id').references(() => library.id),
rotation_id: integer('rotation_id').references(() => rotation.id),
show_id: integer('show_id').references(() => shows.id, { onDelete: 'set null' }),
album_id: integer('album_id').references(() => library.id, { onDelete: 'set null' }),
rotation_id: integer('rotation_id').references(() => rotation.id, { onDelete: 'set null' }),
entry_type: flowsheetEntryTypeEnum('entry_type').notNull().default('track'),
track_title: varchar('track_title', { length: 128 }),
album_title: varchar('album_title', { length: 128 }),
Expand Down Expand Up @@ -326,7 +326,7 @@ export type Review = InferSelectModel<typeof reviews>;
export const reviews = wxyc_schema.table('reviews', {
id: serial('id').primaryKey(),
album_id: integer('album_id')
.references(() => library.id)
.references(() => library.id, { onDelete: 'cascade' })
.notNull()
.unique(),
review: text('review'),
Expand Down Expand Up @@ -355,10 +355,10 @@ export const genre_artist_crossreference = wxyc_schema.table(
{
artist_id: integer('artist_id')
.notNull()
.references(() => artists.id),
.references(() => artists.id, { onDelete: 'cascade' }),
genre_id: integer('genre_id')
.notNull()
.references(() => genres.id),
.references(() => genres.id, { onDelete: 'cascade' }),
artist_genre_code: integer('artist_genre_code').notNull(),
},
(table) => [uniqueIndex('artist_genre_key').on(table.artist_id, table.genre_id)]
Expand All @@ -369,8 +369,8 @@ export type ArtistLibraryCrossreference = InferSelectModel<typeof artist_library
export const artist_library_crossreference = wxyc_schema.table(
'artist_library_crossreference',
{
artist_id: integer('artist_id').references(() => artists.id),
library_id: integer('library_id').references(() => library.id),
artist_id: integer('artist_id').references(() => artists.id, { onDelete: 'cascade' }),
library_id: integer('library_id').references(() => library.id, { onDelete: 'cascade' }),
},
(table) => [uniqueIndex('library_id_artist_id').on(table.artist_id, table.library_id)]
);
Expand All @@ -379,7 +379,7 @@ export type NewShow = InferInsertModel<typeof shows>;
export type Show = InferSelectModel<typeof shows>;
export const shows = wxyc_schema.table('shows', {
id: serial('id').primaryKey(),
primary_dj_id: varchar('primary_dj_id', { length: 255 }).references(() => user.id),
primary_dj_id: varchar('primary_dj_id', { length: 255 }).references(() => user.id, { onDelete: 'set null' }),
specialty_id: integer('specialty_id') //Null for regular shows
.references(() => specialty_shows.id),
show_name: varchar('show_name', { length: 128 }), //Null if not provided or specialty show
Expand All @@ -391,7 +391,7 @@ export type NewShowDJ = InferInsertModel<typeof show_djs>;
export type ShowDJ = InferSelectModel<typeof show_djs>;
export const show_djs = wxyc_schema.table('show_djs', {
show_id: integer('show_id')
.references(() => shows.id)
.references(() => shows.id, { onDelete: 'cascade' })
.notNull(),
dj_id: varchar('dj_id', { length: 255 })
.references(() => user.id, { onDelete: 'cascade' })
Expand Down
131 changes: 131 additions & 0 deletions tests/unit/database/schema.fk-cascades.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import * as fs from 'fs';
import * as path from 'path';

const schemaSource = fs.readFileSync(path.resolve(__dirname, '../../../shared/database/src/schema.ts'), 'utf-8');

/**
* Extract the full column definition block for a given column in a given table.
* Captures from the column's DB name through to the next column or closing brace.
*/
function getColumnBlock(tableVar: string, columnDbName: string): string | null {
const tablePattern = new RegExp(`export\\s+const\\s+${tableVar}\\s*=`);
const tableMatch = tablePattern.exec(schemaSource);
if (!tableMatch) return null;

const afterTable = schemaSource.slice(tableMatch.index);

// Find the column by its DB name, then grab everything until the next
// property definition (a line starting with whitespace + identifier + colon)
// or until a closing brace/paren.
const colPattern = new RegExp(
`(${columnDbName}:\\s*(?:integer|varchar|serial)\\([\\s\\S]*?)(?=\\n\\s+\\w+:\\s|\\n\\s*\\}|\\n\\s*\\))`
);
const colMatch = colPattern.exec(afterTable);
if (!colMatch) return null;

return colMatch[1];
}

function expectOnDelete(tableVar: string, columnDbName: string, expectedAction: 'set null' | 'cascade') {
const block = getColumnBlock(tableVar, columnDbName);
expect(block).not.toBeNull();
expect(block).toContain('.references(');
expect(block).toContain('onDelete');
expect(block).toContain(`'${expectedAction}'`);
}

describe('FK cascade/set-null rules in schema.ts', () => {
describe('should use onDelete: "set null"', () => {
it('schedule.assigned_dj_id → user.id', () => {
expectOnDelete('schedule', 'assigned_dj_id', 'set null');
});

it('schedule.assigned_dj_id2 → user.id', () => {
expectOnDelete('schedule', 'assigned_dj_id2', 'set null');
});

it('schedule.specialty_id → specialty_shows.id', () => {
expectOnDelete('schedule', 'specialty_id', 'set null');
});

it('shows.primary_dj_id → user.id', () => {
expectOnDelete('shows', 'primary_dj_id', 'set null');
});

it('shift_covers.cover_dj_id → user.id', () => {
expectOnDelete('shift_covers', 'cover_dj_id', 'set null');
});

it('flowsheet.show_id → shows.id', () => {
expectOnDelete('flowsheet', 'show_id', 'set null');
});

it('flowsheet.album_id → library.id', () => {
expectOnDelete('flowsheet', 'album_id', 'set null');
});

it('flowsheet.rotation_id → rotation.id', () => {
expectOnDelete('flowsheet', 'rotation_id', 'set null');
});
});

describe('should use onDelete: "cascade"', () => {
it('rotation.album_id → library.id', () => {
expectOnDelete('rotation', 'album_id', 'cascade');
});

it('reviews.album_id → library.id', () => {
expectOnDelete('reviews', 'album_id', 'cascade');
});

it('genre_artist_crossreference.artist_id → artists.id', () => {
expectOnDelete('genre_artist_crossreference', 'artist_id', 'cascade');
});

it('genre_artist_crossreference.genre_id → genres.id', () => {
expectOnDelete('genre_artist_crossreference', 'genre_id', 'cascade');
});

it('artist_library_crossreference.artist_id → artists.id', () => {
expectOnDelete('artist_library_crossreference', 'artist_id', 'cascade');
});

it('artist_library_crossreference.library_id → library.id', () => {
expectOnDelete('artist_library_crossreference', 'library_id', 'cascade');
});

it('show_djs.show_id → shows.id', () => {
expectOnDelete('show_djs', 'show_id', 'cascade');
});
});

describe('should NOT have onDelete (intentional NO ACTION)', () => {
it('library.artist_id → artists.id', () => {
const block = getColumnBlock('library', 'artist_id');
expect(block).not.toBeNull();
expect(block).toContain('.references(');
expect(block).not.toContain('onDelete');
});

it('library.genre_id → genres.id', () => {
const block = getColumnBlock('library', 'genre_id');
expect(block).not.toBeNull();
expect(block).toContain('.references(');
expect(block).not.toContain('onDelete');
});

it('library.format_id → format.id', () => {
const block = getColumnBlock('library', 'format_id');
expect(block).not.toBeNull();
expect(block).toContain('.references(');
expect(block).not.toContain('onDelete');
});

it('artists.genre_id → genres.id', () => {
const block = getColumnBlock('artists', 'genre_id');
expect(block).not.toBeNull();
expect(block).toContain('.references(');
expect(block).not.toContain('onDelete');
});
});
});
Loading