Skip to content
Merged
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
210 changes: 200 additions & 10 deletions graphql/codegen/src/core/codegen/orm/model-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@
*/
import * as t from '@babel/types';

import { singularize } from 'inflekt';

import type { Table } from '../../../types/schema';
import { asConst, generateCode } from '../babel-ast';
import {
getCreateInputTypeName,
getCreateMutationName,
getDeleteInputTypeName,
getDeleteMutationName,
getFilterTypeName,
getGeneratedFileHeader,
getOrderByTypeName,
Expand All @@ -17,6 +23,7 @@ import {
getTableNames,
hasValidPrimaryKey,
lcFirst,
ucFirst,
} from '../utils';

export interface GeneratedModelFile {
Expand Down Expand Up @@ -165,6 +172,7 @@ export function generateModelFile(
table: Table,
_useSharedTypes: boolean,
options?: { condition?: boolean },
allTables?: Table[],
): GeneratedModelFile {
const conditionEnabled = options?.condition !== false;
const { typeName, singularName, pluralName } = getTableNames(table);
Expand Down Expand Up @@ -196,16 +204,27 @@ export function generateModelFile(
const statements: t.Statement[] = [];

statements.push(createImportDeclaration('../client', ['OrmClient']));
const m2nRels = table.relations.manyToMany.filter(
(r) => r.junctionLeftKeyFields?.length && r.junctionRightKeyFields?.length,
);
// Check if any remove methods will actually be generated (need junction table with delete mutation)
const needsJunctionRemove = m2nRels.some((r) => {
const jt = allTables?.find((tb) => tb.name === r.junctionTable);
return jt?.query?.delete != null;
});

const queryBuilderImports = [
'QueryBuilder',
'buildFindManyDocument',
'buildFindFirstDocument',
'buildFindOneDocument',
'buildCreateDocument',
'buildUpdateByPkDocument',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 buildJunctionRemoveDocument imported based on M:N existence, not on whether remove methods are actually generated

At line 214, buildJunctionRemoveDocument is added to the import list whenever hasM2n is true (any M:N relation with junction key fields exists). However, the remove<Relation> method that actually uses this function is only generated when junctionDeleteMutation is defined (line 1087) AND the junction table is found in allTables (line 998-999). If neither condition is met for any M:N relation, the generated file will contain an unused import of buildJunctionRemoveDocument, which could cause lint errors in the generated output.

Prompt for agents
In graphql/codegen/src/core/codegen/orm/model-generator.ts, the decision to import buildJunctionRemoveDocument (line 214) is based on hasM2n alone, but the function is only used when junctionDeleteMutation is defined for at least one junction table. Either:
1. Move the import decision after the M:N method generation loop and track whether any remove methods were actually generated, OR
2. Pre-check whether any junction tables in allTables have query.delete defined before adding the import.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

'buildDeleteByPkDocument',
...(needsJunctionRemove ? ['buildJunctionRemoveDocument'] : []),
];
statements.push(
createImportDeclaration('../query-builder', [
'QueryBuilder',
'buildFindManyDocument',
'buildFindFirstDocument',
'buildFindOneDocument',
'buildCreateDocument',
'buildUpdateByPkDocument',
'buildDeleteByPkDocument',
]),
createImportDeclaration('../query-builder', queryBuilderImports),
);
statements.push(
createImportDeclaration(
Expand Down Expand Up @@ -982,6 +1001,177 @@ export function generateModelFile(
);
}

// ── M:N add/remove methods ────────────────────────────────────────────
for (const rel of m2nRels) {
if (!rel.fieldName) continue;

const junctionTable = allTables?.find((tb) => tb.name === rel.junctionTable);
if (!junctionTable) continue;

const junctionNames = getTableNames(junctionTable);
const junctionCreateMutation = getCreateMutationName(junctionTable);
const junctionCreateInputType = getCreateInputTypeName(junctionTable);
const junctionDeleteMutation = junctionTable.query?.delete ?? getDeleteMutationName(junctionTable);
const junctionDeleteInputType = getDeleteInputTypeName(junctionTable);
const junctionSingular = junctionNames.singularName;

// Derive a friendly singular name from the fieldName (e.g., "tags" → "Tag", "categories" → "Category")
const relSingular = ucFirst(singularize(rel.fieldName));

const leftKeys = rel.junctionLeftKeyFields!;
const rightKeys = rel.junctionRightKeyFields!;
const leftPkFields = rel.leftKeyFields ?? ['id'];
const rightPkFields = rel.rightKeyFields ?? ['id'];

// Resolve actual PK types from left (current) and right tables
const leftPkInfo = getPrimaryKeyInfo(table);
const rightTable = allTables?.find((tb) => tb.name === rel.rightTable);
const rightPkInfo = rightTable ? getPrimaryKeyInfo(rightTable) : [];

// ── add<Relation> ───────────────────────────────────────────────
{
// Parameters: one param per left PK + one param per right PK, with actual types
const params: t.Identifier[] = [];
for (let i = 0; i < leftPkFields.length; i++) {
const p = t.identifier(leftPkFields[i]);
const pkInfo = leftPkInfo.find((pk) => pk.name === leftPkFields[i]);
p.typeAnnotation = t.tsTypeAnnotation(tsTypeFromPrimitive(pkInfo?.tsType ?? 'string'));
params.push(p);
}
for (let i = 0; i < rightPkFields.length; i++) {
const rk = rightPkFields[i];
const p = t.identifier(rk === leftPkFields[0] ? `right${ucFirst(rk)}` : rk);
const pkInfo = rightPkInfo.find((pk) => pk.name === rk);
p.typeAnnotation = t.tsTypeAnnotation(tsTypeFromPrimitive(pkInfo?.tsType ?? 'string'));
params.push(p);
}

// Build the junction row data object: { junctionLeftKey: leftPk, junctionRightKey: rightPk }
const dataProps: t.ObjectProperty[] = [];
for (let i = 0; i < leftKeys.length; i++) {
dataProps.push(
t.objectProperty(t.identifier(leftKeys[i]), t.identifier(params[i].name)),
);
}
for (let i = 0; i < rightKeys.length; i++) {
dataProps.push(
t.objectProperty(
t.identifier(rightKeys[i]),
t.identifier(params[leftPkFields.length + i].name),
),
);
}

const body: t.Statement[] = [
t.variableDeclaration('const', [
t.variableDeclarator(
t.objectPattern([
t.objectProperty(t.identifier('document'), t.identifier('document'), false, true),
t.objectProperty(t.identifier('variables'), t.identifier('variables'), false, true),
]),
t.callExpression(t.identifier('buildCreateDocument'), [
t.stringLiteral(junctionNames.typeName),
t.stringLiteral(junctionCreateMutation),
t.stringLiteral(junctionSingular),
t.objectExpression([t.objectProperty(t.identifier('id'), t.booleanLiteral(true))]),
t.objectExpression(dataProps),
t.stringLiteral(junctionCreateInputType),
]),
),
]),
t.returnStatement(
t.newExpression(t.identifier('QueryBuilder'), [
t.objectExpression([
t.objectProperty(
t.identifier('client'),
t.memberExpression(t.thisExpression(), t.identifier('client')),
),
t.objectProperty(t.identifier('operation'), t.stringLiteral('mutation')),
t.objectProperty(t.identifier('operationName'), t.stringLiteral(junctionNames.typeName)),
t.objectProperty(t.identifier('fieldName'), t.stringLiteral(junctionCreateMutation)),
t.objectProperty(t.identifier('document'), t.identifier('document'), false, true),
t.objectProperty(t.identifier('variables'), t.identifier('variables'), false, true),
]),
]),
),
];

classBody.push(
t.classMethod('method', t.identifier(`add${relSingular}`), params, t.blockStatement(body)),
);
}

// ── remove<Relation> ────────────────────────────────────────────
if (junctionTable.query?.delete) {
const params: t.Identifier[] = [];
for (let i = 0; i < leftPkFields.length; i++) {
const p = t.identifier(leftPkFields[i]);
const pkInfo = leftPkInfo.find((pk) => pk.name === leftPkFields[i]);
p.typeAnnotation = t.tsTypeAnnotation(tsTypeFromPrimitive(pkInfo?.tsType ?? 'string'));
params.push(p);
}
for (let i = 0; i < rightPkFields.length; i++) {
const rk = rightPkFields[i];
const p = t.identifier(rk === leftPkFields[0] ? `right${ucFirst(rk)}` : rk);
const pkInfo = rightPkInfo.find((pk) => pk.name === rk);
p.typeAnnotation = t.tsTypeAnnotation(tsTypeFromPrimitive(pkInfo?.tsType ?? 'string'));
params.push(p);
}

// Build the keys object for junction delete
const keysProps: t.ObjectProperty[] = [];
for (let i = 0; i < leftKeys.length; i++) {
keysProps.push(
t.objectProperty(t.identifier(leftKeys[i]), t.identifier(params[i].name)),
);
}
for (let i = 0; i < rightKeys.length; i++) {
keysProps.push(
t.objectProperty(
t.identifier(rightKeys[i]),
t.identifier(params[leftPkFields.length + i].name),
),
);
}

const body: t.Statement[] = [
t.variableDeclaration('const', [
t.variableDeclarator(
t.objectPattern([
t.objectProperty(t.identifier('document'), t.identifier('document'), false, true),
t.objectProperty(t.identifier('variables'), t.identifier('variables'), false, true),
]),
t.callExpression(t.identifier('buildJunctionRemoveDocument'), [
t.stringLiteral(junctionNames.typeName),
t.stringLiteral(junctionDeleteMutation),
t.objectExpression(keysProps),
t.stringLiteral(junctionDeleteInputType),
]),
),
]),
t.returnStatement(
t.newExpression(t.identifier('QueryBuilder'), [
t.objectExpression([
t.objectProperty(
t.identifier('client'),
t.memberExpression(t.thisExpression(), t.identifier('client')),
),
t.objectProperty(t.identifier('operation'), t.stringLiteral('mutation')),
t.objectProperty(t.identifier('operationName'), t.stringLiteral(junctionNames.typeName)),
t.objectProperty(t.identifier('fieldName'), t.stringLiteral(junctionDeleteMutation)),
t.objectProperty(t.identifier('document'), t.identifier('document'), false, true),
t.objectProperty(t.identifier('variables'), t.identifier('variables'), false, true),
]),
]),
),
];

classBody.push(
t.classMethod('method', t.identifier(`remove${relSingular}`), params, t.blockStatement(body)),
);
}
}

const classDecl = t.classDeclaration(
t.identifier(modelName),
null,
Expand All @@ -1005,5 +1195,5 @@ export function generateAllModelFiles(
useSharedTypes: boolean,
options?: { condition?: boolean },
): GeneratedModelFile[] {
return tables.map((table) => generateModelFile(table, useSharedTypes, options));
return tables.map((table) => generateModelFile(table, useSharedTypes, options, tables));
}
19 changes: 19 additions & 0 deletions graphql/codegen/src/core/codegen/templates/query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,25 @@ export function buildDeleteByPkDocument<TSelect = undefined>(
};
}

export function buildJunctionRemoveDocument(
operationName: string,
mutationField: string,
keys: Record<string, unknown>,
inputTypeName: string,
): { document: string; variables: Record<string, unknown> } {
return {
document: buildInputMutationDocument({
operationName,
mutationField,
inputTypeName,
resultSelections: [t.field({ name: 'clientMutationId' })],
}),
variables: {
input: keys,
},
};
}

export function buildCustomDocument<TSelect, TArgs>(
operationType: 'query' | 'mutation',
operationName: string,
Expand Down
42 changes: 42 additions & 0 deletions graphql/codegen/src/core/introspect/enrich-relations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* M:N Relation Enrichment
*
* After table inference from introspection, enriches ManyToManyRelation objects
* with junction key field metadata from _cachedTablesMeta (MetaSchemaPlugin).
*/
import type { Table } from '../../types/schema';
import type { MetaTableInfo } from './source/types';

/**
* Enrich M:N relations with junction key field metadata from _meta.
* Mutates the tables array in-place.
*/
export function enrichManyToManyRelations(
tables: Table[],
tablesMeta?: MetaTableInfo[],
): void {
if (!tablesMeta?.length) return;

const metaByName = new Map(tablesMeta.map((m) => [m.name, m]));

for (const table of tables) {
const meta = metaByName.get(table.name);
if (!meta?.relations.manyToMany.length) continue;

for (const rel of table.relations.manyToMany) {
const metaRel = meta.relations.manyToMany.find(
(m) => m.fieldName === rel.fieldName,
);
if (!metaRel) continue;

rel.junctionLeftKeyFields = metaRel.junctionLeftKeyAttributes.map(
(a) => a.name,
);
rel.junctionRightKeyFields = metaRel.junctionRightKeyAttributes.map(
(a) => a.name,
);
rel.leftKeyFields = metaRel.leftKeyAttributes.map((a) => a.name);
rel.rightKeyFields = metaRel.rightKeyAttributes.map((a) => a.name);
}
}
}
9 changes: 8 additions & 1 deletion graphql/codegen/src/core/pipeline/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
Table,
TypeRegistry,
} from '../../types/schema';
import { enrichManyToManyRelations } from '../introspect/enrich-relations';
import { inferTablesFromIntrospection } from '../introspect/infer-tables';
import type { SchemaSource } from '../introspect/source';
import { filterTables } from '../introspect/transform';
Expand Down Expand Up @@ -112,7 +113,7 @@ export async function runCodegenPipeline(

// 1. Fetch introspection from source
log(`Fetching schema from ${source.describe()}...`);
const { introspection } = await source.fetch();
const { introspection, tablesMeta } = await source.fetch();

// 2. Infer tables from introspection (replaces _meta)
log('Inferring table metadata from schema...');
Expand All @@ -121,6 +122,12 @@ export async function runCodegenPipeline(
const totalTables = tables.length;
log(` Found ${totalTables} tables`);

// 2a. Enrich M:N relations with junction key metadata from _meta
if (tablesMeta?.length) {
enrichManyToManyRelations(tables, tablesMeta);
log(` Enriched M:N relations from _meta (${tablesMeta.length} tables)`);
}

// 3. Filter tables by config (combine exclude and systemExclude)
tables = filterTables(tables, config.tables.include, [
...config.tables.exclude,
Expand Down
Loading