Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SQLite 3.35 supports returning #6195

Closed
zzzeek opened this issue Apr 4, 2021 · 20 comments
Closed

SQLite 3.35 supports returning #6195

zzzeek opened this issue Apr 4, 2021 · 20 comments
Labels
returning SQL returning support sql sqlite use case not really a feature or a bug; can be support for new DB features or user use cases not anticipated
Milestone

Comments

@zzzeek
Copy link
Member

zzzeek commented Apr 4, 2021

this is pretty major functionality so we should try to support it, somewhat like how we support mariadb returning, i.e. where it's not supported on many variants but works when called upon if the backend supports it.

@zzzeek zzzeek added this to the 1.4.x milestone Apr 4, 2021
@zzzeek
Copy link
Member Author

zzzeek commented Apr 4, 2021

it's brand new as of three weeks ago in https://www.sqlite.org/releaselog/3_35_0.html. pysqlite driver might not even support it, have to try it.

@CaselIT
Copy link
Member

CaselIT commented Apr 4, 2021

I had already opened an issue here #6038.

Will close the old one.
From that issue:

SQLite 3.35 added returning and some other useful stuff, like column removal that may be useful to support in alembic.

See for a list of features: https://nalgeon.github.io/sqlite-3-35/

@CaselIT CaselIT changed the title SQLite supports returning now SQLite 3.35 supports returning Apr 4, 2021
@gordthompson
Copy link
Member

it's brand new as of three weeks ago in https://www.sqlite.org/releaselog/3_35_0.html. pysqlite driver might not even support it, have to try it.

That will likely take some time unless we want to build everything from source. Python 3.9 ships with SQLite 3.31.1. At the moment, Python 3.10a6 includes that same version.

@zzzeek
Copy link
Member Author

zzzeek commented Apr 5, 2021

@CaselIT oh whoops!

@gordthompson I have sqlite building from source in our jenkins CI in any case to support some other sqlite features too : https://github.com/sqlalchemyorg/ci_containers/blob/master/roles/jenkins/files/install_sqlite.sh

@CaselIT
Copy link
Member

CaselIT commented Apr 5, 2021

Conda (on windows but I would guess also on linux) already ships with sqlite 3.35.4 for python 3.9.2.
It's actually installed separately

sqlite pkgs/main/win-64::sqlite-3.35.4-h2bbff1b_0

@zzzeek zzzeek added sql use case not really a feature or a bug; can be support for new DB features or user use cases not anticipated and removed feature labels May 14, 2021
@honglei
Copy link

honglei commented May 21, 2021

Windows Python 3.9.5 ships with 3.35.5

Python 3.9.5 (tags/v3.9.5:0a7dcbd, May  3 2021, 17:27:52) [MSC v.1928 64 bit (AMD64)]
Type "help", "copyright", "credits" or "license" for more information.
import sqlite3
sqlite3.sqlite_version_info
(3, 35, 5)

@CaselIT
Copy link
Member

CaselIT commented May 21, 2021

On the github action we have:

  • pysqlite_3_31_1 on ubuntu 3.9
  • pysqlite_3_35_5 on windows 3.9
  • pysqlite_3_35_5 on osx 3.9

Also tried in the docker python images and the debian based are still on 3.27, while alpine is on 3.34

@zzzeek
Copy link
Member Author

zzzeek commented May 21, 2021

In our own CI my images build the latest SQLite from source before compiling python so I can have these on jenkins after doing a rebuild.

@zljubisic
Copy link

@zzzeek Do we know approximately the release date of 2.0?

@CaselIT
Copy link
Member

CaselIT commented Dec 9, 2021

there is no current eta yet

@ghuname
Copy link

ghuname commented Dec 11, 2021

I tried to improvise "returning" in the following way:

import asyncio
from typing import Optional

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, declarative_base, DeclarativeMeta
from sqlalchemy import select, text, Column, Integer, String, Index, or_, func
from sqlalchemy.dialects.sqlite import insert

from pydantic import BaseModel
import pandas as pd
import sys

Base: DeclarativeMeta = declarative_base()

db_path = "sqlite+aiosqlite:///database.sqlite"
engine = create_async_engine(db_path, echo=True, future=True)
async_session_maker = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)

print(f"{sys.version=}")

class NewTest(Base):
    __tablename__ = 'new_test'

    id = Column(Integer, primary_key=True)
    fld_text = Column(String)
    fld_int1 = Column(Integer, nullable=True)
    fld_int2 = Column(Integer, nullable=True)

