Skip to content

Commit a38080f

Browse files
authored
Merge pull request #50 from GBSL-Informatik:feature/introduce-view-migrator
add helper script for view migrations
2 parents b39965d + df84129 commit a38080f

9 files changed

Lines changed: 341 additions & 1 deletion

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,21 @@ cp .example.env .env
100100
## Dev Services
101101

102102
## Database
103+
104+
### Database Views
105+
106+
The access policies and users documents are implemented as database views. To keep track of views and changes, make sure to use `yarn db:migrate-views` when changing views:
107+
108+
1. Edit or create a new view file in `prisma/view-migrations/views/`.
109+
2. Make sure the dependencies are correct in [migrate.config.yml](prisma/view-migrations/migrate.config.yml).
110+
3. Run `yarn db:migrate-views` to create a new migration for the changed views (this won't run `prisma migrate:dev`, it only creates the migration files).
111+
4. Eventually change the [schema.prisma](prisma/schema.prisma) file to reflect changes in the views (e.g. new fields).
112+
5. Run `yarn run prisma migrate:dev` to create a new migration for the schema changes.
113+
114+
> [!WARNING]
115+
> Never edit views directly in a prisma migration file (under `prisma/migrations/`), as these files are auto-generated and will be overwritten the next time `yarn db:migrate-views` is run.
116+
117+
103118
### Docker Compose
104119

105120
Run `scripts/purge_dev_services.sh` or the `purge_dev_services` run config to remove all containers **and volumes** associated with the dev services.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"format:check": "prettier --check ./**/*.{ts,json}",
1515
"db:migrate": "yarn prisma migrate deploy",
1616
"db:migrate:dev": "yarn prisma migrate dev",
17+
"db:migrate-view": "ts-node -r dotenv/config ./prisma/view-migrations/create-view-migration.ts",
1718
"db:seed": "yarn prisma db seed",
1819
"db:reset": "dotenv -- ts-node prisma/reset.ts",
1920
"db:recreate": "yarn run db:reset && yarn run db:migrate && yarn run db:seed",
@@ -40,6 +41,7 @@
4041
"@mermaid-js/mermaid-cli": "^10.9.1",
4142
"@types/cors": "^2.8.17",
4243
"@types/express": "^5.0.3",
44+
"@types/js-yaml": "^4.0.9",
4345
"@types/morgan": "^1.9.9",
4446
"@types/node": "^20.14.6",
4547
"@typescript-eslint/eslint-plugin": "^8.44.1",
@@ -48,6 +50,8 @@
4850
"eslint": "^9.36.0",
4951
"eslint-config-prettier": "^10.1.8",
5052
"eslint-plugin-prettier": "^5.5.4",
53+
"js-yaml": "^4.1.1",
54+
"minimist": "^1.2.8",
5155
"nodemon": "^3.1.10",
5256
"prettier": "^3.6.2",
5357
"prisma": "^6.17.1",
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import * as yaml from 'js-yaml';
4+
import { default as parseArgs } from 'minimist';
5+
import { exit } from 'process';
6+
7+
const currentDir = __dirname;
8+
const CONFIG_FILENAME = 'migrate.config.yml' as const;
9+
interface Config {
10+
name: string;
11+
depends_on: string[];
12+
}
13+
const config = yaml.load(fs.readFileSync(path.resolve(currentDir, CONFIG_FILENAME), 'utf8')) as Config[];
14+
const HELP_TEXT = `
15+
yarn run db:migrate-view [view-name [view-name ...]]
16+
17+
Example:
18+
yarn run db:migrate-view view__users_documents view__document_user_permissions
19+
20+
available views (configured in ${CONFIG_FILENAME}):
21+
${config.map((c) => ` - ${c.name}`).join('\n')}
22+
`;
23+
24+
const argv = parseArgs(process.argv.slice(2));
25+
26+
if (argv.help) {
27+
console.log(HELP_TEXT);
28+
exit(0);
29+
}
30+
31+
const viewNames = argv._.filter(Boolean);
32+
if (viewNames.length === 0) {
33+
console.error('Error: No view name provided.');
34+
console.log(HELP_TEXT);
35+
exit(1);
36+
}
37+
if (viewNames.some((viewName) => !config.find((c) => c.name === viewName))) {
38+
console.error(
39+
'Error: Invalid view name provided. Unknown views:\n',
40+
viewNames
41+
.filter((viewName) => !config.find((c) => c.name === viewName))
42+
.map((n) => `- ${n}`)
43+
.join(`\n`),
44+
`\nCheck ${CONFIG_FILENAME} to configure additional views.`
45+
);
46+
console.log(HELP_TEXT);
47+
exit(1);
48+
}
49+
50+
async function createViewMigration(viewNames: string[]) {
51+
const migrationsFor: string[] = [];
52+
const gatherDependencies = (viewName: string) => {
53+
const viewConfig = config.find((c) => c.name === viewName);
54+
if (!viewConfig) {
55+
throw new Error(`View configuration for "${viewName}" not found.`);
56+
}
57+
const idx = migrationsFor.findIndex((name) => name === viewName);
58+
if (idx >= 0) {
59+
return;
60+
}
61+
const dependents = config.filter((dep) => dep.depends_on.includes(viewName)).map((d) => d.name);
62+
for (const dep of dependents) {
63+
gatherDependencies(dep);
64+
}
65+
if (!migrationsFor.includes(viewName)) {
66+
migrationsFor.push(viewName);
67+
}
68+
};
69+
viewNames.forEach(gatherDependencies);
70+
console.log(migrationsFor.join(' -> '));
71+
72+
const commands: string[] = [];
73+
commands.push(
74+
`-- NEVER MODIFY THIS FILE MANUALLY! IT IS AUTO-GENERATED USING prisma/view-migrations/create-view-migration.ts`
75+
);
76+
migrationsFor.forEach((viewName) => {
77+
commands.push(`DROP VIEW IF EXISTS ${viewName};`);
78+
});
79+
for (const viewName of migrationsFor.toReversed()) {
80+
const viewSqlPath = path.resolve(currentDir, 'views', `${viewName}.sql`);
81+
const viewSql = await fs.promises.readFile(viewSqlPath, 'utf8');
82+
commands.push(`
83+
CREATE VIEW ${viewName} AS
84+
${viewSql
85+
.replace(/;+\s*$/, '')
86+
.trim()
87+
.split('\n')
88+
.map((line) => ` ${line}`)
89+
.join('\n')};
90+
`);
91+
}
92+
const migrationContent = commands.join('\n\n');
93+
const timestamp = new Date()
94+
.toISOString()
95+
.replace(/[-:TZ.]/g, '')
96+
.slice(0, 14);
97+
const migrationFilename = `${timestamp}_create_views__${viewNames.map((name) => name.replace(/^view__/, '')).join('__')}`;
98+
const migrationsDir = path.resolve(currentDir, '..', 'migrations', migrationFilename);
99+
await fs.promises.mkdir(migrationsDir, { recursive: true });
100+
const migrationFilePath = path.resolve(migrationsDir, 'migration.sql');
101+
await fs.promises.writeFile(migrationFilePath, migrationContent, 'utf8');
102+
console.log(`✅ Created view migration at: ${migrationFilePath}`);
103+
}
104+
105+
createViewMigration(viewNames);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
- name: view__users_documents
2+
depends_on:
3+
- view__document_user_permissions
4+
- name: view__document_user_permissions
5+
depends_on:
6+
- view__all_document_user_permissions
7+
- name: view__all_document_user_permissions
8+
depends_on: []
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"strict": true,
4+
"lib": ["esnext", "dom"],
5+
"target": "esnext",
6+
"module": "commonjs",
7+
"outDir": "../../dist/view-migrations",
8+
"rootDir": "./",
9+
"esModuleInterop": true,
10+
"skipLibCheck": true,
11+
"baseUrl": "../../",
12+
"paths": {
13+
"*": ["node_modules/*"]
14+
}
15+
}
16+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
-- view: view__all_document_user_permissions
2+
3+
-- assumption: all child documents of a document share the same document_root_id
4+
SELECT
5+
document_root_id,
6+
user_id,
7+
access,
8+
document_id,
9+
root_user_permission_id,
10+
root_group_permission_id,
11+
group_id,
12+
ROW_NUMBER() OVER (PARTITION BY document_root_id, user_id, document_id ORDER BY access DESC) AS access_rank
13+
FROM (
14+
-- get all documents where the user **is the author**
15+
SELECT
16+
document_roots.id AS document_root_id,
17+
documents.author_id AS user_id,
18+
document_roots.access AS access,
19+
documents.id AS document_id,
20+
NULL::uuid AS root_user_permission_id,
21+
NULL::uuid AS root_group_permission_id,
22+
NULL::uuid AS group_id
23+
FROM
24+
document_roots
25+
INNER JOIN documents ON document_roots.id = documents.document_root_id
26+
UNION ALL
27+
-- get all documents where the user **is not the author** but has shared access
28+
SELECT
29+
document_roots.id AS document_root_id,
30+
all_users.id AS user_id,
31+
CASE
32+
WHEN document_roots.shared_access <= document_roots.access THEN document_roots.shared_access
33+
ELSE document_roots.access
34+
END AS access,
35+
documents.id AS document_id,
36+
NULL::uuid AS root_user_permission_id,
37+
NULL::uuid AS root_group_permission_id,
38+
NULL::uuid AS group_id
39+
FROM
40+
document_roots
41+
INNER JOIN documents ON document_roots.id = documents.document_root_id
42+
CROSS JOIN users all_users
43+
WHERE documents.author_id != all_users.id
44+
AND (
45+
document_roots.shared_access='RO_DocumentRoot'
46+
OR
47+
document_roots.shared_access='RW_DocumentRoot'
48+
)
49+
UNION ALL
50+
-- get all documents where the user has been granted shared access
51+
-- or the access has been extended by user permissions
52+
SELECT
53+
document_roots.id AS document_root_id,
54+
rup.user_id AS user_id,
55+
rup.access AS access,
56+
documents.id AS document_id,
57+
rup.id AS root_user_permission_id,
58+
NULL::uuid AS root_group_permission_id,
59+
NULL::uuid AS group_id
60+
FROM
61+
document_roots
62+
LEFT JOIN documents ON document_roots.id=documents.document_root_id
63+
LEFT JOIN root_user_permissions rup
64+
ON (
65+
document_roots.id = rup.document_root_id
66+
AND (
67+
documents.author_id = rup.user_id
68+
OR
69+
rup.access >= document_roots.shared_access
70+
)
71+
)
72+
WHERE rup.user_id IS NOT NULL
73+
UNION ALL
74+
-- all group-based permissions for the documents author
75+
SELECT
76+
document_roots.id AS document_root_id,
77+
user_to_sg.user_id AS user_id,
78+
rgp.access AS access,
79+
documents.id AS document_id,
80+
NULL::uuid AS root_user_permission_id,
81+
rgp.id AS root_group_permission_id,
82+
sg.id AS group_id
83+
FROM
84+
document_roots
85+
INNER JOIN root_group_permissions rgp ON document_roots.id=rgp.document_root_id
86+
INNER JOIN student_groups sg ON rgp.student_group_id=sg.id
87+
LEFT JOIN documents ON document_roots.id=documents.document_root_id
88+
LEFT JOIN user_student_groups user_to_sg
89+
ON (
90+
user_to_sg.student_group_id=sg.id
91+
AND (
92+
user_to_sg.user_id=documents.author_id
93+
OR documents.author_id is null
94+
)
95+
)
96+
WHERE user_to_sg.user_id IS NOT NULL
97+
UNION ALL
98+
-- all group based permissions for the user, which is not the author
99+
SELECT
100+
document_roots.id AS document_root_id,
101+
user_to_sg.user_id AS user_id,
102+
rgp.access AS access,
103+
documents.id AS document_id,
104+
NULL::uuid AS root_user_permission_id,
105+
rgp.id AS root_group_permission_id,
106+
sg.id AS group_id
107+
FROM
108+
document_roots
109+
INNER JOIN root_group_permissions rgp
110+
ON (
111+
document_roots.id=rgp.document_root_id
112+
AND rgp.access >= document_roots.shared_access
113+
)
114+
INNER JOIN student_groups sg ON rgp.student_group_id=sg.id
115+
LEFT JOIN documents ON document_roots.id=documents.document_root_id
116+
LEFT JOIN user_student_groups user_to_sg
117+
ON (
118+
user_to_sg.student_group_id=sg.id
119+
AND user_to_sg.user_id!=documents.author_id
120+
)
121+
WHERE user_to_sg.user_id IS NOT NULL
122+
) as doc_user_permissions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- view: view__document_user_permissions
2+
3+
SELECT
4+
document_root_id,
5+
user_id,
6+
access,
7+
document_id,
8+
root_user_permission_id,
9+
root_group_permission_id,
10+
group_id
11+
FROM view__all_document_user_permissions
12+
WHERE access_rank = 1
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
-- view: view__users_documents
2+
3+
SELECT
4+
view__document_user_permissions.user_id AS user_id,
5+
document_roots.*,
6+
COALESCE(
7+
JSONB_AGG(
8+
DISTINCT JSONB_BUILD_OBJECT(
9+
'id', view__document_user_permissions.root_group_permission_id,
10+
'access', view__document_user_permissions.access,
11+
'groupId', view__document_user_permissions.group_id
12+
)
13+
) FILTER (WHERE view__document_user_permissions.root_group_permission_id IS NOT NULL),
14+
'[]'::jsonb
15+
) AS "groupPermissions",
16+
COALESCE(
17+
JSONB_AGG(
18+
DISTINCT JSONB_BUILD_OBJECT(
19+
'id', view__document_user_permissions.root_user_permission_id,
20+
'access', view__document_user_permissions.access,
21+
'userId', view__document_user_permissions.user_id
22+
)
23+
) FILTER (WHERE view__document_user_permissions.root_user_permission_id IS NOT NULL),
24+
'[]'::jsonb
25+
) AS "userPermissions",
26+
COALESCE(
27+
JSONB_AGG(
28+
JSONB_BUILD_OBJECT(
29+
'id', d.id,
30+
'authorId', d.author_id,
31+
'type', d.type,
32+
'data', CASE WHEN (view__document_user_permissions.access='None_DocumentRoot' OR view__document_user_permissions.access='None_StudentGroup' OR view__document_user_permissions.access='None_User') THEN NULL ELSE d.data END,
33+
'parentId', d.parent_id,
34+
'documentRootId', d.document_root_id,
35+
'createdAt', d.created_at,
36+
'updatedAt', d.updated_at
37+
)
38+
) FILTER (WHERE d.id IS NOT NULL),
39+
'[]'::jsonb
40+
) AS documents
41+
FROM
42+
document_roots
43+
LEFT JOIN view__document_user_permissions ON document_roots.id=view__document_user_permissions.document_root_id
44+
LEFT JOIN documents d ON document_roots.id=d.document_root_id AND view__document_user_permissions.document_id=d.id
45+
WHERE view__document_user_permissions.user_id IS NOT NULL
46+
GROUP BY document_roots.id, view__document_user_permissions.user_id

0 commit comments

Comments
 (0)