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
44 changes: 34 additions & 10 deletions graphql/codegen/src/generators/field-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ import type {
SimpleFieldSelection,
} from '../types/selection';

const relationalFieldSetCache = new WeakMap<CleanTable, Set<string>>();

function getRelationalFieldSet(table: CleanTable): Set<string> {
const cached = relationalFieldSetCache.get(table);
if (cached) return cached;

const set = new Set<string>();

for (const rel of table.relations.belongsTo) {
if (rel.fieldName) set.add(rel.fieldName);
}
for (const rel of table.relations.hasOne) {
if (rel.fieldName) set.add(rel.fieldName);
}
for (const rel of table.relations.hasMany) {
if (rel.fieldName) set.add(rel.fieldName);
}
for (const rel of table.relations.manyToMany) {
if (rel.fieldName) set.add(rel.fieldName);
}

relationalFieldSetCache.set(table, set);
return set;
}

/**
* Convert simplified field selection to QueryBuilder SelectionOptions
*/
Expand Down Expand Up @@ -210,14 +235,7 @@ export function isRelationalField(
fieldName: string,
table: CleanTable,
): boolean {
const { belongsTo, hasOne, hasMany, manyToMany } = table.relations;

return (
belongsTo.some((rel) => rel.fieldName === fieldName) ||
hasOne.some((rel) => rel.fieldName === fieldName) ||
hasMany.some((rel) => rel.fieldName === fieldName) ||
manyToMany.some((rel) => rel.fieldName === fieldName)
);
return getRelationalFieldSet(table).has(fieldName);
}

/**
Expand Down Expand Up @@ -308,12 +326,18 @@ function getRelatedTableScalarFields(
'updatedAt',
];

const scalarFieldSet = new Set(scalarFields);

// Use Set for O(1) duplicate checking
const includedSet = new Set<string>();
const included: string[] = [];

const push = (fieldName: string | undefined) => {
if (!fieldName) return;
if (!scalarFields.includes(fieldName)) return;
if (included.includes(fieldName)) return;
if (!scalarFieldSet.has(fieldName)) return;
if (includedSet.has(fieldName)) return;
if (included.length >= MAX_RELATED_FIELDS) return;
includedSet.add(fieldName);
included.push(fieldName);
};

Expand Down
33 changes: 22 additions & 11 deletions graphql/codegen/src/generators/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import * as t from 'gql-ast';
import { OperationTypeNode, print } from 'graphql';
import type { ArgumentNode, FieldNode, VariableDefinitionNode } from 'graphql';
import { camelize } from 'inflekt';

import { TypedDocumentString } from '../client/typed-document';
import {
Expand All @@ -15,6 +14,15 @@ import {
import type { MutationOptions } from '../types/mutation';
import type { CleanTable } from '../types/schema';
import { isRelationalField } from './field-selector';
import {
toCamelCaseSingular,
toCreateInputTypeName,
toCreateMutationName,
toDeleteInputTypeName,
toDeleteMutationName,
toUpdateInputTypeName,
toUpdateMutationName,
} from './naming-helpers';

/**
* Generate field selections for PostGraphile mutations using custom AST logic
Expand Down Expand Up @@ -46,15 +54,16 @@ export function buildPostGraphileCreate(
Record<string, unknown>,
{ input: { [key: string]: Record<string, unknown> } }
> {
const mutationName = `create${table.name}`;
const singularName = camelize(table.name, true);
const mutationName = toCreateMutationName(table.name, table);
const singularName = toCamelCaseSingular(table.name, table);
const inputTypeName = toCreateInputTypeName(table.name, table);

// Create the variable definition for $input
const variableDefinitions: VariableDefinitionNode[] = [
t.variableDefinition({
variable: t.variable({ name: 'input' }),
type: t.nonNullType({
type: t.namedType({ type: `Create${table.name}Input` }),
type: t.namedType({ type: inputTypeName }),
}),
}),
];
Expand Down Expand Up @@ -120,17 +129,18 @@ export function buildPostGraphileUpdate(
_options: MutationOptions = {},
): TypedDocumentString<
Record<string, unknown>,
{ input: { id: string | number; patch: Record<string, unknown> } }
{ input: { id: string | number } & Record<string, unknown> }
> {
const mutationName = `update${table.name}`;
const singularName = camelize(table.name, true);
const mutationName = toUpdateMutationName(table.name, table);
const singularName = toCamelCaseSingular(table.name, table);
const inputTypeName = toUpdateInputTypeName(table.name);

// Create the variable definition for $input
const variableDefinitions: VariableDefinitionNode[] = [
t.variableDefinition({
variable: t.variable({ name: 'input' }),
type: t.nonNullType({
type: t.namedType({ type: `Update${table.name}Input` }),
type: t.namedType({ type: inputTypeName }),
}),
}),
];
Expand Down Expand Up @@ -182,7 +192,7 @@ export function buildPostGraphileUpdate(
__ast: ast,
}) as TypedDocumentString<
Record<string, unknown>,
{ input: { id: string | number; patch: Record<string, unknown> } }
{ input: { id: string | number } & Record<string, unknown> }
>;
}

Expand All @@ -198,14 +208,15 @@ export function buildPostGraphileDelete(
Record<string, unknown>,
{ input: { id: string | number } }
> {
const mutationName = `delete${table.name}`;
const mutationName = toDeleteMutationName(table.name, table);
const inputTypeName = toDeleteInputTypeName(table.name);

// Create the variable definition for $input
const variableDefinitions: VariableDefinitionNode[] = [
t.variableDefinition({
variable: t.variable({ name: 'input' }),
type: t.nonNullType({
type: t.namedType({ type: `Delete${table.name}Input` }),
type: t.namedType({ type: inputTypeName }),
}),
}),
];
Expand Down
226 changes: 226 additions & 0 deletions graphql/codegen/src/generators/naming-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/**
* Server-aware naming helpers for GraphQL query/mutation generation.
*
* These functions prefer names already discovered from the GraphQL schema
* (stored on `table.query` and `table.inflection` by `infer-tables.ts`)
* and fall back to local inflection when introspection data is unavailable.
*
* Back-ported from Dashboard's `packages/data/src/query-generator.ts`.
*/
import { camelize, pluralize } from 'inflekt';