Index('new_test_idx', NewTest.fld_text, unique=True)

class NewTestInDb(BaseModel):
    id: int
    fld_text: str
    fld_int1: Optional[int]
    fld_int2: Optional[int]

    class Config:
        orm_mode = True

old_records = [
    NewTestInDb(id=1, fld_text='A', fld_int1=1, fld_int2=1),
    NewTestInDb(id=2, fld_text='B', fld_int1=2, fld_int2=2),
    NewTestInDb(id=3, fld_text='C', fld_int1=3, fld_int2=3),
    NewTestInDb(id=4, fld_text='D', fld_int1=4, fld_int2=4),
    NewTestInDb(id=5, fld_text='E', fld_int1=None, fld_int2=None),
    NewTestInDb(id=6, fld_text='F', fld_int1=None, fld_int2=None),
    NewTestInDb(id=7, fld_text='G', fld_int1=None, fld_int2=None),
    NewTestInDb(id=8, fld_text='H', fld_int1=None, fld_int2=None),
]

updated_records = [
    NewTestInDb(id=1, fld_text='A', fld_int1=1, fld_int2=1),
    NewTestInDb(id=2, fld_text='B', fld_int1=None, fld_int2=20),
    NewTestInDb(id=3, fld_text='C', fld_int1=30, fld_int2=None),
    NewTestInDb(id=4, fld_text='D', fld_int1=40, fld_int2=40),
    NewTestInDb(id=5, fld_text='E', fld_int1=None, fld_int2=None),
    NewTestInDb(id=6, fld_text='F', fld_int1=6, fld_int2=None),
    NewTestInDb(id=7, fld_text='G', fld_int1=None, fld_int2=7),
    NewTestInDb(id=8, fld_text='H', fld_int1=8, fld_int2=8),
]

async def main():
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
        await conn.run_sync(Base.metadata.create_all)

    async with async_session_maker() as session:
        async with session.begin():
            print('----------------- beginning state --------------------')
            await session.execute(insert(NewTest).values([val.dict() for val in old_records]))
            res = (await session.execute(select(NewTest))).scalars().all()
            df = pd.DataFrame([NewTestInDb.from_orm(row).dict() for row in res])
            print(df)

            # inserting/updating rows inserted above
            ins_stmt = insert(NewTest).values([val.dict() for val in updated_records])
            ins_stmt = ins_stmt.on_conflict_do_update(
                index_elements=('fld_text',),
                index_where=or_(
                        NewTest.fld_int1 != func.ifnull(ins_stmt.excluded.fld_int1, NewTest.fld_int1),
                        NewTest.fld_int2 != func.ifnull(ins_stmt.excluded.fld_int2, NewTest.fld_int2)
                    ),
                set_=dict(
                    fld_int1=func.ifnull(ins_stmt.excluded.fld_int1, NewTest.fld_int1),
                    fld_int2=func.ifnull(ins_stmt.excluded.fld_int2, NewTest.fld_int2)
                )
            )

            stmt = str(ins_stmt.compile(compile_kwargs={"literal_binds": True})) + ' returning *;'
            res_final = (await session.execute(text(stmt))).all()
            df = pd.DataFrame([row for row in res_final])
            print(df)

loop = asyncio.get_event_loop()
loop.run_until_complete(main())

Output:

sys.version='3.9.2 (default, Mar  3 2021, 15:03:14) [MSC v.1916 64 bit (AMD64)]'
2021-12-11 17:03:03,028 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2021-12-11 17:03:03,028 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("new_test")
2021-12-11 17:03:03,028 INFO sqlalchemy.engine.Engine [raw sql] ()
2021-12-11 17:03:03,028 INFO sqlalchemy.engine.Engine 
DROP TABLE new_test
2021-12-11 17:03:03,028 INFO sqlalchemy.engine.Engine [no key 0.00027s] ()
2021-12-11 17:03:03,044 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("new_test")
2021-12-11 17:03:03,044 INFO sqlalchemy.engine.Engine [raw sql] ()
2021-12-11 17:03:03,044 INFO sqlalchemy.engine.Engine PRAGMA temp.table_info("new_test")
2021-12-11 17:03:03,044 INFO sqlalchemy.engine.Engine [raw sql] ()
2021-12-11 17:03:03,044 INFO sqlalchemy.engine.Engine 
CREATE TABLE new_test (
	id INTEGER NOT NULL, 
	fld_text VARCHAR, 
	fld_int1 INTEGER, 
	fld_int2 INTEGER, 
	PRIMARY KEY (id)
)


