Skip to content

fix(sqlite): support INSERT IGNORE and offset-only limits [CODEX]#7674

Open
russellromney wants to merge 3 commits into
tobymao:mainfrom
russellromney:codex/sqlite-valid-transpilation
Open

fix(sqlite): support INSERT IGNORE and offset-only limits [CODEX]#7674
russellromney wants to merge 3 commits into
tobymao:mainfrom
russellromney:codex/sqlite-valid-transpilation

Conversation

@russellromney
Copy link
Copy Markdown
Contributor

@russellromney russellromney commented May 23, 2026

Summary

Fix SQLite-targeted transpilation for three cases that currently produce SQL SQLite rejects:

  • MySQL INSERT IGNORE ... -> SQLite INSERT OR IGNORE ...
  • Postgres LIMIT ALL -> no SQLite LIMIT clause
  • Postgres offset-only OFFSET n -> SQLite LIMIT -1 OFFSET n

These are represented directly in SQLite syntax rather than unsupported semantics.

Examples

sqlglot.transpile(
    "INSERT IGNORE INTO t (id, a) VALUES (1, 2)",
    read="mysql",
    write="sqlite",
)[0]
# before: INSERT IGNORE INTO t (id, a) VALUES (1, 2)
# after:  INSERT OR IGNORE INTO t (id, a) VALUES (1, 2)
sqlglot.transpile("SELECT x FROM t LIMIT ALL", read="postgres", write="sqlite")[0]
# before: SELECT x FROM t LIMIT ALL
# after:  SELECT x FROM t
sqlglot.transpile("SELECT x FROM t OFFSET 1", read="postgres", write="sqlite")[0]
# before: SELECT x FROM t OFFSET 1
# after:  SELECT x FROM t LIMIT -1 OFFSET 1

I also checked the combined case:

sqlglot.transpile("SELECT x FROM t LIMIT ALL OFFSET 1", read="postgres", write="sqlite")[0]
# after: SELECT x FROM t LIMIT -1 OFFSET 1

Why

SQLite uses INSERT OR IGNORE for this conflict behavior, not MySQL's INSERT IGNORE. SQLite's INSERT docs describe the INSERT OR action form for choosing a constraint conflict algorithm: https://www.sqlite.org/lang_insert.html

SQLite also requires OFFSET to appear as part of a LIMIT clause; LIMIT -1 OFFSET n is the common SQLite spelling for offset without an upper row bound. Related prior art/background on emulating MySQL conflict behavior in other dialects: https://stackoverflow.com/questions/1009584/how-to-emulate-insert-ignore-and-on-duplicate-key-update-sql-merge-with-po

I searched for existing SQLGlot issues/PRs mentioning these exact SQLite-target gaps and did not find an existing report.

Tests

  • python3 -m unittest tests.dialects.test_sqlite.TestSQLite.test_sqlite
  • python3 -m unittest tests.dialects.test_dialect.TestDialect.test_limit tests.dialects.test_mysql.TestMySQL.test_mysql
  • python3 -m unittest tests.dialects.test_sqlite
  • python3 -m ruff check sqlglot/generators/sqlite.py tests/dialects/test_sqlite.py
  • git diff --check

@russellromney russellromney changed the title Fix SQLite output for INSERT IGNORE and offset limits fix(sqlite): support INSERT IGNORE and offset-only limits [CODEX] May 23, 2026
Copy link
Copy Markdown
Collaborator

@georgesittas georgesittas left a comment

Choose a reason for hiding this comment

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

Thank you for the PR!

Comment thread sqlglot/generators/sqlite.py Outdated
Comment on lines +76 to +85
def _limit_all_to_no_limit(expression: exp.Expr) -> exp.Expr:
limit = expression.args.get("limit")

if isinstance(limit, exp.Limit) and limit.expression and limit.expression.name.upper() == "ALL":
if expression.args.get("offset"):
limit.set("expression", exp.Literal.number(-1))
else:
expression.set("limit", None)

return expression
Copy link
Copy Markdown
Collaborator

@georgesittas georgesittas May 25, 2026

Choose a reason for hiding this comment

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

Let's not do this. Instead, in the base LIMIT parser, if you encounter ALL right after the LIMIT keyword, do an early return, effectively simulating the "no limit" semantics. You don't have to set limit in that case.

SQLGlot does not guarantee input preservation on roundtrip, as long as the semantics are respected, so dropping the LIMIT ALL is not an issue.

The only thing we need to verify for this to be complete is whether LIMIT all may actually mean "takeall columns from the result", where all is a column. There may be a dialect that doesn't match Postgres' semantics.

return expression


def _offset_to_limit(expression: exp.Expr) -> exp.Expr:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: let's gate any logic in this transformation with an instance check:

if isinstance(expr, exp.Select):
    ...

return expr

Comment thread sqlglot/generators/sqlite.py Outdated
Comment on lines +91 to +98
if offset and not expression.args.get("limit"):
expression.set(
"limit",
exp.Limit(
expression=exp.Literal.number(-1),
comments=offset.comments,
),
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why do we need to replicate the comments in Limit as well?

Btw, I think we should be able to do expression.limit(-1, copy=False), which is simpler.

Comment on lines +215 to +218
if expression.args.get("ignore"):
expression.set("ignore", False)
expression.set("alternative", "IGNORE")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hmm... this is fine for now, but looks like a code smell to me. Having two ways to represent the IGNORE semantics doesn't look right. Perhaps something to clean up in the future...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good catch here. let me think about a better way to do this

Copy link
Copy Markdown
Collaborator

@georgesittas georgesittas May 25, 2026

Choose a reason for hiding this comment

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

This is fine for now, let's not deal with it in this PR. Just wanted to leave a note for future reference.

@georgesittas georgesittas self-assigned this May 25, 2026
Comment thread sqlglot/parser.py Outdated
Comment on lines +5491 to +5492
if self._match(TokenType.ALL):
return this
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Did you happen to test/check in other dialects whether LIMIT all has different semantics vs postgres? I'd try queries like this:

WITH t AS (SELECT 1 AS all) SELECT 1 FROM t LIMIT all

Copy link
Copy Markdown
Contributor Author

@russellromney russellromney May 25, 2026

Choose a reason for hiding this comment

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

Good catch, I've updated it so LIMIT ALL is dialect-gated:

  • default behavior preserves LIMIT all
  • MySQL preserves it as LIMIT \all
  • Postgres drops it as “no limit”

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