import type { CleanTable } from '../types/schema';

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/**
* Safely normalise a server-provided inflection value.
* Returns `null` for null, undefined, or whitespace-only strings.
*/
export function normalizeInflectionValue(
value: string | null | undefined,
): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}

// ---------------------------------------------------------------------------
// Plural / Singular
// ---------------------------------------------------------------------------

/**
* Convert PascalCase table name to camelCase plural for GraphQL queries.
* Prefers server-provided `table.query.all` / `table.inflection.allRows`
* when available, with guards against naive pluralisation drift and
* missing camelCase boundaries.
*
* Example: "ActionGoal" -> "actionGoals", "User" -> "users", "Person" -> "people"
*/
export function toCamelCasePlural(
tableName: string,
table?: CleanTable | null,
): string {
const singular = camelize(tableName, true);
const inflectedPlural = pluralize(singular);
const serverPluralCandidates = [
table?.query?.all,
table?.inflection?.allRows,
];

for (const candidateRaw of serverPluralCandidates) {
const candidate = normalizeInflectionValue(candidateRaw);
if (!candidate) continue;

// Guard against known fallback drift:
// 1. Naive pluralisation: "activitys" instead of "activities"
const isNaivePlural =
candidate === `${singular}s` && candidate !== inflectedPlural;
// 2. Missing camelCase boundaries: "deliveryzones" instead of "deliveryZones"
const isMiscased =
candidate !== inflectedPlural &&
candidate.toLowerCase() === inflectedPlural.toLowerCase();
if (isNaivePlural || isMiscased) continue;

return candidate;
}

return inflectedPlural;
}