2021-12-11 17:03:03,044 INFO sqlalchemy.engine.Engine [no key 0.00012s] ()
2021-12-11 17:03:03,059 INFO sqlalchemy.engine.Engine CREATE UNIQUE INDEX new_test_idx ON new_test (fld_text)
2021-12-11 17:03:03,059 INFO sqlalchemy.engine.Engine [no key 0.00020s] ()
2021-12-11 17:03:03,059 INFO sqlalchemy.engine.Engine COMMIT
----------------- beginning state --------------------
2021-12-11 17:03:03,075 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2021-12-11 17:03:03,075 INFO sqlalchemy.engine.Engine INSERT INTO new_test (id, fld_text, fld_int1, fld_int2) VALUES (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?)
2021-12-11 17:03:03,075 INFO sqlalchemy.engine.Engine [no key 0.00032s] (1, 'A', 1, 1, 2, 'B', 2, 2, 3, 'C', 3, 3, 4, 'D', 4, 4, 5, 'E', None, None, 6, 'F', None, None, 7, 'G', None, None, 8, 'H', None, None)
2021-12-11 17:03:03,075 INFO sqlalchemy.engine.Engine SELECT new_test.id, new_test.fld_text, new_test.fld_int1, new_test.fld_int2 
FROM new_test
2021-12-11 17:03:03,090 INFO sqlalchemy.engine.Engine [generated in 0.00026s] ()
   id fld_text  fld_int1  fld_int2
0   1        A       1.0       1.0
1   2        B       2.0       2.0
2   3        C       3.0       3.0
3   4        D       4.0       4.0
4   5        E       NaN       NaN
5   6        F       NaN       NaN
6   7        G       NaN       NaN
7   8        H       NaN       NaN
2021-12-11 17:03:03,138 INFO sqlalchemy.engine.Engine INSERT INTO new_test (id, fld_text, fld_int1, fld_int2) VALUES (1, 'A', 1, 1), (2, 'B', NULL, 20), (3, 'C', 30, NULL), (4, 'D', 40, 40), (5, 'E', NULL, NULL), (6, 'F', 6, NULL), (7, 'G', NULL, 7), (8, 'H', 8, 8) ON CONFLICT (fld_text) WHERE fld_int1 != ifnull(fld_int1, fld_int1) OR fld_int2 != ifnull(fld_int2, fld_int2) DO UPDATE SET fld_int1 = ifnull(excluded.fld_int1, new_test.fld_int1), fld_int2 = ifnull(excluded.fld_int2, new_test.fld_int2) returning *;
2021-12-11 17:03:03,138 INFO sqlalchemy.engine.Engine [generated in 0.00022s] ()
   0  1     2     3
0  1  A   1.0   1.0
1  2  B   2.0  20.0
2  3  C  30.0   3.0
3  4  D  40.0  40.0
4  5  E   NaN   NaN
5  6  F   6.0   NaN
6  7  G   NaN   7.0
7  8  H   8.0   8.0
2021-12-11 17:03:03,143 INFO sqlalchemy.engine.Engine COMMIT

The idea here is to be able to insert the same records or records with updated fields to the table, but get back only rows that have been inserted/updated. Update only if new value is not null.
As you can see it doesn't work, because in final result I got not changed rows "A" and "E" which is wrong.
If I got final result without rows "A" and "E", this solution would satisfy me while I am waiting for the official support of "returning".
I cannot figure out why I got back rows "A" and "E".

@natskvi
Copy link

natskvi commented Feb 2, 2022

Above workaround by @ghuname looks promising and works for me because my tables include updated_at timestamp columns that are always updated on any INSERT or UPDATE. It seems that RETURNING works inside transactions, but if executed by a Connection or Engine object requires .execution_options(autocommit=False).

import sqlite3
import traceback
from sqlalchemy import create_engine, text

ver = sqlite3.sqlite_version.split('.')
major = int(ver[0])
minor = int(ver[1])
assert major > 3 or major == 3 and minor >= 35

with sqlite3.connect(':memory:') as conn:
    print('########## sqlite3 test ##########')
    conn.execute('CREATE TABLE test(id INTEGER PRIMARY KEY, s TEXT)')
    cursor = conn.execute(
        "INSERT INTO test (s) VALUES ('one'), ('two') RETURNING *"
    )
    rows = list(cursor)
    assert len(rows) == 2
    assert rows[0][0] == 1 and rows[0][1] == 'one'
    assert rows[1][0] == 2 and rows[1][1] == 'two'
    print('OK')

