Skip to content

feat(editor): apply the row cap as a LIMIT on executed queries#1804

Open
datlechin wants to merge 2 commits into
mainfrom
feat/1794-auto-query-limit
Open

feat(editor): apply the row cap as a LIMIT on executed queries#1804
datlechin wants to merge 2 commits into
mainfrom
feat/1794-auto-query-limit

Conversation

@datlechin

Copy link
Copy Markdown
Member

Closes #1794.

Problem

The row cap trimmed results only after the full result set crossed the wire. PostgreSQL materializes the whole result in libpq before the app sees a row, and MySQL streams but drains everything up to the 5,000,000-row emergency ceiling. A forgotten LIMIT still pulled millions of rows. On top of that, Execute All Statements bypassed the cap entirely, and a query starting with a comment slipped past both the cap and Safe Mode classification.

What changed

  • SELECT and WITH statements with no row constraint of their own now run with LIMIT (cap+1) appended to the SQL sent to the server. The editor text never changes. The cap+1 over-fetch keeps the existing post-fetch cap, the "truncated / Fetch All" status bar link, and Fetch All (which stores the pre-injection SQL) working unchanged.
  • New SQLLimitInjector scans with paren depth, string, comment, and dollar-quote awareness. A user-written top-level LIMIT, OFFSET, or FETCH always wins. An inner LIMIT in a CTE or subquery does not suppress injection. Insertion lands before trailing comments and semicolons. Statements ending in FOR UPDATE, INTO, LOCK, ClickHouse FORMAT/SETTINGS bail out to the post-fetch cap.
  • Dialect routing wires up the previously unused AutoLimitStyle metadata: MySQL, PostgreSQL, SQLite, ClickHouse, DuckDB, Cassandra, Cloudflare D1, and libSQL get injection; Redis, MongoDB, etcd resolve .none; SQL Server and Oracle keep post-fetch capping only. A new additive PluginKit hook injectRowLimit(_:limit:) (default nil) lets those plugins own TOP/FETCH FIRST insertion later without an ABI change.
  • Execute All Statements applies the same per-SELECT cap and gains the Fetch All affordance.
  • Comment-prefixed queries are now classified correctly for the row cap and for Safe Mode write/destructive checks.
  • Query history records the statement that actually ran.
  • MySQL threads the user cap into its streaming fetch loop break, mirroring the SQLite override.
  • UI: Execute is a split button (SwiftUI Menu with primaryAction) with Execute Without Limit; the same command is in the Query menu with Cmd+Option+Return (customizable). Settings footer copy and docs updated.
  • Removed the dead MainContentCoordinator+MultiStatement.swift proxy (zero callers) and deduplicated the two multi-statement loops into shared helpers.

Design note

