From d92bc4d3ab1ca45e0e2d096e4d66a9dcc7e14511 Mon Sep 17 00:00:00 2001 From: Savelii Date: Tue, 29 Sep 2026 08:45:29 +0000 Subject: [PATCH] perf(core): Load project relations without joining every role scope (#39756) Co-authored-by: Claude Fable 5.1 --- .../project-relation.repository.test.ts | 84 +++- .../project-relation.repository.ts | 43 +- .../cli/src/services/project.service.ee.ts | 10 +- .../project-relations.benchmark.test.ts | 415 ++++++++++++++++++ .../project.service.integration.test.ts | 114 ++++- 5 files changed, 652 insertions(+), 14 deletions(-) create mode 100644 packages/cli/test/integration/project-relations.benchmark.test.ts diff --git a/packages/@n8n/db/src/repositories/__tests__/project-relation.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/project-relation.repository.test.ts index 86d21b3170a..38348b2b8bd 100644 --- a/packages/@n8n/db/src/repositories/__tests__/project-relation.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/project-relation.repository.test.ts @@ -1,9 +1,9 @@ import { Container } from '@n8n/di'; import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; -import type { SelectQueryBuilder } from '@n8n/typeorm'; +import { In, type SelectQueryBuilder } from '@n8n/typeorm'; import { mock } from 'vitest-mock-extended'; -import { ProjectRelation } from '../../entities'; +import { ProjectRelation, Role } from '../../entities'; import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager'; import { ProjectRelationRepository } from '../project-relation.repository'; @@ -54,6 +54,86 @@ describe('ProjectRelationRepository', () => { }); }); + describe('findAllByUser', () => { + beforeEach(() => { + entityManager.find.mockReset(); + }); + + const relation = (projectId: string, roleSlug: string) => + ({ userId: 'user1', projectId, role: { slug: roleSlug } }) as ProjectRelation; + const role = (slug: string, ...scopeSlugs: string[]) => + ({ slug, scopes: scopeSlugs.map((scopeSlug) => ({ slug: scopeSlug })) }) as Role; + + it('loads the relations without the eager role scopes', async () => { + entityManager.find.mockResolvedValueOnce([relation('project1', 'project:admin')]); + entityManager.find.mockResolvedValueOnce([role('project:admin', 'workflow:read')]); + + await projectRelationRepository.findAllByUser('user1'); + + expect(entityManager.find).toHaveBeenNthCalledWith(1, ProjectRelation, { + where: { userId: 'user1' }, + relations: { role: true, project: false }, + loadEagerRelations: false, + }); + }); + + it('joins the project only when asked to', async () => { + entityManager.find.mockResolvedValueOnce([relation('project1', 'project:admin')]); + entityManager.find.mockResolvedValueOnce([role('project:admin', 'workflow:read')]); + + await projectRelationRepository.findAllByUser('user1', { withProject: true }); + + expect(entityManager.find).toHaveBeenNthCalledWith( + 1, + ProjectRelation, + expect.objectContaining({ relations: { role: true, project: true } }), + ); + }); + + it('attaches the scopes of each role from one query over the distinct roles', async () => { + entityManager.find.mockResolvedValueOnce([ + relation('project1', 'project:admin'), + relation('project2', 'project:editor'), + relation('project3', 'project:admin'), + ]); + entityManager.find.mockResolvedValueOnce([ + role('project:admin', 'workflow:read', 'workflow:update'), + role('project:editor', 'workflow:read'), + ]); + + const result = await projectRelationRepository.findAllByUser('user1'); + + expect(entityManager.find).toHaveBeenCalledTimes(2); + expect(entityManager.find).toHaveBeenNthCalledWith(2, Role, { + where: { slug: In(['project:admin', 'project:editor']) }, + relations: ['scopes'], + }); + expect(result.map((r) => r.role.scopes.map((s) => s.slug))).toEqual([ + ['workflow:read', 'workflow:update'], + ['workflow:read'], + ['workflow:read', 'workflow:update'], + ]); + }); + + it('leaves the scopes empty for a role the query did not return', async () => { + entityManager.find.mockResolvedValueOnce([relation('project1', 'project:custom')]); + entityManager.find.mockResolvedValueOnce([]); + + const [result] = await projectRelationRepository.findAllByUser('user1'); + + expect(result.role.scopes).toEqual([]); + }); + + it('does not query the roles when the user has no relations', async () => { + entityManager.find.mockResolvedValueOnce([]); + + const result = await projectRelationRepository.findAllByUser('user1'); + + expect(result).toEqual([]); + expect(entityManager.find).toHaveBeenCalledTimes(1); + }); + }); + describe('findProjectIdsByUserIds', () => { beforeEach(() => { entityManager.find.mockReset(); diff --git a/packages/@n8n/db/src/repositories/project-relation.repository.ts b/packages/@n8n/db/src/repositories/project-relation.repository.ts index aea635fbd90..adbbca87c45 100644 --- a/packages/@n8n/db/src/repositories/project-relation.repository.ts +++ b/packages/@n8n/db/src/repositories/project-relation.repository.ts @@ -2,7 +2,7 @@ import { Service } from '@n8n/di'; import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole } from '@n8n/permissions'; import { DataSource, In, Repository } from '@n8n/typeorm'; -import { ProjectRelation } from '../entities'; +import { ProjectRelation, Role } from '../entities'; import { chunkIds } from '../utils/chunk-ids'; @Service() @@ -92,13 +92,42 @@ export class ProjectRelationRepository extends Repository { return [...new Set(rows.map((r) => r.userId))]; } - async findAllByUser(userId: string) { - return await this.find({ - where: { - userId, - }, - relations: { role: true }, + /** + * Every relation of a user, with its role and the role's scopes, and with the + * project when `withProject` is set. + * + * `Role.scopes` is eager, so a plain `find` with `role` joins every scope of every + * role: the database returns (relations x scopes of the role) rows, about 70 per + * project for `project:admin`. A user in hundreds of projects pays tens of + * thousands of rows to transfer and hydrate on every call. Load the relations + * without the eager join and attach the scopes from one query over the distinct + * roles instead, so the row count follows the number of relations. + */ + async findAllByUser( + userId: string, + { withProject = false }: { withProject?: boolean } = {}, + ): Promise { + const relations = await this.find({ + where: { userId }, + relations: { role: true, project: withProject }, + loadEagerRelations: false, }); + await this.attachRoleScopes(relations); + return relations; + } + + private async attachRoleScopes(relations: ProjectRelation[]): Promise { + const slugs = [...new Set(relations.map((relation) => relation.role.slug))]; + if (slugs.length === 0) return; + + const roles = await this.manager.find(Role, { + where: { slug: In(slugs) }, + relations: ['scopes'], + }); + const scopesBySlug = new Map(roles.map((role) => [role.slug, role.scopes])); + for (const relation of relations) { + relation.role.scopes = scopesBySlug.get(relation.role.slug) ?? []; + } } /** diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index 8e8d9effa5c..0cab4fb79f7 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -558,11 +558,13 @@ export class ProjectService { return await this.projectRepository.getPersonalProjectForUser(user.id); } + /** + * The user's project relations with project, role and role scopes. This runs on + * most editor requests (scope resolution for lists and single resources), so the + * repository keeps the row count proportional to the number of relations. + */ async getProjectRelationsForUser(user: User): Promise { - return await this.projectRelationRepository.find({ - where: { userId: user.id }, - relations: ['project', 'role'], - }); + return await this.projectRelationRepository.findAllByUser(user.id, { withProject: true }); } async syncProjectRelations( diff --git a/packages/cli/test/integration/project-relations.benchmark.test.ts b/packages/cli/test/integration/project-relations.benchmark.test.ts new file mode 100644 index 00000000000..c67703dc49f --- /dev/null +++ b/packages/cli/test/integration/project-relations.benchmark.test.ts @@ -0,0 +1,415 @@ +import { createWorkflow, mockInstance, testDb } from '@n8n/backend-test-utils'; +import type { User } from '@n8n/db'; +import { Project, ProjectRelation } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; +import { generateNanoId } from '@n8n/utils/generate-nano-id'; +import { appendFileSync, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; + +import { ActiveWorkflowManager } from '@/active-workflow-manager'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { NodeTypes } from '@/node-types'; +import { ProjectService } from '@/services/project.service.ee'; +import { WorkflowService } from '@/workflows/workflow.service'; + +import { createAdmin, createMember } from './shared/db/users'; + +/** + * Query-level profile of `ProjectService.getProjectRelationsForUser` and the editor + * endpoints that call it on every page load: + * + * GET /rest/projects/my-projects -> ProjectService.getMyProjects + * GET /rest/workflows/:id -> WorkflowService.getWorkflowScopes + * + CredentialsService.getCredentialsAUserCanUseInAWorkflow + * GET /rest/credentials/for-workflow -> CredentialsService.getCredentialsAUserCanUseInAWorkflow + * GET /rest/workflows (includeScopes) -> RoleService.addScopes per row + * + * `Role.scopes` is eager, so a relation query that joins `role` also joins + * `role_scope -> scope`: the rows the database materializes and TypeORM hydrates grow with + * (projects the user is a member of) x (scopes of their project role), not with the size + * of the response. `ProjectRelationRepository.findAllByUser` now loads the relations + * without that join and attaches the scopes from one query over the distinct roles. + * + * This suite seeds tiers that differ only in how many team projects one instance admin + * created (and so is `project:admin` in), plus a member with `project:editor` in the same + * projects, and reports for each call: + * - STATEMENTS: SQL statements and the rows each one materializes before hydration. + * - LATENCY: p50/p95 wall time and the longest event-loop stall during the timed runs. + * + * Only one thing is asserted: the relation statement materializes at most one row per + * relation, so a return of the scope join fails the suite. Timing is report-only. Opt in with: + * + * N8N_PROJECT_RELATIONS_BENCHMARK=1 pnpm --filter n8n test:sqlite project-relations.benchmark --silent=false + * N8N_PROJECT_RELATIONS_BENCHMARK=1 pnpm --filter n8n test:postgres:integration:tc project-relations.benchmark --silent=false + * + * Knobs: N8N_PR_BENCH_TIERS ("100,500,1000"), N8N_PR_BENCH_ITERS, N8N_PR_BENCH_OUT (JSONL file). + */ + +const runBenchmarks = process.env.N8N_PROJECT_RELATIONS_BENCHMARK === '1'; + +// Constructed by `WorkflowService`'s dependency graph, never called by the profiled paths. +mockInstance(ActiveWorkflowManager); +mockInstance(NodeTypes); +const dialect = process.env.DB_TYPE === 'postgresdb' ? 'postgres' : 'sqlite'; + +const envInt = (name: string, fallback: number): number => { + const parsed = Number(process.env[name]); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +}; +const envIntList = (name: string, fallback: number[]): number[] => { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = raw + .split(',') + .map((v) => Number(v.trim())) + .filter((v) => Number.isFinite(v) && v > 0) + .map(Math.floor); + return parsed.length > 0 ? parsed : fallback; +}; + +const TIERS = envIntList('N8N_PR_BENCH_TIERS', [100, 500, 1000]); +const ITERS = envInt('N8N_PR_BENCH_ITERS', 10); +const OUT_FILE = process.env.N8N_PR_BENCH_OUT; +const INSERT_CHUNK = 500; +const TEST_TIMEOUT_MS = 900_000; + +const commas = (n: number) => Math.round(n).toLocaleString('en-US'); +const ms = (n: number) => `${n.toFixed(1)}ms`; +const percentiles = (samples: number[]) => { + const sorted = [...samples].sort((a, b) => a - b); + const at = (p: number) => + sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]; + return { p50: at(50), p95: at(95) }; +}; +const report = (title: string, lines: string[]) => { + console.log( + `\n [project-relations ยท ${dialect}] ${title}\n${lines.map((l) => ` ${l}`).join('\n')}\n`, + ); +}; + +type Statement = { sql: string; parameters: unknown[] }; + +type Tier = { + projects: number; + projectIds: string[]; + /** Instance admin who created every project of the tier: `project:admin` in each. */ + admin: User; + /** Global member with `project:editor` in every project of the tier. */ + member: User; + workflowId: string; +}; + +type ScenarioResult = { + statements: Statement[]; + rows: number[]; + durations: number[]; + /** Longest event-loop stall observed while the timed iterations ran. */ + maxEventLoopDelayMs: number; + returned: number; + /** Wall time of the captured statements replayed through the driver, without hydration. */ + driverOnlyP50?: number; +}; + +describe.runIf(runBenchmarks)('project relations benchmarks', () => { + let dataSource: DataSource; + let projectService: ProjectService; + let workflowService: WorkflowService; + let credentialsService: CredentialsService; + const tiers: Tier[] = []; + + async function bulkInsert( + entity: new () => T, + rows: Array>, + ): Promise { + for (let i = 0; i < rows.length; i += INSERT_CHUNK) { + await dataSource + .createQueryBuilder() + .insert() + .into(entity) + .values(rows.slice(i, i + INSERT_CHUNK)) + .orIgnore() + .execute(); + } + } + + // Both drivers call `logger.logQuery` for every statement regardless of the `logging` + // option, so swapping the logger captures statements without reconfiguring the connection. + async function captureStatements( + run: () => Promise, + ): Promise<{ result: T; statements: Statement[] }> { + const original = dataSource.logger; + const statements: Statement[] = []; + const capturing = Object.create(original) as DataSource['logger']; + capturing.logQuery = (sql: string, parameters?: unknown[]) => { + statements.push({ sql, parameters: parameters ?? [] }); + }; + dataSource.logger = capturing; + try { + const result = await run(); + return { result, statements }; + } finally { + dataSource.logger = original; + } + } + + // Rows the database hands back for a statement, before TypeORM collapses the joined + // rows into entities. + async function rowsMaterialized({ sql, parameters }: Statement): Promise { + if (!/^\s*SELECT/i.test(sql)) return 0; + const inner = sql.replace(/^\s*SELECT COUNT\(.*?\) AS "cnt"/i, 'SELECT 1'); + const rows = await dataSource.query>( + `SELECT COUNT(*) AS n FROM (${inner}) AS bench_rows`, + parameters, + ); + return Number(rows[0].n); + } + + const describeStatement = (sql: string): string => { + const normalized = sql.replace(/\s+/g, ' '); + if (/^SELECT COUNT\(/i.test(normalized)) return 'count'; + const from = /FROM (?:"?\w+"?\.)?"?(\w+)"?/i.exec(normalized)?.[1] ?? '?'; + const joins = (normalized.match(/ JOIN /gi) ?? []).length; + return joins > 0 ? `${from} +${joins} joins` : from; + }; + + async function runScenario( + call: () => Promise, + { measureDriverOnly = false }: { measureDriverOnly?: boolean } = {}, + ): Promise { + // warm-up: role cache, driver plan cache + await call(); + + const { result, statements } = await captureStatements(call); + const rows: number[] = []; + for (const statement of statements) rows.push(await rowsMaterialized(statement)); + + const histogram = monitorEventLoopDelay({ resolution: 1 }); + histogram.enable(); + const durations: number[] = []; + for (let i = 0; i < ITERS; i++) { + const start = performance.now(); + await call(); + durations.push(performance.now() - start); + } + histogram.disable(); + + let driverOnlyP50: number | undefined; + if (measureDriverOnly) { + const raw: number[] = []; + for (let i = 0; i < ITERS; i++) { + const start = performance.now(); + for (const { sql, parameters } of statements) await dataSource.query(sql, parameters); + raw.push(performance.now() - start); + } + driverOnlyP50 = percentiles(raw).p50; + } + + return { + statements, + rows, + durations, + maxEventLoopDelayMs: histogram.max / 1e6, + returned: Array.isArray(result) ? result.length : 1, + driverOnlyP50, + }; + } + + function reportScenario(title: string, tier: Tier, who: string, result: ScenarioResult) { + const { p50, p95 } = percentiles(result.durations); + const rowsTotal = result.rows.reduce((a, b) => a + b, 0); + const lines = [ + `tier: ${commas(tier.projects)} team projects, caller: ${who}`, + `statements: ${result.statements.length}`, + ...result.statements.map( + (s, i) => + ` #${i + 1} ${describeStatement(s.sql).padEnd(32)} rows materialized: ${commas(result.rows[i])}`, + ), + `rows materialized total: ${commas(rowsTotal)} returned: ${result.returned}`, + `latency (n=${ITERS}): p50 ${ms(p50)} p95 ${ms(p95)} max event-loop stall ${ms(result.maxEventLoopDelayMs)}`, + ...(result.driverOnlyP50 !== undefined + ? [ + `driver only (same statements, no hydration): p50 ${ms(result.driverOnlyP50)} => hydration ~${ms(Math.max(0, p50 - result.driverOnlyP50))}`, + ] + : []), + ]; + report(title, lines); + + if (OUT_FILE) { + mkdirSync(dirname(OUT_FILE), { recursive: true }); + appendFileSync( + OUT_FILE, + JSON.stringify({ + recordedAt: new Date().toISOString(), + dialect, + scenario: title, + caller: who, + projects: tier.projects, + iterations: ITERS, + statements: result.statements.map((s, i) => ({ + kind: describeStatement(s.sql), + rows: result.rows[i], + })), + rowsTotal, + returned: result.returned, + p50: Math.round(p50 * 10) / 10, + p95: Math.round(p95 * 10) / 10, + maxEventLoopDelayMs: Math.round(result.maxEventLoopDelayMs * 10) / 10, + driverOnlyP50: + result.driverOnlyP50 !== undefined + ? Math.round(result.driverOnlyP50 * 10) / 10 + : undefined, + }) + '\n', + ); + } + } + + async function seedTier(projects: number): Promise { + const admin = await createAdmin(); + const member = await createMember(); + + const projectIds = Array.from({ length: projects }, () => generateNanoId()); + await bulkInsert( + Project, + projectIds.map((id, i) => ({ + id, + name: `bench-${projects}-${i}`, + type: 'team' as const, + creatorId: admin.id, + })), + ); + + // `createTeamProject` links the creating user as `project:admin`; mirror that, and add + // a plain member so the two callers differ only in global role and project role. + const relations: Array> = []; + for (const projectId of projectIds) { + relations.push({ projectId, userId: admin.id, role: { slug: 'project:admin' } }); + relations.push({ projectId, userId: member.id, role: { slug: 'project:editor' } }); + } + await bulkInsert(ProjectRelation, relations); + + const firstProject = await dataSource + .getRepository(Project) + .findOneByOrFail({ id: projectIds[0] }); + const workflow = await createWorkflow({ name: `bench-${projects}` }, firstProject); + + return { projects, projectIds, admin, member, workflowId: workflow.id }; + } + + beforeAll(async () => { + await testDb.init(); + dataSource = Container.get(DataSource); + projectService = Container.get(ProjectService); + workflowService = Container.get(WorkflowService); + credentialsService = Container.get(CredentialsService); + + await testDb.truncate([ + 'SharedWorkflow', + 'WorkflowEntity', + 'SharedCredentials', + 'CredentialsEntity', + 'ProjectRelation', + 'Project', + 'User', + ]); + + const seedStart = performance.now(); + for (const projects of TIERS) tiers.push(await seedTier(projects)); + + const counts = await Promise.all( + [Project, ProjectRelation].map( + async (entity) => await dataSource.getRepository(entity).count(), + ), + ); + report('corpus seeded', [ + `tiers (team projects): ${TIERS.join(', ')}`, + `project rows: ${commas(counts[0])} project_relation rows: ${commas(counts[1])}`, + `seed time: ${ms(performance.now() - seedStart)}`, + ]); + }, TEST_TIMEOUT_MS); + + afterAll(async () => { + await testDb.terminate(); + }); + + test( + 'ProjectService.getProjectRelationsForUser (shared primitive)', + async () => { + for (const tier of tiers) { + for (const [who, user] of [ + ['instance admin, project:admin everywhere', tier.admin], + ['member, project:editor everywhere', tier.member], + ] as const) { + const result = await runScenario( + async () => await projectService.getProjectRelationsForUser(user), + { measureDriverOnly: true }, + ); + reportScenario('getProjectRelationsForUser', tier, who, result); + // The relation statement must not fan out over the scopes of each role. + const relationRows = result.rows.find((_, i) => + /project_relation"\s+"ProjectRelation"/.test(result.statements[i].sql), + ); + expect(relationRows).toBeLessThanOrEqual(result.returned); + } + } + }, + TEST_TIMEOUT_MS, + ); + + test( + 'GET /rest/projects/my-projects -> ProjectService.getMyProjects', + async () => { + for (const tier of tiers) { + for (const [who, user] of [ + ['instance admin', tier.admin], + ['member', tier.member], + ] as const) { + const result = await runScenario(async () => await projectService.getMyProjects(user)); + reportScenario('getMyProjects', tier, who, result); + } + } + }, + TEST_TIMEOUT_MS, + ); + + test( + 'GET /rest/workflows/:id -> WorkflowService.getWorkflowScopes', + async () => { + for (const tier of tiers) { + for (const [who, user] of [ + ['instance admin', tier.admin], + ['member', tier.member], + ] as const) { + const result = await runScenario( + async () => await workflowService.getWorkflowScopes(user, tier.workflowId), + ); + reportScenario('getWorkflowScopes', tier, who, result); + } + } + }, + TEST_TIMEOUT_MS, + ); + + test( + 'GET /rest/credentials/for-workflow (also inside GET /rest/workflows/:id) -> getCredentialsAUserCanUseInAWorkflow', + async () => { + for (const tier of tiers) { + for (const [who, user] of [ + ['instance admin', tier.admin], + ['member', tier.member], + ] as const) { + const result = await runScenario( + async () => + await credentialsService.getCredentialsAUserCanUseInAWorkflow(user, { + workflowId: tier.workflowId, + }), + ); + reportScenario('getCredentialsAUserCanUseInAWorkflow', tier, who, result); + } + } + }, + TEST_TIMEOUT_MS, + ); +}); diff --git a/packages/cli/test/integration/project.service.integration.test.ts b/packages/cli/test/integration/project.service.integration.test.ts index 3e5c0017531..bf974475bac 100644 --- a/packages/cli/test/integration/project.service.integration.test.ts +++ b/packages/cli/test/integration/project.service.integration.test.ts @@ -6,8 +6,9 @@ import { createWorkflow, testDb, } from '@n8n/backend-test-utils'; -import { SharedWorkflowRepository } from '@n8n/db'; +import { ProjectRelationRepository, RoleRepository, SharedWorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; import { LicenseMocker } from '@test-integration/license'; import { createUser } from './shared/db/users'; @@ -188,4 +189,115 @@ describe('ProjectService', () => { }); }); }); + + describe('getProjectRelationsForUser', () => { + type Statement = { sql: string; parameters: unknown[] }; + + // Both drivers call `logger.logQuery` for every statement, so swapping the logger + // captures what a call runs without reconfiguring the connection. + async function captureStatements(run: () => Promise) { + const dataSource = Container.get(DataSource); + const original = dataSource.logger; + const statements: Statement[] = []; + const capturing = Object.create(original) as DataSource['logger']; + capturing.logQuery = (sql: string, parameters?: unknown[]) => { + statements.push({ sql, parameters: parameters ?? [] }); + }; + dataSource.logger = capturing; + try { + return { result: await run(), statements }; + } finally { + dataSource.logger = original; + } + } + + // Rows the database returns for a statement, before TypeORM collapses them into entities. + async function rowsMaterialized({ sql, parameters }: Statement) { + const rows = await Container.get(DataSource).query>( + `SELECT COUNT(*) AS n FROM (${sql}) AS materialized`, + parameters, + ); + return Number(rows[0].n); + } + + const normalize = ( + relations: Awaited>, + ) => + relations + .map((relation) => ({ + projectId: relation.projectId, + project: relation.project.id, + role: relation.role.slug, + scopes: relation.role.scopes.map((scope) => scope.slug).sort(), + })) + .sort((a, b) => a.projectId.localeCompare(b.projectId)); + + it('returns every relation with its project, role and the scopes of that role', async () => { + const user = await createUser(); + const adminProject = await createTeamProject('Admin project'); + const editorProject = await createTeamProject('Editor project'); + await linkUserToProject(user, adminProject, 'project:admin'); + await linkUserToProject(user, editorProject, 'project:editor'); + + const relations = await projectService.getProjectRelationsForUser(user); + + expect(relations).toHaveLength(3); + const byRole = new Map(relations.map((relation) => [relation.role.slug, relation])); + expect(byRole.get('project:admin')?.project.id).toBe(adminProject.id); + expect(byRole.get('project:editor')?.project.id).toBe(editorProject.id); + expect(byRole.get('project:personalOwner')?.project.type).toBe('personal'); + + const roleRepository = Container.get(RoleRepository); + for (const slug of ['project:personalOwner', 'project:admin', 'project:editor']) { + const role = await roleRepository.findBySlug(slug); + const expectedScopes = role!.scopes.map((scope) => scope.slug).sort(); + expect(expectedScopes.length).toBeGreaterThan(0); + expect( + byRole + .get(slug) + ?.role.scopes.map((scope) => scope.slug) + .sort(), + ).toEqual(expectedScopes); + } + }); + + it('matches a find with eager role scopes relation for relation', async () => { + const user = await createUser(); + const projects = await Promise.all( + ['P1', 'P2', 'P3'].map(async (name) => await createTeamProject(name)), + ); + await linkUserToProject(user, projects[0], 'project:admin'); + await linkUserToProject(user, projects[1], 'project:editor'); + await linkUserToProject(user, projects[2], 'project:viewer'); + + const eager = await Container.get(ProjectRelationRepository).find({ + where: { userId: user.id }, + relations: ['project', 'role'], + }); + const actual = await projectService.getProjectRelationsForUser(user); + + expect(normalize(actual)).toEqual(normalize(eager)); + expect(actual).toHaveLength(4); + }); + + it('materializes one row per relation instead of one row per scope', async () => { + const user = await createUser(); + for (const name of ['P1', 'P2', 'P3']) { + await linkUserToProject(user, await createTeamProject(name), 'project:admin'); + } + + const { result, statements } = await captureStatements( + async () => await projectService.getProjectRelationsForUser(user), + ); + + expect(result).toHaveLength(4); + // One statement for the relations, one for the distinct roles and their scopes. + expect(statements).toHaveLength(2); + const [relationStatement, roleStatement] = statements; + expect(relationStatement.sql).toMatch(/project_relation"\s+"ProjectRelation"/); + expect(relationStatement.sql).not.toMatch(/role_scope/); + expect(await rowsMaterialized(relationStatement)).toBe(4); + expect(roleStatement.sql).toMatch(/role_scope/); + }); + }); });