Releases: JingYu-create520/sql-index-advisor
Release list
0.1.16: every rewrite is now checked against a real server
New this release: node scripts/verify-rewrites.mjs asks a live MySQL whether every rewrite the tool prints selects exactly the same rows as the query it came from, and fails if not. It found two that did not, on its first run. LEFT(code, 3) = 123 became code LIKE '2%' because the value was cut as if it were quoted, and LEFT(code, 3) = remark became code LIKE 'emar%' - both execute, neither selects the same rows. And DATE(create_time) = '2026-09-17 13:00:00' matches no row at all on 8.0.46, so it now gets an explanation instead of a rewrite that changes the answer.
0.1.16 — 2026-09-22
Fixed
LEFT(col, n) = <not a string literal>produced a LIKE pattern out of thin air.
The value was cut withslice(1, -1)on the assumption that its first and last
characters were quotes.LEFT(code, 3) = 123therefore becamecode LIKE '2%'
(the number lost its first digit) andLEFT(code, 3) = remarkbecame
code LIKE 'emar%'. Both execute cleanly and select different rows than the
original, which is the failure mode this project rates worst. A rewrite now
requires a genuine single-quoted (or double-quoted) literal; a number, a column
reference or anything unreadable gets no rewrite at all. A numeric comparison is
not prefix semantics in MySQL either - both sides are cast to a number - so it is
not merely hard to rewrite, it should not be rewritten.- A day window built from a datetime literal was anchored at the wrong hour.
DATE(create_time) = '2026-09-17 13:00:00'produced
create_time >= '2026-09-17 13:00:00' AND create_time < '2026-09-18 13:00:00'.
Measured on 8.0.46, the original predicate returns no rows at all -DATE()is
midnight and the comparison runs against the whole literal - so no time-carrying
window can be equivalent. The rewrite is withdrawn for that shape and the finding
says what is actually wrong: the predicate can never be true, with the day window
it probably meant. A midnight literal keeps the ordinary (correct) range. - The bound-parameter date template assumed a date-only value. It is now
col >= DATE(?) AND col < DATE(?) + INTERVAL 1 DAY, which keeps day semantics
whatever the parameter carries. The same template is only offered when the right
side really is a parameter, never for an arbitrary value.
Added
scripts/verify-rewrites.mjs+scripts/verify-rewrites.sql: asks a live MySQL
whether each rewrite the tool emits selects exactly the same rows as the original,
comparing primary-key sets rather than row counts, and exits non-zero when any
differs. Both defects above were found by it; neither was visible to an assertion
about the emitted text.
Verified
13 predicates run through the new check against MySQL 8.0.46 - every emitted rewrite
is equivalent, every withdrawn one is skipped with a reason. 299 tests pass, both
example runs unchanged at 6 and 9 suggestions.
0.1.15: the pagination rewrite now runs on MySQL
The audit that found this was the obvious one the test suite had never done: take the SQL the tool emits and run it. SIA006's deferred join for an unaliased deep-page query failed on a live MySQL 8.0.46 with ERROR 1052: Column 'id' in on clause is ambiguous - the derived table exposes id too - and it had quietly widened a three-column projection to SELECT *. The rewrite now carries its own alias, and it is withdrawn whenever the projection cannot be carried over honestly.
0.1.15 — 2026-09-22
Fixed
- SIA006's deferred-join rewrite did not run. For a deep page on a table the
query did not alias —SELECT id, user_id, amount FROM orders WHERE … LIMIT 100000, 20—
the emitted SQL was
SELECT * FROM (SELECTidFROMorders… ) AS page JOIN orders ONid= page.id``,
and MySQL answeredERROR 1052 (23000): Column 'id' in on clause is ambiguous:
the derived table exposes `id` too. The same statement also turned a three-column
projection into `SELECT *`, so even if it had run it would have handed the caller
columns nothing asked for. Found by executing the tool's own output against a live
8.0.46, which is now the standard way this project audits a rewrite.
The rewrite always introduces its own alias (`t`) and qualifies the join, the
projection and the outer sort through it. - A rewrite that cannot be faithful is withdrawn instead of approximated. The
projection is rebuilt column by column only when it is a column list:SELECT *
becomest.*; a computed value, anASrename, a barex yrename,DISTINCT
or a sort key that cannot be re-qualified all produce norewritefield, with the
template and the reason in the message (both languages).SELECT amount + 0was
the case that made this a parse-level flag rather than a guess: the column inside
the expression was being collected as if the projection were that column, which
would have quietly changed a returned value.
Added
ParsedQuery.selectPlain/selectDistinct, so a rule can tell "a list of columns
I can carry over" from "a projection I must not rebuild".
Verified
CREATE TEMPORARY TABLE … AS <rewrite> against the live 8.0.46 demo database: the
rewrite runs, returns the same 20 rows and the same three columns as the original,
and the outer ORDER BY is preserved. Re-running the whole pipeline after applying
the generated migration produced no repeated DDL. 290 tests pass.
0.1.14: a functional index no longer breaks the schema import
Two bugs found by following the tool's own advice on a live MySQL 8.0.46: create the functional index SIA004 recommends, re-export schema.json with examples/schema-dump.sql, and the 0.1.13 loader rejected the file outright - one expression key part cost every schema-gated rule. The second run then asked for the same index again, which in a migration file is ERROR 1061. Both are fixed here, and the fixture is now a real dump instead of an imagined one.
0.1.14 — 2026-09-22
Found by doing the one thing the tool tells you to do: create the functional index
it recommended, re-export the schema, and run it again. Verified on a live MySQL
8.0.46.
Fixed
- One functional index made the whole
schema.jsonunreadable. MySQL reports
COLUMN_NAME = NULLfor an expression key part — a functional index
((DATE(create_time))), a multi-valued JSON index, or the second part of
(user_id, UPPER(status))— soexamples/schema-dump.sqllegitimately emits
"columns": [null], and the loader'sz.array(z.string())rejected it. Because
validation is all-or-nothing, that single index cost every schema-gated rule: the
output wasschema 校验失败and nothing else. This is the second time the same
mistake bit the same file — the first was.optional()rejecting the nulls
information_schemaproduces for length and charset, which 0.1.x already fixed.
nullis now a valid key part, and rules treat it as what it is: a part with no
column name, which cannot serve a plain-column lookup and cannot be named in DDL.
SIA003 and SIA007 stay out of such indexes, and SIA001 prints one as
〈表达式〉/(expression)rather than as the wordnull. - SIA004 recommended the same index after you had created it. With
idx_orders_create_timein place, the rule still emitted
ALTER TABLE orders ADD INDEX idx_orders_create_time ((DATE(create_time))), so
following the advice produced a second run that repeated itself and a migration
file that fails on duplicate key name. The advice is now suppressed when the table
already carries an index with the name this advice would use, the finding drops to
warn, and the message says plainly that a name match is not proof — an
expression key part has no readable text ininformation_schemafor a dump that
still has to run on 5.7, where theEXPRESSIONcolumn does not exist at all.
Confirm withSHOW INDEX, which the message says too.
Added
tests/fixtures/schema-functional.json— a dump taken from the live 8.0.46 probe
database, containing a functional index, a multi-valued JSON index, a mixed
(column, expression) index, a FULLTEXT index, and the index this tool's own advice
creates after that advice has been applied.tests/functional-index.test.tsand
three new SIA004 cases pin the behaviour. The reason this class of defect keeps
being found late is fixtures: a hand-written schema never contains the shapes the
author did not think of.
Verified
node dist/cli.js query … --schema <live dump> over the 8.0.46 dump before and after
the fix (before: rejected; after: one warn, no repeated DDL), the same statement run
against --mysql-version 5.7 (no functional DDL either way), and both example runs
unchanged at 6 and 9 suggestions. 283 tests pass.
0.1.13: the SELECT inside a SELECT is its own statement
A SELECT inside parentheses is its own statement: the list of an IN (SELECT ...), the body of an EXISTS (SELECT ...), the table behind FROM (SELECT ...) d, and a bracketed UNION branch. Four open-source MyBatis projects (378 mapper XML files, 1666 statement records) contain 17 such bodies between them; all 17 now get examined, 11 extra findings appeared and none disappeared.
0.1.13 — 2026-09-22
Closes the one miss the previous release documented instead of fixing: a table that
only appears inside parentheses was never examined.
Added
- A
SELECTinside parentheses is analysed as a statement of its own, at any
nesting depth: the list of anIN (SELECT ...), the body of an
EXISTS (SELECT ...), the derived table behindFROM (SELECT ...) d, and a
bracketedUNIONbranch. Each one runs against its own tables and needs its own
indexes, so the outer statement's parse could not stand in for it. They enter the
run as sibling records (src/core/subqueries.ts), inheriting the parent's
occurrences and cost, and identical bodies fold into one record by fingerprint.
Measured over four open-source MyBatis projects — 378 mapper XML files, 1,666
statement records — which hold 17 such bodies between them, and every one of them
produced advice:paicoding'sEXISTS (SELECT 1 FROM column_article ca WHERE ca.article_id = a.id AND ca.column_id = ?),zheng'supms_user_role, whose DDL
has no secondary index at all, andmall's two bracketedUNIONbranches. Cost
on the 908-recordmallrun: +40 ms. - A skip now says how much of the run it covers.
skippedwas a per-rule
summary, so aWITHstatement — outer query with no resolvable table, inner body
with a real access path — printed "not evaluated: SIA001" underneath the
suggestion SIA001 had just produced. The footer, the CI annotation and the
migration header now readno table could be identified, on 1 of 2 statements,
and stay unqualified when the skip really is the whole run.
Fixed
- Inside a subquery, a bare column on the right of
=is no longer an index
candidate. MySQL resolves such a name inner-first, soWHERE child_id = parent_idmay be testing the outer query's column, and an unqualified name
thatorder_itemdoes not have would have been written intoALTER TABLE order_item. Qualified names needed no new rule — an unknown qualifier was
already dropped. Pinned both ways intests/subquery.test.ts. - The
WITHcaveat reached an English report half in Chinese
(不支持的语句类型, skipped(支持 …)). The reason map now covers that note and
the two new ones, and a test asserts the caveats added for known gaps contain no
untranslated Chinese when--lang enis asked for.
What this still does not do
A filter on a derived table's alias (WHERE d.total > 10) names no physical table
and is reported rather than guessed at; an unbracketed UNION branch is still not
analysed, because a trailing ORDER BY / LIMIT belongs to the union result
rather than to the last query; and no cost model is applied to the correlation
itself (a correlated body is weighted as one execution per parent execution, which
understates a per-row probe and overstates a one-shot IN list).
0.1.12: five things the tool used to stay silent about
This release is about what the tool failed to say, found by auditing three more MyBatis projects (421, 277 and 45 statements) instead of our own fixtures.
WHERE create_time >= ?with no index on that column produced no advice at all: the guard that keeps a sort behind a range also deleted the rangeAND (a = 1 OR b = 2), the most common shape in a MyBatis<where>block, was dropped whole because parenthesised groups were opaque to the AND/OR splittersa = 1 OR b = 2drew an error-severity index for one side and lost the other; an OR over one column is an IN list and now gets advice, while an OR over different columns gets an explanation with no DDL- a chain of absorbed suggestions could disappear without a trace; the survivor now reports everything it covers
- a bound
LIMIT ?, ?offset, tables insideIN (SELECT ...), and each distinct parse reason are all attributed instead of being silently skipped
249 tests, examples output unchanged, README screenshot still accurate.
0.1.11: the machine-readable outputs explain themselves too
0.1.10 made an empty report explain itself in the terminal, the JSON and the MCP tools. This closes the remaining two surfaces, which are the ones a machine reads.
--format githubnow emits a workflow notice per caveat plus one line naming the rules that did not run and why, so a misconfigured workflow can no longer produce zero annotations and a green check--emit-sqlcarries the same caveats as-- !comments above the DDL, so a reviewer sees what the file does not cover before running it- tests assert both, including that every executable line in a migration file is still only
ALTER TABLE ... ADD INDEX
0.1.10: an empty report now says why it is empty
An empty report used to be able to claim a pass. sia mapper <wrong path> printed a green checkmark even though the loader had already noticed there were no mappers there, and a project whose 205 statements all filter through ${criterion.condition} came back silent instead of saying that nothing was judgeable.
- loader notes are now part of the analysis result and reach every output: terminal, JSON, and the MCP tools
- an empty run with a caveat gets different wording from an empty run that is genuinely clean, and no green check
- the README's collapsible output block is now labelled as an excerpt, and the screenshot was regenerated from a live run (the old one predated the current SIA004 wording)
0.1.9: a review with fail-on off no longer reddens the build
The Action now comments without gating, which is what fail-on: off always promised.
Getting here took four attempts on this repository's own pull request, and each one exposed a different broken assumption: the floating tag did not resolve, the bundle needed a node_modules that a checkout does not have, a failed step reported green because of continue-on-error, and finally findings at exit 1 reddened a job that was only asked to comment. Attempt 4 is green with seven annotations on the lines that caused them.
- exit 1 means findings and no longer fails the job; only an exit above 1 (the tool could not run) does
- an empty JSON report after a clean run is treated as a failure
- a checkout path containing a space now fails with an instruction instead of a module-not-found
0.1.8: the shipped binaries run from a bare checkout
The Action runs node dist/cli.js from the checked-out tag, and tsup had been externalising the runtime dependencies, so on a runner with only the checkout it died with ERR_MODULE_NOT_FOUND. Both binaries vendor their dependencies now, with a createRequire shim for the CommonJS ones.
Verified the way the runner does it: dist/ and package.json copied into a directory with no node_modules at all, CLI and MCP handshake run from there.
0.1.6: installs without a toolchain, five wrong answers fixed
This release closes six findings, five from running the tool over other people's projects and one from this repository's own Action reviewing its own pull request.
The install command in the README was broken on a clean machine. npm prepares a git dependency in a throwaway clone, the dev toolchain is not there, and prepare died on tsup: not found. The bundle is now committed, prepare rebuilds only when it can, and CI rebuilds plus fails on drift, then installs the package into an empty project.
- SIA004 stopped reporting functions that sit in the projection or GROUP BY while the WHERE clause is fine
- no more DDL for runtime-built table names such as
device_message_${deviceId} - "pass --schema" is no longer shown to someone who passed one
- the same column set written in another order is one index, not two
- a composite made only of flag columns is capped at
info, with the ratio query
Full detail in the changelog.