Skip to content

Commit 3b0cac9

Browse files
committed
fix(azure-data-explorer): handle commas inside quoted properties and empty extent IDs
Two defects in the shared command helpers: buildWithClause split the property list on every comma before validating, so a value that legally contains one — a docstring sentence, or a tags array with more than one entry — was torn in half and rejected. Splitting is now quote-aware, and an unterminated quote is rejected outright rather than swallowing the rest of the clause. transformColumnListResponse dropped empty strings, but `.ingest inline` reports "no data shards were generated" as a single record carrying an empty extent ID. A no-op load therefore looked like a missing column instead of an empty result. Only non-strings are skipped now.
1 parent de7d283 commit 3b0cac9

5 files changed

Lines changed: 116 additions & 5 deletions

File tree

apps/docs/content/docs/en/integrations/azure_data_explorer.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ Push rows directly into an Azure Data Explorer table with .ingest inline. Data i
329329
| `rowCount` | number | Rows carried in this result, after the row cap |
330330
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
331331
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
332-
| `extentIds` | array | Extent IDs created by the ingestion. A single empty ID means no data shard was generated |
332+
| `extentIds` | array | Extent IDs created by the ingestion — one per data shard. A single empty or zero-valued ID means no data shard was generated |
333333

334334
### Azure Data Explorer Ingest From Query
335335

apps/sim/tools/azure_data_explorer/ingest_inline.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export const azureDataExplorerIngestInlineTool: ToolConfig<
134134
extentIds: {
135135
type: 'array',
136136
description:
137-
'Extent IDs created by the ingestion. A single empty ID means no data shard was generated',
137+
'Extent IDs created by the ingestion — one per data shard. A single empty or zero-valued ID means no data shard was generated',
138138
items: { type: 'string' },
139139
},
140140
},

apps/sim/tools/azure_data_explorer/utils.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
renderEntityName,
99
renderIngestMode,
1010
renderOperationId,
11+
transformColumnListResponse,
1112
} from '@/tools/azure_data_explorer/utils'
1213

