graphile-settings
Version:
graphile settings
544 lines (543 loc) • 25.2 kB
JavaScript
import { singularize, singularizeLast, pluralizeLast, distinctPluralize, fixCapitalisedPlural, toCamelCase, } from 'inflekt';
/**
* Custom inflector plugin for Constructive using the inflekt library.
*
* This plugin provides inflection rules based on the inflekt package from dev-utils.
* It gives us full control over naming conventions and handles Latin plural suffixes
* correctly (e.g., "schemata" -> "schema" instead of "schematum").
*
* Key features:
* - Uses inflekt for pluralization/singularization with PostGraphile-compatible Latin handling
* - Simplifies field names (allUsers -> users, postsByAuthorId -> posts)
* - Customizable opposite name mappings for relations
*/
/**
* Custom opposite name mappings for relations.
* For example, if you have a `parent_id` column, this determines
* what the reverse relation should be called.
*
* Add your own mappings here as needed.
*/
const CUSTOM_OPPOSITES = {
parent: 'child',
child: 'parent',
author: 'authored',
editor: 'edited',
reviewer: 'reviewed',
owner: 'owned',
creator: 'created',
updater: 'updated',
};
/**
* Extract base name from attribute names like "author_id" -> "author"
*/
function getBaseName(attributeName) {
const matches = attributeName.match(/^(.+?)(_row_id|_id|_uuid|_fk|_pk|RowId|Id|Uuid|UUID|Fk|Pk)$/);
if (matches) {
return matches[1];
}
return null;
}
/**
* Check if a base name matches another name (singularized)
*/
function baseNameMatches(baseName, otherName) {
const singularizedName = singularize(otherName);
return toCamelCase(baseName) === toCamelCase(singularizedName);
}
/**
* Get the opposite name for a relation base name
*/
function getOppositeBaseName(baseName) {
return CUSTOM_OPPOSITES[baseName] || null;
}
/**
* Returns true if array1 and array2 have the same length and values
*/
function arraysMatch(array1, array2, comparator = (v1, v2) => v1 === v2) {
if (array1 === array2)
return true;
const l = array1.length;
if (l !== array2.length)
return false;
for (let i = 0; i < l; i++) {
if (!comparator(array1[i], array2[i]))
return false;
}
return true;
}
/**
* Handle @listSuffix smart tag for per-table control of Connection/List suffix.
* When @listSuffix is "omit", connections get a "Connection" suffix and lists are bare.
* When @listSuffix is "include" (or absent), default behavior applies.
*/
let globalPgOmitListSuffix = null;
function overrideListSuffix(listSuffix, cb) {
if (listSuffix == null) {
return cb();
}
if (listSuffix !== 'include' && listSuffix !== 'omit') {
throw new Error(`Unrecognized value "${String(listSuffix)}". Must be "omit" or "include".`);
}
const old = globalPgOmitListSuffix;
try {
globalPgOmitListSuffix = listSuffix === 'omit';
return cb();
}
finally {
globalPgOmitListSuffix = old;
}
}
export const InflektPlugin = {
name: 'InflektPlugin',
version: '1.0.0',
inflection: {
add: {
/**
* Pluralize ensuring the result differs from the singular.
* Registered as an inflection method so other plugins can call this.distinctPluralize().
*/
distinctPluralize(_preset, str) {
return distinctPluralize(str);
},
/**
* Extract base name from FK attribute names (e.g. "author_id" -> "author").
* Registered so other plugins can call this._getBaseName().
*/
_getBaseName(_preset, attributeName) {
return getBaseName(attributeName);
},
/**
* Check if a base name matches another name when singularized.
* Registered so other plugins can call this._baseNameMatches().
*/
_baseNameMatches(_preset, baseName, otherName) {
return baseNameMatches(baseName, otherName);
},
/**
* Get the opposite name for a relation base name (e.g. "parent" -> "child").
* Registered so other plugins can call this._getOppositeBaseName().
*/
_getOppositeBaseName(_preset, baseName) {
return getOppositeBaseName(baseName);
},
/**
* Extract base name from composite FK keys.
* Handles single-key (delegates to getBaseName) and multi-key
* (joins base names with hyphens) FK relations.
*/
_getBaseNameFromKeys(preset, detailedKeys) {
if (detailedKeys.length === 1) {
const key = detailedKeys[0];
const attributeName = this._attributeName({
...key,
skipRowId: true,
});
return getBaseName(attributeName);
}
if (preset.schema?.pgSimplifyMultikeyRelations) {
const attributeNames = detailedKeys.map((key) => this._attributeName({ ...key, skipRowId: true }));
const baseNames = attributeNames.map((attr) => getBaseName(attr));
if (baseNames.every((n) => n)) {
return baseNames.join('-');
}
}
return null;
},
},
replace: {
/**
* Remove schema prefixes from all schemas.
*
* WHY THIS EXISTS:
* PostGraphile v5's default `_schemaPrefix` inflector only removes the prefix
* for the FIRST schema in the pgServices.schemas array. All other schemas get
* prefixed with their schema name (e.g., "services_public_api" -> "servicesPublicApi").
*
* This is problematic for multi-schema setups where you want clean, consistent
* naming across all schemas.
*
* SOURCE CODE REFERENCE:
* https://github.com/graphile/crystal/blob/924b2515c6bd30e5905ac1419a25244b40c8bb4d/graphile-build/graphile-build-pg/src/plugins/PgTablesPlugin.ts#L261-L271
*
* The relevant v5 code:
* ```typescript
* _schemaPrefix(options, { pgNamespace, serviceName }) {
* const pgService = options.pgServices?.find((db) => db.name === serviceName);
* const databasePrefix = serviceName === "main" ? "" : `${serviceName}_`;
* const schemaPrefix =
* pgNamespace.nspname === pgService?.schemas?.[0] // <-- Only first schema!
* ? ""
* : `${pgNamespace.nspname}_`;
* return `${databasePrefix}${schemaPrefix}`;
* }
* ```
*
* OUR FIX:
* We override this to always return an empty string, giving clean names for
* all schemas. Use the ConflictDetectorPlugin to detect naming conflicts.
*
* WARNING: This may cause naming conflicts if you have tables with the
* same name in different schemas. Use @name smart tags to disambiguate.
*/
_schemaPrefix(_previous, _options, _details) {
return '';
},
/**
* Keep `id` columns as `id` instead of renaming to `rowId`.
*
* WHY THIS EXISTS:
* PostGraphile v5's default `_attributeName` inflector renames any column
* named "id" to "row_id" to avoid conflicts with the Relay Global Object
* Identification spec's `id` field. Since we don't use Relay/Node (we use
* UUIDs), there's no conflict to avoid.
*
* NOTE: Disabling NodePlugin does NOT fix this! The renaming happens in
* PgAttributesPlugin which is a core plugin we need for basic column
* functionality.
*
* SOURCE CODE REFERENCE:
* https://github.com/graphile/crystal/blob/924b2515c6bd30e5905ac1419a25244b40c8bb4d/graphile-build/graphile-build-pg/src/plugins/PgAttributesPlugin.ts#L289-L298
*
* The relevant v5 code:
* ```typescript
* _attributeName(options, { attributeName, codec, skipRowId }) {
* const attribute = codec.attributes[attributeName];
* const name = attribute.extensions?.tags?.name || attributeName;
* // Avoid conflict with 'id' field used for Relay.
* const nonconflictName =
* !skipRowId && name.toLowerCase() === "id" && !codec.isAnonymous
* ? "row_id" // <-- This renames id to row_id!
* : name;
* return this.coerceToGraphQLName(nonconflictName);
* }
* ```
*
* OUR FIX:
* We override this to always use the original attribute name, never
* renaming `id` to `row_id`. Since we use UUIDs and don't use Relay,
* there's no naming conflict.
*/
_attributeName(_previous, _options, details) {
const attribute = details.codec.attributes[details.attributeName];
const name = attribute?.extensions?.tags?.name || details.attributeName;
return this.coerceToGraphQLName(name);
},
/**
* Fix capitalized plurals (e.g., "Table1S" -> "Table1s")
*/
camelCase(previous, _preset, str) {
const original = previous(str);
return fixCapitalisedPlural(original);
},
upperCamelCase(previous, _preset, str) {
const original = previous(str);
return fixCapitalisedPlural(original);
},
/**
* Use inflekt's singularize/pluralize which only changes the last word
*/
pluralize(_previous, _preset, str) {
return pluralizeLast(str);
},
singularize(_previous, _preset, str) {
return singularizeLast(str);
},
/**
* Connection field suffix — respects @listSuffix smart tag.
* When pgOmitListSuffix is true, connections get "Connection" suffix.
*/
connectionField(_prev, options, baseName) {
return (globalPgOmitListSuffix ?? options.schema?.pgOmitListSuffix)
? baseName + 'Connection'
: baseName;
},
/**
* List field suffix — respects @listSuffix smart tag.
* When pgOmitListSuffix is true, lists are bare (no suffix).
*/
listField(_prev, options, baseName) {
return (globalPgOmitListSuffix ?? options.schema?.pgOmitListSuffix)
? baseName
: baseName + 'List';
},
/**
* Simplify root query connection fields (allUsers -> users)
* Supports @listSuffix smart tag per-table.
*/
allRowsConnection(_previous, _options, resource) {
const listSuffix = resource.extensions?.tags?.listSuffix;
return overrideListSuffix(listSuffix, () => {
const resourceName = this._singularizedResourceName(resource);
return this.connectionField(toCamelCase(distinctPluralize(resourceName)));
});
},
/**
* Simplify root query list fields.
* Supports @listSuffix smart tag per-table.
*/
allRowsList(_previous, _options, resource) {
const listSuffix = resource.extensions?.tags?.listSuffix;
return overrideListSuffix(listSuffix, () => {
const resourceName = this._singularizedResourceName(resource);
return this.listField(toCamelCase(distinctPluralize(resourceName)));
});
},
/**
* @listSuffix passthrough for many-relation connection fields
*/
manyRelationConnection(previous, _options, details) {
const { registry, codec, relationName } = details;
const relation = registry.pgRelations[codec.name]?.[relationName];
const listSuffix = (relation?.extensions?.tags?.listSuffix ??
relation?.remoteResource?.extensions?.tags?.listSuffix);
return overrideListSuffix(listSuffix, () => previous(details));
},
/**
* @listSuffix passthrough for many-relation list fields
*/
manyRelationList(previous, _options, details) {
const { registry, codec, relationName } = details;
const relation = registry.pgRelations[codec.name]?.[relationName];
const listSuffix = (relation?.extensions?.tags?.listSuffix ??
relation?.remoteResource?.extensions?.tags?.listSuffix);
return overrideListSuffix(listSuffix, () => previous(details));
},
/**
* @listSuffix passthrough for custom query connection fields
*/
customQueryConnectionField(previous, _options, details) {
const listSuffix = details.resource?.extensions?.tags?.listSuffix;
return overrideListSuffix(listSuffix, () => previous(details));
},
/**
* @listSuffix passthrough for custom query list fields
*/
customQueryListField(previous, _options, details) {
const listSuffix = details.resource?.extensions?.tags?.listSuffix;
return overrideListSuffix(listSuffix, () => previous(details));
},
/**
* @listSuffix passthrough for computed attribute connection fields
*/
computedAttributeConnectionField(previous, _options, details) {
const listSuffix = details.resource?.extensions?.tags?.listSuffix;
return overrideListSuffix(listSuffix, () => previous(details));
},
/**
* @listSuffix passthrough for computed attribute list fields
*/
computedAttributeListField(previous, _options, details) {
const listSuffix = details.resource?.extensions?.tags?.listSuffix;
return overrideListSuffix(listSuffix, () => previous(details));
},
/**
* Simplify single relation field names (userByAuthorId -> author).
* Uses _getBaseNameFromKeys for composite FK support.
*/
singleRelation(previous, _options, details) {
const { registry, codec, relationName } = details;
const relation = registry.pgRelations[codec.name]?.[relationName];
if (!relation) {
return previous(details);
}
if (typeof relation.extensions?.tags?.fieldName === 'string') {
return relation.extensions.tags.fieldName;
}
const detailedKeys = relation.localAttributes.map((attributeName) => ({
codec,
attributeName,
}));
if (relation.localAttributes.length === 1) {
const attributeName = relation.localAttributes[0];
const baseName = getBaseName(attributeName);
if (baseName) {
return toCamelCase(baseName);
}
}
// Fall back to the remote resource name
const foreignPk = relation.remoteResource.uniques.find((u) => u.isPrimary);
if (foreignPk &&
arraysMatch(foreignPk.attributes, relation.remoteAttributes)) {
return toCamelCase(this._singularizedCodecName(relation.remoteResource.codec));
}
return previous(details);
},
/**
* Simplify backwards single relation field names.
* Uses _getBaseNameFromKeys for composite FK support.
*/
singleRelationBackwards(previous, _options, details) {
const { registry, codec, relationName } = details;
const relation = registry.pgRelations[codec.name]?.[relationName];
if (!relation) {
return previous(details);
}
if (typeof relation.extensions?.tags?.foreignSingleFieldName === 'string') {
return relation.extensions.tags.foreignSingleFieldName;
}
if (typeof relation.extensions?.tags?.foreignFieldName === 'string') {
return relation.extensions.tags.foreignFieldName;
}
if (relation.remoteAttributes.length === 1) {
const attributeName = relation.remoteAttributes[0];
const baseName = getBaseName(attributeName);
if (baseName) {
const oppositeBaseName = getOppositeBaseName(baseName);
if (oppositeBaseName) {
return toCamelCase(`${oppositeBaseName}_${this._singularizedCodecName(relation.remoteResource.codec)}`);
}
if (baseNameMatches(baseName, codec.name)) {
return toCamelCase(this._singularizedCodecName(relation.remoteResource.codec));
}
}
}
return previous(details);
},
/**
* Simplify many relation field names (postsByAuthorId -> posts).
* Uses _getBaseNameFromKeys for composite FK support.
*/
_manyRelation(previous, _options, details) {
const { registry, codec, relationName } = details;
const relation = registry.pgRelations[codec.name]?.[relationName];
if (!relation) {
return previous(details);
}
const baseOverride = relation.extensions?.tags?.foreignFieldName;
if (typeof baseOverride === 'string') {
return baseOverride;
}
if (relation.remoteAttributes.length === 1) {
const attributeName = relation.remoteAttributes[0];
const baseName = getBaseName(attributeName);
if (baseName) {
const oppositeBaseName = getOppositeBaseName(baseName);
if (oppositeBaseName) {
return toCamelCase(`${oppositeBaseName}_${distinctPluralize(this._singularizedCodecName(relation.remoteResource.codec))}`);
}
if (baseNameMatches(baseName, codec.name)) {
return toCamelCase(distinctPluralize(this._singularizedCodecName(relation.remoteResource.codec)));
}
}
}
// Fall back to pluralized remote resource name
const pk = relation.remoteResource.uniques.find((u) => u.isPrimary);
if (pk && arraysMatch(pk.attributes, relation.remoteAttributes)) {
return toCamelCase(distinctPluralize(this._singularizedCodecName(relation.remoteResource.codec)));
}
return previous(details);
},
/**
* Simplify many-to-many relation field names with conflict detection.
*
* Default pg-many-to-many naming: tagsByPostTagPostIdAndTagId
* Our simplified naming: tags
*
* Falls back to verbose naming if:
* - Smart tag override exists (manyToManyFieldName)
* - There's a direct relation to the same target table (would conflict)
* - There are multiple many-to-many relations to the same target table
*/
_manyToManyRelation(previous, _options, details) {
const { leftTable, rightTable, junctionTable, rightRelationName } = details;
const junctionRightRelation = junctionTable.getRelation?.(rightRelationName);
if (!junctionRightRelation) {
return previous(details);
}
const baseOverride = junctionRightRelation.extensions?.tags?.manyToManyFieldName;
if (typeof baseOverride === 'string') {
return baseOverride;
}
const simpleName = toCamelCase(distinctPluralize(this._singularizedCodecName(rightTable.codec)));
const leftRelations = leftTable.getRelations();
let hasDirectRelation = false;
let manyToManyCount = 0;
for (const [_relName, rel] of Object.entries(leftRelations)) {
if (rel.remoteResource?.codec?.name === rightTable.codec.name) {
if (!rel.isReferencee) {
hasDirectRelation = true;
}
}
if (rel.isReferencee) {
const junctionRelations = rel.remoteResource?.getRelations?.() || {};
for (const [_jRelName, jRel] of Object.entries(junctionRelations)) {
if (!jRel.isReferencee &&
jRel.remoteResource?.codec?.name === rightTable.codec.name) {
manyToManyCount++;
}
}
}
}
if (hasDirectRelation || manyToManyCount > 1) {
return previous(details);
}
return simpleName;
},
/**
* Shorten primary key lookups (userById -> user)
*/
rowByUnique(previous, _options, details) {
const { unique, resource } = details;
if (typeof unique.extensions?.tags?.fieldName === 'string') {
return unique.extensions?.tags?.fieldName;
}
if (unique.isPrimary) {
return toCamelCase(this._singularizedCodecName(resource.codec));
}
return previous(details);
},
/**
* Shorten update mutation names
*/
updateByKeysField(previous, _options, details) {
const { resource, unique } = details;
if (typeof unique.extensions?.tags.updateFieldName === 'string') {
return unique.extensions.tags.updateFieldName;
}
if (unique.isPrimary) {
return toCamelCase(`update_${this._singularizedCodecName(resource.codec)}`);
}
return previous(details);
},
/**
* Shorten delete mutation names
*/
deleteByKeysField(previous, _options, details) {
const { resource, unique } = details;
if (typeof unique.extensions?.tags.deleteFieldName === 'string') {
return unique.extensions.tags.deleteFieldName;
}
if (unique.isPrimary) {
return toCamelCase(`delete_${this._singularizedCodecName(resource.codec)}`);
}
return previous(details);
},
/**
* Uppercase enum values to match GraphQL CONSTANT_CASE convention.
*
* WHY THIS EXISTS:
* In PostGraphile v4, custom PostgreSQL enum values (e.g., 'app', 'core', 'module')
* were automatically uppercased to CONSTANT_CASE ('APP', 'CORE', 'MODULE').
* In PostGraphile v5, the default `enumValue` inflector preserves the original
* PostgreSQL casing via `coerceToGraphQLName(value)`, resulting in lowercase
* enum values in the GraphQL schema.
*
* OUR FIX:
* We call the previous inflector to retain all special character handling
* (asterisks, symbols, etc.), then uppercase the result to restore v4 behavior.
*/
enumValue(previous, _options, value, codec) {
const result = previous(value, codec);
return result.toUpperCase();
},
},
},
};
/**
* Preset that includes the inflekt-based inflector plugin.
* Use this in your main preset's `extends` array.
*/
export const InflektPreset = {
plugins: [InflektPlugin],
};