Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 22 additions & 11 deletions graphile/graphile-settings/src/plugins/conflict-detector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { GraphileConfig } from 'graphile-config';
import type { GraphileConfig } from "graphile-config";

/**
* Plugin that detects naming conflicts between tables in different schemas.
Expand All @@ -20,27 +20,40 @@ interface CodecInfo {
}

export const ConflictDetectorPlugin: GraphileConfig.Plugin = {
name: 'ConflictDetectorPlugin',
version: '1.0.0',
name: "ConflictDetectorPlugin",
version: "1.0.0",

schema: {
hooks: {
build(build) {
// Track codecs by their GraphQL name to detect conflicts
const codecsByName = new Map<string, CodecInfo[]>();

// Get configured schemas from pgServices to only check relevant codecs
const configuredSchemas = new Set<string>();
const pgServices = (build as any).resolvedPreset?.pgServices ?? [];

for (const service of pgServices) {
for (const schema of service.schemas ?? ["public"]) {
configuredSchemas.add(schema);
}
}

// Iterate through all codecs to find tables
for (const codec of Object.values(build.input.pgRegistry.pgCodecs)) {
// Skip non-table codecs (those without attributes or anonymous ones)
if (!codec.attributes || codec.isAnonymous) continue;

// Get the schema name from the codec's extensions
const pgExtensions = codec.extensions?.pg as
| { schemaName?: string }
| undefined;
const schemaName = pgExtensions?.schemaName || 'unknown';
const pgExtensions = codec.extensions?.pg as { schemaName?: string } | undefined;
const schemaName = pgExtensions?.schemaName || "unknown";
const tableName = codec.name;

// Skip codecs from schemas not in the configured list
if (configuredSchemas.size > 0 && !configuredSchemas.has(schemaName)) {
continue;
}

// Get the GraphQL name that would be generated
const graphqlName = build.inflection.tableType(codec);

Expand All @@ -59,17 +72,15 @@ export const ConflictDetectorPlugin: GraphileConfig.Plugin = {
// Check for conflicts and log warnings
for (const [graphqlName, codecs] of codecsByName) {
if (codecs.length > 1) {
const locations = codecs
.map((c) => `${c.schemaName}.${c.tableName}`)
.join(', ');
const locations = codecs.map((c) => `${c.schemaName}.${c.tableName}`).join(", ");

console.warn(
`\nNAMING CONFLICT DETECTED: GraphQL type "${graphqlName}" would be generated from multiple tables:\n` +
` Tables: ${locations}\n` +
` Resolution options:\n` +
` 1. Add @name smart tag to one table: COMMENT ON TABLE schema.table IS E'@name UniqueTypeName';\n` +
` 2. Rename one of the tables in the database\n` +
` 3. Exclude one table from the schema using @omit smart tag\n`
` 3. Exclude one table from the schema using @omit smart tag\n`,
);
}
}
Expand Down
148 changes: 78 additions & 70 deletions graphile/graphile-settings/src/plugins/custom-inflector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { GraphileConfig } from 'graphile-config';
import type { GraphileConfig } from "graphile-config";
import {
singularize,
pluralize,
Expand All @@ -7,7 +7,7 @@ import {
distinctPluralize,
fixCapitalisedPlural,
camelize,
} from 'inflekt';
} from "inflekt";

/**
* Custom inflector plugin for Constructive using the inflekt library.
Expand All @@ -30,22 +30,22 @@ import {
* Add your own mappings here as needed.
*/
const CUSTOM_OPPOSITES: Record<string, string> = {
parent: 'child',
child: 'parent',
author: 'authored',
editor: 'edited',
reviewer: 'reviewed',
owner: 'owned',
creator: 'created',
updater: 'updated',
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: string): string | null {
const matches = attributeName.match(
/^(.+?)(_row_id|_id|_uuid|_fk|_pk|RowId|Id|Uuid|UUID|Fk|Pk)$/
/^(.+?)(_row_id|_id|_uuid|_fk|_pk|RowId|Id|Uuid|UUID|Fk|Pk)$/,
);
if (matches) {
return matches[1];
Expand Down Expand Up @@ -74,7 +74,7 @@ function getOppositeBaseName(baseName: string): string | null {
function arraysMatch<T>(
array1: readonly T[],
array2: readonly T[],
comparator: (v1: T, v2: T) => boolean = (v1, v2) => v1 === v2
comparator: (v1: T, v2: T) => boolean = (v1, v2) => v1 === v2,
): boolean {
if (array1 === array2) return true;
const l = array1.length;
Expand All @@ -86,8 +86,8 @@ function arraysMatch<T>(
}

export const InflektPlugin: GraphileConfig.Plugin = {
name: 'InflektPlugin',
version: '1.0.0',
name: "InflektPlugin",
version: "1.0.0",

inflection: {
replace: {
Expand Down Expand Up @@ -126,7 +126,40 @@ export const InflektPlugin: GraphileConfig.Plugin = {
* same name in different schemas. Use @name smart tags to disambiguate.
*/
_schemaPrefix(_previous, _options, _details) {
return '';
return "";
},

/**
* Restore PostGraphile's default schema prefix logic for functions.
*
* Our _schemaPrefix override returns '' for all schemas to give clean
* table names. However, this strips prefixes from functions too, causing
* resource naming collisions when a function and table share the same
* base name across schemas (e.g., actions_public.table_grant() collides
* with metaschema_public.table_grant table).
*
* Fix: bypass our _schemaPrefix override for functions and use
* PostGraphile's default prefix logic instead.
*/
functionResourceName(_previous, options: any, details: any) {
const { serviceName, pgProc } = details;
const { tags } = pgProc.getTagsAndDescription();

if (typeof tags.name === "string") {
return tags.name;
}

const pgNamespace = pgProc.getNamespace();

if (!pgNamespace) {
return pgProc.proname;
}

const pgService = (options.pgServices ?? []).find((db: any) => db.name === serviceName);
const primarySchema = pgService?.schemas?.[0] ?? "public";
const databasePrefix = serviceName === "main" ? "" : `${serviceName}_`;
const schemaPrefix = pgNamespace.nspname === primarySchema ? "" : `${pgNamespace.nspname}_`;
return `${databasePrefix}${schemaPrefix}${pgProc.proname}`;
},

/**
Expand Down Expand Up @@ -167,7 +200,10 @@ export const InflektPlugin: GraphileConfig.Plugin = {
_attributeName(
_previous,
_options,
details: { attributeName: string; codec: { attributes: Record<string, { extensions?: { tags?: { name?: string } } }> } }
details: {
attributeName: string;
codec: { attributes: Record<string, { extensions?: { tags?: { name?: string } } }> };
},
) {
const attribute = details.codec.attributes[details.attributeName];
const name = attribute?.extensions?.tags?.name || details.attributeName;
Expand Down Expand Up @@ -211,7 +247,7 @@ export const InflektPlugin: GraphileConfig.Plugin = {
*/
allRowsList(_previous, _options, resource) {
const resourceName = this._singularizedResourceName(resource);
return camelize(distinctPluralize(resourceName), true) + 'List';
return camelize(distinctPluralize(resourceName), true) + "List";
},

/**
Expand All @@ -220,7 +256,7 @@ export const InflektPlugin: GraphileConfig.Plugin = {
singleRelation(previous, _options, details) {
const { registry, codec, relationName } = details;
const relation = registry.pgRelations[codec.name]?.[relationName];
if (typeof relation.extensions?.tags?.fieldName === 'string') {
if (typeof relation.extensions?.tags?.fieldName === "string") {
return relation.extensions.tags.fieldName;
}

Expand All @@ -235,16 +271,10 @@ export const InflektPlugin: GraphileConfig.Plugin = {

// Fall back to the remote resource name
const foreignPk = relation.remoteResource.uniques.find(
(u: { isPrimary: boolean }) => u.isPrimary
(u: { isPrimary: boolean }) => u.isPrimary,
);
if (
foreignPk &&
arraysMatch(foreignPk.attributes, relation.remoteAttributes)
) {
return camelize(
this._singularizedCodecName(relation.remoteResource.codec),
true
);
if (foreignPk && arraysMatch(foreignPk.attributes, relation.remoteAttributes)) {
return camelize(this._singularizedCodecName(relation.remoteResource.codec), true);
}
return previous!(details);
},
Expand All @@ -255,12 +285,10 @@ export const InflektPlugin: GraphileConfig.Plugin = {
singleRelationBackwards(previous, _options, details) {
const { registry, codec, relationName } = details;
const relation = registry.pgRelations[codec.name]?.[relationName];
if (
typeof relation.extensions?.tags?.foreignSingleFieldName === 'string'
) {
if (typeof relation.extensions?.tags?.foreignSingleFieldName === "string") {
return relation.extensions.tags.foreignSingleFieldName;
}
if (typeof relation.extensions?.tags?.foreignFieldName === 'string') {
if (typeof relation.extensions?.tags?.foreignFieldName === "string") {
return relation.extensions.tags.foreignFieldName;
}

Expand All @@ -273,14 +301,11 @@ export const InflektPlugin: GraphileConfig.Plugin = {
if (oppositeBaseName) {
return camelize(
`${oppositeBaseName}_${this._singularizedCodecName(relation.remoteResource.codec)}`,
true
true,
);
}
if (baseNameMatches(baseName, codec.name)) {
return camelize(
this._singularizedCodecName(relation.remoteResource.codec),
true
);
return camelize(this._singularizedCodecName(relation.remoteResource.codec), true);
}
}
}
Expand All @@ -295,7 +320,7 @@ export const InflektPlugin: GraphileConfig.Plugin = {
const { registry, codec, relationName } = details;
const relation = registry.pgRelations[codec.name]?.[relationName];
const baseOverride = relation.extensions?.tags.foreignFieldName;
if (typeof baseOverride === 'string') {
if (typeof baseOverride === "string") {
return baseOverride;
}

Expand All @@ -308,30 +333,24 @@ export const InflektPlugin: GraphileConfig.Plugin = {
if (oppositeBaseName) {
return camelize(
`${oppositeBaseName}_${distinctPluralize(this._singularizedCodecName(relation.remoteResource.codec))}`,
true
true,
);
}
if (baseNameMatches(baseName, codec.name)) {
return camelize(
distinctPluralize(
this._singularizedCodecName(relation.remoteResource.codec)
),
true
distinctPluralize(this._singularizedCodecName(relation.remoteResource.codec)),
true,
);
}
}
}

// Fall back to pluralized remote resource name
const pk = relation.remoteResource.uniques.find(
(u: { isPrimary: boolean }) => u.isPrimary
);
const pk = relation.remoteResource.uniques.find((u: { isPrimary: boolean }) => u.isPrimary);
if (pk && arraysMatch(pk.attributes, relation.remoteAttributes)) {
return camelize(
distinctPluralize(
this._singularizedCodecName(relation.remoteResource.codec)
),
true
distinctPluralize(this._singularizedCodecName(relation.remoteResource.codec)),
true,
);
}
return previous!(details);
Expand All @@ -349,19 +368,17 @@ export const InflektPlugin: GraphileConfig.Plugin = {
* - There are multiple many-to-many relations to the same target table
*/
_manyToManyRelation(previous, _options, details) {
const { leftTable, rightTable, junctionTable, rightRelationName } =
details;
const { leftTable, rightTable, junctionTable, rightRelationName } = details;

const junctionRightRelation = junctionTable.getRelation(rightRelationName);
const baseOverride =
junctionRightRelation.extensions?.tags?.manyToManyFieldName;
if (typeof baseOverride === 'string') {
const baseOverride = junctionRightRelation.extensions?.tags?.manyToManyFieldName;
if (typeof baseOverride === "string") {
return baseOverride;
}

const simpleName = camelize(
distinctPluralize(this._singularizedCodecName(rightTable.codec)),
true
true,
);

const leftRelations = leftTable.getRelations();
Expand All @@ -374,10 +391,7 @@ export const InflektPlugin: GraphileConfig.Plugin = {
hasDirectRelation = true;
}
}
if (
rel.isReferencee &&
rel.remoteResource?.codec?.name !== rightTable.codec.name
) {
if (rel.isReferencee && rel.remoteResource?.codec?.name !== rightTable.codec.name) {
const junctionRelations = rel.remoteResource?.getRelations?.() || {};
for (const [_jRelName, jRel] of Object.entries(junctionRelations)) {
if (
Expand All @@ -402,7 +416,7 @@ export const InflektPlugin: GraphileConfig.Plugin = {
*/
rowByUnique(previous, _options, details) {
const { unique, resource } = details;
if (typeof unique.extensions?.tags?.fieldName === 'string') {
if (typeof unique.extensions?.tags?.fieldName === "string") {
return unique.extensions?.tags?.fieldName;
}
if (unique.isPrimary) {
Expand All @@ -416,14 +430,11 @@ export const InflektPlugin: GraphileConfig.Plugin = {
*/
updateByKeysField(previous, _options, details) {
const { resource, unique } = details;
if (typeof unique.extensions?.tags.updateFieldName === 'string') {
if (typeof unique.extensions?.tags.updateFieldName === "string") {
return unique.extensions.tags.updateFieldName;
}
if (unique.isPrimary) {
return camelize(
`update_${this._singularizedCodecName(resource.codec)}`,
true
);
return camelize(`update_${this._singularizedCodecName(resource.codec)}`, true);
}
return previous!(details);
},
Expand All @@ -433,14 +444,11 @@ export const InflektPlugin: GraphileConfig.Plugin = {
*/
deleteByKeysField(previous, _options, details) {
const { resource, unique } = details;
if (typeof unique.extensions?.tags.deleteFieldName === 'string') {
if (typeof unique.extensions?.tags.deleteFieldName === "string") {
return unique.extensions.tags.deleteFieldName;
}
if (unique.isPrimary) {
return camelize(
`delete_${this._singularizedCodecName(resource.codec)}`,
true
);
return camelize(`delete_${this._singularizedCodecName(resource.codec)}`, true);
}
return previous!(details);
},
Expand Down
Loading