graphile-settings 4.9.3 → 4.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm/plugins/PublicKeySignature.d.ts +11 -0
- package/esm/plugins/PublicKeySignature.js +184 -0
- package/esm/plugins/conflict-detector.d.ts +7 -0
- package/esm/plugins/conflict-detector.js +67 -0
- package/esm/plugins/custom-inflector.d.ts +9 -0
- package/esm/plugins/custom-inflector.js +394 -0
- package/esm/plugins/enable-all-filter-columns.d.ts +60 -0
- package/esm/plugins/enable-all-filter-columns.js +85 -0
- package/esm/plugins/index.d.ts +16 -4
- package/esm/plugins/index.js +25 -4
- package/esm/plugins/inflector-logger.d.ts +7 -0
- package/esm/plugins/inflector-logger.js +215 -0
- package/esm/plugins/many-to-many-preset.d.ts +62 -0
- package/esm/plugins/many-to-many-preset.js +86 -0
- package/esm/plugins/meta-schema/cache.d.ts +4 -0
- package/esm/plugins/meta-schema/cache.js +7 -0
- package/esm/plugins/meta-schema/constraint-meta-builders.d.ts +13 -0
- package/esm/plugins/meta-schema/constraint-meta-builders.js +51 -0
- package/esm/plugins/meta-schema/graphql-meta-field.d.ts +4 -0
- package/esm/plugins/meta-schema/graphql-meta-field.js +201 -0
- package/esm/plugins/meta-schema/inflection-utils.d.ts +4 -0
- package/esm/plugins/meta-schema/inflection-utils.js +20 -0
- package/esm/plugins/meta-schema/name-meta-builders.d.ts +4 -0
- package/esm/plugins/meta-schema/name-meta-builders.js +38 -0
- package/esm/plugins/meta-schema/plugin.d.ts +2 -0
- package/esm/plugins/meta-schema/plugin.js +23 -0
- package/esm/plugins/meta-schema/relation-meta-builders.d.ts +8 -0
- package/esm/plugins/meta-schema/relation-meta-builders.js +115 -0
- package/esm/plugins/meta-schema/table-meta-builder.d.ts +2 -0
- package/esm/plugins/meta-schema/table-meta-builder.js +69 -0
- package/esm/plugins/meta-schema/table-meta-context.d.ts +13 -0
- package/esm/plugins/meta-schema/table-meta-context.js +11 -0
- package/esm/plugins/meta-schema/table-resource-utils.d.ts +12 -0
- package/esm/plugins/meta-schema/table-resource-utils.js +50 -0
- package/esm/plugins/meta-schema/type-mappings.d.ts +3 -0
- package/esm/plugins/meta-schema/type-mappings.js +75 -0
- package/esm/plugins/meta-schema/types.d.ts +206 -0
- package/esm/plugins/meta-schema/types.js +1 -0
- package/esm/plugins/meta-schema.d.ts +19 -0
- package/esm/plugins/meta-schema.js +20 -0
- package/esm/plugins/minimal-preset.d.ts +7 -0
- package/esm/plugins/minimal-preset.js +42 -0
- package/esm/plugins/pg-type-mappings.d.ts +41 -0
- package/esm/plugins/pg-type-mappings.js +122 -0
- package/esm/plugins/primary-key-only.d.ts +96 -0
- package/esm/plugins/primary-key-only.js +143 -0
- package/esm/plugins/required-input-plugin.d.ts +37 -0
- package/esm/plugins/required-input-plugin.js +88 -0
- package/esm/presets/constructive-preset.js +2 -1
- package/package.json +11 -9
- package/plugins/PublicKeySignature.d.ts +11 -0
- package/plugins/PublicKeySignature.js +191 -0
- package/plugins/conflict-detector.d.ts +7 -0
- package/plugins/conflict-detector.js +70 -0
- package/plugins/custom-inflector.d.ts +9 -0
- package/plugins/custom-inflector.js +397 -0
- package/plugins/enable-all-filter-columns.d.ts +60 -0
- package/plugins/enable-all-filter-columns.js +88 -0
- package/plugins/index.d.ts +16 -4
- package/plugins/index.js +54 -31
- package/plugins/inflector-logger.d.ts +7 -0
- package/plugins/inflector-logger.js +218 -0
- package/plugins/many-to-many-preset.d.ts +62 -0
- package/plugins/many-to-many-preset.js +89 -0
- package/plugins/meta-schema/cache.d.ts +4 -0
- package/plugins/meta-schema/cache.js +12 -0
- package/plugins/meta-schema/constraint-meta-builders.d.ts +13 -0
- package/plugins/meta-schema/constraint-meta-builders.js +58 -0
- package/plugins/meta-schema/graphql-meta-field.d.ts +4 -0
- package/plugins/meta-schema/graphql-meta-field.js +204 -0
- package/plugins/meta-schema/inflection-utils.d.ts +4 -0
- package/plugins/meta-schema/inflection-utils.js +25 -0
- package/plugins/meta-schema/name-meta-builders.d.ts +4 -0
- package/plugins/meta-schema/name-meta-builders.js +43 -0
- package/plugins/meta-schema/plugin.d.ts +2 -0
- package/plugins/meta-schema/plugin.js +26 -0
- package/plugins/meta-schema/relation-meta-builders.d.ts +8 -0
- package/plugins/meta-schema/relation-meta-builders.js +120 -0
- package/plugins/meta-schema/table-meta-builder.d.ts +2 -0
- package/plugins/meta-schema/table-meta-builder.js +72 -0
- package/plugins/meta-schema/table-meta-context.d.ts +13 -0
- package/plugins/meta-schema/table-meta-context.js +15 -0
- package/plugins/meta-schema/table-resource-utils.d.ts +12 -0
- package/plugins/meta-schema/table-resource-utils.js +60 -0
- package/plugins/meta-schema/type-mappings.d.ts +3 -0
- package/plugins/meta-schema/type-mappings.js +79 -0
- package/plugins/meta-schema/types.d.ts +206 -0
- package/plugins/meta-schema/types.js +2 -0
- package/plugins/meta-schema.d.ts +19 -0
- package/plugins/meta-schema.js +20 -0
- package/plugins/minimal-preset.d.ts +7 -0
- package/plugins/minimal-preset.js +45 -0
- package/plugins/pg-type-mappings.d.ts +41 -0
- package/plugins/pg-type-mappings.js +128 -0
- package/plugins/primary-key-only.d.ts +96 -0
- package/plugins/primary-key-only.js +147 -0
- package/plugins/required-input-plugin.d.ts +37 -0
- package/plugins/required-input-plugin.js +91 -0
- package/presets/constructive-preset.js +11 -10
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
2
|
+
export interface PublicKeyChallengeConfig {
|
|
3
|
+
schema: string;
|
|
4
|
+
crypto_network: string;
|
|
5
|
+
sign_up_with_key: string;
|
|
6
|
+
sign_in_request_challenge: string;
|
|
7
|
+
sign_in_record_failure: string;
|
|
8
|
+
sign_in_with_challenge: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const PublicKeySignature: (pubkey_challenge: PublicKeyChallengeConfig) => GraphileConfig.Plugin;
|
|
11
|
+
export default PublicKeySignature;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// import Networks from '@pyramation/crypto-networks';
|
|
2
|
+
// import { verifyMessage } from '@pyramation/crypto-keys';
|
|
3
|
+
import { context as grafastContext, lambda, object } from 'grafast';
|
|
4
|
+
import { extendSchema, gql } from 'graphile-utils';
|
|
5
|
+
import { QuoteUtils } from '@pgsql/quotes';
|
|
6
|
+
import pgQueryWithContext from 'pg-query-context';
|
|
7
|
+
const SAFE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
|
8
|
+
const SAFE_CRYPTO_NETWORK = /^[a-z0-9_-]{1,64}$/i;
|
|
9
|
+
function validateIdentifier(name, label) {
|
|
10
|
+
if (!SAFE_IDENTIFIER.test(name)) {
|
|
11
|
+
throw new Error(`PublicKeySignature: invalid ${label} "${name}" — must match /^[a-z_][a-z0-9_]*$/`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function validateCryptoNetwork(name) {
|
|
15
|
+
if (!SAFE_CRYPTO_NETWORK.test(name)) {
|
|
16
|
+
throw new Error('PublicKeySignature: invalid crypto_network — must match /^[a-z0-9_-]{1,64}$/i');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const MAX_PUBLIC_KEY_LENGTH = 256;
|
|
20
|
+
const MAX_MESSAGE_LENGTH = 4096;
|
|
21
|
+
const MAX_SIGNATURE_LENGTH = 1024;
|
|
22
|
+
const ENABLE_SIGNATURE_VERIFICATION = process.env.ENABLE_SIGNATURE_VERIFICATION === 'true';
|
|
23
|
+
export const PublicKeySignature = (pubkey_challenge) => {
|
|
24
|
+
const { schema, crypto_network, sign_up_with_key, sign_in_request_challenge, sign_in_record_failure, sign_in_with_challenge } = pubkey_challenge;
|
|
25
|
+
validateIdentifier(schema, 'schema');
|
|
26
|
+
validateIdentifier(sign_up_with_key, 'sign_up_with_key');
|
|
27
|
+
validateIdentifier(sign_in_request_challenge, 'sign_in_request_challenge');
|
|
28
|
+
validateIdentifier(sign_in_record_failure, 'sign_in_record_failure');
|
|
29
|
+
validateIdentifier(sign_in_with_challenge, 'sign_in_with_challenge');
|
|
30
|
+
validateCryptoNetwork(crypto_network);
|
|
31
|
+
return extendSchema(() => ({
|
|
32
|
+
typeDefs: gql `
|
|
33
|
+
input CreateUserAccountWithPublicKeyInput {
|
|
34
|
+
publicKey: String!
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
input GetMessageForSigningInput {
|
|
38
|
+
publicKey: String!
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
input VerifyMessageForSigningInput {
|
|
42
|
+
publicKey: String!
|
|
43
|
+
message: String!
|
|
44
|
+
signature: String!
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type createUserAccountWithPublicKeyPayload {
|
|
48
|
+
message: String!
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type getMessageForSigningPayload {
|
|
52
|
+
message: String!
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type verifyMessageForSigningPayload {
|
|
56
|
+
access_token: String!
|
|
57
|
+
access_token_expires_at: Datetime!
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
extend type Mutation {
|
|
61
|
+
createUserAccountWithPublicKey(
|
|
62
|
+
input: CreateUserAccountWithPublicKeyInput
|
|
63
|
+
): createUserAccountWithPublicKeyPayload
|
|
64
|
+
|
|
65
|
+
getMessageForSigning(
|
|
66
|
+
input: GetMessageForSigningInput
|
|
67
|
+
): getMessageForSigningPayload
|
|
68
|
+
|
|
69
|
+
verifyMessageForSigning(
|
|
70
|
+
input: VerifyMessageForSigningInput
|
|
71
|
+
): verifyMessageForSigningPayload
|
|
72
|
+
}
|
|
73
|
+
`,
|
|
74
|
+
plans: {
|
|
75
|
+
Mutation: {
|
|
76
|
+
createUserAccountWithPublicKey(_$mutation, fieldArgs) {
|
|
77
|
+
const $input = fieldArgs.getRaw('input');
|
|
78
|
+
const $withPgClient = grafastContext().get('withPgClient');
|
|
79
|
+
const $combined = object({ input: $input, withPgClient: $withPgClient });
|
|
80
|
+
return lambda($combined, async ({ input, withPgClient }) => {
|
|
81
|
+
if (!input.publicKey || typeof input.publicKey !== 'string' || input.publicKey.length > MAX_PUBLIC_KEY_LENGTH) {
|
|
82
|
+
throw new Error('INVALID_PUBLIC_KEY');
|
|
83
|
+
}
|
|
84
|
+
return withPgClient(null, async (pgClient) => {
|
|
85
|
+
await pgClient.query('BEGIN');
|
|
86
|
+
try {
|
|
87
|
+
await pgQueryWithContext({
|
|
88
|
+
client: pgClient,
|
|
89
|
+
context: { role: 'anonymous' },
|
|
90
|
+
query: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_up_with_key)}($1)`,
|
|
91
|
+
variables: [input.publicKey],
|
|
92
|
+
skipTransaction: true
|
|
93
|
+
});
|
|
94
|
+
const { rows: [{ [sign_in_request_challenge]: message }] } = await pgQueryWithContext({
|
|
95
|
+
client: pgClient,
|
|
96
|
+
context: { role: 'anonymous' },
|
|
97
|
+
query: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_in_request_challenge)}($1)`,
|
|
98
|
+
variables: [input.publicKey],
|
|
99
|
+
skipTransaction: true
|
|
100
|
+
});
|
|
101
|
+
await pgClient.query('COMMIT');
|
|
102
|
+
return { message };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
await pgClient.query('ROLLBACK');
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
getMessageForSigning(_$mutation, fieldArgs) {
|
|
112
|
+
const $input = fieldArgs.getRaw('input');
|
|
113
|
+
const $withPgClient = grafastContext().get('withPgClient');
|
|
114
|
+
const $combined = object({ input: $input, withPgClient: $withPgClient });
|
|
115
|
+
return lambda($combined, async ({ input, withPgClient }) => {
|
|
116
|
+
if (!input.publicKey || typeof input.publicKey !== 'string' || input.publicKey.length > MAX_PUBLIC_KEY_LENGTH) {
|
|
117
|
+
throw new Error('INVALID_PUBLIC_KEY');
|
|
118
|
+
}
|
|
119
|
+
return withPgClient(null, async (pgClient) => {
|
|
120
|
+
const { rows: [{ [sign_in_request_challenge]: message }] } = await pgQueryWithContext({
|
|
121
|
+
client: pgClient,
|
|
122
|
+
context: { role: 'anonymous' },
|
|
123
|
+
query: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_in_request_challenge)}($1)`,
|
|
124
|
+
variables: [input.publicKey]
|
|
125
|
+
});
|
|
126
|
+
if (!message)
|
|
127
|
+
throw new Error('NO_ACCOUNT_EXISTS');
|
|
128
|
+
return { message };
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
// NOTE: Verification remains behind a feature flag until crypto
|
|
133
|
+
// verification is re-implemented.
|
|
134
|
+
verifyMessageForSigning(_$mutation, fieldArgs) {
|
|
135
|
+
const $input = fieldArgs.getRaw('input');
|
|
136
|
+
const $withPgClient = grafastContext().get('withPgClient');
|
|
137
|
+
const $combined = object({ input: $input, withPgClient: $withPgClient });
|
|
138
|
+
return lambda($combined, async ({ input, withPgClient }) => {
|
|
139
|
+
const { publicKey, message, signature: _signature } = input;
|
|
140
|
+
if (!publicKey || typeof publicKey !== 'string' || publicKey.length > MAX_PUBLIC_KEY_LENGTH) {
|
|
141
|
+
throw new Error('INVALID_PUBLIC_KEY');
|
|
142
|
+
}
|
|
143
|
+
if (!message || typeof message !== 'string' || message.length > MAX_MESSAGE_LENGTH) {
|
|
144
|
+
throw new Error('INVALID_MESSAGE');
|
|
145
|
+
}
|
|
146
|
+
if (!_signature || typeof _signature !== 'string' || _signature.length > MAX_SIGNATURE_LENGTH) {
|
|
147
|
+
throw new Error('INVALID_SIGNATURE');
|
|
148
|
+
}
|
|
149
|
+
if (!ENABLE_SIGNATURE_VERIFICATION) {
|
|
150
|
+
// Fail closed without mutating lockout counters while verification
|
|
151
|
+
// is disabled.
|
|
152
|
+
throw new Error('FEATURE_DISABLED');
|
|
153
|
+
}
|
|
154
|
+
return withPgClient(null, async (pgClient) => {
|
|
155
|
+
// Only the success path needs a transaction (multi-step)
|
|
156
|
+
await pgClient.query('BEGIN');
|
|
157
|
+
try {
|
|
158
|
+
const { rows: [token] } = await pgQueryWithContext({
|
|
159
|
+
client: pgClient,
|
|
160
|
+
context: { role: 'anonymous' },
|
|
161
|
+
query: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_in_with_challenge)}($1, $2)`,
|
|
162
|
+
variables: [publicKey, message],
|
|
163
|
+
skipTransaction: true
|
|
164
|
+
});
|
|
165
|
+
if (!token?.access_token)
|
|
166
|
+
throw new Error('BAD_SIGNIN');
|
|
167
|
+
await pgClient.query('COMMIT');
|
|
168
|
+
return {
|
|
169
|
+
access_token: token.access_token,
|
|
170
|
+
access_token_expires_at: token.access_token_expires_at
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
await pgClient.query('ROLLBACK');
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}));
|
|
183
|
+
};
|
|
184
|
+
export default PublicKeySignature;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
2
|
+
export declare const ConflictDetectorPlugin: GraphileConfig.Plugin;
|
|
3
|
+
/**
|
|
4
|
+
* Preset that includes the conflict detector plugin.
|
|
5
|
+
*/
|
|
6
|
+
export declare const ConflictDetectorPreset: GraphileConfig.Preset;
|
|
7
|
+
export default ConflictDetectorPlugin;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const ConflictDetectorPlugin = {
|
|
2
|
+
name: 'ConflictDetectorPlugin',
|
|
3
|
+
version: '1.0.0',
|
|
4
|
+
schema: {
|
|
5
|
+
hooks: {
|
|
6
|
+
build(build) {
|
|
7
|
+
// Track codecs by their GraphQL name to detect conflicts
|
|
8
|
+
const codecsByName = new Map();
|
|
9
|
+
// Get configured schemas from pgServices to only check relevant codecs
|
|
10
|
+
const configuredSchemas = new Set();
|
|
11
|
+
const pgServices = build.resolvedPreset?.pgServices ?? [];
|
|
12
|
+
for (const service of pgServices) {
|
|
13
|
+
for (const schema of service.schemas ?? ['public']) {
|
|
14
|
+
configuredSchemas.add(schema);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// Iterate through all codecs to find tables
|
|
18
|
+
for (const codec of Object.values(build.input.pgRegistry.pgCodecs)) {
|
|
19
|
+
// Skip non-table codecs (those without attributes or anonymous ones)
|
|
20
|
+
if (!codec.attributes || codec.isAnonymous)
|
|
21
|
+
continue;
|
|
22
|
+
// Get the schema name from the codec's extensions
|
|
23
|
+
const pgExtensions = codec.extensions?.pg;
|
|
24
|
+
const schemaName = pgExtensions?.schemaName || 'unknown';
|
|
25
|
+
const tableName = codec.name;
|
|
26
|
+
// Skip codecs from schemas not in the configured list
|
|
27
|
+
if (configuredSchemas.size > 0 && !configuredSchemas.has(schemaName)) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
// Get the GraphQL name that would be generated
|
|
31
|
+
const graphqlName = build.inflection.tableType(codec);
|
|
32
|
+
const info = {
|
|
33
|
+
name: graphqlName,
|
|
34
|
+
schemaName,
|
|
35
|
+
tableName,
|
|
36
|
+
};
|
|
37
|
+
if (!codecsByName.has(graphqlName)) {
|
|
38
|
+
codecsByName.set(graphqlName, []);
|
|
39
|
+
}
|
|
40
|
+
codecsByName.get(graphqlName).push(info);
|
|
41
|
+
}
|
|
42
|
+
// Check for conflicts and log warnings
|
|
43
|
+
for (const [graphqlName, codecs] of codecsByName) {
|
|
44
|
+
if (codecs.length > 1) {
|
|
45
|
+
const locations = codecs
|
|
46
|
+
.map((c) => `${c.schemaName}.${c.tableName}`)
|
|
47
|
+
.join(', ');
|
|
48
|
+
console.warn(`\nNAMING CONFLICT DETECTED: GraphQL type "${graphqlName}" would be generated from multiple tables:\n` +
|
|
49
|
+
` Tables: ${locations}\n` +
|
|
50
|
+
` Resolution options:\n` +
|
|
51
|
+
` 1. Add @name smart tag to one table: COMMENT ON TABLE schema.table IS E'@name UniqueTypeName';\n` +
|
|
52
|
+
` 2. Rename one of the tables in the database\n` +
|
|
53
|
+
` 3. Exclude one table from the schema using @omit smart tag\n`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return build;
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Preset that includes the conflict detector plugin.
|
|
63
|
+
*/
|
|
64
|
+
export const ConflictDetectorPreset = {
|
|
65
|
+
plugins: [ConflictDetectorPlugin],
|
|
66
|
+
};
|
|
67
|
+
export default ConflictDetectorPlugin;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
2
|
+
export declare const InflektPlugin: GraphileConfig.Plugin;
|
|
3
|
+
/**
|
|
4
|
+
* Preset that includes the inflekt-based inflector plugin.
|
|
5
|
+
* Use this in your main preset's `extends` array.
|
|
6
|
+
*/
|
|
7
|
+
export declare const InflektPreset: GraphileConfig.Preset;
|
|
8
|
+
export declare const CustomInflectorPlugin: GraphileConfig.Plugin;
|
|
9
|
+
export declare const CustomInflectorPreset: GraphileConfig.Preset;
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { singularize, singularizeLast, pluralizeLast, distinctPluralize, fixCapitalisedPlural, camelize, } from 'inflekt';
|
|
2
|
+
/**
|
|
3
|
+
* Custom inflector plugin for Constructive using the inflekt library.
|
|
4
|
+
*
|
|
5
|
+
* This plugin provides inflection rules based on the inflekt package from dev-utils.
|
|
6
|
+
* It gives us full control over naming conventions and handles Latin plural suffixes
|
|
7
|
+
* correctly (e.g., "schemata" -> "schema" instead of "schematum").
|
|
8
|
+
*
|
|
9
|
+
* Key features:
|
|
10
|
+
* - Uses inflekt for pluralization/singularization with PostGraphile-compatible Latin handling
|
|
11
|
+
* - Simplifies field names (allUsers -> users, postsByAuthorId -> posts)
|
|
12
|
+
* - Customizable opposite name mappings for relations
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Custom opposite name mappings for relations.
|
|
16
|
+
* For example, if you have a `parent_id` column, this determines
|
|
17
|
+
* what the reverse relation should be called.
|
|
18
|
+
*
|
|
19
|
+
* Add your own mappings here as needed.
|
|
20
|
+
*/
|
|
21
|
+
const CUSTOM_OPPOSITES = {
|
|
22
|
+
parent: 'child',
|
|
23
|
+
child: 'parent',
|
|
24
|
+
author: 'authored',
|
|
25
|
+
editor: 'edited',
|
|
26
|
+
reviewer: 'reviewed',
|
|
27
|
+
owner: 'owned',
|
|
28
|
+
creator: 'created',
|
|
29
|
+
updater: 'updated',
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Extract base name from attribute names like "author_id" -> "author"
|
|
33
|
+
*/
|
|
34
|
+
function getBaseName(attributeName) {
|
|
35
|
+
const matches = attributeName.match(/^(.+?)(_row_id|_id|_uuid|_fk|_pk|RowId|Id|Uuid|UUID|Fk|Pk)$/);
|
|
36
|
+
if (matches) {
|
|
37
|
+
return matches[1];
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Check if a base name matches another name (singularized)
|
|
43
|
+
*/
|
|
44
|
+
function baseNameMatches(baseName, otherName) {
|
|
45
|
+
const singularizedName = singularize(otherName);
|
|
46
|
+
return camelize(baseName, true) === camelize(singularizedName, true);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Get the opposite name for a relation base name
|
|
50
|
+
*/
|
|
51
|
+
function getOppositeBaseName(baseName) {
|
|
52
|
+
return CUSTOM_OPPOSITES[baseName] || null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Returns true if array1 and array2 have the same length and values
|
|
56
|
+
*/
|
|
57
|
+
function arraysMatch(array1, array2, comparator = (v1, v2) => v1 === v2) {
|
|
58
|
+
if (array1 === array2)
|
|
59
|
+
return true;
|
|
60
|
+
const l = array1.length;
|
|
61
|
+
if (l !== array2.length)
|
|
62
|
+
return false;
|
|
63
|
+
for (let i = 0; i < l; i++) {
|
|
64
|
+
if (!comparator(array1[i], array2[i]))
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
export const InflektPlugin = {
|
|
70
|
+
name: 'InflektPlugin',
|
|
71
|
+
version: '1.0.0',
|
|
72
|
+
inflection: {
|
|
73
|
+
replace: {
|
|
74
|
+
/**
|
|
75
|
+
* Remove schema prefixes from all schemas.
|
|
76
|
+
*
|
|
77
|
+
* WHY THIS EXISTS:
|
|
78
|
+
* PostGraphile v5's default `_schemaPrefix` inflector only removes the prefix
|
|
79
|
+
* for the FIRST schema in the pgServices.schemas array. All other schemas get
|
|
80
|
+
* prefixed with their schema name (e.g., "services_public_api" -> "servicesPublicApi").
|
|
81
|
+
*
|
|
82
|
+
* This is problematic for multi-schema setups where you want clean, consistent
|
|
83
|
+
* naming across all schemas.
|
|
84
|
+
*
|
|
85
|
+
* SOURCE CODE REFERENCE:
|
|
86
|
+
* https://github.com/graphile/crystal/blob/924b2515c6bd30e5905ac1419a25244b40c8bb4d/graphile-build/graphile-build-pg/src/plugins/PgTablesPlugin.ts#L261-L271
|
|
87
|
+
*
|
|
88
|
+
* The relevant v5 code:
|
|
89
|
+
* ```typescript
|
|
90
|
+
* _schemaPrefix(options, { pgNamespace, serviceName }) {
|
|
91
|
+
* const pgService = options.pgServices?.find((db) => db.name === serviceName);
|
|
92
|
+
* const databasePrefix = serviceName === "main" ? "" : `${serviceName}_`;
|
|
93
|
+
* const schemaPrefix =
|
|
94
|
+
* pgNamespace.nspname === pgService?.schemas?.[0] // <-- Only first schema!
|
|
95
|
+
* ? ""
|
|
96
|
+
* : `${pgNamespace.nspname}_`;
|
|
97
|
+
* return `${databasePrefix}${schemaPrefix}`;
|
|
98
|
+
* }
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* OUR FIX:
|
|
102
|
+
* We override this to always return an empty string, giving clean names for
|
|
103
|
+
* all schemas. Use the ConflictDetectorPlugin to detect naming conflicts.
|
|
104
|
+
*
|
|
105
|
+
* WARNING: This may cause naming conflicts if you have tables with the
|
|
106
|
+
* same name in different schemas. Use @name smart tags to disambiguate.
|
|
107
|
+
*/
|
|
108
|
+
_schemaPrefix(_previous, _options, _details) {
|
|
109
|
+
return '';
|
|
110
|
+
},
|
|
111
|
+
/**
|
|
112
|
+
* Keep `id` columns as `id` instead of renaming to `rowId`.
|
|
113
|
+
*
|
|
114
|
+
* WHY THIS EXISTS:
|
|
115
|
+
* PostGraphile v5's default `_attributeName` inflector renames any column
|
|
116
|
+
* named "id" to "row_id" to avoid conflicts with the Relay Global Object
|
|
117
|
+
* Identification spec's `id` field. Since we don't use Relay/Node (we use
|
|
118
|
+
* UUIDs), there's no conflict to avoid.
|
|
119
|
+
*
|
|
120
|
+
* NOTE: Disabling NodePlugin does NOT fix this! The renaming happens in
|
|
121
|
+
* PgAttributesPlugin which is a core plugin we need for basic column
|
|
122
|
+
* functionality.
|
|
123
|
+
*
|
|
124
|
+
* SOURCE CODE REFERENCE:
|
|
125
|
+
* https://github.com/graphile/crystal/blob/924b2515c6bd30e5905ac1419a25244b40c8bb4d/graphile-build/graphile-build-pg/src/plugins/PgAttributesPlugin.ts#L289-L298
|
|
126
|
+
*
|
|
127
|
+
* The relevant v5 code:
|
|
128
|
+
* ```typescript
|
|
129
|
+
* _attributeName(options, { attributeName, codec, skipRowId }) {
|
|
130
|
+
* const attribute = codec.attributes[attributeName];
|
|
131
|
+
* const name = attribute.extensions?.tags?.name || attributeName;
|
|
132
|
+
* // Avoid conflict with 'id' field used for Relay.
|
|
133
|
+
* const nonconflictName =
|
|
134
|
+
* !skipRowId && name.toLowerCase() === "id" && !codec.isAnonymous
|
|
135
|
+
* ? "row_id" // <-- This renames id to row_id!
|
|
136
|
+
* : name;
|
|
137
|
+
* return this.coerceToGraphQLName(nonconflictName);
|
|
138
|
+
* }
|
|
139
|
+
* ```
|
|
140
|
+
*
|
|
141
|
+
* OUR FIX:
|
|
142
|
+
* We override this to always use the original attribute name, never
|
|
143
|
+
* renaming `id` to `row_id`. Since we use UUIDs and don't use Relay,
|
|
144
|
+
* there's no naming conflict.
|
|
145
|
+
*/
|
|
146
|
+
_attributeName(_previous, _options, details) {
|
|
147
|
+
const attribute = details.codec.attributes[details.attributeName];
|
|
148
|
+
const name = attribute?.extensions?.tags?.name || details.attributeName;
|
|
149
|
+
return this.coerceToGraphQLName(name);
|
|
150
|
+
},
|
|
151
|
+
/**
|
|
152
|
+
* Fix capitalized plurals (e.g., "Table1S" -> "Table1s")
|
|
153
|
+
*/
|
|
154
|
+
camelCase(previous, _preset, str) {
|
|
155
|
+
const original = previous(str);
|
|
156
|
+
return fixCapitalisedPlural(original);
|
|
157
|
+
},
|
|
158
|
+
upperCamelCase(previous, _preset, str) {
|
|
159
|
+
const original = previous(str);
|
|
160
|
+
return fixCapitalisedPlural(original);
|
|
161
|
+
},
|
|
162
|
+
/**
|
|
163
|
+
* Use inflekt's singularize/pluralize which only changes the last word
|
|
164
|
+
*/
|
|
165
|
+
pluralize(_previous, _preset, str) {
|
|
166
|
+
return pluralizeLast(str);
|
|
167
|
+
},
|
|
168
|
+
singularize(_previous, _preset, str) {
|
|
169
|
+
return singularizeLast(str);
|
|
170
|
+
},
|
|
171
|
+
/**
|
|
172
|
+
* Simplify root query connection fields (allUsers -> users)
|
|
173
|
+
*/
|
|
174
|
+
allRowsConnection(_previous, _options, resource) {
|
|
175
|
+
const resourceName = this._singularizedResourceName(resource);
|
|
176
|
+
return camelize(distinctPluralize(resourceName), true);
|
|
177
|
+
},
|
|
178
|
+
/**
|
|
179
|
+
* Simplify root query list fields
|
|
180
|
+
*/
|
|
181
|
+
allRowsList(_previous, _options, resource) {
|
|
182
|
+
const resourceName = this._singularizedResourceName(resource);
|
|
183
|
+
return camelize(distinctPluralize(resourceName), true) + 'List';
|
|
184
|
+
},
|
|
185
|
+
/**
|
|
186
|
+
* Simplify single relation field names (userByAuthorId -> author)
|
|
187
|
+
*/
|
|
188
|
+
singleRelation(previous, _options, details) {
|
|
189
|
+
const { registry, codec, relationName } = details;
|
|
190
|
+
const relation = registry.pgRelations[codec.name]?.[relationName];
|
|
191
|
+
if (!relation) {
|
|
192
|
+
return previous(details);
|
|
193
|
+
}
|
|
194
|
+
if (typeof relation.extensions?.tags?.fieldName === 'string') {
|
|
195
|
+
return relation.extensions.tags.fieldName;
|
|
196
|
+
}
|
|
197
|
+
// Try to extract base name from the local attribute
|
|
198
|
+
if (relation.localAttributes.length === 1) {
|
|
199
|
+
const attributeName = relation.localAttributes[0];
|
|
200
|
+
const baseName = getBaseName(attributeName);
|
|
201
|
+
if (baseName) {
|
|
202
|
+
return camelize(baseName, true);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Fall back to the remote resource name
|
|
206
|
+
const foreignPk = relation.remoteResource.uniques.find((u) => u.isPrimary);
|
|
207
|
+
if (foreignPk &&
|
|
208
|
+
arraysMatch(foreignPk.attributes, relation.remoteAttributes)) {
|
|
209
|
+
return camelize(this._singularizedCodecName(relation.remoteResource.codec), true);
|
|
210
|
+
}
|
|
211
|
+
return previous(details);
|
|
212
|
+
},
|
|
213
|
+
/**
|
|
214
|
+
* Simplify backwards single relation field names
|
|
215
|
+
*/
|
|
216
|
+
singleRelationBackwards(previous, _options, details) {
|
|
217
|
+
const { registry, codec, relationName } = details;
|
|
218
|
+
const relation = registry.pgRelations[codec.name]?.[relationName];
|
|
219
|
+
if (!relation) {
|
|
220
|
+
return previous(details);
|
|
221
|
+
}
|
|
222
|
+
if (typeof relation.extensions?.tags?.foreignSingleFieldName === 'string') {
|
|
223
|
+
return relation.extensions.tags.foreignSingleFieldName;
|
|
224
|
+
}
|
|
225
|
+
if (typeof relation.extensions?.tags?.foreignFieldName === 'string') {
|
|
226
|
+
return relation.extensions.tags.foreignFieldName;
|
|
227
|
+
}
|
|
228
|
+
// Try to extract base name from the remote attribute
|
|
229
|
+
if (relation.remoteAttributes.length === 1) {
|
|
230
|
+
const attributeName = relation.remoteAttributes[0];
|
|
231
|
+
const baseName = getBaseName(attributeName);
|
|
232
|
+
if (baseName) {
|
|
233
|
+
const oppositeBaseName = getOppositeBaseName(baseName);
|
|
234
|
+
if (oppositeBaseName) {
|
|
235
|
+
return camelize(`${oppositeBaseName}_${this._singularizedCodecName(relation.remoteResource.codec)}`, true);
|
|
236
|
+
}
|
|
237
|
+
if (baseNameMatches(baseName, codec.name)) {
|
|
238
|
+
return camelize(this._singularizedCodecName(relation.remoteResource.codec), true);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return previous(details);
|
|
243
|
+
},
|
|
244
|
+
/**
|
|
245
|
+
* Simplify many relation field names (postsByAuthorId -> posts)
|
|
246
|
+
*/
|
|
247
|
+
_manyRelation(previous, _options, details) {
|
|
248
|
+
const { registry, codec, relationName } = details;
|
|
249
|
+
const relation = registry.pgRelations[codec.name]?.[relationName];
|
|
250
|
+
if (!relation) {
|
|
251
|
+
return previous(details);
|
|
252
|
+
}
|
|
253
|
+
const baseOverride = relation.extensions?.tags?.foreignFieldName;
|
|
254
|
+
if (typeof baseOverride === 'string') {
|
|
255
|
+
return baseOverride;
|
|
256
|
+
}
|
|
257
|
+
// Try to extract base name from the remote attribute
|
|
258
|
+
if (relation.remoteAttributes.length === 1) {
|
|
259
|
+
const attributeName = relation.remoteAttributes[0];
|
|
260
|
+
const baseName = getBaseName(attributeName);
|
|
261
|
+
if (baseName) {
|
|
262
|
+
const oppositeBaseName = getOppositeBaseName(baseName);
|
|
263
|
+
if (oppositeBaseName) {
|
|
264
|
+
return camelize(`${oppositeBaseName}_${distinctPluralize(this._singularizedCodecName(relation.remoteResource.codec))}`, true);
|
|
265
|
+
}
|
|
266
|
+
if (baseNameMatches(baseName, codec.name)) {
|
|
267
|
+
return camelize(distinctPluralize(this._singularizedCodecName(relation.remoteResource.codec)), true);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Fall back to pluralized remote resource name
|
|
272
|
+
const pk = relation.remoteResource.uniques.find((u) => u.isPrimary);
|
|
273
|
+
if (pk && arraysMatch(pk.attributes, relation.remoteAttributes)) {
|
|
274
|
+
return camelize(distinctPluralize(this._singularizedCodecName(relation.remoteResource.codec)), true);
|
|
275
|
+
}
|
|
276
|
+
return previous(details);
|
|
277
|
+
},
|
|
278
|
+
/**
|
|
279
|
+
* Simplify many-to-many relation field names with conflict detection.
|
|
280
|
+
*
|
|
281
|
+
* Default pg-many-to-many naming: tagsByPostTagPostIdAndTagId
|
|
282
|
+
* Our simplified naming: tags
|
|
283
|
+
*
|
|
284
|
+
* Falls back to verbose naming if:
|
|
285
|
+
* - Smart tag override exists (manyToManyFieldName)
|
|
286
|
+
* - There's a direct relation to the same target table (would conflict)
|
|
287
|
+
* - There are multiple many-to-many relations to the same target table
|
|
288
|
+
*/
|
|
289
|
+
_manyToManyRelation(previous, _options, details) {
|
|
290
|
+
const { leftTable, rightTable, junctionTable, rightRelationName } = details;
|
|
291
|
+
const junctionRightRelation = junctionTable.getRelation?.(rightRelationName);
|
|
292
|
+
if (!junctionRightRelation) {
|
|
293
|
+
return previous(details);
|
|
294
|
+
}
|
|
295
|
+
const baseOverride = junctionRightRelation.extensions?.tags?.manyToManyFieldName;
|
|
296
|
+
if (typeof baseOverride === 'string') {
|
|
297
|
+
return baseOverride;
|
|
298
|
+
}
|
|
299
|
+
const simpleName = camelize(distinctPluralize(this._singularizedCodecName(rightTable.codec)), true);
|
|
300
|
+
const leftRelations = leftTable.getRelations();
|
|
301
|
+
let hasDirectRelation = false;
|
|
302
|
+
let manyToManyCount = 0;
|
|
303
|
+
for (const [_relName, rel] of Object.entries(leftRelations)) {
|
|
304
|
+
if (rel.remoteResource?.codec?.name === rightTable.codec.name) {
|
|
305
|
+
if (!rel.isReferencee) {
|
|
306
|
+
hasDirectRelation = true;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (rel.isReferencee &&
|
|
310
|
+
rel.remoteResource?.codec?.name !== rightTable.codec.name) {
|
|
311
|
+
const junctionRelations = rel.remoteResource?.getRelations?.() || {};
|
|
312
|
+
for (const [_jRelName, jRel] of Object.entries(junctionRelations)) {
|
|
313
|
+
if (!jRel.isReferencee &&
|
|
314
|
+
jRel.remoteResource?.codec?.name === rightTable.codec.name) {
|
|
315
|
+
manyToManyCount++;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (hasDirectRelation || manyToManyCount > 1) {
|
|
321
|
+
return previous(details);
|
|
322
|
+
}
|
|
323
|
+
return simpleName;
|
|
324
|
+
},
|
|
325
|
+
/**
|
|
326
|
+
* Shorten primary key lookups (userById -> user)
|
|
327
|
+
*/
|
|
328
|
+
rowByUnique(previous, _options, details) {
|
|
329
|
+
const { unique, resource } = details;
|
|
330
|
+
if (typeof unique.extensions?.tags?.fieldName === 'string') {
|
|
331
|
+
return unique.extensions?.tags?.fieldName;
|
|
332
|
+
}
|
|
333
|
+
if (unique.isPrimary) {
|
|
334
|
+
return camelize(this._singularizedCodecName(resource.codec), true);
|
|
335
|
+
}
|
|
336
|
+
return previous(details);
|
|
337
|
+
},
|
|
338
|
+
/**
|
|
339
|
+
* Shorten update mutation names
|
|
340
|
+
*/
|
|
341
|
+
updateByKeysField(previous, _options, details) {
|
|
342
|
+
const { resource, unique } = details;
|
|
343
|
+
if (typeof unique.extensions?.tags.updateFieldName === 'string') {
|
|
344
|
+
return unique.extensions.tags.updateFieldName;
|
|
345
|
+
}
|
|
346
|
+
if (unique.isPrimary) {
|
|
347
|
+
return camelize(`update_${this._singularizedCodecName(resource.codec)}`, true);
|
|
348
|
+
}
|
|
349
|
+
return previous(details);
|
|
350
|
+
},
|
|
351
|
+
/**
|
|
352
|
+
* Shorten delete mutation names
|
|
353
|
+
*/
|
|
354
|
+
deleteByKeysField(previous, _options, details) {
|
|
355
|
+
const { resource, unique } = details;
|
|
356
|
+
if (typeof unique.extensions?.tags.deleteFieldName === 'string') {
|
|
357
|
+
return unique.extensions.tags.deleteFieldName;
|
|
358
|
+
}
|
|
359
|
+
if (unique.isPrimary) {
|
|
360
|
+
return camelize(`delete_${this._singularizedCodecName(resource.codec)}`, true);
|
|
361
|
+
}
|
|
362
|
+
return previous(details);
|
|
363
|
+
},
|
|
364
|
+
/**
|
|
365
|
+
* Uppercase enum values to match GraphQL CONSTANT_CASE convention.
|
|
366
|
+
*
|
|
367
|
+
* WHY THIS EXISTS:
|
|
368
|
+
* In PostGraphile v4, custom PostgreSQL enum values (e.g., 'app', 'core', 'module')
|
|
369
|
+
* were automatically uppercased to CONSTANT_CASE ('APP', 'CORE', 'MODULE').
|
|
370
|
+
* In PostGraphile v5, the default `enumValue` inflector preserves the original
|
|
371
|
+
* PostgreSQL casing via `coerceToGraphQLName(value)`, resulting in lowercase
|
|
372
|
+
* enum values in the GraphQL schema.
|
|
373
|
+
*
|
|
374
|
+
* OUR FIX:
|
|
375
|
+
* We call the previous inflector to retain all special character handling
|
|
376
|
+
* (asterisks, symbols, etc.), then uppercase the result to restore v4 behavior.
|
|
377
|
+
*/
|
|
378
|
+
enumValue(previous, _options, value, codec) {
|
|
379
|
+
const result = previous(value, codec);
|
|
380
|
+
return result.toUpperCase();
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
/**
|
|
386
|
+
* Preset that includes the inflekt-based inflector plugin.
|
|
387
|
+
* Use this in your main preset's `extends` array.
|
|
388
|
+
*/
|
|
389
|
+
export const InflektPreset = {
|
|
390
|
+
plugins: [InflektPlugin],
|
|
391
|
+
};
|
|
392
|
+
// Re-export for backwards compatibility
|
|
393
|
+
export const CustomInflectorPlugin = InflektPlugin;
|
|
394
|
+
export const CustomInflectorPreset = InflektPreset;
|