Skip to content

Commit 61d6550

Browse files
committed
implement-variable-file-flag
1 parent ac1fd53 commit 61d6550

File tree

9 files changed

+196
-34
lines changed

9 files changed

+196
-34
lines changed

packages/app/src/cli/commands/app/execute.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export default class Execute extends AppLinkedCommand {
3939
storeFqdn: store.shopDomain,
4040
query: flags.query,
4141
variables: flags.variables,
42+
variableFile: flags['variable-file'],
4243
})
4344

4445
return {app: appContextResult.app}

packages/app/src/cli/flags.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ export const bulkOperationFlags = {
4848
'The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.',
4949
env: 'SHOPIFY_FLAG_VARIABLES',
5050
multiple: true,
51+
exclusive: ['variable-file'],
52+
}),
53+
'variable-file': Flags.string({
54+
description:
55+
"Path to a file containing GraphQL variables in JSONL format (one JSON object per line). Can't be used with --variables.",
56+
env: 'SHOPIFY_FLAG_VARIABLE_FILE',
57+
parse: async (input) => resolvePath(input),
58+
exclusive: ['variables'],
5159
}),
5260
store: Flags.string({
5361
char: 's',

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

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {runBulkOperationMutation} from './run-mutation.js'
44
import {AppLinkedInterface} from '../../models/app/app.js'
55
import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
66
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
7+
import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
8+
import {joinPath} from '@shopify/cli-kit/node/path'
79
import {describe, test, expect, vi, beforeEach} from 'vitest'
810

911
vi.mock('./run-query.js')
@@ -50,7 +52,6 @@ describe('executeBulkOperation', () => {
5052
expect(runBulkOperationQuery).toHaveBeenCalledWith({
5153
adminSession: mockAdminSession,
5254
query,
53-
variables: undefined,
5455
})
5556
expect(runBulkOperationMutation).not.toHaveBeenCalled()
5657
})
@@ -72,7 +73,6 @@ describe('executeBulkOperation', () => {
7273
expect(runBulkOperationQuery).toHaveBeenCalledWith({
7374
adminSession: mockAdminSession,
7475
query,
75-
variables: undefined,
7676
})
7777
expect(runBulkOperationMutation).not.toHaveBeenCalled()
7878
})
@@ -94,7 +94,7 @@ describe('executeBulkOperation', () => {
9494
expect(runBulkOperationMutation).toHaveBeenCalledWith({
9595
adminSession: mockAdminSession,
9696
query: mutation,
97-
variables: undefined,
97+
variablesJsonl: undefined,
9898
})
9999
expect(runBulkOperationQuery).not.toHaveBeenCalled()
100100
})
@@ -118,7 +118,7 @@ describe('executeBulkOperation', () => {
118118
expect(runBulkOperationMutation).toHaveBeenCalledWith({
119119
adminSession: mockAdminSession,
120120
query: mutation,
121-
variables,
121+
variablesJsonl: '{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}',
122122
})
123123
})
124124

@@ -216,4 +216,118 @@ describe('executeBulkOperation', () => {
216216
expect(runBulkOperationQuery).not.toHaveBeenCalled()
217217
expect(runBulkOperationMutation).not.toHaveBeenCalled()
218218
})
219+
220+
test('reads variables from file when variableFile is provided', async () => {
221+
await inTemporaryDirectory(async (tmpDir) => {
222+
const variableFilePath = joinPath(tmpDir, 'variables.jsonl')
223+
const variables = [
224+
'{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}',
225+
'{"input":{"id":"gid://shopify/Product/456","tags":["test2"]}}',
226+
]
227+
await writeFile(variableFilePath, variables.join('\n'))
228+
229+
const mutation =
230+
'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }'
231+
const mockResponse = {
232+
bulkOperation: successfulBulkOperation,
233+
userErrors: [],
234+
}
235+
vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any)
236+
237+
await executeBulkOperation({
238+
app: mockApp,
239+
storeFqdn,
240+
query: mutation,
241+
variableFile: variableFilePath,
242+
})
243+
244+
expect(runBulkOperationMutation).toHaveBeenCalledWith({
245+
adminSession: mockAdminSession,
246+
query: mutation,
247+
variablesJsonl: variables.join('\n'),
248+
})
249+
})
250+
})
251+
252+
test('throws error when both variables and variableFile are provided', async () => {
253+
await inTemporaryDirectory(async (tmpDir) => {
254+
const variableFilePath = joinPath(tmpDir, 'variables.jsonl')
255+
await writeFile(variableFilePath, '{"input":{}}')
256+
257+
const mutation =
258+
'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }'
259+
const variables = ['{"input":{"id":"gid://shopify/Product/123"}}']
260+
261+
await expect(
262+
executeBulkOperation({
263+
app: mockApp,
264+
storeFqdn,
265+
query: mutation,
266+
variables,
267+
variableFile: variableFilePath,
268+
}),
269+
).rejects.toThrow(/Can't use both.*--variables.*and.*--variable-file/)
270+
271+
expect(runBulkOperationQuery).not.toHaveBeenCalled()
272+
expect(runBulkOperationMutation).not.toHaveBeenCalled()
273+
})
274+
})
275+
276+
test('throws error when variableFile does not exist', async () => {
277+
await inTemporaryDirectory(async (tmpDir) => {
278+
const nonExistentPath = joinPath(tmpDir, 'nonexistent.jsonl')
279+
const mutation =
280+
'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }'
281+
282+
await expect(
283+
executeBulkOperation({
284+
app: mockApp,
285+
storeFqdn,
286+
query: mutation,
287+
variableFile: nonExistentPath,
288+
}),
289+
).rejects.toThrow('Variable file not found')
290+
291+
expect(runBulkOperationQuery).not.toHaveBeenCalled()
292+
expect(runBulkOperationMutation).not.toHaveBeenCalled()
293+
})
294+
})
295+
296+
test('throws error when variables are provided with a query (not mutation)', async () => {
297+
const query = 'query { products { edges { node { id } } } }'
298+
const variables = ['{"input":{"id":"gid://shopify/Product/123"}}']
299+
300+
await expect(
301+
executeBulkOperation({
302+
app: mockApp,
303+
storeFqdn,
304+
query,
305+
variables,
306+
}),
307+
).rejects.toThrow('can only be used with mutations, not queries')
308+
309+
expect(runBulkOperationQuery).not.toHaveBeenCalled()
310+
expect(runBulkOperationMutation).not.toHaveBeenCalled()
311+
})
312+
313+
test('throws error when variableFile is provided with a query (not mutation)', async () => {
314+
await inTemporaryDirectory(async (tmpDir) => {
315+
const variableFilePath = joinPath(tmpDir, 'variables.jsonl')
316+
await writeFile(variableFilePath, '{"input":{}}')
317+
318+
const query = 'query { products { edges { node { id } } } }'
319+
320+
await expect(
321+
executeBulkOperation({
322+
app: mockApp,
323+
storeFqdn,
324+
query,
325+
variableFile: variableFilePath,
326+
}),
327+
).rejects.toThrow('can only be used with mutations, not queries')
328+
329+
expect(runBulkOperationQuery).not.toHaveBeenCalled()
330+
expect(runBulkOperationMutation).not.toHaveBeenCalled()
331+
})
332+
})
219333
})

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

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,58 @@ import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
66
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
77
import {AbortError} from '@shopify/cli-kit/node/error'
88
import {parse} from 'graphql'
9+
import {readFile, fileExists} from '@shopify/cli-kit/node/fs'
910