engine = create_engine('sqlite:///:memory:', echo=True)

with engine.connect() as conn:
    print('\n############ sqlalchemy test ############')
    conn.execute('CREATE TABLE test(id INTEGER PRIMARY KEY, s TEXT)')

    try:  # ERROR (missing .execution_options(autocommit=False)
        print('\n############ INSERT ... RETURNING - ERROR ############')
        result = conn.execute(
            text("INSERT INTO test (s) VALUES ('one'), ('two') RETURNING *")
        )
    except Exception:
        traceback.print_exc()

    print('\n############ INSERT ... RETURNING ############')
    cursor = conn.execute(
        text(
            "INSERT INTO test (s) VALUES ('one'), ('two') RETURNING *"
        ).execution_options(autocommit=False)
    )

    rows = list(cursor)
    assert len(rows) == 2
    assert rows[0].id == 1 and rows[0].s == 'one'
    assert rows[1].id == 2 and rows[1].s == 'two'

    try:
        conn.execute('COMMIT')
        assert False
    except Exception:
        pass  # COMMIT happened despite autocommit=False

    with conn.begin():
        print('\n############ INSERT ... RETURNING inside transaction ############')
        cursor = conn.execute(
            text(
                "INSERT INTO test (s) VALUES ('three'), ('four') RETURNING *"
            )
        )

        rows = list(cursor)
        assert len(rows) == 2
        assert rows[0].id == 3 and rows[0].s == 'three'
        assert rows[1].id == 4 and rows[1].s == 'four'

Output:

########## sqlite3 test ##########
OK

############ sqlalchemy test ############
2022-02-02 15:30:24,994 INFO sqlalchemy.engine.Engine CREATE TABLE test(id INTEGER PRIMARY KEY, s TEXT)
2022-02-02 15:30:24,994 INFO sqlalchemy.engine.Engine [raw sql] ()
2022-02-02 15:30:24,994 INFO sqlalchemy.engine.Engine COMMIT

############ INSERT ... RETURNING - ERROR ############
2022-02-02 15:30:24,995 INFO sqlalchemy.engine.Engine INSERT INTO test (s) VALUES ('one'), ('two') RETURNING *
2022-02-02 15:30:24,995 INFO sqlalchemy.engine.Engine [generated in 0.00013s] ()
2022-02-02 15:30:24,995 INFO sqlalchemy.engine.Engine COMMIT
2022-02-02 15:30:24,995 INFO sqlalchemy.engine.Engine ROLLBACK
2022-02-02 15:30:24,995 INFO sqlalchemy.engine.Engine ROLLBACK
Traceback (most recent call last):
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 998, in _commit_impl
    self.engine.dialect.do_commit(self.connection)
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/engine/default.py", line 685, in do_commit
    dbapi_connection.commit()