1314
describe('renderEntityName', () => {
@@ -55,6 +56,27 @@ describe('buildWithClause', () => {
5556
)
5657
})
5758

59+
it('keeps a comma that sits inside a quoted value', () => {
60+
expect(buildWithClause('docstring="Raw logs, archived nightly"', 'format="json"')).toBe(
61+
' with (docstring="Raw logs, archived nightly")'
62+
)
63+
expect(buildWithClause(`tags="['daily','prod']"`, 'format="json"')).toBe(
64+
` with (tags="['daily','prod']")`
65+
)
66+
})
67+
68+
it('still separates properties on the commas between them', () => {
69+
expect(
70+
buildWithClause('docstring="Logs, raw", folder="Ingest", distributed=true', 'format="json"')
71+
).toBe(' with (docstring="Logs, raw", folder="Ingest", distributed=true)')
72+
})
73+
74+
it('rejects an unterminated quote rather than swallowing the rest of the clause', () => {
75+
expect(() => buildWithClause('docstring="never closed', 'format="json"')).toThrow(
76+
/Unterminated " quote/
77+
)
78+
})
79+
5880
it('rejects a value that would close the clause and extend the command', () => {
5981
expect(() => buildWithClause('format="json") <| evil', 'format="json"')).toThrow(
6082
/Invalid property/
@@ -130,3 +152,49 @@ describe('renderOperationId', () => {
130152
expect(() => renderOperationId('abc") | drop table X //')).toThrow(/Invalid operation ID/)
131153
})
132154
})
155+
156+
/** Minimal stand-in for the proxy's JSON envelope. */
157+
function proxyResponse(records: Array<Record<string, unknown>>): Response {
158+
return {
159+
ok: true,
160+
status: 200,
161+
json: async () => ({
162+
success: true,
163+
output: {
164+
tableName: 'Table_0',
165+
columns: [],
166+
rows: [],
167+
records,
168+
rowCount: records.length,
169+
totalRowCount: records.length,
170+
truncated: false,
171+
},
172+
}),
173+
} as unknown as Response
174+
}
175+
176+
describe('transformColumnListResponse', () => {
177+
it('collects the values of the named column', async () => {
178+
const transform = transformColumnListResponse('TableName', 'tables')
179+
const result = await transform(
180+
proxyResponse([{ TableName: 'StormEvents' }, { TableName: 'Logs' }])
181+
)
182+
183+
expect(result.output.tables).toEqual(['StormEvents', 'Logs'])
184+
})
185+
186+
it("keeps an empty extent ID, which is how Kusto reports 'no data shard was written'", async () => {
187+
const transform = transformColumnListResponse('ExtentId', 'extentIds')
188+
const result = await transform(proxyResponse([{ ExtentId: '' }]))
189+
190+
expect(result.output.extentIds).toEqual([''])
191+
expect(result.output.rowCount).toBe(1)
192+
})
193+
194+
it('skips a null value rather than coercing it to a string', async () => {
195+
const transform = transformColumnListResponse('TableName', 'tables')
196+
const result = await transform(proxyResponse([{ TableName: null }, { TableName: 'Logs' }]))
197+
198+
expect(result.output.tables).toEqual(['Logs'])
199+
})
200+
})

apps/sim/tools/azure_data_explorer/utils.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,44 @@ export function renderEntityName(name: string): string {
3636
/** One `name = value` command property, with a quoted, numeric, or bare value. */
3737
const COMMAND_PROPERTY = /^[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:"[^"\\]*"|'[^'\\]*'|[A-Za-z0-9_.:+-]+)$/
3838

39+
/**
40+
* Splits a property list on the commas that separate properties, ignoring the
41+
* ones inside a quoted value.
42+
*
43+
* A plain `split(',')` would break the documented cases where a value legally
44+
* contains a comma — a `docstring` sentence, or a `tags` array with more than
45+
* one entry.
46+
*/
47+
function splitProperties(input: string): string[] {
48+
const parts: string[] = []
49+
let current = ''
50+
let quote: '"' | "'" | null = null
51+
52+
for (const char of input) {
53+
if (quote) {
54+
current += char
55+
if (char === quote) quote = null
56+
continue
57+
}
58+
if (char === '"' || char === "'") {
59+
quote = char
60+
current += char
61+
continue
62+
}
63+
if (char === ',') {
64+
parts.push(current)
65+
current = ''
66+
continue
67+
}
68+
current += char
69+
}
70+
71+
if (quote) throw new Error(`Unterminated ${quote} quote in property list: ${input}`)
72+
parts.push(current)
73+
74+
return parts.map((part) => part.trim()).filter(Boolean)
75+
}
76+
3977
/**
4078
* Builds the `with (...)` clause shared by the create and ingest commands.
4179
*
@@ -53,7 +91,7 @@ export function buildWithClause(input: string | undefined, example: string): str
5391
.trim()
5492
if (!inner) return ''
5593

56-
const properties = inner.split(',').map((property) => property.trim())
94+
const properties = splitProperties(inner)
5795
for (const property of properties) {
5896
if (!COMMAND_PROPERTY.test(property)) {
5997
throw new Error(
@@ -207,13 +245,18 @@ export async function transformAzureDataExplorerResponse(response: Response) {
207245
/**
208246
* Adds a flat list of the strings in one documented column, so a `.show` or
209247
* `.ingest` command surfaces its identifiers alongside the full result table.
248+
*
249+
* Every string value is kept, including an empty one: `.ingest inline` reports
250+
* "no data shards were generated" as a single record carrying an empty
251+
* (zero-valued) extent ID, so dropping it would turn a no-op load into what
252+
* looks like a missing column.
210253
*/
211254
export function transformColumnListResponse<K extends string>(columnName: string, outputKey: K) {
212255
return async (response: Response) => {
213256
const output = await readProxyEnvelope(response)
214257
const values = output.records
215258
.map((record) => record[columnName])
216-
.filter((value): value is string => typeof value === 'string' && value.length > 0)
259+
.filter((value): value is string => typeof value === 'string')
217260
return {
218261
success: true as const,
219262
output: { ...output, [outputKey]: values } as AzureDataExplorerTable & Record<K, string[]>,

apps/sim/tools/generated/tool-outputs.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)