perf(core): Load project relations without joining every role scope (#39756)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Savelii 2026-09-29 08:45:29 +00:00 committed by GitHub
parent 84efbac6f1
commit d92bc4d3ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 652 additions and 14 deletions

View File

@ -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();

View File

@ -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<ProjectRelation> {
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<ProjectRelation[]> {
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<void> {
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) ?? [];
}
}
/**

View File

@ -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<ProjectRelation[]> {
return await this.projectRelationRepository.find({
where: { userId: user.id },
relations: ['project', 'role'],
});
return await this.projectRelationRepository.findAllByUser(user.id, { withProject: true });
}
async syncProjectRelations(

View File

@ -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<T>(
entity: new () => T,
rows: Array<QueryDeepPartialEntity<T>>,
): Promise<void> {
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<T>(
run: () => Promise<T>,
): 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<number> {
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<Array<{ n: number | string }>>(
`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<unknown>,
{ measureDriverOnly = false }: { measureDriverOnly?: boolean } = {},
): Promise<ScenarioResult> {
// 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<Tier> {
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<QueryDeepPartialEntity<ProjectRelation>> = [];
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,
);
});

View File

@ -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<T>(run: () => Promise<T>) {
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<Array<{ n: number | string }>>(
`SELECT COUNT(*) AS n FROM (${sql}) AS materialized`,
parameters,
);
return Number(rows[0].n);
}
const normalize = (
relations: Awaited<ReturnType<ProjectService['getProjectRelationsForUser']>>,
) =>
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/);
});
});
});