sqlite3.OperationalError: cannot commit transaction - SQL statements in progress

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "test.py", line 30, in <module>
    result = conn.execute(
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 1289, in execute
    return meth(self, multiparams, params, _EMPTY_EXECUTION_OPTS)
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/sql/elements.py", line 325, in _execute_on_connection
    return connection._execute_clauseelement(
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 1481, in _execute_clauseelement
    ret = self._execute_context(
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 1845, in _execute_context
    self._handle_dbapi_exception(
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 2030, in _handle_dbapi_exception
    util.raise_(exc_info[1], with_traceback=exc_info[2])
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/util/compat.py", line 207, in raise_
    raise exception
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 1832, in _execute_context
    self._commit_impl(autocommit=True)
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 1000, in _commit_impl
    self._handle_dbapi_exception(e, None, None, None, None)
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 2026, in _handle_dbapi_exception
    util.raise_(
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/util/compat.py", line 207, in raise_
    raise exception
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 998, in _commit_impl
    self.engine.dialect.do_commit(self.connection)
  File "/home/vscode/.local/lib/python3.8/site-packages/sqlalchemy/engine/default.py", line 685, in do_commit
    dbapi_connection.commit()
sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) cannot commit transaction - SQL statements in progress
(Background on this error at: https://sqlalche.me/e/14/e3q8)

############ INSERT ... RETURNING ############
2022-02-02 15:30:24,998 INFO sqlalchemy.engine.Engine INSERT INTO test (s) VALUES ('one'), ('two') RETURNING *
2022-02-02 15:30:24,998 INFO sqlalchemy.engine.Engine [cached since 0.003499s ago] ()
2022-02-02 15:30:24,999 INFO sqlalchemy.engine.Engine COMMIT
2022-02-02 15:30:24,999 INFO sqlalchemy.engine.Engine [raw sql] ()

############ INSERT ... RETURNING inside transaction ############
2022-02-02 12:22:21,637 INFO sqlalchemy.engine.Engine INSERT INTO test (s) VALUES ('three'), ('four') RETURNING *
2022-02-02 12:22:21,637 INFO sqlalchemy.engine.Engine [generated in 0.00013s] ()
2022-02-02 12:22:21,637 INFO sqlalchemy.engine.Engine COMMIT

@natskvi
Copy link

natskvi commented Feb 7, 2022

A note on adding RETURNING support for the SQLite dialect: I don't know what its current state is in 2.0, but as a quick hack I was able to apply a patch directly in lib/python3.8/site-packages/sqlalchemy/dialects/sqlite/base.py (based on the logic in lib/python3.8/site-packages/sqlalchemy/dialects/postgresql/base.py):

-            "restrict",
-            "right",
+            "restrict",
+            "returning",  # Adds RETURNING reserved keyword.
+            "right",
-class SQLiteCompiler(compiler.SQLCompiler):
+class SQLiteCompiler(compiler.SQLCompiler):
+    def returning_clause(self, stmt, returning_cols):
+        """Adds compile logic for RETURNING clause."""
+
+        columns = [
+            self._label_returning_column(stmt, c)
+            for c in sql.expression._select_iterables(returning_cols)
+        ]
+        return "RETURNING " + ", ".join(columns)
-    construct_arguments = [
+    implicit_returning = True  # Enables RETURNING syntax.
+    full_returning = True  # Enables RETURNING syntax.
+
+    construct_arguments = [

And although due to the following SQLite limitation,

Even though a DML statement with a RETURNING clause returns table content, it cannot be used as a subquery. The RETURNING clause can only return data to the application. It is not currently possible to divert the RETURNING output into another table or query. PostgreSQL has the ability to use a DML statement with a RETURNING clause like a view in a common table expressions. SQLite does not currently have that ability, though that is something that might be added in a future release.

you can't embed it in a CTE via sa.dialects.sqlite.insert(table).values(...).returning(...).cte('test_cte'), RETURNING without a CTE does work, as in sa.dialects.sqlite.insert(table).values(...).returning(...).

@CaselIT
Copy link
Member

CaselIT commented Feb 7, 2022

If you can submit a pr with the changes and the tests it would be great!

@zzzeek
Copy link
Member Author

zzzeek commented Feb 7, 2022

I havent yet looked to see what kinds of curveballs this one might have. we'll be getting to this in the coming weeks regardless.

@sqla-tester
Copy link
Collaborator

Daniel Black has proposed a fix for this issue in the main branch:

Generalize RETURNING and suppor for MariaDB / SQLite https://gerrit.sqlalchemy.org/c/sqlalchemy/sqlalchemy/+/3889

sqlalchemy 2 automation moved this from 2.0 deliverables backlog to 2.0 deliverables done Jun 5, 2022
@ghallberg
Copy link

Is this fix waiting for 2.0 release?

Would it be possible to get it into a 1.4.x?

@zzzeek
Copy link
Member Author

zzzeek commented Sep 15, 2022

Is this fix waiting for 2.0 release?

yes

Would it be possible to get it into a 1.4.x?

no, the patch that generalized RETURNING was for sqlite and mariadb and was fairly enormous (466ed5b) when test support is taken into account and also had to make some very esoteric changes to the way INSERT statements are rendered for dialects that already support cursor.lastrowid. Adding a feature to 1.4.x means I have to support it when it breaks, and as we are at 1.4.41 very deep into the 1.4 series, this is not the time for major new behaviors / support cases to be added.

That said, to get SQLite to render an explicit RETURNING clause, you could likely just add the part of the code right here:

def returning_clause(
self,
stmt,
returning_cols,
*,
populate_result_map,
**kw,
):
kw["include_table"] = False
return super().returning_clause(
stmt, returning_cols, populate_result_map=populate_result_map, **kw
)

that won't integrate RETURNING into any ORM processes or anything like that, however.

@ghallberg
Copy link

ghallberg commented Sep 16, 2022 via email

@CaselIT
Copy link
Member

CaselIT commented Sep 16, 2022

the first betas should came out in the fall

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
returning SQL returning support sql sqlite use case not really a feature or a bug; can be support for new DB features or user use cases not anticipated
Projects
No open projects
sqlalchemy 2
  
2.0 deliverables done
Development

No branches or pull requests

9 participants