Skip to content

Commit 0bda790

Browse files
committed
fix: composite PK detection + filter type-safety from schema source of truth
Issue 1: buildTableFilterProperties now uses the schema's filter input type as the sole source of truth (via TypeRegistry), capturing plugin-injected filter fields (bm25Body, tsvTsv, trgmName, vectorEmbedding, geom, etc.) that are not present on the entity type. Same pattern as buildOrderByValues. Adds collectFilterExtraInputTypes to discover custom filter types referenced by plugin fields. Issue 2: inferPrimaryKeyFromInputObject now returns all candidate key fields instead of null when multiple exist, enabling composite PK detection for junction tables like PostTag(postId, tagId). Prefers DeleteInput over UpdateInput as signal source (fewer non-PK fields to filter).
1 parent 72acea4 commit 0bda790

2 files changed

Lines changed: 117 additions & 25 deletions

File tree

graphql/codegen/src/core/codegen/orm/input-types-generator.ts

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,10 +1077,38 @@ function getFilterTypeForField(fieldType: string, isArray = false): string {
10771077
}
10781078

10791079
/**
1080-
* Build properties for a table filter interface
1080+
* Build properties for a table filter interface.
1081+
*
1082+
* When typeRegistry is available, uses the schema's filter input type as
1083+
* the sole source of truth — this captures plugin-injected filter fields
1084+
* (e.g., bm25Body, tsvTsv, trgmName, vectorEmbedding, geom) that are not
1085+
* present on the entity type itself. Same pattern as buildOrderByValues().
10811086
*/
1082-
function buildTableFilterProperties(table: Table): InterfaceProperty[] {
1087+
function buildTableFilterProperties(
1088+
table: Table,
1089+
typeRegistry?: TypeRegistry,
1090+
): InterfaceProperty[] {
10831091
const filterName = getFilterTypeName(table);
1092+
1093+
// When the schema's filter type is available, use it as the source of truth
1094+
if (typeRegistry) {
1095+
const filterType = typeRegistry.get(filterName);
1096+
if (filterType?.kind === 'INPUT_OBJECT' && filterType.inputFields) {
1097+
const properties: InterfaceProperty[] = [];
1098+
for (const field of filterType.inputFields) {
1099+
const tsType = typeRefToTs(field.type);
1100+
properties.push({
1101+
name: field.name,
1102+
type: tsType,
1103+
optional: true,
1104+
description: stripSmartComments(field.description, true),
1105+
});
1106+
}
1107+
return properties;
1108+
}
1109+
}
1110+
1111+
// Fallback: derive from table fields when schema filter type is not available
10841112
const properties: InterfaceProperty[] = [];
10851113

10861114
for (const field of table.fields) {
@@ -1105,13 +1133,19 @@ function buildTableFilterProperties(table: Table): InterfaceProperty[] {
11051133
/**
11061134
* Generate table filter type statements
11071135
*/
1108-
function generateTableFilterTypes(tables: Table[]): t.Statement[] {
1136+
function generateTableFilterTypes(
1137+
tables: Table[],
1138+
typeRegistry?: TypeRegistry,
1139+
): t.Statement[] {
11091140
const statements: t.Statement[] = [];
11101141

11111142
for (const table of tables) {
11121143
const filterName = getFilterTypeName(table);
11131144
statements.push(
1114-
createExportedInterface(filterName, buildTableFilterProperties(table)),
1145+
createExportedInterface(
1146+
filterName,
1147+
buildTableFilterProperties(table, typeRegistry),
1148+
),
11151149
);
11161150
}
11171151

@@ -1956,6 +1990,54 @@ function generateConnectionFieldsMap(
19561990
// Plugin-Injected Type Collector
19571991
// ============================================================================
19581992

1993+
/**
1994+
* Collect extra input type names referenced by plugin-injected filter fields.
1995+
*
1996+
* When the schema's filter type is used as source of truth, plugin-injected
1997+
* fields reference custom filter types (e.g., Bm25BodyFilter, TsvectorFilter,
1998+
* GeometryFilter) that also need to be generated. This function discovers
1999+
* those types by comparing the schema's filter type fields against the
2000+
* standard scalar filter types.
2001+
*/
2002+
function collectFilterExtraInputTypes(
2003+
tables: Table[],
2004+
typeRegistry: TypeRegistry,
2005+
): Set<string> {
2006+
const extraTypes = new Set<string>();
2007+
2008+
for (const table of tables) {
2009+
const filterTypeName = getFilterTypeName(table);
2010+
const filterType = typeRegistry.get(filterTypeName);
2011+
if (
2012+
!filterType ||
2013+
filterType.kind !== 'INPUT_OBJECT' ||
2014+
!filterType.inputFields
2015+
) {
2016+
continue;
2017+
}
2018+
2019+
const tableFieldNames = new Set(
2020+
table.fields
2021+
.filter((f) => !isRelationField(f.name, table))
2022+
.map((f) => f.name),
2023+
);
2024+
2025+
for (const field of filterType.inputFields) {
2026+
// Skip standard column-derived fields and logical operators
2027+
if (tableFieldNames.has(field.name)) continue;
2028+
if (['and', 'or', 'not'].includes(field.name)) continue;
2029+
2030+
// Collect the base type name of this extra field
2031+
const baseName = getTypeBaseName(field.type);
2032+
if (baseName && !SCALAR_NAMES.has(baseName)) {
2033+
extraTypes.add(baseName);
2034+
}
2035+
}
2036+
}
2037+
2038+
return extraTypes;
2039+
}
2040+
19592041
/**
19602042
* Collect extra input type names referenced by plugin-injected condition fields.
19612043
*
@@ -2048,7 +2130,9 @@ export function generateInputTypesFile(
20482130
statements.push(...generateEntitySelectTypes(tablesList, tableByName));
20492131

20502132
// 4. Table filter types
2051-
statements.push(...generateTableFilterTypes(tablesList));
2133+
// Pass typeRegistry to use schema's filter type as source of truth,
2134+
// capturing plugin-injected filter fields (e.g., bm25, tsvector, trgm, vector, geom)
2135+
statements.push(...generateTableFilterTypes(tablesList, typeRegistry));
20522136

20532137
// 4b. Table condition types (simple equality filter)
20542138
// Pass typeRegistry to merge plugin-injected condition fields
@@ -2071,8 +2155,17 @@ export function generateInputTypesFile(
20712155
statements.push(...generateConnectionFieldsMap(tablesList, tableByName));
20722156

20732157
// 7. Custom input types from TypeRegistry
2074-
// Also include any extra types referenced by plugin-injected condition fields
2158+
// Also include any extra types referenced by plugin-injected filter/condition fields
20752159
const mergedUsedInputTypes = new Set(usedInputTypes);
2160+
if (hasTables) {
2161+
const filterExtraTypes = collectFilterExtraInputTypes(
2162+
tablesList,
2163+
typeRegistry,
2164+
);
2165+
for (const typeName of filterExtraTypes) {
2166+
mergedUsedInputTypes.add(typeName);
2167+
}
2168+
}
20762169
if (hasTables && conditionEnabled) {
20772170
const conditionExtraTypes = collectConditionExtraInputTypes(
20782171
tablesList,

graphql/query/src/introspect/infer-tables.ts

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -723,19 +723,19 @@ function inferConstraints(
723723
const updateInput = typeMap.get(updateInputName);
724724
const deleteInput = typeMap.get(deleteInputName);
725725

726-
const keyInputField =
727-
inferPrimaryKeyFromInputObject(updateInput) ||
728-
inferPrimaryKeyFromInputObject(deleteInput);
726+
// Prefer Delete input (fewer non-PK fields) over Update input
727+
const keyFields =
728+
inferPrimaryKeyFromInputObject(deleteInput).length > 0
729+
? inferPrimaryKeyFromInputObject(deleteInput)
730+
: inferPrimaryKeyFromInputObject(updateInput);
729731

730-
if (keyInputField) {
732+
if (keyFields.length > 0) {
731733
primaryKey.push({
732734
name: 'primary',
733-
fields: [
734-
{
735-
name: keyInputField.name,
736-
type: convertToCleanFieldType(keyInputField.type),
737-
},
738-
],
735+
fields: keyFields.map((f) => ({
736+
name: f.name,
737+
type: convertToCleanFieldType(f.type),
738+
})),
739739
});
740740
}
741741

@@ -769,27 +769,28 @@ function inferConstraints(
769769
}
770770

771771
/**
772-
* Infer a single-row lookup key from an Update/Delete input object.
772+
* Infer primary key fields from an Update/Delete input object.
773773
*
774774
* Priority:
775775
* 1. Canonical keys: id, nodeId, rowId
776-
* 2. Single non-patch/non-clientMutationId scalar-ish field
776+
* 2. All non-patch/non-clientMutationId fields (supports composite keys)
777777
*
778-
* If multiple possible key fields remain, return null to avoid guessing.
778+
* Returns all candidate key fields, enabling composite PK detection
779+
* for junction tables like PostTag(postId, tagId).
779780
*/
780781
function inferPrimaryKeyFromInputObject(
781782
inputType: IntrospectionType | undefined,
782-
): IntrospectionInputValue | null {
783+
): IntrospectionInputValue[] {
783784
const inputFields = inputType?.inputFields ?? [];
784-
if (inputFields.length === 0) return null;
785+
if (inputFields.length === 0) return [];
785786

786787
const canonicalKey = inputFields.find(
787788
(field) =>
788789
field.name === 'id' || field.name === 'nodeId' || field.name === 'rowId',
789790
);
790-
if (canonicalKey) return canonicalKey;
791+
if (canonicalKey) return [canonicalKey];
791792

792-
const candidates = inputFields.filter((field) => {
793+
return inputFields.filter((field) => {
793794
if (field.name === 'clientMutationId') return false;
794795

795796
const baseTypeName = getBaseTypeName(field.type);
@@ -801,8 +802,6 @@ function inferPrimaryKeyFromInputObject(
801802

802803
return true;
803804
});
804-
805-
return candidates.length === 1 ? candidates[0] : null;
806805
}
807806

808807
/**

0 commit comments

Comments
 (0)