Skip to content

Commit 088b8e3

Browse files
nickwesselmanclaude
andcommitted
Refactor: extract shared GraphQL execution logic
Extract `runGraphQLExecution` from `executeOperation` to enable reuse by both app-authenticated and user-authenticated execution paths. Extract `loadQuery` from `prepareExecuteContext` for standalone query loading without app context. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 758be51 commit 088b8e3

4 files changed

Lines changed: 221 additions & 46 deletions

File tree

packages/app/src/cli/services/execute-operation.test.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {executeOperation} from './execute-operation.js'
1+
import {executeOperation, runGraphQLExecution} from './execute-operation.js'
22
import {createAdminSessionAsApp, resolveApiVersion, validateMutationStore} from './graphql/common.js'
33
import {OrganizationApp, OrganizationSource, OrganizationStore} from '../models/organization.js'
44
import {renderSuccess, renderError, renderSingleTask} from '@shopify/cli-kit/node/ui'
@@ -360,3 +360,103 @@ describe('executeOperation', () => {
360360
expect(adminRequestDoc).not.toHaveBeenCalled()
361361
})
362362
})
363+
364+
describe('runGraphQLExecution', () => {
365+
const mockAdminSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'}
366+
367+
beforeEach(() => {
368+
vi.mocked(renderSingleTask).mockImplementation(async ({task}) => {
369+
return task(() => {})
370+
})
371+
})
372+
373+
afterEach(() => {
374+
mockAndCaptureOutput().clear()
375+
})
376+
377+
test('executes GraphQL operation and renders success', async () => {
378+
const query = 'query { shop { name } }'
379+
const mockResult = {data: {shop: {name: 'Test Shop'}}}
380+
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)
381+
382+
await runGraphQLExecution({
383+
adminSession: mockAdminSession,
384+
query,
385+
version: '2024-07',
386+
})
387+
388+
expect(adminRequestDoc).toHaveBeenCalledWith({
389+
query: expect.any(Object),
390+
session: mockAdminSession,
391+
variables: undefined,
392+
version: '2024-07',
393+
responseOptions: {handleErrors: false},
394+
})
395+
expect(renderSuccess).toHaveBeenCalledWith(
396+
expect.objectContaining({headline: 'Operation succeeded.'}),
397+
)
398+
})
399+
400+
test('parses variables from flag', async () => {
401+
const query = 'query { shop { name } }'
402+
const variables = '{"key":"value"}'
403+
vi.mocked(adminRequestDoc).mockResolvedValue({})
404+
405+
await runGraphQLExecution({
406+
adminSession: mockAdminSession,
407+
query,
408+
variables,
409+
version: '2024-07',
410+
})
411+
412+
expect(adminRequestDoc).toHaveBeenCalledWith(
413+
expect.objectContaining({variables: {key: 'value'}}),
414+
)
415+
})
416+
417+
test('writes output to file when outputFile specified', async () => {
418+
const query = 'query { shop { name } }'
419+
const mockResult = {data: {shop: {name: 'Test Shop'}}}
420+
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)
421+
422+
await runGraphQLExecution({
423+
adminSession: mockAdminSession,
424+
query,
425+
outputFile: '/tmp/results.json',
426+
version: '2024-07',
427+
})
428+
429+
expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', JSON.stringify(mockResult, null, 2))
430+
})
431+
432+
test('handles ClientError gracefully', async () => {
433+
const query = 'query { invalidField }'
434+
const graphqlErrors = [{message: 'Field not found'}]
435+
const clientError = new ClientError({errors: graphqlErrors} as any, {query: '', variables: {}})
436+
;(clientError as any).response = {errors: graphqlErrors}
437+
vi.mocked(adminRequestDoc).mockRejectedValue(clientError)
438+
439+
await runGraphQLExecution({
440+
adminSession: mockAdminSession,
441+
query,
442+
version: '2024-07',
443+
})
444+
445+
expect(renderError).toHaveBeenCalledWith(
446+
expect.objectContaining({headline: 'GraphQL operation failed.'}),
447+
)
448+
})
449+
450+
test('propagates non-ClientError errors', async () => {
451+
const query = 'query { shop { name } }'
452+
vi.mocked(adminRequestDoc).mockRejectedValue(new Error('Network error'))
453+
454+
await expect(
455+
runGraphQLExecution({
456+
adminSession: mockAdminSession,
457+
query,
458+
version: '2024-07',
459+
}),
460+
).rejects.toThrow('Network error')
461+
})
462+
})

packages/app/src/cli/services/execute-operation.ts

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,16 @@ interface ExecuteOperationInput {
2525
version?: string
2626
}
2727

