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
15 changes: 2 additions & 13 deletions graphql/codegen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,26 +86,16 @@ Endpoint introspection:

## Selection Options

Configure result field selections, mutation input style, and connection pagination shape.
Configure mutation input style and connection pagination shape.

```ts
selection: {
defaultMutationModelFields?: string[]
modelFields?: Record<string, string[]>
mutationInputMode?: 'expanded' | 'model' | 'raw' | 'patchCollapsed'
connectionStyle?: 'nodes' | 'edges'
forceModelOutput?: boolean
}
```

- `defaultMutationModelFields`
- Sets default fields selected from the object payload returned by mutations when the mutation exposes an OBJECT output.
- Example: `['id']` will select only the `id` from the returned model unless overridden per model.

- `modelFields`
- Per‑model overrides for returned object payload fields.
- Example: `{ domain: ['id','domain','subdomain'] }` selects those fields from the `domain` object output.

- `mutationInputMode`
- Controls how mutation variables and `input` are generated.
- `expanded`: one variable per input property; `input` is a flat object of those variables.
Expand All @@ -119,8 +109,7 @@ selection: {
- `edges`: emits `totalCount`, `pageInfo { ... }`, and `edges { cursor node { ... } }`.

- `forceModelOutput`
- When `true`, ensures the object payload is selected even if `defaultMutationModelFields` is empty, defaulting to `['id']`.
- Useful to avoid generating mutations that only return `clientMutationId`.
- When `true`, ensures the object payload selection is emitted to avoid a payload with only `clientMutationId`.

### Examples

Expand Down
4 changes: 2 additions & 2 deletions graphql/codegen/__tests__/gql.mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { print } from 'graphql'
import { createOne, patchOne, deleteOne, createMutation, MutationSpec } from '../src'

describe('gql mutation builders', () => {
it('createMutation: builds non-null vars and selects scalar outputs', () => {
it('createMutation: builds non-null vars and includes object outputs', () => {
const mutation: MutationSpec = {
model: 'Actions',
properties: {
Expand All @@ -26,7 +26,7 @@ describe('gql mutation builders', () => {
expect(s).toContain('$foo: String!')
expect(s).toContain('$list: [Int!]!')
expect(s).toContain('clientMutationId')
expect(s).not.toContain('node')
expect(s).toContain('node')
})

it('createOne: omits non-mutable props and uses non-null types', () => {
Expand Down
13 changes: 1 addition & 12 deletions graphql/codegen/codegen-example-config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"input": {
"endpoint": "http://localhost:3000/graphql",
"endpoint": "http://localhost:5555/graphql",
"headers": {
"Host": "api.localhost"
}
Expand Down Expand Up @@ -34,17 +34,6 @@
"PgpmInternalTypeHostname": "String"
},
"selection": {
"defaultMutationModelFields": [],
"modelFields": {
"extension": [],
"databaseExtension": [],
"table": ["id", "name", "schemaId"],
"field": ["id", "name", "tableId"],
"schema": ["id", "name", "databaseId"],
"api": ["id", "name", "databaseId"],
"domain": ["id", "domain", "subdomain", "databaseId"],
"site": ["id", "title", "databaseId"]
},
"mutationInputMode": "patchCollapsed",
"connectionStyle": "edges",
"forceModelOutput": true
Expand Down
2 changes: 1 addition & 1 deletion graphql/codegen/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ async function getIntrospectionFromEndpoint(endpoint: string, headers?: Record<s
return res as any
}

function generateKeyedObjFromGqlMap(gqlMap: GqlMap, selection?: { defaultMutationModelFields?: string[]; modelFields?: Record<string, string[]> }, typeNameOverrides?: Record<string, string>, typeIndex?: any): Record<string, string> {
function generateKeyedObjFromGqlMap(gqlMap: GqlMap, selection?: GraphQLCodegenOptions['selection'], typeNameOverrides?: Record<string, string>, typeIndex?: any): Record<string, string> {
const gen = generateGql(gqlMap, selection as any, typeNameOverrides, typeIndex)
return Object.entries(gen).reduce<Record<string, string>>((acc, [key, val]) => {
if (val?.ast) acc[key] = print(val.ast)
Expand Down
93 changes: 66 additions & 27 deletions graphql/codegen/src/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ export const getOne = ({
export interface CreateOneArgs {
operationName: string;
mutation: MutationSpec;
selection?: { defaultMutationModelFields?: string[]; modelFields?: Record<string, string[]>; mutationInputMode?: 'expanded' | 'model' | 'raw' | 'patchCollapsed'; connectionStyle?: 'nodes' | 'edges'; forceModelOutput?: boolean };
selection?: { mutationInputMode?: 'expanded' | 'model' | 'raw' | 'patchCollapsed'; connectionStyle?: 'nodes' | 'edges'; forceModelOutput?: boolean };
}

export interface CreateOneResult {
Expand Down Expand Up @@ -716,16 +716,28 @@ export const createOne = ({
}),
];

const selections: FieldNode[] = allAttrs.map((field) =>
t.field({ name: 'id' })
);
let idExists = true;
let availableFieldNames: string[] = [];
if (typeIndex) {
const typ = (typeIndex as any).byName?.[mutation.model];
const fields = (typ && Array.isArray(typ.fields)) ? typ.fields : [];
idExists = fields.some((f: any) => f && f.name === 'id');
availableFieldNames = fields
.filter((f: any) => {
let r = f.type;
while (r && (r.kind === 'NON_NULL' || r.kind === 'LIST')) r = r.ofType;
const kind = r?.kind;
return kind === 'SCALAR' || kind === 'ENUM';
})
.map((f: any) => f.name);
}

const modelFields = selection?.modelFields?.[modelName] || selection?.defaultMutationModelFields || ['id'];
const finalFields = Array.from(new Set([...(idExists ? ['id'] : []), ...availableFieldNames]));

const nested: FieldNode[] = (modelFields.length > 0)
const nested: FieldNode[] = (finalFields.length > 0)
? [t.field({
name: modelName,
selectionSet: t.selectionSet({ selections: modelFields.map((f) => t.field({ name: f })) }),
selectionSet: t.selectionSet({ selections: finalFields.map((f) => t.field({ name: f })) }),
})]
: [];

Expand Down Expand Up @@ -764,7 +776,7 @@ export interface MutationSpec {
export interface PatchOneArgs {
operationName: string;
mutation: MutationSpec;
selection?: { defaultMutationModelFields?: string[]; modelFields?: Record<string, string[]>; mutationInputMode?: 'expanded' | 'model' | 'raw' | 'patchCollapsed'; connectionStyle?: 'nodes' | 'edges'; forceModelOutput?: boolean };
selection?: { mutationInputMode?: 'expanded' | 'model' | 'raw' | 'patchCollapsed'; connectionStyle?: 'nodes' | 'edges'; forceModelOutput?: boolean };
}

export interface PatchOneResult {
Expand Down Expand Up @@ -869,7 +881,7 @@ export const patchOne = ({
return t.variableDefinition({ variable: t.variable({ name }), type: gqlType as any });
});

const mustUseRaw = useCollapsedOpt || unresolved > 0;
const mustUseRaw = unresolved > 0;
const selectArgs: ArgumentNode[] = mustUseRaw
? [t.argument({ name: 'input', value: t.variable({ name: 'input' }) as any })]
: [
Expand All @@ -891,12 +903,19 @@ export const patchOne = ({
}),
];

const modelFields = selection?.modelFields?.[modelName] || selection?.defaultMutationModelFields || ['id'];
let idExistsPatch = true;
if (typeIndex) {
const typ = (typeIndex as any).byName?.[mutation.model];
const fields = (typ && Array.isArray(typ.fields)) ? typ.fields : [];
idExistsPatch = fields.some((f: any) => f && f.name === 'id');
}
const shouldDropIdPatch = /Extension$/i.test(modelName) || !idExistsPatch;
const idSelection = shouldDropIdPatch ? [] : ['id'];

const nestedPatch: FieldNode[] = (modelFields.length > 0)
const nestedPatch: FieldNode[] = (idSelection.length > 0)
? [t.field({
name: modelName,
selectionSet: t.selectionSet({ selections: modelFields.map((f) => t.field({ name: f })) }),
selectionSet: t.selectionSet({ selections: idSelection.map((f) => t.field({ name: f })) }),
})]
: [];

Expand Down Expand Up @@ -1008,7 +1027,7 @@ export const deleteOne = ({
export interface CreateMutationArgs {
operationName: string;
mutation: MutationSpec;
selection?: { defaultMutationModelFields?: string[]; modelFields?: Record<string, string[]>; mutationInputMode?: 'expanded' | 'model' | 'raw' | 'patchCollapsed'; connectionStyle?: 'nodes' | 'edges'; forceModelOutput?: boolean };
selection?: { mutationInputMode?: 'expanded' | 'model' | 'raw' | 'patchCollapsed'; connectionStyle?: 'nodes' | 'edges'; forceModelOutput?: boolean };
}

export interface CreateMutationResult {
Expand Down Expand Up @@ -1082,22 +1101,42 @@ export const createMutation = ({
.filter((field) => field.type.kind === 'SCALAR')
.map((f) => f.name);

const objectOutput = (mutation.outputs || []).find((field) => field.type.kind === 'OBJECT');
let objectOutputName: string | undefined = (mutation.outputs || [])
.find((field) => field.type.kind === 'OBJECT')?.name;

if (!objectOutputName) {
const payloadTypeName = (mutation as any)?.output?.name;
if (typeIndex && payloadTypeName) {
const payloadType = (typeIndex as any).byName?.[payloadTypeName];
const fields = (payloadType && Array.isArray(payloadType.fields)) ? payloadType.fields : [];
const match = fields
.filter((f: any) => f && f.name !== 'clientMutationId')
.filter((f: any) => (refToNamedTypeName(f.type) || f.type?.name) !== 'Query')
.find((f: any) => (refToNamedTypeName(f.type) || f.type?.name) === (mutation as any)?.model);
if (match) objectOutputName = match.name;
}
}

const selections: FieldNode[] = [];
if (objectOutput?.name) {
const modelFieldsRaw = selection?.modelFields?.[objectOutput.name] || selection?.defaultMutationModelFields || [];
const shouldDropId = /Extension$/i.test(objectOutput.name);
const fallbackFields = shouldDropId ? [] : ['id'];
const modelFields = (selection?.forceModelOutput && modelFieldsRaw.length === 0) ? fallbackFields : (modelFieldsRaw.length > 0 ? modelFieldsRaw : []);
if (modelFields.length > 0) {
selections.push(
t.field({
name: objectOutput.name,
selectionSet: t.selectionSet({ selections: modelFields.map((f) => t.field({ name: f })) }),
})
);
}
if (objectOutputName) {
const modelTypeName = (mutation as any)?.model;
const modelType = typeIndex && modelTypeName ? (typeIndex as any).byName?.[modelTypeName] : null;
const fieldNames: string[] = (modelType && Array.isArray(modelType.fields))
? modelType.fields
.filter((f: any) => {
let r = f.type;
while (r && (r.kind === 'NON_NULL' || r.kind === 'LIST')) r = r.ofType;
const kind = r?.kind;
return kind === 'SCALAR' || kind === 'ENUM';
})
.map((f: any) => f.name)
: [];
selections.push(
t.field({
name: objectOutputName,
selectionSet: t.selectionSet({ selections: fieldNames.map((n) => t.field({ name: n })) }),
})
);
}
if (scalarOutputs.length > 0) {
selections.push(...scalarOutputs.map((o) => t.field({ name: o })));
Expand Down
4 changes: 1 addition & 3 deletions graphql/codegen/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ export interface GraphQLCodegenOptions {
reactQueryVersion?: number
}
selection?: {
defaultMutationModelFields?: string[]
modelFields?: Record<string, string[]>
mutationInputMode?: 'expanded' | 'model' | 'raw' | 'patchCollapsed'
connectionStyle?: 'nodes' | 'edges'
forceModelOutput?: boolean
Expand All @@ -57,7 +55,7 @@ export const defaultGraphQLCodegenOptions: GraphQLCodegenOptions = {
documents: { format: 'gql', convention: 'dashed', allowQueries: [], excludeQueries: [], excludePatterns: [] },
features: { emitTypes: true, emitOperations: true, emitSdk: true, emitReactQuery: true },
reactQuery: { fetcher: 'graphql-request', legacyMode: false, exposeDocument: false, addInfiniteQuery: false, reactQueryVersion: 5 },
selection: { defaultMutationModelFields: ['id'], modelFields: {}, mutationInputMode: 'patchCollapsed', connectionStyle: 'edges', forceModelOutput: true },
selection: { mutationInputMode: 'patchCollapsed', connectionStyle: 'edges', forceModelOutput: true },
scalars: {},
typeNameOverrides: {}
}
Expand Down
4 changes: 4 additions & 0 deletions graphql/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ Configuration is merged from defaults, config files, and env vars via `@construc

Use `supertest` or your HTTP client of choice against `/graphql`. For RLS-aware tests, provide a `Bearer` token and ensure the API's auth function is available.

## Codegen

For local codegen test, use `PORT=5555 API_ENABLE_META=true PGDATABASE=launchql pnpm dev`

## Related Packages

- `@constructive-io/graphql-env` - env parsing + defaults for GraphQL
Expand Down
4 changes: 2 additions & 2 deletions pgpm/cli/__tests__/__snapshots__/init.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ exports[`cmds:init initializes module: module-only - files 1`] = `
"packages/my-module/Makefile",
"packages/my-module/LICENSE",
"packages/my-module/__tests__/basic.test.ts",
".github/ci.yaml",
".github/workflows/ci.yml",
]
`;

Expand Down Expand Up @@ -124,7 +124,7 @@ exports[`cmds:init initializes workspace: workspace - files 1`] = `
"my-workspace/.prettierrc.json",
"my-workspace/.gitignore",
"my-workspace/.eslintrc.json",
"my-workspace/.github/ci.yaml",
"my-workspace/.github/workflows/ci.yml",
]
`;

Expand Down