diff --git a/graphql/codegen/src/__tests__/introspect/infer-tables.test.ts b/graphql/codegen/src/__tests__/introspect/infer-tables.test.ts index df21806b24..d5678101db 100644 --- a/graphql/codegen/src/__tests__/introspect/infer-tables.test.ts +++ b/graphql/codegen/src/__tests__/introspect/infer-tables.test.ts @@ -732,7 +732,7 @@ describe('Mutation Operation Matching', () => { fields: [{ name: 'id', type: nonNull(scalar('UUID')) }], }, { name: 'UsersConnection', kind: 'OBJECT', fields: [] }, - { name: 'CreateUserPayload', kind: 'OBJECT', fields: [] }, + { name: 'CreateUserPayload', kind: 'OBJECT', fields: [{ name: 'user', type: object('User') }] }, ], [{ name: 'users', type: object('UsersConnection') }], [ @@ -760,8 +760,8 @@ describe('Mutation Operation Matching', () => { fields: [{ name: 'id', type: nonNull(scalar('UUID')) }], }, { name: 'UsersConnection', kind: 'OBJECT', fields: [] }, - { name: 'UpdateUserPayload', kind: 'OBJECT', fields: [] }, - { name: 'DeleteUserPayload', kind: 'OBJECT', fields: [] }, + { name: 'UpdateUserPayload', kind: 'OBJECT', fields: [{ name: 'user', type: object('User') }] }, + { name: 'DeleteUserPayload', kind: 'OBJECT', fields: [{ name: 'user', type: object('User') }] }, ], [{ name: 'users', type: object('UsersConnection') }], [ @@ -797,7 +797,7 @@ describe('Mutation Operation Matching', () => { fields: [{ name: 'id', type: nonNull(scalar('UUID')) }], }, { name: 'UsersConnection', kind: 'OBJECT', fields: [] }, - { name: 'UpdateUserPayload', kind: 'OBJECT', fields: [] }, + { name: 'UpdateUserPayload', kind: 'OBJECT', fields: [{ name: 'user', type: object('User') }] }, ], [{ name: 'users', type: object('UsersConnection') }], [ diff --git a/graphql/query/src/introspect/infer-tables.ts b/graphql/query/src/introspect/infer-tables.ts index e5b78652c4..51c10e9432 100644 --- a/graphql/query/src/introspect/infer-tables.ts +++ b/graphql/query/src/introspect/infer-tables.ts @@ -290,7 +290,7 @@ function buildCleanTable( // Match query and mutation operations const queryOps = matchQueryOperations(entityName, queryFields, entityToConnection); - const mutationOps = matchMutationOperations(entityName, mutationFields); + const mutationOps = matchMutationOperations(entityName, mutationFields, typeMap); // Check if we found at least one real operation (not a fallback) const hasRealOperation = !!( @@ -699,6 +699,30 @@ interface MutationOperations { bulkDelete: string | null; } +/** + * Check whether a mutation field is a real PostGraphile CRUD mutation. + * + * CRUD mutation payloads always contain a field named lcFirst(entityName) + * whose type is the entity itself (e.g. DeleteUserPayload has `user: User`). + * Custom SQL-function mutations that happen to follow the same naming + * convention (e.g. `deletePrincipal`) return something else — typically + * `result: Boolean` or `result: UUID` — and must not be treated as CRUD. + */ +function isCrudMutation( + field: IntrospectionField, + entityName: string, + typeMap: Map, +): boolean { + const payloadTypeName = getBaseTypeName(field.type); + if (!payloadTypeName) return false; + const payloadType = typeMap.get(payloadTypeName); + if (!payloadType || !payloadType.fields) return false; + const entityFieldName = lcFirst(entityName); + return payloadType.fields.some( + (f) => f.name === entityFieldName && getBaseTypeName(f.type) === entityName, + ); +} + /** * Match mutation operations for an entity * @@ -710,10 +734,15 @@ interface MutationOperations { * - bulkUpsert{PluralName} (bulk upsert) * - bulkUpdate{PluralName} (bulk update) * - bulkDelete{PluralName} (bulk delete) + * + * A candidate is only accepted if its payload type returns the entity + * (i.e. it is a real CRUD mutation, not a custom SQL function that + * happens to share the naming convention). */ function matchMutationOperations( entityName: string, mutationFields: IntrospectionField[], + typeMap: Map, ): MutationOperations { let create: string | null = null; let update: string | null = null; @@ -736,28 +765,30 @@ function matchMutationOperations( for (const field of mutationFields) { // Exact match for create - if (field.name === expectedCreate) { + if (field.name === expectedCreate && isCrudMutation(field, entityName, typeMap)) { create = field.name; } // Match update (could be updateUser, updateUserById, or updateUserByFooAndBar for composite PKs) - if (field.name === expectedUpdate) { + if (field.name === expectedUpdate && isCrudMutation(field, entityName, typeMap)) { update = field.name; } else if ( !update && (field.name === `${expectedUpdate}ById` || - field.name.startsWith(`${expectedUpdate}By`)) + field.name.startsWith(`${expectedUpdate}By`)) && + isCrudMutation(field, entityName, typeMap) ) { update = field.name; } // Match delete (could be deleteUser, deleteUserById, or deleteUserByFooAndBar for composite PKs) - if (field.name === expectedDelete) { + if (field.name === expectedDelete && isCrudMutation(field, entityName, typeMap)) { del = field.name; } else if ( !del && (field.name === `${expectedDelete}ById` || - field.name.startsWith(`${expectedDelete}By`)) + field.name.startsWith(`${expectedDelete}By`)) && + isCrudMutation(field, entityName, typeMap) ) { del = field.name; } diff --git a/sdk/constructive-cli/src/auth/cli/commands/principal.ts b/sdk/constructive-cli/src/auth/cli/commands/principal.ts index b3b742cc78..9066f9d2e1 100644 --- a/sdk/constructive-cli/src/auth/cli/commands/principal.ts +++ b/sdk/constructive-cli/src/auth/cli/commands/principal.ts @@ -3,70 +3,52 @@ * @generated by @constructive-io/graphql-codegen * DO NOT EDIT - changes will be overwritten */ -import { CLIOptions, Inquirerer, extractFirst } from 'inquirerer'; -import { getClient } from '../executor'; -import { coerceAnswers, parseFindFirstArgs, parseFindManyArgs, stripUndefined } from '../utils'; -import type { FieldSchema } from '../utils'; -import type { - CreatePrincipalInput, - PrincipalPatch, - PrincipalSelect, - PrincipalFilter, - PrincipalOrderBy, -} from '../../orm/input-types'; -import type { FindManyArgs, FindFirstArgs } from '../../orm/select-types'; +import { CLIOptions, Inquirerer, extractFirst } from "inquirerer"; +import { getClient } from "../executor"; +import { coerceAnswers, parseFindFirstArgs, parseFindManyArgs, stripUndefined } from "../utils"; +import type { FieldSchema } from "../utils"; +import type { CreatePrincipalInput, PrincipalPatch, PrincipalSelect, PrincipalFilter, PrincipalOrderBy } from "../../orm/input-types"; +import type { FindManyArgs, FindFirstArgs } from "../../orm/select-types"; const fieldSchema: FieldSchema = { - id: 'uuid', - createdAt: 'string', - updatedAt: 'string', - ownerId: 'uuid', - userId: 'uuid', - name: 'string', - allowedMask: 'string', - isReadOnly: 'boolean', - bypassStepUp: 'boolean', + id: "uuid", + createdAt: "string", + updatedAt: "string", + ownerId: "uuid", + userId: "uuid", + name: "string", + allowedMask: "string", + isReadOnly: "boolean", + bypassStepUp: "boolean" }; -const usage = - '\nprincipal \n\nCommands:\n list List principal records\n find-first Find first matching principal record\n get Get a principal by ID\n create Create a new principal\n delete Delete a principal\n\nList Options:\n --limit Max number of records to return (forward pagination)\n --last Number of records from the end (backward pagination)\n --after Cursor for forward pagination\n --before Cursor for backward pagination\n --offset Number of records to skip\n --select Comma-separated list of fields to return\n --where.. Filter (dot-notation, e.g. --where.name.equalTo foo)\n --condition.. Condition filter (dot-notation)\n --orderBy Comma-separated ordering values (e.g. NAME_ASC,CREATED_AT_DESC)\n\nFind-First Options:\n --select Comma-separated list of fields to return\n --where.. Filter (dot-notation, e.g. --where.status.equalTo active)\n --condition.. Condition filter (dot-notation)\n --orderBy Comma-separated ordering values (e.g. NAME_ASC,CREATED_AT_DESC)\n\n --help, -h Show this help message\n'; -export default async ( - argv: Partial>, - prompter: Inquirerer, - _options: CLIOptions -) => { +const usage = "\nprincipal \n\nCommands:\n list List principal records\n find-first Find first matching principal record\n create Create a new principal\n\nList Options:\n --limit Max number of records to return (forward pagination)\n --last Number of records from the end (backward pagination)\n --after Cursor for forward pagination\n --before Cursor for backward pagination\n --offset Number of records to skip\n --select Comma-separated list of fields to return\n --where.. Filter (dot-notation, e.g. --where.name.equalTo foo)\n --condition.. Condition filter (dot-notation)\n --orderBy Comma-separated ordering values (e.g. NAME_ASC,CREATED_AT_DESC)\n\nFind-First Options:\n --select Comma-separated list of fields to return\n --where.. Filter (dot-notation, e.g. --where.status.equalTo active)\n --condition.. Condition filter (dot-notation)\n --orderBy Comma-separated ordering values (e.g. NAME_ASC,CREATED_AT_DESC)\n\n --help, -h Show this help message\n"; +export default async (argv: Partial>, prompter: Inquirerer, _options: CLIOptions) => { if (argv.help || argv.h) { console.log(usage); process.exit(0); } - const { first: subcommand, newArgv } = extractFirst(argv); + const { + first: subcommand, + newArgv + } = extractFirst(argv); if (!subcommand) { - const answer = await prompter.prompt(argv, [ - { - type: 'autocomplete', - name: 'subcommand', - message: 'What do you want to do?', - options: ['list', 'find-first', 'get', 'create', 'delete'], - }, - ]); + const answer = await prompter.prompt(argv, [{ + type: "autocomplete", + name: "subcommand", + message: "What do you want to do?", + options: ["list", "find-first", "create"] + }]); return handleTableSubcommand(answer.subcommand as string, newArgv, prompter); } return handleTableSubcommand(subcommand, newArgv, prompter); }; -async function handleTableSubcommand( - subcommand: string, - argv: Partial>, - prompter: Inquirerer -) { +async function handleTableSubcommand(subcommand: string, argv: Partial>, prompter: Inquirerer) { switch (subcommand) { - case 'list': + case "list": return handleList(argv, prompter); - case 'find-first': + case "find-first": return handleFindFirst(argv, prompter); - case 'get': - return handleGet(argv, prompter); - case 'create': + case "create": return handleCreate(argv, prompter); - case 'delete': - return handleDelete(argv, prompter); default: console.log(usage); process.exit(1); @@ -83,18 +65,16 @@ async function handleList(argv: Partial>, _prompter: Inq name: true, allowedMask: true, isReadOnly: true, - bypassStepUp: true, + bypassStepUp: true }; - const findManyArgs = parseFindManyArgs< - FindManyArgs & { - select: PrincipalSelect; - } - >(argv, defaultSelect); + const findManyArgs = parseFindManyArgs & { + select: PrincipalSelect; + }>(argv, defaultSelect); const client = getClient(); const result = await client.principal.findMany(findManyArgs).execute(); console.log(JSON.stringify(result, null, 2)); } catch (error) { - console.error('Failed to list records.'); + console.error("Failed to list records."); if (error instanceof Error) { console.error(error.message); } @@ -112,54 +92,16 @@ async function handleFindFirst(argv: Partial>, _prompter name: true, allowedMask: true, isReadOnly: true, - bypassStepUp: true, + bypassStepUp: true }; - const findFirstArgs = parseFindFirstArgs< - FindFirstArgs & { - select: PrincipalSelect; - } - >(argv, defaultSelect); + const findFirstArgs = parseFindFirstArgs & { + select: PrincipalSelect; + }>(argv, defaultSelect); const client = getClient(); const result = await client.principal.findFirst(findFirstArgs).execute(); console.log(JSON.stringify(result, null, 2)); } catch (error) { - console.error('Failed to find record.'); - if (error instanceof Error) { - console.error(error.message); - } - process.exit(1); - } -} -async function handleGet(argv: Partial>, prompter: Inquirerer) { - try { - const answers = await prompter.prompt(argv, [ - { - type: 'text', - name: 'principalId', - message: 'principalId', - required: true, - }, - ]); - const client = getClient(); - const result = await client.principal - .findOne({ - principalId: answers.principalId as string, - select: { - id: true, - createdAt: true, - updatedAt: true, - ownerId: true, - userId: true, - name: true, - allowedMask: true, - isReadOnly: true, - bypassStepUp: true, - }, - }) - .execute(); - console.log(JSON.stringify(result, null, 2)); - } catch (error) { - console.error('Record not found.'); + console.error("Failed to find record."); if (error instanceof Error) { console.error(error.message); } @@ -168,107 +110,67 @@ async function handleGet(argv: Partial>, prompter: Inqui } async function handleCreate(argv: Partial>, prompter: Inquirerer) { try { - const rawAnswers = await prompter.prompt(argv, [ - { - type: 'text', - name: 'ownerId', - message: 'ownerId', - required: true, - }, - { - type: 'text', - name: 'userId', - message: 'userId', - required: true, - }, - { - type: 'text', - name: 'name', - message: 'name', - required: true, - }, - { - type: 'text', - name: 'allowedMask', - message: 'allowedMask', - required: true, - }, - { - type: 'boolean', - name: 'isReadOnly', - message: 'isReadOnly', - required: true, - }, - { - type: 'boolean', - name: 'bypassStepUp', - message: 'bypassStepUp', - required: true, - }, - ]); + const rawAnswers = await prompter.prompt(argv, [{ + type: "text", + name: "ownerId", + message: "ownerId", + required: true + }, { + type: "text", + name: "userId", + message: "userId", + required: true + }, { + type: "text", + name: "name", + message: "name", + required: true + }, { + type: "text", + name: "allowedMask", + message: "allowedMask", + required: true + }, { + type: "boolean", + name: "isReadOnly", + message: "isReadOnly", + required: true + }, { + type: "boolean", + name: "bypassStepUp", + message: "bypassStepUp", + required: true + }]); const answers = coerceAnswers(rawAnswers, fieldSchema); - const cleanedData = stripUndefined(answers, fieldSchema) as CreatePrincipalInput['principal']; + const cleanedData = stripUndefined(answers, fieldSchema) as CreatePrincipalInput["principal"]; const client = getClient(); - const result = await client.principal - .create({ - data: { - ownerId: cleanedData.ownerId, - userId: cleanedData.userId, - name: cleanedData.name, - allowedMask: cleanedData.allowedMask, - isReadOnly: cleanedData.isReadOnly, - bypassStepUp: cleanedData.bypassStepUp, - }, - select: { - id: true, - createdAt: true, - updatedAt: true, - ownerId: true, - userId: true, - name: true, - allowedMask: true, - isReadOnly: true, - bypassStepUp: true, - }, - }) - .execute(); - console.log(JSON.stringify(result, null, 2)); - } catch (error) { - console.error('Failed to create record.'); - if (error instanceof Error) { - console.error(error.message); - } - process.exit(1); - } -} -async function handleDelete(argv: Partial>, prompter: Inquirerer) { - try { - const rawAnswers = await prompter.prompt(argv, [ - { - type: 'text', - name: 'principalId', - message: 'principalId', - required: true, + const result = await client.principal.create({ + data: { + ownerId: cleanedData.ownerId, + userId: cleanedData.userId, + name: cleanedData.name, + allowedMask: cleanedData.allowedMask, + isReadOnly: cleanedData.isReadOnly, + bypassStepUp: cleanedData.bypassStepUp }, - ]); - const answers = coerceAnswers(rawAnswers, fieldSchema); - const client = getClient(); - const result = await client.principal - .delete({ - where: { - principalId: answers.principalId as string, - }, - select: { - principalId: true, - }, - }) - .execute(); + select: { + id: true, + createdAt: true, + updatedAt: true, + ownerId: true, + userId: true, + name: true, + allowedMask: true, + isReadOnly: true, + bypassStepUp: true + } + }).execute(); console.log(JSON.stringify(result, null, 2)); } catch (error) { - console.error('Failed to delete record.'); + console.error("Failed to create record."); if (error instanceof Error) { console.error(error.message); } process.exit(1); } -} +} \ No newline at end of file