1011
interface ExecuteBulkOperationInput {
1112
app: AppLinkedInterface
1213
storeFqdn: string
1314
query: string
1415
variables?: string[]
16+
variableFile?: string
17+
}
18+
19+
async function parseVariablesToJsonl(variables?: string[], variableFile?: string): Promise<string | undefined> {
20+
if (variables && variableFile) {
21+
throw new AbortError(
22+
outputContent`Can't use both ${outputToken.yellow('--variables')} and ${outputToken.yellow(
23+
'--variable-file',
24+
)} flags. Please use only one.`,
25+
)
26+
}
27+
28+
if (!variables && !variableFile) {
29+
return undefined
30+
}
31+
32+
if (variables) {
33+
return variables.join('\n')
34+
}
35+
36+
if (!(await fileExists(variableFile!))) {
37+
throw new AbortError(
38+
outputContent`Variable file not found at ${outputToken.path(
39+
variableFile!,
40+
)}. Please check the path and try again.`,
41+
)
42+
}
43+
44+
return readFile(variableFile!, {encoding: 'utf8'})
1545
}
1646

1747
export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise<void> {
18-
const {app, storeFqdn, query, variables} = input
48+
const {app, storeFqdn, query, variables, variableFile} = input
1949

2050
renderInfo({
2151
headline: 'Starting bulk operation.',
2252
body: `App: ${app.name}\nStore: ${storeFqdn}`,
2353
})
24-
2554
const adminSession = await ensureAuthenticatedAdmin(storeFqdn)
2655

27-
const operationIsMutation = validateGraphQLDocument(query, variables)
56+
const variablesJsonl = await parseVariablesToJsonl(variables, variableFile)
57+
const operationIsMutation = validateGraphQLDocument(query, variablesJsonl)
2858

2959
const bulkOperationResponse = operationIsMutation
30-
? await runBulkOperationMutation({adminSession, query, variables})
60+
? await runBulkOperationMutation({adminSession, query, variablesJsonl})
3161
: await runBulkOperationQuery({adminSession, query})
3262

3363
if (bulkOperationResponse?.userErrors?.length) {
@@ -72,7 +102,7 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
72102
}
73103
}
74104

