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
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,6 @@ export const BulkOperationRunQuery = {
name: {kind: 'Name', value: 'query'},
value: {kind: 'Variable', name: {kind: 'Name', value: 'query'}},
},
{
kind: 'Argument',
name: {kind: 'Name', value: 'groupObjects'},
value: {kind: 'BooleanValue', value: false},
},
],
selectionSet: {
kind: 'SelectionSet',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
mutation BulkOperationRunQuery($query: String!) {
bulkOperationRunQuery(
query: $query
# Set to false to optimize for speed over grouped results
groupObjects: false
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing this argument so that this executable document will be compatible with all supported API versions. (API versions before 2025-07 do not support groupObjects.)

More info here.

) {
bulkOperation {
completedAt
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/cli/commands/app/bulk/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default class BulkExecute extends AppLinkedCommand {
variableFile: flags['variable-file'],
watch: flags.watch,
outputFile: flags['output-file'],
...(flags.version && {version: flags.version}),
})

return {app: appContextResult.app}
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/cli/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,8 @@ export const bulkOperationFlags = {
description: 'The file path where results should be written. If not specified, results will be written to STDOUT.',
env: 'SHOPIFY_FLAG_OUTPUT_FILE',
}),
version: Flags.string({
description: 'The API version to use for the bulk operation. If not specified, uses the latest stable version.',
env: 'SHOPIFY_FLAG_VERSION',
}),
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ vi.mock('@shopify/cli-kit/node/session', async () => {
ensureAuthenticatedAdminAsApp: vi.fn(),
}
})
vi.mock('@shopify/cli-kit/node/api/admin', async () => {
const actual = await vi.importActual('@shopify/cli-kit/node/api/admin')
return {
...actual,
supportedApiVersions: vi.fn(() => Promise.resolve(['2025-01', '2025-04', '2025-07', '2025-10'])),
}
})

describe('executeBulkOperation', () => {
const mockRemoteApp = {
Expand Down Expand Up @@ -512,4 +519,63 @@ describe('executeBulkOperation', () => {

expect(renderSuccess).not.toHaveBeenCalled()
})

test('allows executing bulk operations against unstable', async () => {
const query = '{ products { edges { node { id } } } }'
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)

await executeBulkOperation({
remoteApp: mockRemoteApp,
storeFqdn,
query,
version: 'unstable',
})

expect(runBulkOperationQuery).toHaveBeenCalledWith({
adminSession: mockAdminSession,
query,
version: 'unstable',
})
})

test('allows executing bulk operations against a specific stable version', async () => {
const query = '{ products { edges { node { id } } } }'
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)

await executeBulkOperation({
remoteApp: mockRemoteApp,
storeFqdn,
query,
version: '2025-01',
})

expect(runBulkOperationQuery).toHaveBeenCalledWith({
adminSession: mockAdminSession,
query,
version: '2025-01',
})
})

test('throws error when an API version is specified but is not supported', async () => {
const query = '{ products { edges { node { id } } } }'

await expect(
executeBulkOperation({
remoteApp: mockRemoteApp,
storeFqdn,
query,
version: '2099-12',
}),
).rejects.toThrow('Invalid API version')

expect(runBulkOperationQuery).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {OrganizationApp} from '../../models/organization.js'
import {renderSuccess, renderInfo, renderError, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui'
import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output'
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
import {supportedApiVersions} from '@shopify/cli-kit/node/api/admin'
import {AbortError, BugError} from '@shopify/cli-kit/node/error'
import {AbortController} from '@shopify/cli-kit/node/abort'
import {parse} from 'graphql'
Expand All @@ -20,6 +21,7 @@ interface ExecuteBulkOperationInput {
variableFile?: string
watch?: boolean
outputFile?: string
version?: string
}

async function parseVariablesToJsonl(variables?: string[], variableFile?: string): Promise<string | undefined> {
Expand All @@ -40,25 +42,37 @@ async function parseVariablesToJsonl(variables?: string[], variableFile?: string
}

export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise<void> {
const {remoteApp, storeFqdn, query, variables, variableFile, outputFile, watch = false} = input

renderInfo({
headline: 'Starting bulk operation.',
body: `App: ${remoteApp.title}\nStore: ${storeFqdn}`,
})
const {remoteApp, storeFqdn, query, variables, variableFile, outputFile, watch = false, version} = input

const appSecret = remoteApp.apiSecretKeys[0]?.secret
if (!appSecret) throw new BugError('No API secret keys found for app')

const adminSession = await ensureAuthenticatedAdminAsApp(storeFqdn, remoteApp.apiKey, appSecret)

if (version) await validateApiVersion(adminSession, version)

const variablesJsonl = await parseVariablesToJsonl(variables, variableFile)

validateGraphQLDocument(query, variablesJsonl)

renderInfo({
headline: 'Starting bulk operation.',
body: [
{
list: {
items: [
`App: ${remoteApp.title}`,
`Store: ${storeFqdn}`,
`API version: ${version || 'default (latest stable)'}`,
],
},
},
],
})

const bulkOperationResponse = isMutation(query)
? await runBulkOperationMutation({adminSession, query, variablesJsonl})
: await runBulkOperationQuery({adminSession, query})
? await runBulkOperationMutation({adminSession, query, variablesJsonl, version})
: await runBulkOperationQuery({adminSession, query, version})

if (bulkOperationResponse?.userErrors?.length) {
const errorMessages = bulkOperationResponse.userErrors
Expand Down Expand Up @@ -172,3 +186,15 @@ function isMutation(graphqlOperation: string): boolean {
const operation = document.definitions.find((def) => def.kind === 'OperationDefinition')
return operation?.kind === 'OperationDefinition' && operation.operation === 'mutation'
}

async function validateApiVersion(adminSession: {token: string; storeFqdn: string}, version: string): Promise<void> {
if (version === 'unstable') return

const supportedVersions = await supportedApiVersions(adminSession)
if (supportedVersions.includes(version)) return

const firstLine = outputContent`Invalid API version: ${version}`.value
const secondLine = outputContent`Supported versions: ${supportedVersions.join(', ')}`.value

throw new AbortError(`${firstLine}\n${secondLine}`)
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,17 @@ describe('runBulkOperationMutation', () => {
expect(bulkOperationResult?.bulkOperation).toEqual(successfulBulkOperation)
expect(bulkOperationResult?.userErrors).toEqual([])
})

test('starts bulk mutation with specific API version when provided', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse)

await runBulkOperationMutation({
adminSession: mockSession,
query: 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }',
variablesJsonl: '{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}',
version: '2025-01',
})

expect(adminRequestDoc).toHaveBeenCalledWith(expect.objectContaining({version: '2025-01'}))
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ interface BulkOperationRunMutationOptions {
adminSession: AdminSession
query: string
variablesJsonl?: string
version?: string
}

export async function runBulkOperationMutation(
options: BulkOperationRunMutationOptions,
): Promise<BulkOperationRunMutationMutation['bulkOperationRunMutation']> {
const {adminSession, query: mutation, variablesJsonl} = options
const {adminSession, query: mutation, variablesJsonl, version} = options

const stagedUploadPath = await stageFile({
adminSession,
Expand All @@ -30,6 +31,7 @@ export async function runBulkOperationMutation(
mutation,
stagedUploadPath,
},
...(version && {version}),
})

return response.bulkOperationRunMutation
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src/cli/services/bulk-operations/run-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,16 @@ describe('runBulkOperationQuery', () => {
expect(bulkOperationResult?.bulkOperation).toEqual(successfulBulkOperation)
expect(bulkOperationResult?.userErrors).toEqual([])
})

test('starts bulk query with specific API version when provided', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse)

await runBulkOperationQuery({
adminSession: mockSession,
query: 'query { products { edges { node { id } } } }',
version: '2025-01',
})

expect(adminRequestDoc).toHaveBeenCalledWith(expect.objectContaining({version: '2025-01'}))
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import {AdminSession} from '@shopify/cli-kit/node/session'
interface BulkOperationRunQueryOptions {
adminSession: AdminSession
query: string
version?: string
}

export async function runBulkOperationQuery(
options: BulkOperationRunQueryOptions,
): Promise<BulkOperationRunQueryMutation['bulkOperationRunQuery']> {
const {adminSession, query} = options
const {adminSession, query, version} = options

const response = await adminRequestDoc<BulkOperationRunQueryMutation, {query: string}>({
query: BulkOperationRunQuery,
session: adminSession,
variables: {query},
...(version && {version}),
})

return response.bulkOperationRunQuery
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,14 @@
"name": "verbose",
"type": "boolean"
},
"version": {
"description": "The API version to use for the bulk operation. If not specified, uses the latest stable version.",
"env": "SHOPIFY_FLAG_VERSION",
"hasDynamicHelp": false,
"multiple": false,
"name": "version",
"type": "option"
},
"watch": {
"allowNo": false,
"description": "Wait for bulk operation results before exiting.",
Expand Down