This reverses the v0.37.0 removal of SQL rewriting (#956). That removal fixed a naive detector that clobbered user-written LIMITs. The new detector only ever appends and never touches a statement that constrains itself; the ExecuteUserQueryTests boundary contract (SQL at the driver boundary runs verbatim) still holds because injection happens before that boundary.

ABI

Plugins/TableProPluginKit/PluginDatabaseDriver.swift gains one protocol requirement with a default implementation. scripts/check-pluginkit-abi.sh main reports exactly the two added injectRowLimit lines, nothing removed or changed, so no version bump. Label abi-additive applied.

Tests

  • SQLLimitInjectorTests (21 cases): existing-LIMIT detection, CTE and subquery inner LIMITs, trailing comments, UNION forms, dollar quotes, escaped quotes, identifier false positives, unsupported trailing clauses, malformed input.
  • QueryExecutorTests: row-cap qualification including comment-prefixed SELECTs; QueryClassifierTests: comment-prefixed write/destructive classification.
  • ExecuteUserQueryTests: new case documenting the byte-for-byte boundary contract for post-injection SQL; existing cases unchanged.
  • DriverPluginMetadataTests: registry defaults declare the expected auto-limit style per dialect.
  • Pagination and status bar suites green. swiftlint --strict clean on changed app files.

No UI automation added: the without-limit flow needs a live database connection, which TableProUITests cannot drive deterministically.

Known flake (pre-existing, not touched by this change): QueryExecutorTests/parseSchemaMetadataExtractsEnumValues fails in parallel batch runs and passes alone.

@datlechin datlechin added the abi-additive PluginKit ABI diff reviewed as additive; no version bump needed label Jul 2, 2026
@mintlify

mintlify Bot commented Jul 2, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🟢 Ready View Preview Jul 2, 2026, 2:08 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 708e9488cd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +184 to +185
guard !inString, !inBlockComment, !inDollarQuote, parenDepth == 0, lastCodeEnd > 0 else { return nil }
return lastCodeEnd

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Insert Cassandra LIMIT before ALLOW FILTERING

For Cassandra connections, which are registered with .limit auto-limiting, this always appends the cap at the physical end of the statement. A valid CQL query such as SELECT * FROM users WHERE email = 'x' ALLOW FILTERING is rewritten to ... ALLOW FILTERING LIMIT 501, but ALLOW FILTERING is the trailing clause and LIMIT must come before it, so row-capped executions of these common Cassandra selects now fail instead of falling back to the post-fetch cap.

Useful? React with 👍 / 👎.

Comment on lines +90 to +94
if !inString, ch == dash, i + 1 < length, buffer.character(at: i + 1) == dash {
inLineComment = true
i += 2
continue
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep injected LIMIT out of MySQL hash comments

In MySQL/MariaDB, # starts a line comment, but the scanner only recognizes -- and /* ... */. With a query like SELECT * FROM huge_table # inspect rows, the injector appends LIMIT 501 after the hash comment, so the server executes the original uncapped SELECT and the MySQL driver still has to stream/drain the full result set, defeating the new server-side row cap for this valid MySQL comment form.

Useful? React with 👍 / 👎.

: SQLParameterExtractor.convertToNativeStyle(sql: stmtSQL, parameters: parameters, style: style)
let statementSQL = conversion?.sql ?? stmtSQL

let plan = resolveExecutionPlan(sql: statementSQL, tabType: tabType)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor without-limit for parameterized batches

When Execute Query Without Limit is used on a selection containing multiple parameterized statements, bypassRowLimit is passed into executeParameterizedAfterSafeMode but this batch path drops it and calls resolveExecutionPlan with the default bypassLimit: false. As a result any SELECT in that parameterized batch still gets LIMIT cap+1 injected, so the new command does not actually bypass the cap for this scenario.

Useful? React with 👍 / 👎.

@datlechin

Copy link
Copy Markdown
Member Author

Review pass on this branch surfaced 8 confirmed findings. All are fixed in 123e6fe, plus the lower-priority items the review noted.

Correctness fixes

  1. Cassandra/ScyllaDB ALLOW FILTERING: ALLOW and FILTERING are now blocking keywords, so those queries are sent as written with the post-fetch cap instead of getting an injected LIMIT that CQL rejects.
  2. Fetch All after a multi-SELECT batch no longer replaces the visible result set with rows from a different query. Truncation state (isTruncated, baseQuery, baseQueryParameterValues) now lives on each ResultSet; switching result tabs syncs the tab-level pagination from the active result set, so Fetch All always re-runs the query behind the grid you are looking at.
  3. A capped SELECT earlier in a batch is no longer silent: its result tab keeps its own truncated state and shows Fetch All when selected.
  4. bypassRowLimit reaches the parameterized multi-statement branch. The two batch loops are also merged into one (the duplication caused the drop), so the flag cannot diverge again.
  5. Sorting after a parameterized run falls back to the editor text when bound parameter values are present, so the $1/? form is never re-run unbound.
  6. History records your query text again: the original :name form for parameterized statements (single and batch) and the pre-injection SQL for capped SELECTs. No more LIMIT cap+1 or $1 placeholders in history or favorites. Failed queries still record the statement that actually ran, for debugging.
  7. Statement classification is token-based now: SELECT\n, SELECT*, tab after the keyword, and the same shapes for writes and destructive statements are all classified correctly, for the row cap and for Safe Mode.
  8. "Your own LIMIT always wins" is now true: a statement with its own top-level LIMIT/FETCH FIRST (or TOP on SQL Server) runs with no cap at all instead of being post-fetch truncated. SQLLimitInjector returns injected / alreadyLimited / notInjectable and the plan drops the cap for alreadyLimited.

Also picked up

  • MySQL # line comments are handled by the injector (dialect-gated via SqlDialect.supportsHashLineComments), so the LIMIT lands before a trailing # comment instead of inside it.
  • The MySQL parameterized fetch loop now honors the user row cap instead of reading up to the 5M emergency ceiling and trimming after.
  • The injector reuses SqlDollarQuote's identifier classification and matches keywords without per-word string allocation; insertion is a single in-place edit instead of three full copies.
  • Dead proxies removed (resolveRowCap, the parameterized forwarders on MainContentCoordinator).
  • Fixed a pre-existing always-failing test on main (parseSchemaMetadataExtractsEnumValues): parseSchemaMetadata now falls back to EnumValueParser.parseMySQLEnumOrSet when a driver reports enum values only in the column type.

Docs and CHANGELOG updated to match the corrected semantics. ABI check against main reports only the three additive symbols (injectRowLimit + default, supportsHashLineComments); no version bump. 86 tests green across the affected suites.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 123e6fe47d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1389 to +1390
let baseQuery = hasBoundParameters
? tab.content.query

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Sort the executed parameterized statement, not the editor

When a parameterized result was produced from a selected statement or the statement at the cursor in an editor that contains other SQL, this branch discards the stored executed query and builds the sort override from tab.content.query (the entire editor). Because runQuery() consumes sortExecutionOverride directly instead of re-selecting the cursor statement, clicking a column header can rerun unrelated statements or append ORDER BY to the wrong SQL rather than sorting the result set the user is viewing.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

abi-additive PluginKit ABI diff reviewed as additive; no version bump needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

自动添加 limit

1 participant