Skip to content

Commit a28e8f4

Browse files
Merge pull request #6733 from Shopify/app_execute_query_file
Add `--query-file` flag to `app execute` and `app bulk execute` for loading GraphQL queries from files
2 parents aee9944 + 1ffab0d commit a28e8f4

File tree

6 files changed

+121
-22
lines changed

6 files changed

+121
-22
lines changed

packages/app/src/cli/flags.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export const bulkOperationFlags = {
4141
description: 'The GraphQL query or mutation to run as a bulk operation. If omitted, reads from standard input.',
4242
env: 'SHOPIFY_FLAG_QUERY',
4343
required: false,
44+
exclusive: ['query-file'],
45+
}),
46+
'query-file': Flags.string({
47+
description: "Path to a file containing the GraphQL query or mutation. Can't be used with --query.",
48+
env: 'SHOPIFY_FLAG_QUERY_FILE',
49+
parse: async (input) => resolvePath(input),
50+
exclusive: ['query'],
4451
}),
4552
variables: Flags.string({
4653
char: 'v',
@@ -85,6 +92,13 @@ export const operationFlags = {
8592
description: 'The GraphQL query or mutation, as a string.',
8693
env: 'SHOPIFY_FLAG_QUERY',
8794
required: false,
95+
exclusive: ['query-file'],
96+
}),
97+
'query-file': Flags.string({
98+
description: "Path to a file containing the GraphQL query or mutation. Can't be used with --query.",
99+
env: 'SHOPIFY_FLAG_QUERY_FILE',
100+
parse: async (input) => resolvePath(input),
101+
exclusive: ['query'],
88102
}),
89103
variables: Flags.string({
90104
char: 'v',

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

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,7 @@ import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
55
import {downloadBulkOperationResults} from './download-bulk-operation-results.js'
66
import {extractBulkOperationId} from './bulk-operation-status.js'
77
import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js'
8-
import {
9-
createAdminSessionAsApp,
10-
validateSingleOperation,
11-
formatOperationInfo,
12-
resolveApiVersion,
13-
} from '../graphql/common.js'
8+
import {createAdminSessionAsApp, formatOperationInfo, resolveApiVersion} from '../graphql/common.js'
149
import {OrganizationApp, Organization} from '../../models/organization.js'
1510
import {renderSuccess, renderInfo, renderError, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui'
1611
import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output'
@@ -71,7 +66,7 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
7166

7267
const variablesJsonl = await parseVariablesToJsonl(variables, variableFile)
7368

74-
validateGraphQLDocument(query, variablesJsonl)
69+
validateBulkOperationVariables(query, variablesJsonl)
7570

7671
renderInfo({
7772
headline: 'Starting bulk operation.',
@@ -206,9 +201,10 @@ function resultsContainUserErrors(results: string): boolean {
206201
})
207202
}
208203

209-
function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: string): void {
210-
validateSingleOperation(graphqlOperation)
211-
204+
/**
205+
* Validates bulk operation-specific constraints for variables.
206+
*/
207+
function validateBulkOperationVariables(graphqlOperation: string, variablesJsonl?: string): void {
212208
if (!isMutation(graphqlOperation) && variablesJsonl) {
213209
throw new AbortError(
214210
outputContent`The ${outputToken.yellow('--variables')} and ${outputToken.yellow(

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

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
import {
2-
createAdminSessionAsApp,
3-
validateSingleOperation,
4-
resolveApiVersion,
5-
formatOperationInfo,
6-
} from './graphql/common.js'
1+
import {createAdminSessionAsApp, resolveApiVersion, formatOperationInfo} from './graphql/common.js'
72
import {OrganizationApp, Organization} from '../models/organization.js'
83
import {renderSuccess, renderError, renderInfo, renderSingleTask} from '@shopify/cli-kit/node/ui'
94
import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output'
@@ -89,8 +84,6 @@ export async function executeOperation(input: ExecuteOperationInput): Promise<vo
8984

9085
const parsedVariables = await parseVariables(variables, variableFile)
9186

92-
validateSingleOperation(query)
93-
9487
try {
9588
const result = await renderSingleTask({
9689
title: outputContent`Executing GraphQL operation`,

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import {prepareAppStoreContext, prepareExecuteContext} from './execute-command-helpers.js'
22
import {linkedAppContext} from '../services/app-context.js'
33
import {storeContext} from '../services/store-context.js'
4+
import {validateSingleOperation} from '../services/graphql/common.js'
5+
import {readFile, fileExists} from '@shopify/cli-kit/node/fs'
46
import {readStdinString} from '@shopify/cli-kit/node/system'
57
import {describe, test, expect, vi, beforeEach} from 'vitest'
68

79
vi.mock('../services/app-context.js')
810
vi.mock('../services/store-context.js')
11+
vi.mock('@shopify/cli-kit/node/fs')
912
vi.mock('@shopify/cli-kit/node/system')
13+
vi.mock('../services/graphql/common.js', () => ({
14+
validateSingleOperation: vi.fn(),
15+
}))
1016

1117
describe('prepareAppStoreContext', () => {
1218
const mockFlags = {
@@ -170,4 +176,44 @@ describe('prepareExecuteContext', () => {
170176
expect(linkedAppContext).toHaveBeenCalled()
171177
expect(storeContext).toHaveBeenCalled()
172178
})
179+
180+
test('reads query from file when query-file flag is provided', async () => {
181+
const queryFileContent = 'query { shop { name } }'
182+
vi.mocked(fileExists).mockResolvedValue(true)
183+
vi.mocked(readFile).mockResolvedValue(queryFileContent as any)
184+
185+
const flagsWithQueryFile = {...mockFlags, query: undefined, 'query-file': '/path/to/query.graphql'}
186+
const result = await prepareExecuteContext(flagsWithQueryFile)
187+
188+
expect(fileExists).toHaveBeenCalledWith('/path/to/query.graphql')
189+
expect(readFile).toHaveBeenCalledWith('/path/to/query.graphql', {encoding: 'utf8'})
190+
expect(result.query).toBe(queryFileContent)
191+
expect(readStdinString).not.toHaveBeenCalled()
192+
})
193+
194+
test('throws AbortError when query file does not exist', async () => {
195+
vi.mocked(fileExists).mockResolvedValue(false)
196+
197+
const flagsWithQueryFile = {...mockFlags, query: undefined, 'query-file': '/path/to/nonexistent.graphql'}
198+
199+
await expect(prepareExecuteContext(flagsWithQueryFile)).rejects.toThrow('Query file not found')
200+
expect(readFile).not.toHaveBeenCalled()
201+
})
202+
203+
test('falls back to stdin when neither query nor query-file provided', async () => {
204+
const stdinQuery = 'query { shop { name } }'
205+
vi.mocked(readStdinString).mockResolvedValue(stdinQuery)
206+
207+
const flagsWithoutQueryOrFile = {...mockFlags, query: undefined}
208+
const result = await prepareExecuteContext(flagsWithoutQueryOrFile)
209+
210+
expect(readStdinString).toHaveBeenCalled()
211+
expect(result.query).toBe(stdinQuery)
212+
})
213+
214+
test('validates GraphQL query using validateSingleOperation', async () => {
215+
await prepareExecuteContext(mockFlags)
216+
217+
expect(validateSingleOperation).toHaveBeenCalledWith(mockFlags.query)
218+
})
173219
})

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

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import {linkedAppContext, LoadedAppContextOutput} from '../services/app-context.js'
22
import {storeContext} from '../services/store-context.js'
3+
import {validateSingleOperation} from '../services/graphql/common.js'
34
import {OrganizationStore} from '../models/organization.js'
45
import {readStdinString} from '@shopify/cli-kit/node/system'
56
import {AbortError} from '@shopify/cli-kit/node/error'
7+
import {readFile, fileExists} from '@shopify/cli-kit/node/fs'
8+
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
69

710
interface AppStoreContextFlags {
811
path: string
@@ -19,6 +22,7 @@ interface AppStoreContext {
1922

2023
interface ExecuteCommandFlags extends AppStoreContextFlags {
2124
query?: string
25+
'query-file'?: string
2226
}
2327

2428
interface ExecuteContext extends AppStoreContext {
@@ -51,7 +55,7 @@ export async function prepareAppStoreContext(flags: AppStoreContextFlags): Promi
5155

5256
/**
5357
* Prepares the execution context for GraphQL operations.
54-
* Handles query input from flag or stdin, and sets up app and store contexts.
58+
* Handles query input from flag, file, or stdin, validates GraphQL syntax, and sets up app and store contexts.
5559
*
5660
* @param flags - Command flags containing configuration options.
5761
* @param commandName - Name of the command for error messages (e.g., 'execute', 'bulk execute').
@@ -61,14 +65,32 @@ export async function prepareExecuteContext(
6165
flags: ExecuteCommandFlags,
6266
commandName = 'execute',
6367
): Promise<ExecuteContext> {
64-
const query = flags.query ?? (await readStdinString())
68+
let query: string | undefined
69+
70+
if (flags.query) {
71+
query = flags.query
72+
} else if (flags['query-file']) {
73+
const queryFile = flags['query-file']
74+
if (!(await fileExists(queryFile))) {
75+
throw new AbortError(
76+
outputContent`Query file not found at ${outputToken.path(queryFile)}. Please check the path and try again.`,
77+
)
78+
}
79+
query = await readFile(queryFile, {encoding: 'utf8'})
80+
} else {
81+
query = await readStdinString()
82+
}
83+
6584
if (!query) {
6685
throw new AbortError(
67-
'No query provided. Use the --query flag or pipe input via stdin.',
68-
`Example: echo "query { ... }" | shopify app ${commandName}`,
86+
'No query provided. Use the --query flag, --query-file flag, or pipe input via stdin.',
87+
`Example: shopify app ${commandName} --query-file query.graphql`,
6988
)
7089
}
7190

91+
// Validate GraphQL syntax and ensure single operation
92+
validateSingleOperation(query)
93+
7294
const {appContextResult, store} = await prepareAppStoreContext(flags)
7395

7496
return {query, appContextResult, store}

packages/cli/oclif.manifest.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,12 +244,26 @@
244244
"char": "q",
245245
"description": "The GraphQL query or mutation to run as a bulk operation. If omitted, reads from standard input.",
246246
"env": "SHOPIFY_FLAG_QUERY",
247+
"exclusive": [
248+
"query-file"
249+
],
247250
"hasDynamicHelp": false,
248251
"multiple": false,
249252
"name": "query",
250253
"required": false,
251254
"type": "option"
252255
},
256+
"query-file": {
257+
"description": "Path to a file containing the GraphQL query or mutation. Can't be used with --query.",
258+
"env": "SHOPIFY_FLAG_QUERY_FILE",
259+
"exclusive": [
260+
"query"
261+
],
262+
"hasDynamicHelp": false,
263+
"multiple": false,
264+
"name": "query-file",
265+
"type": "option"
266+
},
253267
"reset": {
254268
"allowNo": false,
255269
"description": "Reset all your settings.",
@@ -1282,12 +1296,26 @@
12821296
"char": "q",
12831297
"description": "The GraphQL query or mutation, as a string.",
12841298
"env": "SHOPIFY_FLAG_QUERY",
1299+
"exclusive": [
1300+
"query-file"
1301+
],
12851302
"hasDynamicHelp": false,
12861303
"multiple": false,
12871304
"name": "query",
12881305
"required": false,
12891306
"type": "option"
12901307
},
1308+
"query-file": {
1309+
"description": "Path to a file containing the GraphQL query or mutation. Can't be used with --query.",
1310+
"env": "SHOPIFY_FLAG_QUERY_FILE",
1311+
"exclusive": [
1312+
"query"
1313+
],
1314+
"hasDynamicHelp": false,
1315+
"multiple": false,
1316+
"name": "query-file",
1317+
"type": "option"
1318+
},
12911319
"reset": {
12921320
"allowNo": false,
12931321
"description": "Reset all your settings.",

0 commit comments

Comments
 (0)