28-
async function parseVariables(
28+
export interface RunGraphQLExecutionInput {
29+
adminSession: AdminSession
30+
query: string
31+
variables?: string
32+
variableFile?: string
33+
outputFile?: string
34+
version: string
35+
}
36+
37+
export async function parseVariables(
2938
variables?: string,
3039
variableFile?: string,
3140
): Promise<{[key: string]: unknown} | undefined> {
@@ -61,23 +70,12 @@ async function parseVariables(
6170
return undefined
6271
}
6372

64-
export async function executeOperation(input: ExecuteOperationInput): Promise<void> {
65-
const {remoteApp, store, query, variables, variableFile, version: userSpecifiedVersion, outputFile} = input
66-
67-
const {adminSession, version} = await renderSingleTask({
68-
title: outputContent`Authenticating`,
69-
task: async (): Promise<{adminSession: AdminSession; version: string}> => {
70-
const adminSession = await createAdminSessionAsApp(remoteApp, store.shopDomain)
71-
const version = await resolveApiVersion({adminSession, userSpecifiedVersion})
72-
return {adminSession, version}
73-
},
74-
renderOptions: {stdout: process.stderr},
75-
})
73+
export async function runGraphQLExecution(input: RunGraphQLExecutionInput): Promise<void> {
74+
const {adminSession, query, variables, variableFile, outputFile, version} = input
7675

7776
const parsedVariables = await parseVariables(variables, variableFile)
7877

7978
validateSingleOperation(query)
80-
validateMutationStore(query, store)
8179

8280
try {
8381
const result = await renderSingleTask({
@@ -126,3 +124,21 @@ export async function executeOperation(input: ExecuteOperationInput): Promise<vo
126124
throw error
127125
}
128126
}
127+
128+
export async function executeOperation(input: ExecuteOperationInput): Promise<void> {
129+
const {remoteApp, store, query, variables, variableFile, version: userSpecifiedVersion, outputFile} = input
130+
131+
const {adminSession, version} = await renderSingleTask({
132+
title: outputContent`Authenticating`,
133+
task: async (): Promise<{adminSession: AdminSession; version: string}> => {
134+
const adminSession = await createAdminSessionAsApp(remoteApp, store.shopDomain)
135+
const version = await resolveApiVersion({adminSession, userSpecifiedVersion})
136+
return {adminSession, version}
137+
},
138+
renderOptions: {stdout: process.stderr},
139+
})
140+
141+
validateMutationStore(query, store)
142+
143+
await runGraphQLExecution({adminSession, query, variables, variableFile, outputFile, version})
144+
}

packages/app/src/cli/utilities/execute-command-helpers.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {prepareAppStoreContext, prepareExecuteContext} from './execute-command-helpers.js'
1+
import {prepareAppStoreContext, prepareExecuteContext, loadQuery} from './execute-command-helpers.js'
22
import {linkedAppContext} from '../services/app-context.js'
33
import {storeContext} from '../services/store-context.js'
44
import {validateSingleOperation} from '../services/graphql/common.js'
@@ -89,6 +89,53 @@ describe('prepareAppStoreContext', () => {
8989
})
9090
})
9191

92+
describe('loadQuery', () => {
93+
test('returns query from --query flag', async () => {
94+
const result = await loadQuery({query: 'query { shop { name } }'})
95+
expect(result).toBe('query { shop { name } }')
96+
})
97+
98+
test('throws AbortError when query flag is empty', async () => {
99+
await expect(loadQuery({query: ''})).rejects.toThrow('--query flag value is empty')
100+
})
101+
102+
test('throws AbortError when query flag is whitespace', async () => {
103+
await expect(loadQuery({query: ' \n\t '})).rejects.toThrow('--query flag value is empty')
104+
})
105+
106+
test('reads query from file', async () => {
107+
const queryFileContent = 'query { shop { name } }'
108+
vi.mocked(fileExists).mockResolvedValue(true)
109+
vi.mocked(readFile).mockResolvedValue(queryFileContent as any)
110+
111+
const result = await loadQuery({'query-file': '/path/to/query.graphql'})
112+
113+
expect(fileExists).toHaveBeenCalledWith('/path/to/query.graphql')
114+
expect(readFile).toHaveBeenCalledWith('/path/to/query.graphql', {encoding: 'utf8'})
115+
expect(result).toBe(queryFileContent)
116+
})
117+
118+
test('throws when query file does not exist', async () => {
119+
vi.mocked(fileExists).mockResolvedValue(false)
120+
await expect(loadQuery({'query-file': '/path/to/missing.graphql'})).rejects.toThrow('Query file not found')
121+
})
122+
123+
test('throws when query file is empty', async () => {
124+
vi.mocked(fileExists).mockResolvedValue(true)
125+
vi.mocked(readFile).mockResolvedValue('' as any)
126+
await expect(loadQuery({'query-file': '/path/to/empty.graphql'})).rejects.toThrow('is empty')
127+
})
128+
129+
test('throws BugError when no query provided', async () => {
130+
await expect(loadQuery({})).rejects.toThrow('exactlyOne constraint')
131+
})
132+
133+
test('validates GraphQL syntax via validateSingleOperation', async () => {
134+
await loadQuery({query: 'query { shop { name } }'})
135+
expect(validateSingleOperation).toHaveBeenCalledWith('query { shop { name } }')
136+
})
137+
})
138+
92139
describe('prepareExecuteContext', () => {
93140
const mockFlags = {
94141
path: '/test/path',

packages/app/src/cli/utilities/execute-command-helpers.ts

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -29,38 +29,13 @@ interface ExecuteContext extends AppStoreContext {
2929
}
3030

3131
/**
32-
* Prepares the app and store context for commands.
33-
* Sets up app linking and store selection without query handling.
32+
* Loads a GraphQL query from the --query flag or --query-file flag.
33+
* Validates that the query is non-empty and has valid GraphQL syntax.
3434
*
35-
* @param flags - Command flags containing configuration options.
36-
* @returns Context object containing app context and store information.
35+
* @param flags - Flags containing query or query-file.
36+
* @returns The loaded GraphQL query string.
3737
*/
38-
export async function prepareAppStoreContext(flags: AppStoreContextFlags): Promise<AppStoreContext> {
39-
const appContextResult = await linkedAppContext({
40-
directory: flags.path,
41-
clientId: flags['client-id'],
42-
forceRelink: flags.reset,
43-
userProvidedConfigName: flags.config,
44-
})
45-
46-
const store = await storeContext({
47-
appContextResult,
48-
storeFqdn: flags.store,
49-
forceReselectStore: flags.reset,
50-
storeTypes: ['APP_DEVELOPMENT', 'DEVELOPMENT', 'DEVELOPMENT_SUPERSET', 'PRODUCTION'],
51-
})
52-
53-
return {appContextResult, store}
54-
}
55-
56-
/**
57-
* Prepares the execution context for GraphQL operations.
58-
* Handles query input from flag or file, validates GraphQL syntax, and sets up app and store contexts.
59-
*
60-
* @param flags - Command flags containing configuration options.
61-
* @returns Context object containing query, app context, and store information.
62-
*/
63-
export async function prepareExecuteContext(flags: ExecuteCommandFlags): Promise<ExecuteContext> {
38+
export async function loadQuery(flags: {query?: string; 'query-file'?: string}): Promise<string> {
6439
let query: string | undefined
6540

6641
if (flags.query !== undefined) {
@@ -94,6 +69,43 @@ export async function prepareExecuteContext(flags: ExecuteCommandFlags): Promise
9469
// Validate GraphQL syntax and ensure single operation
9570
validateSingleOperation(query)
9671

72+
return query
73+
}
74+
75+
/**
76+
* Prepares the app and store context for commands.
77+
* Sets up app linking and store selection without query handling.
78+
*
79+
* @param flags - Command flags containing configuration options.
80+
* @returns Context object containing app context and store information.
81+
*/
82+
export async function prepareAppStoreContext(flags: AppStoreContextFlags): Promise<AppStoreContext> {
83+
const appContextResult = await linkedAppContext({
84+
directory: flags.path,
85+
clientId: flags['client-id'],
86+
forceRelink: flags.reset,
87+
userProvidedConfigName: flags.config,
88+
})
89+
90+
const store = await storeContext({
91+
appContextResult,
92+
storeFqdn: flags.store,
93+
forceReselectStore: flags.reset,
94+
storeTypes: ['APP_DEVELOPMENT', 'DEVELOPMENT', 'DEVELOPMENT_SUPERSET', 'PRODUCTION'],
95+
})
96+
97+
return {appContextResult, store}
98+
}
99+
100+
/**
101+
* Prepares the execution context for GraphQL operations.
102+
* Handles query input from flag or file, validates GraphQL syntax, and sets up app and store contexts.
103+
*
104+
* @param flags - Command flags containing configuration options.
105+
* @returns Context object containing query, app context, and store information.
106+
*/
107+
export async function prepareExecuteContext(flags: ExecuteCommandFlags): Promise<ExecuteContext> {
108+
const query = await loadQuery(flags)
97109
const {appContextResult, store} = await prepareAppStoreContext(flags)
98110

99111
return {query, appContextResult, store}

0 commit comments

Comments
 (0)