Visitar URL original
fix: don't break the JSON export on a single quote in the path by tconbeer · Pull Request #1146 · tconbeer/harlequin · GitHub
Skip to content

fix: don't break the JSON export on a single quote in the path - #1146

Merged
tconbeer merged 1 commit into
mainfrom
claude/rebase-merge-pr-1142-buttwd
Sep 11, 2026
Merged

tconbeer merged 1 commit into
mainfrom
claude/rebase-merge-pr-1142-buttwd

Conversation

@tconbeer

Copy link
Copy Markdown
Owner

Supersedes #1142, which is @MaxFreedomPollard's work rebased onto main unchanged. The commit keeps their authorship; this PR exists only because the branch lives on their fork and could not be pushed to from here. #1142 closes when this merges.

No existing issue — the bug was found reading export.py, so the CHANGELOG entry references none.

What are the key elements of this solution?

Exporting a query to JSON failed with a duckdb Parser Error whenever the destination path held a single quote, so a folder called Bob's exports could not be exported into. json, jsonl and ndjson were affected; csv, tsv, parquet, orc and feather write into that same folder fine. The two free-text JSON options had it too: a Date Format of '%Y' came back as Catalog Error: Type with name Y does not exist!.

_export_json is the one writer that reaches duckdb as SQL text rather than through a binding, and it built the whole statement with f-strings: the destination as to '{dest_path}', and the options as DATEFORMAT '{...}' and TIMESTAMPFORMAT '{...}'. A single quote in any of the three ends the literal it sits in, and the rest of the statement no longer parses.

The statement now binds its values: copy (select * from data) to ?, with COMPRESSION ?, DATEFORMAT ? and TIMESTAMPFORMAT ? appended for the options that were set. ARRAY TRUE stays inline, since it is a fixed keyword rather than a value.

Two regression tests. TestDestinationPaths::test_a_quote_in_the_path_is_part_of_the_name writes into a Bob's exports folder for every one of the nine formats, so the assertion is that no writer is special about what a file may be called; on main the six non-JSON formats already pass it and json, jsonl and ndjson fail. TestJsonOptions::test_a_quote_in_a_format_string_is_part_of_the_format passes '%Y' and '%H:%M' and reads the quotes back out of the JSON.

Why did you design your solution this way? Did you assess any alternatives? Are there tradeoffs?

(From the original PR.) Doubling the quotes (dest_path.replace("'", "''")) fixes the same three cases, but it puts a hand-rolled SQL escaper in a module where the other four writers hand duckdb and pyarrow their arguments directly. Binding is what those writers effectively do, and it is why csv and parquet never had this.

COMPRESSION is bound as well, so no value is pasted into the statement any more; it was checked behavior-neutral first — for each of the four choices the JSON format declares (auto, gzip, zstd, uncompressed), writing to out.json and to out.json.gz gives byte-identical files whether the codec is a bare token or a bound parameter, extension detection under auto included.

The hsql side is only reachable on a machine whose temp directory holds a quote, since write_stream() writes into tempfile.TemporaryDirectory() and copies the bytes out. The Data Exporter is where a user picks the path, so that is where this bites.

Does this PR require a change to Harlequin's docs?

  • No.
  • Yes, and I have opened a PR at tconbeer/harlequin-web.
  • Yes; I haven't opened a PR, but the gist of the change is: ...

Did you add or update tests for this change?

  • Yes.
  • No, I believe tests aren't necessary.
  • No, I need help with testing this change.

Verification of the rebase, on this branch:

  • uv run pytest tests/unit_tests/test_export.py: 117 passed.
  • uv run pytest -m "not online": 1821 passed, 11 failed, 2 errors — every one of those 13 reproduces identically on unmodified main in this container (test_hsql.py, test_config_schema.py, test_config_wizard.py, test_import_hygiene.py), so none is this change's.
  • The single failure on fix: don't break the JSON export on a single quote in the path #1142's own CI run, test_ssh_recovery.py::test_a_refresh_that_reconnects_does_not_ask_for_a_second_one, is a pre-existing flake: it failed on main itself at 58e3e40 (macOS 3.11) with this change nowhere in the tree.

Please complete the following checklist:

  • I have added an entry to CHANGELOG.md, under the [Unreleased] section heading.
  • I acknowledge Harlequin's MIT license. I do not own my contribution.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EUP3KxZA5scpUDqZdozAjF


Generated by Claude Code

Exporting to JSON failed with a duckdb Parser Error whenever the
destination path held a single quote, so a folder like `Bob's exports`
could not be written into. The two free-text format options had the same
problem: a Date Format of `'%Y'` came back as a Catalog Error.

`_export_json` in src/harlequin/export.py is the only writer that reaches
duckdb as SQL text rather than through a binding, and it built the whole
statement with f-strings: `to '{dest_path}'`, `DATEFORMAT '{...}'` and
`TIMESTAMPFORMAT '{...}'`. A single quote in any of the three ends the
literal it sits in, and the rest of the statement no longer parses.

The statement now binds its values: `copy (select * from data) to ?`,
with `COMPRESSION ?`, `DATEFORMAT ?` and `TIMESTAMPFORMAT ?` appended for
the options that were set. `ARRAY TRUE` stays inline, since it is a fixed
keyword rather than a value.

Copy link
Copy Markdown
Owner Author

CI is red here on two macOS jobs, and neither failure is this PR's. Both are in tests/functional_tests/test_ssh_recovery.py, with the same signature — a Tabs race at mount, ValueError: No Tab with id …, raised inside code_editor.py's action_new_buffer — and nothing in this diff is reachable from that path. The change is confined to _export_json in src/harlequin/export.py.

The same failure is red on main itself right now. At e90efcf (current main, with none of this PR's code in the tree), test_a_refresh_that_reconnects_does_not_ask_for_a_second_one fails on MacOs 3.10 and MacOs 3.12; at 58e3e40 it failed on MacOs 3.11.

It lands on a different job each run, which is what a race looks like rather than a break:

Run Red job(s) Test
main @ 58e3e40 MacOs 3.11 test_a_refresh_that_reconnects_does_not_ask_for_a_second_one
main @ e90efcf (HEAD) MacOs 3.10, MacOs 3.12 same
#1142 (pre-rebase) MacOs 3.12 same
this branch, workflow_dispatch MacOs 3.11 same
this PR MacOs 3.12 same
this PR MacOs 3.11 test_a_reconnect_leaves_a_catalog_that_still_loads

Always macOS, never Linux or Windows, always that one file. The other 13 jobs pass on every run, Static Analysis and Validate Plugin included.

There is no fix to port — no open PR addresses it — so there is nothing to bring into this branch, and it wants its own change rather than being repaired from inside an export fix. Re-running would move which job is red, not settle whose it is.

What does hold for the export change: tests/unit_tests/test_export.py is 117 passed locally on this rebased tree, including the two new regression tests, and the full -m "not online" run shows no failure attributable to this diff.

(The 23 snapshots failed line on MacOs 3.12 is also pre-existing — the same count appears on main's MacOs 3.12 job — and fails no test.)


Generated by Claude Code

@tconbeer
tconbeer merged commit 556f894 into main Sep 11, 2026
44 of 47 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants