Skip to content

Commit 65c7f8f

Browse files
committed
added a migration and a migration testing for v3 -> v4 existing configs
1 parent 65bf2ae commit 65c7f8f

File tree

6 files changed

+423
-14
lines changed

6 files changed

+423
-14
lines changed
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { MigrationExecutor } from '../pg-migrator';
2+
3+
export default {
4+
name: '2025.03.26T00-00-00.graphql-eslint.v4.ts',
5+
noTransaction: true,
6+
run: async ({ sql, connection }) => {
7+
const existingV3Configs = await connection.query(
8+
sql`SELECT resource_type, resource_id, config FROM schema_policy_config`,
9+
);
10+
11+
await Promise.all(
12+
existingV3Configs.rows.map(async config => {
13+
const { resource_type, resource_id, config: v3Config } = config;
14+
15+
if (v3Config && typeof v3Config === 'object') {
16+
const v4Config = migrateConfig(v3Config as any as Record<string, RuleStruct>);
17+
18+
console.log('after', JSON.stringify(v4Config, null, 2));
19+
await connection.query(
20+
sql`UPDATE schema_policy_config SET config = ${sql.json(v4Config)} WHERE resource_type = ${resource_type} AND resource_id = ${resource_id}`,
21+
);
22+
return { resource_type, resource_id, config: v4Config };
23+
}
24+
25+
return null;
26+
}),
27+
);
28+
},
29+
} satisfies MigrationExecutor;
30+
31+
function migrateConfig(v3Config: Record<string, RuleStruct>): Record<string, any> {
32+
return Object.keys(v3Config).reduce(
33+
(acc, ruleName) => {
34+
const ruleConfig = v3Config[ruleName];
35+
36+
if (Array.isArray(ruleConfig)) {
37+
const [severity, options] = ruleConfig;
38+
const newConfig = migrateRuleConfig(ruleName, options);
39+
40+
if (options || newConfig.options) {
41+
return {
42+
...acc,
43+
[newConfig.ruleName]: [severity, newConfig.options],
44+
};
45+
} else {
46+
return {
47+
...acc,
48+
[newConfig.ruleName]: [severity],
49+
};
50+
}
51+
}
52+
53+
return {
54+
...acc,
55+
[ruleName]: ruleConfig,
56+
};
57+
},
58+
{} as Record<string, any>,
59+
);
60+
}
61+
62+
type RuleStruct = [0 | 1 | 2] | [0 | 1 | 2, any];
63+
64+
function migrateRuleConfig(ruleName: string, options: any) {
65+
switch (ruleName) {
66+
case 'alphabetize': {
67+
return {
68+
ruleName: 'alphabetize',
69+
options: alphabetize(options),
70+
};
71+
}
72+
case 'require-description': {
73+
return {
74+
ruleName: 'require-description',
75+
options: requireDescription(options),
76+
};
77+
}
78+
case 'no-case-insensitive-enum-values-duplicates': {
79+
return {
80+
ruleName: 'unique-enum-value-names',
81+
options,
82+
};
83+
}
84+
default: {
85+
return {
86+
ruleName,
87+
options,
88+
};
89+
}
90+
}
91+
}
92+
93+
function alphabetize(cfgSource: any) {
94+
const cfg = JSON.parse(JSON.stringify(cfgSource));
95+
96+
if ('values' in cfg) {
97+
if (Array.isArray(cfg.values) && cfg.values.length >= 1) {
98+
cfg.values = true;
99+
} else {
100+
cfg.values = false;
101+
}
102+
}
103+
104+
return cfg;
105+
}
106+
107+
function requireDescription(cfgSource: any) {
108+
const cfg = JSON.parse(JSON.stringify(cfgSource));
109+
110+
if ('rootField' in cfg) {
111+
if (typeof cfg.rootField === 'boolean' && cfg.rootField === false) {
112+
cfg.rootField = undefined;
113+
}
114+
}
115+
116+
if ('types' in cfg) {
117+
if (typeof cfg.types === 'boolean' && cfg.types === false) {
118+
cfg.types = undefined;
119+
}
120+
}
121+
122+
return cfg;
123+
}