75-
function validateGraphQLDocument(graphqlOperation: string, variables?: string[]): boolean {
105+
function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: string): boolean {
76106
const document = parse(graphqlOperation)
77107
const operationDefinitions = document.definitions.filter((def) => def.kind === 'OperationDefinition')
78108

@@ -85,9 +115,11 @@ function validateGraphQLDocument(graphqlOperation: string, variables?: string[])
85115
const operation = operationDefinitions[0]
86116
const operationIsMutation = operation?.kind === 'OperationDefinition' && operation.operation === 'mutation'
87117

88-
if (!operationIsMutation && variables) {
118+
if (!operationIsMutation && variablesJsonl) {
89119
throw new AbortError(
90-
outputContent`The ${outputToken.yellow('--variables')} flag can only be used with mutations, not queries.`,
120+
outputContent`The ${outputToken.yellow('--variables')} and ${outputToken.yellow(
121+
'--variable-file',
122+
)} flags can only be used with mutations, not queries.`,
91123
)
92124
}
93125

packages/app/src/cli/services/bulk-operations/run-mutation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe('runBulkOperationMutation', () => {
3434
const bulkOperationResult = await runBulkOperationMutation({
3535
adminSession: mockSession,
3636
query: 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }',
37-
variables: ['{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}'],
37+
variablesJsonl: '{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}',
3838
})
3939

4040
expect(bulkOperationResult?.bulkOperation).toEqual(successfulBulkOperation)

packages/app/src/cli/services/bulk-operations/run-mutation.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,17 @@ import {AdminSession} from '@shopify/cli-kit/node/session'
1010
interface BulkOperationRunMutationOptions {
1111
adminSession: AdminSession
1212
query: string
13-
variables?: string[]
13+
variablesJsonl?: string
1414
}
1515

1616
export async function runBulkOperationMutation(
1717
options: BulkOperationRunMutationOptions,
1818
): Promise<BulkOperationRunMutationMutation['bulkOperationRunMutation']> {
19-
const {adminSession, query: mutation, variables} = options
19+
const {adminSession, query: mutation, variablesJsonl} = options
2020

2121
const stagedUploadPath = await stageFile({
2222
adminSession,
23-
jsonVariables: variables,
23+
variablesJsonl,
2424
})
2525

2626
const response = await adminRequestDoc<BulkOperationRunMutationMutation, BulkOperationRunMutationMutationVariables>({

packages/app/src/cli/services/bulk-operations/stage-file.test.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,55 +44,53 @@ describe('stageFile', () => {
4444
} as any)
4545
})
4646

47-
test('returns staged upload key when file is successfully staged with empty variables', async () => {
47+
test('returns staged upload key when file is successfully staged with no variables', async () => {
4848
vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse)
4949

5050
const result = await stageFile({
5151
adminSession: mockSession,
52-
jsonVariables: [],
52+
variablesJsonl: undefined,
5353
})
5454

5555
expect(result).toBe('test-key')
5656
})
5757

58-
test('converts JSON strings array to JSONL format when uploading file', async () => {
58+
test('converts JSONL string to buffer when uploading file', async () => {
5959
vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse)
6060
const mockAppend = vi.fn()
6161
vi.mocked(formData).mockReturnValue({append: mockAppend} as any)
6262

63-
const jsonVariables = ['{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}']
63+
const variablesJsonl = '{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}'
6464

6565
await stageFile({
6666
adminSession: mockSession,
67-
jsonVariables,
67+
variablesJsonl,
6868
})
6969

70-
// Find the form.append('file', buffer, options) call among all append calls
7170
const fileAppendCall = mockAppend.mock.calls.find((call) => {
7271
const fieldName = call[0]
7372
return fieldName === 'file'
7473
})
75-
// Extract the buffer (second argument) that was uploaded
7674
const uploadedBuffer = fileAppendCall?.[1]
7775
const uploadedContent = uploadedBuffer?.toString('utf-8')
7876

7977
expect(uploadedContent).toBe('{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}\n')
8078
})
8179

82-
test('handles multiple JSON variable strings correctly', async () => {
80+
test('handles JSONL with multiple lines correctly', async () => {
8381
vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse)
8482
const mockAppend = vi.fn()
8583
vi.mocked(formData).mockReturnValue({append: mockAppend} as any)
8684

87-
const jsonVariables = [
85+
const variablesJsonl = [
8886
'{"input":{"id":"gid://shopify/Product/1","title":"New Shirt"}}',
8987
'{"input":{"id":"gid://shopify/Product/2","title":"Cool Pants"}}',
9088
'{"input":{"id":"gid://shopify/Product/3","title":"Nice Hat"}}',
91-
]
89+
].join('\n')
9290

9391
await stageFile({
9492
adminSession: mockSession,
95-
jsonVariables,
93+
variablesJsonl,
9694
})
9795

9896
const fileAppendCall = mockAppend.mock.calls.find((call) => {

packages/app/src/cli/services/bulk-operations/stage-file.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ import {AbortError} from '@shopify/cli-kit/node/error'
1010

1111
interface StageFileOptions {
1212
adminSession: AdminSession
13-
jsonVariables?: string[]
13+
variablesJsonl?: string
1414
}
1515

1616
export async function stageFile(options: StageFileOptions): Promise<string> {
17-
const {adminSession, jsonVariables = []} = options
17+
const {adminSession, variablesJsonl} = options
1818

19-
const buffer = convertJsonToJsonlBuffer(jsonVariables)
19+
const buffer = Buffer.from(variablesJsonl ? `${variablesJsonl}\n` : '', 'utf-8')
2020
const filename = 'bulk-variables.jsonl'
2121
const size = buffer.length
2222

@@ -28,11 +28,6 @@ export async function stageFile(options: StageFileOptions): Promise<string> {
2828
return target.stagedUploadKey
2929
}
3030

31-
function convertJsonToJsonlBuffer(jsonVariables: string[]): Buffer {
32-
const jsonlContent = `${jsonVariables.join('\n')}\n`
33-
return Buffer.from(jsonlContent, 'utf-8')
34-
}
35-
3631
async function requestStagedUpload(
3732
adminSession: AdminSession,
3833
filename: string,

0 commit comments

Comments
 (0)