diff --git a/migrations/202605111155.ts b/migrations/202605111155.ts new file mode 100644 index 0000000..9d7446b --- /dev/null +++ b/migrations/202605111155.ts @@ -0,0 +1,97 @@ +import { Pool } from "mariadb/*"; +import { MigrationParams } from "umzug"; + +/** + * Migration pour permettre plusieurs catégories par stack via une table de liaison. + * - Crée la table stack_category. + * - Migre les valeurs existantes de stack.category_id vers stack_category. + * - Ajoute un index pour les recherches par catégorie. + */ +export async function up({ context: pool }: MigrationParams) { + const conn = await pool.getConnection(); + try { + await conn.query(` + CREATE TABLE IF NOT EXISTS stack_category ( + stack_id VARCHAR(255) NOT NULL, + category_id VARCHAR(255) NOT NULL, + PRIMARY KEY (stack_id, category_id), + FOREIGN KEY (stack_id) REFERENCES stack(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES category(id) ON DELETE CASCADE + ); + `); + + await conn.query(` + INSERT IGNORE INTO stack_category (stack_id, category_id) + SELECT id, category_id + FROM stack + WHERE category_id IS NOT NULL; + `); + + await conn.query( + `CREATE INDEX IF NOT EXISTS idx_stack_category_category_id ON stack_category(category_id)`, + ); + + const fkRows = await conn.query( + `SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'stack' + AND COLUMN_NAME = 'category_id' + AND REFERENCED_TABLE_NAME = 'category'`, + ); + for (const row of fkRows as { CONSTRAINT_NAME: string }[]) { + await conn.query( + `ALTER TABLE stack DROP FOREIGN KEY \`${row.CONSTRAINT_NAME}\``, + ); + } + + await conn.query( + `ALTER TABLE IF EXISTS stack DROP COLUMN IF EXISTS category_id;`, + ); + + // Nettoyage défensif : si un index explicite subsiste, on le supprime. + await conn.query(`DROP INDEX IF EXISTS idx_stack_category_id ON stack`); + } finally { + conn.release(); + } +} + +export async function down({ context: pool }: MigrationParams) { + const conn = await pool.getConnection(); + try { + await conn.query( + `ALTER TABLE IF EXISTS stack ADD COLUMN IF NOT EXISTS category_id VARCHAR(255) NULL;`, + ); + + await conn.query(` + UPDATE stack s + LEFT JOIN ( + SELECT stack_id, MIN(category_id) AS category_id + FROM stack_category + GROUP BY stack_id + ) sc ON sc.stack_id = s.id + SET s.category_id = sc.category_id; + `); + + await conn.query( + `CREATE INDEX IF NOT EXISTS idx_stack_category_id ON stack(category_id)`, + ); + + await conn + .query( + ` + ALTER TABLE IF EXISTS stack + ADD CONSTRAINT fk_stack_category_id + FOREIGN KEY (category_id) REFERENCES category(id) ON DELETE SET NULL; + `, + ) + .catch(() => undefined); + + await conn.query( + `DROP INDEX IF EXISTS idx_stack_category_category_id ON stack_category`, + ); + await conn.query("DROP TABLE IF EXISTS stack_category"); + } finally { + conn.release(); + } +} diff --git a/src/graphql/resolvers/stackResolver.ts b/src/graphql/resolvers/stackResolver.ts index 4b10fb1..0077bfe 100644 --- a/src/graphql/resolvers/stackResolver.ts +++ b/src/graphql/resolvers/stackResolver.ts @@ -22,8 +22,11 @@ function sanitizeStackInput(input: Partial>) { if (input.description) input.description = sanitizeString(input.description); if (input.versions) input.versions = input.versions.map((v) => sanitizeString(v)); if (input.skills) input.skills = input.skills.map((s) => sanitizeString(s)); - if (input.category && !validator.isUUID(input.category as string)) - delete input.category; + if (input.categories) { + input.categories = input.categories.filter((category) => + validator.isUUID(category as string), + ); + } } // Résolveur GraphQL pour les opérations liées aux stacks diff --git a/src/graphql/schemas/stackSchema.ts b/src/graphql/schemas/stackSchema.ts index 0ec5573..43a1869 100644 --- a/src/graphql/schemas/stackSchema.ts +++ b/src/graphql/schemas/stackSchema.ts @@ -7,7 +7,7 @@ export const stackTypes = ` description: String versions: [String!] skills: [String!] - category: ID + categories: [ID!] } `; export const stackInputs = ` @@ -17,7 +17,7 @@ export const stackInputs = ` description: String versions: [String!] skills: [String!] - category: ID + categories: [ID!] } `; diff --git a/src/repositories/StackRepository.ts b/src/repositories/StackRepository.ts index d1af946..1fee9a6 100644 --- a/src/repositories/StackRepository.ts +++ b/src/repositories/StackRepository.ts @@ -20,10 +20,11 @@ export default class StackRepository extends BaseRepository { async getAll(): Promise { return withConnection(this.pool, async (conn) => { const rows = await conn.query(` - SELECT s.*, v.version, ss.skill + SELECT s.*, v.version, ss.skill, sc.category_id FROM stack s LEFT JOIN stack_version v ON v.stack_id = s.id LEFT JOIN stack_skill ss ON ss.stack_id = s.id + LEFT JOIN stack_category sc ON sc.stack_id = s.id ORDER BY s.label `); const stackMap = new Map(); @@ -36,16 +37,18 @@ export default class StackRepository extends BaseRepository { description: row.description, versions: [], skills: [], - category: row.category_id, + categories: [], }); } if (row.version) stackMap.get(row.id).versions.push(row.version); if (row.skill) stackMap.get(row.id).skills.push(row.skill); + if (row.category_id) stackMap.get(row.id).categories.push(row.category_id); } // Dédupliquer les versions et skills for (const stack of stackMap.values()) { stack.versions = Array.from(new Set(stack.versions)); stack.skills = Array.from(new Set(stack.skills)); + stack.categories = Array.from(new Set(stack.categories)); } return Array.from(stackMap.values()); }); @@ -62,14 +65,8 @@ export default class StackRepository extends BaseRepository { const id = this.generateId(); await withTransaction(this.pool, async (conn) => { await conn.query( - `INSERT INTO stack (id, label, icon_id, description, category_id) VALUES (?, ?, ?, ?, ?);`, - [ - id, - stack.label, - stack.icon, - stack.description || null, - stack.category || null, - ], + `INSERT INTO stack (id, label, icon_id, description) VALUES (?, ?, ?, ?);`, + [id, stack.label, stack.icon, stack.description || null], ); if (stack.versions && stack.versions.length > 0) { await conn.query( @@ -83,6 +80,12 @@ export default class StackRepository extends BaseRepository { stack.skills.flatMap((skill) => [id, skill]), ); } + if (stack.categories && stack.categories.length > 0) { + await conn.query( + `INSERT INTO stack_category (stack_id, category_id) VALUES ${stack.categories.map(() => "(?, ?)").join(", ")};`, + stack.categories.flatMap((category) => [id, category]), + ); + } }); return true; } @@ -103,8 +106,6 @@ export default class StackRepository extends BaseRepository { label: stack.label || undefined, icon_id: stack.icon ? (stack.icon as string) : undefined, description: stack.description, - category_id: - stack.category !== undefined ? stack.category || null : undefined, }); if (set) { await conn.query(`UPDATE stack SET ${set.sql} WHERE id = ?`, [ @@ -161,7 +162,31 @@ export default class StackRepository extends BaseRepository { } } - if (!set && !stack.versions && !stack.skills) { + if (stack.categories) { + const existingRows = await conn.query( + "SELECT category_id FROM stack_category WHERE stack_id = ?", + [stack.id], + ); + const existing: string[] = existingRows.map( + (r: { category_id: string }) => r.category_id, + ); + const toAdd = stack.categories.filter((c) => !existing.includes(c)); + const toRemove = existing.filter((c) => !stack.categories!.includes(c)); + if (toRemove.length) { + await conn.batch( + "DELETE FROM stack_category WHERE stack_id = ? AND category_id = ?", + toRemove.map((c) => [stack.id, c]), + ); + } + if (toAdd.length) { + await conn.batch( + "INSERT INTO stack_category (stack_id, category_id) VALUES (?, ?)", + toAdd.map((c) => [stack.id, c]), + ); + } + } + + if (!set && !stack.versions && !stack.skills && !stack.categories) { return false; } return true; diff --git a/src/types/stackTypes.ts b/src/types/stackTypes.ts index 5c88b06..c7372db 100644 --- a/src/types/stackTypes.ts +++ b/src/types/stackTypes.ts @@ -6,5 +6,5 @@ export interface Stack { description?: string; versions: string[]; skills: string[]; - category?: string | null; + categories?: string[]; }