/**
* Convert PascalCase table name to camelCase singular field name.
* Prefers server-provided names when available.
*/
export function toCamelCaseSingular(
tableName: string,
table?: CleanTable | null,
): string {
const localSingular = camelize(tableName, true);

for (const candidateRaw of [
table?.query?.one,
table?.inflection?.tableFieldName,
]) {
const candidate = normalizeInflectionValue(candidateRaw);
if (!candidate) continue;
// Reject miscased versions: "deliveryzone" vs "deliveryZone"
if (
candidate !== localSingular &&
candidate.toLowerCase() === localSingular.toLowerCase()
)
continue;
return candidate;
}

return localSingular;
}

// ---------------------------------------------------------------------------
// Mutation names
// ---------------------------------------------------------------------------

export function toCreateMutationName(
tableName: string,
table?: CleanTable | null,
): string {
return (
normalizeInflectionValue(table?.query?.create) ?? `create${tableName}`
);
}

export function toUpdateMutationName(
tableName: string,
table?: CleanTable | null,
): string {
return (
normalizeInflectionValue(table?.query?.update) ?? `update${tableName}`
);
}

export function toDeleteMutationName(
tableName: string,
table?: CleanTable | null,
): string {
return (
normalizeInflectionValue(table?.query?.delete) ?? `delete${tableName}`
);
}

// ---------------------------------------------------------------------------
// Input / type names
// ---------------------------------------------------------------------------

export function toCreateInputTypeName(
tableName: string,
table?: CleanTable | null,
): string {
return (
normalizeInflectionValue(table?.inflection?.createInputType) ??
`Create${tableName}Input`
);
}

export function toUpdateInputTypeName(tableName: string): string {
return `Update${tableName}Input`;
}

export function toDeleteInputTypeName(tableName: string): string {
return `Delete${tableName}Input`;
}

export function toFilterTypeName(
tableName: string,
table?: CleanTable | null,
): string {
return (
normalizeInflectionValue(table?.inflection?.filterType) ??
`${tableName}Filter`
);
}

// ---------------------------------------------------------------------------
// Patch field name
// ---------------------------------------------------------------------------

/**
* Resolve PostGraphile patch field name.
* In v5 this is typically entity-specific: e.g. "userPatch", "contactPatch".
* Prefers the value discovered from the schema (`table.query.patchFieldName`
* or `table.inflection.patchField`), falls back to `${singularName}Patch`.
*/
export function toPatchFieldName(
tableName: string,
table?: CleanTable | null,
): string {
// First check the patch field name discovered from UpdateXxxInput
const introspectedPatch = normalizeInflectionValue(
table?.query?.patchFieldName,
);
if (introspectedPatch) return introspectedPatch;

// Then check the inflection table
const explicitPatchField = normalizeInflectionValue(
table?.inflection?.patchField,
);
if (explicitPatchField) return explicitPatchField;

return `${toCamelCaseSingular(tableName, table)}Patch`;
}

// ---------------------------------------------------------------------------
// OrderBy helpers
// ---------------------------------------------------------------------------

/**
* Convert camelCase field name to SCREAMING_SNAKE_CASE for PostGraphile
* orderBy enums.
*
* "displayName" -> "DISPLAY_NAME_ASC"
* "createdAt" -> "CREATED_AT_DESC"
* "id" -> "ID_ASC"
*/
export function toOrderByEnumValue(
fieldName: string,
direction: 'asc' | 'desc',
): string {
const screaming = fieldName
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.toUpperCase();
return `${screaming}_${direction.toUpperCase()}`;
}

/**
* Generate the PostGraphile OrderBy enum type name for a table.
* Prefers server-provided `table.inflection.orderByType` when available.
*/
export function toOrderByTypeName(
tableName: string,
table?: CleanTable | null,
): string {
if (table?.inflection?.orderByType) return table.inflection.orderByType;
const plural = toCamelCasePlural(tableName, table);
return `${plural.charAt(0).toUpperCase() + plural.slice(1)}OrderBy`;
}
Loading