packages/migrations/src/run-pg-migrations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,5 +162,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri
162162
await import('./actions/2025.02.14T00-00-00.schema-versions-metadata'),
163163
await import('./actions/2025.02.21T00-00-00.schema-versions-metadata-attributes'),
164164
await import('./actions/2025.03.20T00-00-00.dangerous_breaking'),
165+
await import('./actions/2025.03.26T00-00-00.graphql-eslint.v4'),
165166
],
166167
});
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import assert from 'node:assert';
2+
import { describe, test } from 'node:test';
3+
import { sql } from 'slonik';
4+
import { createStorage } from '../../services/storage/src/index';
5+
import { initMigrationTestingEnvironment } from './utils/testkit';
6+
7+
const TEST_CASES: Array<{ in: any; out: any }> = [
8+
{
9+
in: {
10+
'input-name': [
11+
1,
12+
{
13+
checkQueries: true,
14+
checkInputType: true,
15+
checkMutations: true,
16+
caseSensitiveInputType: true,
17+
},
18+
],
19+
alphabetize: [
20+
1,
21+
{
22+
fields: ['ObjectTypeDefinition'],
23+
values: ['EnumTypeDefinition'],
24+
arguments: ['FieldDefinition'],
25+
definitions: true,
26+
},
27+
],
28+
'description-style': [1, { style: 'block' }],
29+
'naming-convention': [
30+
1,
31+
{
32+
types: 'PascalCase',
33+
Argument: 'camelCase',
34+
FieldDefinition: 'camelCase',
35+
DirectiveDefinition: 'camelCase',
36+
EnumValueDefinition: 'UPPER_CASE',
37+
InputValueDefinition: 'camelCase',
38+
'FieldDefinition[parent.name.value=Query]': {
39+
forbiddenPrefixes: ['query', 'get'],
40+
forbiddenSuffixes: ['Query'],
41+
},
42+
'FieldDefinition[parent.name.value=Mutation]': {
43+
forbiddenPrefixes: ['mutation'],
44+
forbiddenSuffixes: ['Mutation'],
45+
},
46+
'FieldDefinition[parent.name.value=Subscription]': {
47+
forbiddenPrefixes: ['subscription'],
48+
forbiddenSuffixes: ['Subscription'],
49+
},
50+
},
51+
],
52+
'require-nullable-fields-with-oneof': [1],
53+
'no-case-insensitive-enum-values-duplicates': [1],
54+
'require-field-of-type-query-in-mutation-result': [1],
55+
},
56+
out: {
57+
'input-name': [
58+
1,
59+
{
60+
checkQueries: true,
61+
checkInputType: true,
62+
checkMutations: true,
63+
caseSensitiveInputType: true,
64+
},
65+
],
66+
alphabetize: [
67+
1,
68+
{
69+
fields: ['ObjectTypeDefinition'],
70+
values: true,
71+
arguments: ['FieldDefinition'],
72+
definitions: true,
73+
},
74+
],
75+
'description-style': [1, { style: 'block' }],
76+
'naming-convention': [
77+
1,
78+
{
79+
types: 'PascalCase',
80+
Argument: 'camelCase',
81+
FieldDefinition: 'camelCase',
82+
DirectiveDefinition: 'camelCase',
83+
EnumValueDefinition: 'UPPER_CASE',
84+
InputValueDefinition: 'camelCase',
85+
'FieldDefinition[parent.name.value=Query]': {
86+
forbiddenPrefixes: ['query', 'get'],
87+
forbiddenSuffixes: ['Query'],
88+
},
89+
'FieldDefinition[parent.name.value=Mutation]': {
90+
forbiddenPrefixes: ['mutation'],
91+
forbiddenSuffixes: ['Mutation'],
92+
},
93+
'FieldDefinition[parent.name.value=Subscription]': {
94+
forbiddenPrefixes: ['subscription'],
95+
forbiddenSuffixes: ['Subscription'],
96+
},
97+
},
98+
],
99+
'unique-enum-value-names': [1],
100+
'require-nullable-fields-with-oneof': [1],
101+
'require-field-of-type-query-in-mutation-result': [1],
102+
},
103+
},
104+
{
105+
in: {
106+
'input-name': [
107+
2,
108+
{
109+
checkQueries: false,
110+
checkInputType: true,
111+
checkMutations: false,
112+
caseSensitiveInputType: true,
113+
},
114+
],
115+
'relay-arguments': [2, { includeBoth: true }],
116+
'relay-page-info': [2],
117+
'relay-edge-types': [
118+
2,
119+
{ withEdgeSuffix: true, shouldImplementNode: true, listTypeCanWrapOnlyEdgeType: false },
120+
],
121+
'naming-convention': [
122+
1,
123+
{ types: 'PascalCase', FieldDefinition: 'camelCase', allowLeadingUnderscore: true },
124+
],
125+
'no-typename-prefix': [1],
126+
'strict-id-in-types': [
127+
1,
128+
{
129+
exceptions: {
130+
types: ['Aggregate', 'PageInfo', 'Color', 'Location', 'RGBA', 'RichText'],
131+
suffixes: ['Connection', 'Edge'],
132+
},
133+
acceptedIdNames: ['id'],
134+
acceptedIdTypes: ['ID'],
135+
},
136+
],
137+
'no-hashtag-description': [1],
138+
'relay-connection-types': [2],
139+
'unique-enum-value-names': [1],
140+
},
141+
out: {
142+
'input-name': [
143+
2,
144+
{
145+
checkQueries: false,
146+
checkInputType: true,
147+
checkMutations: false,
148+
caseSensitiveInputType: true,
149+
},
150+
],
151+
'relay-arguments': [2, { includeBoth: true }],
152+
'relay-page-info': [2],
153+
'relay-edge-types': [
154+
2,
155+
{ withEdgeSuffix: true, shouldImplementNode: true, listTypeCanWrapOnlyEdgeType: false },
156+
],
157+
'naming-convention': [
158+
1,
159+
{ types: 'PascalCase', FieldDefinition: 'camelCase', allowLeadingUnderscore: true },
160+
],
161+
'no-typename-prefix': [1],
162+
'strict-id-in-types': [
163+
1,
164+
{
165+
exceptions: {
166+
types: ['Aggregate', 'PageInfo', 'Color', 'Location', 'RGBA', 'RichText'],
167+
suffixes: ['Connection', 'Edge'],
168+
},
169+
acceptedIdNames: ['id'],
170+
acceptedIdTypes: ['ID'],
171+
},
172+
],
173+
'no-hashtag-description': [1],
174+
'relay-connection-types': [2],
175+
'unique-enum-value-names': [1],
176+
},
177+
},
178+
{
179+
in: {
180+
'require-description': [
181+
1,
182+
{
183+
types: true,
184+
rootField: false,
185+
FieldDefinition: false,
186+
EnumTypeDefinition: false,
187+
DirectiveDefinition: false,
188+
OperationDefinition: false,
189+
InputValueDefinition: false,
190+
ScalarTypeDefinition: false,
191+
InputObjectTypeDefinition: false,
192+
},
193+
],
194+
'require-deprecation-reason': [1],
195+
},
196+
out: {
197+
'require-description': [
198+
1,
199+
{
200+
types: true,
201+
FieldDefinition: false,
202+
EnumTypeDefinition: false,
203+
DirectiveDefinition: false,
204+
OperationDefinition: false,
205+
InputValueDefinition: false,
206+
ScalarTypeDefinition: false,
207+
InputObjectTypeDefinition: false,
208+
},
209+
],
210+
'require-deprecation-reason': [1],
211+
},
212+
},
213+
{
214+
in: { 'require-description': [1, { types: false }] },
215+
out: { 'require-description': [1, {}] },
216+
},
217+
];
218+
219+
await describe('migration: policy upgrade: graphql-eslint v3 -> v4', async () => {
220+
for (const [index, testCase] of TEST_CASES.entries()) {
221+
await test('should migrate all known breaking changes, sample ' + index, async () => {
222+
const { db, runTo, complete, done, seed, connectionString } =
223+
await initMigrationTestingEnvironment();
224+
const storage = await createStorage(connectionString, 1);
225+
try {
226+
// Run migrations all the way to the point before the one we are testing
227+
await runTo('2025.03.20T00-00-00.dangerous_breaking.ts');
228+
229+
// Seed the database with some data (schema_sdl, supergraph_sdl, composite_schema_sdl)
230+
const admin = await seed.user({
231+
user: {
232+
name: 'test' + index,
233+
email: `test_${Date.now()}@test.com`,
234+
},
235+
});
236+
const organization = await seed.organization({
237+
organization: {
238+
name: `org-${Date.now()}`,
239+
},
240+
user: admin,
241+
});
242+
243+
// Create an invitation to simulate a pending invitation
244+
await db.query(sql`
245+
INSERT INTO "schema_policy_config" ("resource_type", "resource_id", "config") VALUES ('ORGANIZATION', ${organization.id}, ${sql.jsonb(testCase.in)});
246+
`);
247+
248+
// run the next migrations
249+
await runTo('2025.03.26T00-00-00.graphql-eslint.v4.ts');
250+
251+
// assert scopes are still in place and identical
252+
const newRecord = await db.oneFirst(sql`
253+
SELECT config FROM schema_policy_config WHERE resource_id = ${organization.id}`);
254+
255+
assert.deepStrictEqual(newRecord, testCase.out);
256+
257+
await complete();
258+
} finally {
259+
await done();
260+
await storage.destroy();
261+
}
262+
});
263+
}
264+
});

0 commit comments

Comments
 (0)