Summary
sync pull → sync push roundtrip collapses parameters.blocks[].codes[].script from N elements to 1 whenever the original config has more than one element per code block. KBC API accepts the flattened config silently; runtime fails at job execution because each script[] element is supposed to map 1:1 to a separate executable unit.
Not specific to any particular SQL shape — triggers for any code containing multiple script elements (CREATE + INSERT + UPDATE, TRUNCATE + MERGE, multiple Python blocks, etc.).
Impact
- SQL transformations (Snowflake / BigQuery / Redshift / Synapse): runtime error
Actual statement count N did not match the desired statement count 1, SQL state 0A000 — driver default MULTI_STATEMENT_COUNT=1.
- Python / R transformations: named sub-blocks merge into one element; runtime may still succeed but UI structure is lost.
- No warning at push time. Failure only at job execution.
sync push is effectively unusable as the bulk-edit path for any transformation with multi-statement code blocks.
Reproduction (~2 minutes)
Needs any transformation with len(script) > 1 in at least one code block.
# 1. Create test branch
kbagent --json branch create --project {alias} --name "repro"
# → e.g. branch_id=466659
# 2. Verify starting state
kbagent --json config detail --project {alias} --branch 466659 \
--component-id keboola.snowflake-transformation --config-id {id} \
| jq '.data.configuration.parameters.blocks[0].codes[0].script | length'
# → 6
# 3. Clean sync pull
mkdir /tmp/repro && cd /tmp/repro && git init -q && git commit --allow-empty -m init -q
kbagent sync init --project {alias} --directory /tmp/repro --git-branching
kbagent sync branch-link --project {alias} --branch-id 466659 --directory /tmp/repro
kbagent sync pull --project {alias} --directory /tmp/repro
# 4. Any edit (or none — even no-op push reproduces)
echo "-- test" >> /tmp/repro/{branch}/transformation/{component}/{config}/transform.sql
# 5. Push
kbagent sync push --project {alias} --directory /tmp/repro
# 6. Array has collapsed
kbagent --json config detail --project {alias} --branch 466659 \
--component-id keboola.snowflake-transformation --config-id {id} \
| jq '.data.configuration.parameters.blocks[0].codes[0].script | length'
# → 1 ← BUG
# 7. Run job → error
kbagent --json tool call run_job --project {alias} --branch 466659 \
--input '{"component_id":"keboola.snowflake-transformation","configuration_id":"{id}"}'
Reproduced on real production config (6 CTAS in one code block). Full error text, job ID, and KBC UI diff screenshot available on request.
Root cause
src/keboola_agent_cli/sync/code_extraction.py
_extract_sql_transformation (pull side, lines 139–145)
scripts = code.get("script") or []
for script in scripts: # N elements in
if isinstance(script, str) and "\n" in script:
lines.extend(script.split("\n"))
else:
lines.append(script)
lines.append("") # blank line AFTER loop, not BETWEEN elements
Loop appends content of each script element directly back-to-back. Element boundaries are not recorded anywhere in the output file.
_lines_to_script (helper, lines 21–32)
def _lines_to_script(lines: list[str]) -> list[str]:
"""The Keboola transformation runner treats each element of the ``script``
array as a separate executable statement. Joining all lines of a CODE
block into one string ensures multi-line SQL/Python is executed as a
single unit.
"""
stripped = _strip_trailing_empty(lines)
if not stripped:
return []
return ["\n".join(stripped)] # hardcoded: always returns exactly 1 element
Docstring explicitly acknowledges each script[] element is a separate executable unit, yet the function always returns a single-element list.
_parse_sql_blocks (push parser, lines 173–227)
Tracks only BLOCK: and CODE: markers. No concept of script-level boundaries. All lines under one CODE marker flow into a flat list and collapse through _lines_to_script.
Missing test case
tests/test_sync_code_extraction.py::test_sql_round_trip covers only script = [<single string>]. A multi-element case exposes the bug:
def test_sql_roundtrip_multi_script():
original = {
"parameters": {"blocks": [{"name": "Block 1", "codes": [{
"name": "code",
"script": [
"CREATE OR REPLACE TABLE a AS SELECT 1;",
"INSERT INTO a VALUES (2);",
"UPDATE a SET x = 3 WHERE x = 2;",
],
}]}]},
}
with tempfile.TemporaryDirectory() as tmp:
_extract_sql_transformation(original, Path(tmp))
restored = _merge_sql_transformation({}, Path(tmp))
scripts = restored["parameters"]["blocks"][0]["codes"][0]["script"]
assert len(scripts) == 3 # currently returns 1
Environment
- kbagent CLI: v0.17.4
- keboola-mcp-server: v1.49.1
- Affected components (anything with
parameters.blocks[].codes[].script[] shape):
keboola.snowflake-transformation
keboola.bigquery-transformation
keboola.redshift-transformation
keboola.synapse-transformation
keboola.python-transformation-v2
keboola.r-transformation
Workaround
Use update_sql_transformation MCP tool with str_replace — patches individual script elements via JSONPath and preserves array structure.
Summary
sync pull→sync pushroundtrip collapsesparameters.blocks[].codes[].scriptfrom N elements to 1 whenever the original config has more than one element per code block. KBC API accepts the flattened config silently; runtime fails at job execution because eachscript[]element is supposed to map 1:1 to a separate executable unit.Not specific to any particular SQL shape — triggers for any
codecontaining multiple script elements (CREATE + INSERT + UPDATE, TRUNCATE + MERGE, multiple Python blocks, etc.).Impact
Actual statement count N did not match the desired statement count 1, SQL state0A000— driver defaultMULTI_STATEMENT_COUNT=1.sync pushis effectively unusable as the bulk-edit path for any transformation with multi-statement code blocks.Reproduction (~2 minutes)
Needs any transformation with
len(script) > 1in at least one code block.Reproduced on real production config (6 CTAS in one code block). Full error text, job ID, and KBC UI diff screenshot available on request.
Root cause
src/keboola_agent_cli/sync/code_extraction.py_extract_sql_transformation(pull side, lines 139–145)Loop appends content of each script element directly back-to-back. Element boundaries are not recorded anywhere in the output file.
_lines_to_script(helper, lines 21–32)Docstring explicitly acknowledges each
script[]element is a separate executable unit, yet the function always returns a single-element list._parse_sql_blocks(push parser, lines 173–227)Tracks only
BLOCK:andCODE:markers. No concept of script-level boundaries. All lines under one CODE marker flow into a flat list and collapse through_lines_to_script.Missing test case
tests/test_sync_code_extraction.py::test_sql_round_tripcovers onlyscript = [<single string>]. A multi-element case exposes the bug:Environment
parameters.blocks[].codes[].script[]shape):keboola.snowflake-transformationkeboola.bigquery-transformationkeboola.redshift-transformationkeboola.synapse-transformationkeboola.python-transformation-v2keboola.r-transformationWorkaround
Use
update_sql_transformationMCP tool withstr_replace— patches individual script elements via JSONPath and preserves array structure.