Repository: djrobstep/sqlakeyset
Pull request #77 (2023-03-14 11:45:18)

Add support for sqlalchemy 2
This PR adds support for sqlalchemy 2.

I followed the [docs](https://docs.sqlalchemy.org/en/14/changelog/migration_20.html) for migrating to version 2:

First I made my local environment ready by being able to run the tests (I used only [sqlite](https://hub.docker.com/r/keinos/sqlite3)). 
  - For that I had to upgrade `poetry` (I also tried to make a PR for that but it always failed in ci and I couldn't figure out the reason yet...). I also had to change the syntax of the `toml` file for github dependencies and bump the python version to >=3.7.

Then I turned on [RemovedIn20Warnings](https://docs.sqlalchemy.org/en/14/changelog/migration_20.html#migration-to-2-0-step-two-turn-on-removedin20warnings) and fixed those warnings (here is a list):
   - MovedIn20Warning: The ``declarative_base()`` function is now available as sqlalchemy.orm.declarative_base(). (deprecated since: 1.4) (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)
  - RemovedIn20Warning: The legacy calling style of select() is deprecated and will be removed in SQLAlchemy 2.0.  Please use the new calling style described at select(). (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)
  - RemovedIn20Warning: This Session located a target engine via bound metadata; as this functionality will be removed in SQLAlchemy 2.0, an Engine object should be passed to the Session() constructor directly. (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)
  - RemovedIn20Warning: The Query.from_self() method is considered legacy as of the 1.x series of SQLAlchemy and will be removed in 2.0. The new approach is to use the orm.aliased() construct in conjunction with a subquery.  See the section "Selecting from the query itself as a subquery" in the 2.0 migration notes for an example. (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)

I added `future=True` to engine and sessionmaker. This was only in tests.

Finally I upgraded to sqlachemy 2.

@djrobstep I think I need your help here. I don't seem to be able to upgrade poetry. I tried also in a separate [PR](#76) but did not manage. The output of `poetry install` does not show me any errors but the step fails in ci. Locally I have everything working fine 🤔 I did try a lot of ideas but nothing helped me understand why install fails. Can you help me? I think it would be great if `sqlakeyset` support sqlalchemy 2.0 :) Thanks!

Update 1: I suspect an out of memory error in circleci 🤔 
Update 2: the problem was https://github.com/python-poetry/poetry/issues/7184 and was solved by passing --no-ansi to the poetry install command.

Note: we might need to change the tests if we want to support sqlalchemy < 1.4


acarapetis: Thanks for your effort!
We need to consider the best way forward regarding sqlalchemy-1 compatibility: I thought we'd need to bump our major version and drop support entirely, but from a quick look at your changes it seems we can pretty easily keep the same `select_page` API with backwards compatibility. I'll have a go at updating the tests to work with both versions (and perhaps add some new SQLAlchemy2-style ORM tests using `select_page`).
acarapetis: I've merged #78 to master, rebased this branch on master and squashed a bunch of commits.
There is much less change than I was expecting to be necessary. Having trouble getting tests to run, but looks like it might be an issue with sqlbag and sqlalchemy2, working on it.
To be sure I understand, is the only reason for the `sqla14`/`sqla20` split the fact that `sqlalchemy.engine.row.LegacyRow` no longer exists in `sqlalchemy==2`? If so, we can simplify this quite a bit.
filias: > I've merged #78 to master, rebased this branch on master and squashed a bunch of commits. There is much less change than I was expecting to be necessary. Having trouble getting tests to run, but looks like it might be an issue with sqlbag and sqlalchemy2, working on it.

That's really great :) Thank you for following up on this :)

> To be sure I understand, is the only reason for the `sqla14`/`sqla20` split the fact that `sqlalchemy.engine.row.LegacyRow` no longer exists in `sqlalchemy==2`? If so, we can simplify this quite a bit.

Yes, that was the reason. Great that it can be simplified 👍 
acarapetis: As you can see I left the sqla14/sqla20 split more or less as you had it, just reorganized the files a little and made all imports explicit. Tests are passing with all sqlalchemy versions so I think we're good to go. I'm working on some additional stuff (type hints, asyncio support) on another branch but I'm going to go ahead and release this chunk now. Thanks again for your contribution!

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index a1c72cf..da0474e 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -2,8 +2,9 @@ version: 2
 jobs:
   build:
     working_directory: ~/circleci
+    resource_class: large
     docker:
-      - image: cimg/python:3.8
+      - image: cimg/python:3.7
       - image: cimg/postgres:12.12
         environment:
           POSTGRES_USER: circleci
@@ -21,7 +22,7 @@ jobs:
     steps:
       - checkout
       - restore_cache:
-          key: deps2-{{ .Branch }}-{{ checksum "pyproject.toml" }}
+          key: deps2-py37-{{ .Branch }}-{{ checksum "pyproject.toml" }}
       - run: sudo apt-get update
       - run: sudo apt-get install -y postgresql-client
       - run: sudo apt-get install -y default-mysql-client
@@ -42,8 +43,9 @@ jobs:
 
             poetry config virtualenvs.create false
             poetry install --no-ansi
+            pip install pytest-xdist # we can speed up tests by running in parallel
       - save_cache:
-          key: deps2-{{ .Branch }}-{{ checksum "pyproject.toml" }}
+          key: deps2-py37-{{ .Branch }}-{{ checksum "pyproject.toml" }}
           paths:
             - "~/.venv"
       - run:
@@ -51,31 +53,36 @@ jobs:
           command: |
             . ~/.venv/bin/activate
             make lint
+      - run:
+          name: Test with SQLAlchemy 2.0
+          command: |
+            . ~/.venv/bin/activate
+            pip install 'sqlalchemy>=2.0'
+            PYTEST_ARGS="-n4" make test
       - run:
           name: Test with SQLAlchemy 1.3
           command: |
             . ~/.venv/bin/activate
             pip install 'sqlalchemy>=1.3,<1.4'
-            make test
+            PYTEST_ARGS="-n4" make test
       - run:
           name: Test with SQLAlchemy 1.4
           command: |
             . ~/.venv/bin/activate
-            # sqla14-compatible sqlbag isn't on PyPI yet
-            pip install --upgrade sqlalchemy~=1.4 "git+https://github.com/djrobstep/sqlbag.git@f4759318710ef33dc041710a6ff38f03d3c39b56"
-            make test
+            pip install 'sqlalchemy>=1.4,<2.0'
+            PYTEST_ARGS="-n4" make test
       - store_test_results:
           path: test-reports
 
   publish:
     working_directory: ~/circleci
     docker:
-      - image: cimg/python:3.8
+      - image: cimg/python:3.7
     steps:
       - setup_remote_docker
       - checkout
       - restore_cache:
-          key: deps2-{{ .Branch }}-{{ checksum "pyproject.toml" }}
+          key: deps2-py37-{{ .Branch }}-{{ checksum "pyproject.toml" }}
       - run:
           name: Install deps
           command: |
diff --git a/Makefile b/Makefile
index df32ef7..289e92c 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@
 # test commands and arguments
 tcommand = PYTHONPATH=. py.test -x
 tmessy = -svv
-targs = --cov-report term-missing --cov sqlakeyset --junitxml=test-reports/pytest-`date +%s`.xml
+targs = --cov-report term-missing --cov sqlakeyset --junitxml=test-reports/pytest-`date +%s`.xml ${PYTEST_ARGS}
 
 pip:
 	pip install --upgrade pip
diff --git a/README.rst b/README.rst
index 0f2d74a..be6bec2 100644
--- a/README.rst
+++ b/README.rst
@@ -13,11 +13,11 @@ sqlakeyset: offset-free paging for sqlalchemy
     :alt: conda-forge
     :target: https://anaconda.org/conda-forge/sqlakeyset
 
-sqlakeyset implements keyset-based paging for SQLAlchemy (both ORM and core).
+sqlakeyset implements keyset-based paging for SQLAlchemy (both ORM and core). **Now with full SQLAlchemy 2 support!**
 
-This library is tested with PostgreSQL, MariaDB/MySQL and SQLite. It should work with other SQLAlchemy-supported databases, too; but you should verify the results are correct.
+This library is tested with PostgreSQL, MariaDB/MySQL and SQLite. It should work with many other SQLAlchemy-supported databases, too; but caveat emptor - you should verify the results are correct.
 
-**Notice:** In accordance with Python 2's end-of-life, we've stopped supporting Python versions earlier than 3.4. If you really need it, the latest version to support Python 2 is 0.1.1559103842, but you'll miss out on all the latest features and bugfixes from the latest version. You should be upgrading anyway!
+**Notice:** In accordance with Python end-of-life dates, we've stopped supporting Python versions earlier than 3.7. If you really need it, the latest version to support Python 2 is 0.1.1559103842 and Python 3.4 is 1.0.1679209451, but you'll miss out on all the latest features and bugfixes from the latest version. You should be upgrading anyway!
 
 Background
 ----------
@@ -28,39 +28,52 @@ A lot of people use SQL's ``OFFSET`` syntax to implement paging of query results
 Getting Started
 ---------------
 
-Here's how it works with a typical ORM query:
+Here's how it works with a typical SQLAlchemy 2.0-style query (or SQLAlchemy 1.3 Core):
 
 .. code-block:: python
 
-    from sqlakeyset import get_page
+    from sqlalchemy import select
+    from sqlakeyset import select_page
     from sqlbag import S
 
     from models import Book
 
-    with S('postgresql:///books') as s:  # create a session
-        q = s.query(Book).order_by(Book.author, Book.title, Book.id)  #
+    with S('postgresql:///books') as s:  # s is a session
+        q = select(Book).order_by(Book.author, Book.title, Book.id)
 
         # gets the first page
-        page1 = get_page(q, per_page=20)
+        page1 = select_page(s, q, per_page=20)
 
         # gets the key for the next page
         next_page = page1.paging.next
 
         # gets the second page
-        page2 = get_page(q, per_page=20, page=next_page)
+        page2 = select_page(s, q, per_page=20, page=next_page)
 
         # returning to the first page, getting the key
         previous_page = page2.paging.previous
 
         # the first page again, backwards from the previous page
-        page1 = get_page(q, per_page=20, page=previous_page)
+        page1 = select_page(s, q, per_page=20, page=previous_page)
 
         # what if new items were added at the start?
         if page1.paging.has_previous:
 
             # go back even further
             previous_page = page1.paging.previous
-            page1 = get_page(q, per_page=20, page=previous_page)
+            page1 = select_page(s, q, per_page=20, page=previous_page)
+
+
+If you're still using legacy (i.e. SQLAlchemy 1.3-style) ORM queries, you can
+use ``get_page`` instead, which has an identical API other than the omission of
+the session/connection argument:
+
+.. code-block:: python
+    from sqlakeyset import select_page
+    with S('postgresql:///books') as s:
+        q = s.query(Book).order_by(Book.author, Book.title, Book.id)
+        page1 = get_page(q, per_page=20)
+        # ...
 
 
 Under the Hood
@@ -133,14 +146,14 @@ Limitations
 .. code-block:: python
 
     from sqlakeyset import get_page
-    from sqlalchemy import func
+    from sqlalchemy import func, select
     from sqlbag import S
     from models import Book
     with S('postgresql:///books') as s:
         # If Book.cost can be NULL:
-        q = s.query(Book).order_by(func.coalesce(Book.cost, 0), Book.id)
+        q = select(Book).order_by(func.coalesce(Book.cost, 0), Book.id)
         # Assuming cost is non-negative, page1 will start with books where cost is null:
-        page1 = get_page(q, per_page=20)
+        page1 = select_page(s, q, per_page=20)
 
 - If you're using the in-built keyset serialization, this only handles basic data/column types so far (strings, ints, floats, datetimes, dates, booleans, and a few others). The serialization can be extended to serialize more advanced types as necessary (documentation on this is forthcoming).
 
diff --git a/poetry.lock b/poetry.lock
index 28b37a4..106a763 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand.
 
 [[package]]
 name = "arrow"
@@ -412,18 +412,6 @@ files = [
     {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"},
 ]
 
-[[package]]
-name = "pathlib"
-version = "1.0.1"
-description = "Object-oriented filesystem paths"
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "pathlib-1.0.1-py3-none-any.whl", hash = "sha256:f35f95ab8b0f59e6d354090350b44a80a80635d22efdedfa84c7ad1cf0a74147"},
-    {file = "pathlib-1.0.1.tar.gz", hash = "sha256:6940718dfc3eff4258203ad5021090933e5c04707d5ca8cc9e73c94a7894ea9f"},
-]
-
 [[package]]
 name = "pathspec"
 version = "0.11.1"
@@ -743,78 +731,81 @@ files = [
 
 [[package]]
 name = "sqlalchemy"
-version = "1.4.46"
+version = "2.0.7"
 description = "Database Abstraction Library"
 category = "main"
 optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
-files = [
-    {file = "SQLAlchemy-1.4.46-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:7001f16a9a8e06488c3c7154827c48455d1c1507d7228d43e781afbc8ceccf6d"},
-    {file = "SQLAlchemy-1.4.46-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c7a46639ba058d320c9f53a81db38119a74b8a7a1884df44d09fbe807d028aaf"},
-    {file = "SQLAlchemy-1.4.46-cp27-cp27m-win32.whl", hash = "sha256:c04144a24103135ea0315d459431ac196fe96f55d3213bfd6d39d0247775c854"},
-    {file = "SQLAlchemy-1.4.46-cp27-cp27m-win_amd64.whl", hash = "sha256:7b81b1030c42b003fc10ddd17825571603117f848814a344d305262d370e7c34"},
-    {file = "SQLAlchemy-1.4.46-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:939f9a018d2ad04036746e15d119c0428b1e557470361aa798e6e7d7f5875be0"},
-    {file = "SQLAlchemy-1.4.46-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:b7f4b6aa6e87991ec7ce0e769689a977776db6704947e562102431474799a857"},
-    {file = "SQLAlchemy-1.4.46-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dbf17ac9a61e7a3f1c7ca47237aac93cabd7f08ad92ac5b96d6f8dea4287fc1"},
-    {file = "SQLAlchemy-1.4.46-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7f8267682eb41a0584cf66d8a697fef64b53281d01c93a503e1344197f2e01fe"},
-    {file = "SQLAlchemy-1.4.46-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cb0ad8a190bc22d2112001cfecdec45baffdf41871de777239da6a28ed74b6"},
-    {file = "SQLAlchemy-1.4.46-cp310-cp310-win32.whl", hash = "sha256:5f752676fc126edc1c4af0ec2e4d2adca48ddfae5de46bb40adbd3f903eb2120"},
-    {file = "SQLAlchemy-1.4.46-cp310-cp310-win_amd64.whl", hash = "sha256:31de1e2c45e67a5ec1ecca6ec26aefc299dd5151e355eb5199cd9516b57340be"},
-    {file = "SQLAlchemy-1.4.46-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d68e1762997bfebf9e5cf2a9fd0bcf9ca2fdd8136ce7b24bbd3bbfa4328f3e4a"},
-    {file = "SQLAlchemy-1.4.46-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d112b0f3c1bc5ff70554a97344625ef621c1bfe02a73c5d97cac91f8cd7a41e"},
-    {file = "SQLAlchemy-1.4.46-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69fac0a7054d86b997af12dc23f581cf0b25fb1c7d1fed43257dee3af32d3d6d"},
-    {file = "SQLAlchemy-1.4.46-cp311-cp311-win32.whl", hash = "sha256:887865924c3d6e9a473dc82b70977395301533b3030d0f020c38fd9eba5419f2"},
-    {file = "SQLAlchemy-1.4.46-cp311-cp311-win_amd64.whl", hash = "sha256:984ee13543a346324319a1fb72b698e521506f6f22dc37d7752a329e9cd00a32"},
-    {file = "SQLAlchemy-1.4.46-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:9167d4227b56591a4cc5524f1b79ccd7ea994f36e4c648ab42ca995d28ebbb96"},
-    {file = "SQLAlchemy-1.4.46-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d61e9ecc849d8d44d7f80894ecff4abe347136e9d926560b818f6243409f3c86"},
-    {file = "SQLAlchemy-1.4.46-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3ec187acf85984263299a3f15c34a6c0671f83565d86d10f43ace49881a82718"},
-    {file = "SQLAlchemy-1.4.46-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9883f5fae4fd8e3f875adc2add69f8b945625811689a6c65866a35ee9c0aea23"},
-    {file = "SQLAlchemy-1.4.46-cp36-cp36m-win32.whl", hash = "sha256:535377e9b10aff5a045e3d9ada8a62d02058b422c0504ebdcf07930599890eb0"},
-    {file = "SQLAlchemy-1.4.46-cp36-cp36m-win_amd64.whl", hash = "sha256:18cafdb27834fa03569d29f571df7115812a0e59fd6a3a03ccb0d33678ec8420"},
-    {file = "SQLAlchemy-1.4.46-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:a1ad90c97029cc3ab4ffd57443a20fac21d2ec3c89532b084b073b3feb5abff3"},
-    {file = "SQLAlchemy-1.4.46-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4847f4b1d822754e35707db913396a29d874ee77b9c3c3ef3f04d5a9a6209618"},
-    {file = "SQLAlchemy-1.4.46-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c5a99282848b6cae0056b85da17392a26b2d39178394fc25700bcf967e06e97a"},
-    {file = "SQLAlchemy-1.4.46-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4b1cc7835b39835c75cf7c20c926b42e97d074147c902a9ebb7cf2c840dc4e2"},
-    {file = "SQLAlchemy-1.4.46-cp37-cp37m-win32.whl", hash = "sha256:c522e496f9b9b70296a7675272ec21937ccfc15da664b74b9f58d98a641ce1b6"},
-    {file = "SQLAlchemy-1.4.46-cp37-cp37m-win_amd64.whl", hash = "sha256:ae067ab639fa499f67ded52f5bc8e084f045d10b5ac7bb928ae4ca2b6c0429a5"},
-    {file = "SQLAlchemy-1.4.46-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:e3c1808008124850115a3f7e793a975cfa5c8a26ceeeb9ff9cbb4485cac556df"},
-    {file = "SQLAlchemy-1.4.46-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d164df3d83d204c69f840da30b292ac7dc54285096c6171245b8d7807185aa"},
-    {file = "SQLAlchemy-1.4.46-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b33ffbdbbf5446cf36cd4cc530c9d9905d3c2fe56ed09e25c22c850cdb9fac92"},
-    {file = "SQLAlchemy-1.4.46-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d94682732d1a0def5672471ba42a29ff5e21bb0aae0afa00bb10796fc1e28dd"},
-    {file = "SQLAlchemy-1.4.46-cp38-cp38-win32.whl", hash = "sha256:f8cb80fe8d14307e4124f6fad64dfd87ab749c9d275f82b8b4ec84c84ecebdbe"},
-    {file = "SQLAlchemy-1.4.46-cp38-cp38-win_amd64.whl", hash = "sha256:07e48cbcdda6b8bc7a59d6728bd3f5f574ffe03f2c9fb384239f3789c2d95c2e"},
-    {file = "SQLAlchemy-1.4.46-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:1b1e5e96e2789d89f023d080bee432e2fef64d95857969e70d3cadec80bd26f0"},
-    {file = "SQLAlchemy-1.4.46-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3714e5b33226131ac0da60d18995a102a17dddd42368b7bdd206737297823ad"},
-    {file = "SQLAlchemy-1.4.46-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:955162ad1a931fe416eded6bb144ba891ccbf9b2e49dc7ded39274dd9c5affc5"},
-    {file = "SQLAlchemy-1.4.46-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6e4cb5c63f705c9d546a054c60d326cbde7421421e2d2565ce3e2eee4e1a01f"},
-    {file = "SQLAlchemy-1.4.46-cp39-cp39-win32.whl", hash = "sha256:51e1ba2884c6a2b8e19109dc08c71c49530006c1084156ecadfaadf5f9b8b053"},
-    {file = "SQLAlchemy-1.4.46-cp39-cp39-win_amd64.whl", hash = "sha256:315676344e3558f1f80d02535f410e80ea4e8fddba31ec78fe390eff5fb8f466"},
-    {file = "SQLAlchemy-1.4.46.tar.gz", hash = "sha256:6913b8247d8a292ef8315162a51931e2b40ce91681f1b6f18f697045200c4a30"},
+python-versions = ">=3.7"
+files = [
+    {file = "SQLAlchemy-2.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7917632606fc5d4be661dcde45cc415df835e594e2c50cc999a44f24b6bf6d92"},
+    {file = "SQLAlchemy-2.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32f508fef9c5a7d19411d94ef64cf5405e42c4689e51ddbb81ac9a7be045cce8"},
+    {file = "SQLAlchemy-2.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0995b92612979d208189245bf87349ad9243b97b49652347a28ddee0803225a"},
+    {file = "SQLAlchemy-2.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cebd161f964af58290596523c65e41a5a161a99f7212b1ae675e288a4b5e0a7c"},
+    {file = "SQLAlchemy-2.0.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c38641f5c3714505d65dbbd8fb1350408b9ad8461769ec8e440e1177f9c92d1d"},
+    {file = "SQLAlchemy-2.0.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:921485d1f69ed016e1f756de67d02ad4f143eb6b92b9776bfff78786d8978ab5"},
+    {file = "SQLAlchemy-2.0.7-cp310-cp310-win32.whl", hash = "sha256:a65a8fd09bdffd63fa23b39cd902e6a4ca23d86ecfe129513e43767a1f3e91fb"},
+    {file = "SQLAlchemy-2.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:d2e7411d5ea164c6f4d003f5d4f5e72e202956aaa7496b95bb4a4c39669e001c"},
+    {file = "SQLAlchemy-2.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:432cfd77642771ee7ea0dd0f3fb664f18506a3625eab6e6d5d1d771569171270"},
+    {file = "SQLAlchemy-2.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ce076e25f1170000b4ecdc57a1ff8a70dbe4a5648ec3da0563ef3064e8db4f15"},
+    {file = "SQLAlchemy-2.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14854bdb2a35af536d14f77dfa8dbc20e1bb1972996d64c4147e0d3165c9aaf5"},
+    {file = "SQLAlchemy-2.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9020125e3be677c64d4dda7048e247343f1663089cf268a4cc98c957adb7dbe0"},
+    {file = "SQLAlchemy-2.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fb649c5473f79c9a7b6133f53a31f4d87de14755c79224007eb7ec76e628551e"},
+    {file = "SQLAlchemy-2.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:33f73cc45ffa050f5c3b60ff4490e0ae9e02701461c1600d5ede1b008076b1b9"},
+    {file = "SQLAlchemy-2.0.7-cp311-cp311-win32.whl", hash = "sha256:0789e199fbce8cb1775337afc631ed12bcc5463dd77d7a06b8dafd758cde51f8"},
+    {file = "SQLAlchemy-2.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:013f4f330001e84a2b0ef1f2c9bd73169c79d582e54e1a144be1be1dbc911711"},
+    {file = "SQLAlchemy-2.0.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4339110be209fea37a2bb4f35f1127c7562a0393e9e6df5d9a65cc4f5c167cb6"},
+    {file = "SQLAlchemy-2.0.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7e61e2e4dfe175dc3510889e44eda1c32f55870d6950ef40519640cb266704d"},
+    {file = "SQLAlchemy-2.0.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d44ff7573016fc26311b5a5c54d5656fb9e0c39e138bc8b81cb7c8667485203"},
+    {file = "SQLAlchemy-2.0.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:57b80e877eb6ec63295835f8a3b86ca3a44829f80c4748e1b019e03adea550fc"},
+    {file = "SQLAlchemy-2.0.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e90f0be674e0845c5c1ccfa5e31c9ee28fd406546a61afc734355cc7ea1f8f8b"},
+    {file = "SQLAlchemy-2.0.7-cp37-cp37m-win32.whl", hash = "sha256:e735a635126b2338dfd3a0863b675437cb53d85885a7602b8cffb24345df33ed"},
+    {file = "SQLAlchemy-2.0.7-cp37-cp37m-win_amd64.whl", hash = "sha256:ea1c63e61b5c13161c8468305f0a5837c80aae2070e33654c68dd12572b638eb"},
+    {file = "SQLAlchemy-2.0.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cc337b96ec59ef29907eeadc2ac11188739281568f14c719e61550ca6d201a41"},
+    {file = "SQLAlchemy-2.0.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0eac488be90dd3f7a655d2e34fa59e1305fccabc4abfbd002e3a72ae10bd2f89"},
+    {file = "SQLAlchemy-2.0.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8ab8f90f4a13c979e6c41c9f011b655c1b9ae2df6cffa8fa2c7c4d740f3512e"},
+    {file = "SQLAlchemy-2.0.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc370d53fee7408330099c4bcc2573a107757b203bc61f114467dfe586a0c7bd"},
+    {file = "SQLAlchemy-2.0.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:494db0026918e3f707466a1200a5dedbf254a4bce01a3115fd95f04ba8258f09"},
+    {file = "SQLAlchemy-2.0.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:486015a58c9a67f65a15b4f19468b35b97cee074ae55386a9c240f1da308fbfe"},
+    {file = "SQLAlchemy-2.0.7-cp38-cp38-win32.whl", hash = "sha256:5f7c40ec2e3b31293184020daba95850832bea523a08496ac89b27a5276ec804"},
+    {file = "SQLAlchemy-2.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:3da3dff8d9833a7d7f66a3c45a79a3955f775c79f47bb7eea266d0b4c267b17a"},
+    {file = "SQLAlchemy-2.0.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:774965c41b71c8ebe3c5728bf5b9a948231fc3a0422d9fdace0686f5bb689ad6"},
+    {file = "SQLAlchemy-2.0.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:94556a2a7fc3de094ea056b62845e2e6e271e26d1e1b2540a1cd2d2506257a10"},
+    {file = "SQLAlchemy-2.0.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f15c54713a8dd57a01c974c9f96476688f6f6374d348819ed7e459535844b614"},
+    {file = "SQLAlchemy-2.0.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea9461f6955f3cf9eff6eeec271686caed7792c76f5b966886a36a42ea46e6b2"},
+    {file = "SQLAlchemy-2.0.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:18795e87601b4244fd08b542cd6bff9ef674b17bcd34e4a3c9935398e2cc762c"},
+    {file = "SQLAlchemy-2.0.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0b698440c477c00bdedff87348b19a79630a235864a8f4378098d61079c16ce9"},
+    {file = "SQLAlchemy-2.0.7-cp39-cp39-win32.whl", hash = "sha256:38e26cf6b9b4c6c37846f7e31b42e4d664b35f055691265f07e06aeb6167c494"},
+    {file = "SQLAlchemy-2.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:a6f7d1debb233f1567d700ebcdde0781a0b63db0ef266246dfbf75ae41bfdf85"},
+    {file = "SQLAlchemy-2.0.7-py3-none-any.whl", hash = "sha256:fc67667c8e8c04e5c3250ab2cd51df40bc7c28c7c253d0475b377eff86fe4bb0"},
+    {file = "SQLAlchemy-2.0.7.tar.gz", hash = "sha256:a4c1e1582492c66dfacc9eab52738f3e64d9a2a380e412668f75aa06e540f649"},
 ]
 
 [package.dependencies]
-greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"}
+greenlet = {version = "!=0.4.17", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""}
 importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+typing-extensions = ">=4.2.0"
 
 [package.extras]
 aiomysql = ["aiomysql", "greenlet (!=0.4.17)"]
 aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing-extensions (!=3.10.0.1)"]
 asyncio = ["greenlet (!=0.4.17)"]
-asyncmy = ["asyncmy (>=0.2.3,!=0.2.4)", "greenlet (!=0.4.17)"]
-mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2)"]
+asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"]
+mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"]
 mssql = ["pyodbc"]
 mssql-pymssql = ["pymssql"]
 mssql-pyodbc = ["pyodbc"]
-mypy = ["mypy (>=0.910)", "sqlalchemy2-stubs"]
-mysql = ["mysqlclient (>=1.4.0)", "mysqlclient (>=1.4.0,<2)"]
+mypy = ["mypy (>=0.910)"]
+mysql = ["mysqlclient (>=1.4.0)"]
 mysql-connector = ["mysql-connector-python"]
-oracle = ["cx-oracle (>=7)", "cx-oracle (>=7,<8)"]
+oracle = ["cx-oracle (>=7)"]
+oracle-oracledb = ["oracledb (>=1.0.1)"]
 postgresql = ["psycopg2 (>=2.7)"]
 postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"]
-postgresql-pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"]
+postgresql-pg8000 = ["pg8000 (>=1.29.1)"]
+postgresql-psycopg = ["psycopg (>=3.0.7)"]
 postgresql-psycopg2binary = ["psycopg2-binary"]
 postgresql-psycopg2cffi = ["psycopg2cffi"]
-pymysql = ["pymysql", "pymysql (<1)"]
+pymysql = ["pymysql"]
 sqlcipher = ["sqlcipher3-binary"]
 
 [[package]]
@@ -850,7 +841,7 @@ url = ["furl (>=0.4.1)"]
 [[package]]
 name = "sqlbag"
 version = "0.1.1579049654"
-description = "various snippets of SQL-related boilerplate"
+description = ""
 category = "dev"
 optional = false
 python-versions = "*"
@@ -858,7 +849,6 @@ files = []
 develop = false
 
 [package.dependencies]
-pathlib = "*"
 six = "*"
 sqlalchemy = "*"
 
@@ -869,9 +859,9 @@ pg = ["psycopg2"]
 
 [package.source]
 type = "git"
-url = "https://github.com/djrobstep/sqlbag.git"
-reference = "f4759318710ef33dc041710a6ff38f03d3c39b56"
-resolved_reference = "f4759318710ef33dc041710a6ff38f03d3c39b56"
+url = "https://github.com/acarapetis/sqlbag.git"
+reference = "0a97a185070228b43cb86181e5df8051f46ebc86"
+resolved_reference = "0a97a185070228b43cb86181e5df8051f46ebc86"
 
 [[package]]
 name = "toml"
@@ -962,4 +952,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.7,<4.0"
-content-hash = "67878d5f3acffe88d0dcf420976f453250b94341d70b8e550c625e79e5db176b"
+content-hash = "811e11b27a6160a71a4beecf4abb818a88d74fc670de82f7207213589b7d74eb"
diff --git a/pyproject.toml b/pyproject.toml
index 292a43f..10a18fd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,12 +11,12 @@ homepage = "https://github.com/djrobstep/sqlakeyset"
 
 [tool.poetry.dependencies]
 python = ">=3.7,<4.0"
-sqlalchemy = ">=1.3.11,<2.0"
+sqlalchemy = ">=1.3.11"
 python-dateutil = "*"
 packaging = ">=20.0"
 
 [tool.poetry.dev-dependencies]
-sqlbag = { git = "https://github.com/djrobstep/sqlbag.git", rev = "f4759318710ef33dc041710a6ff38f03d3c39b56" }
+sqlbag = { git = "https://github.com/acarapetis/sqlbag.git", rev = "0a97a185070228b43cb86181e5df8051f46ebc86" }
 pytest = "*"
 pytest-cov = "*"
 pytest-clarity = ">=1.0.0"
diff --git a/sqlakeyset/paging.py b/sqlakeyset/paging.py
index c96248e..35380fc 100644
--- a/sqlakeyset/paging.py
+++ b/sqlakeyset/paging.py
@@ -107,7 +107,10 @@ def perform_paging(q, per_page, place, backwards, orm=True, s=None):
         if not s:
             raise ValueError("Cannot page core selectable without a session/connection")
         selectable = q
-        column_descriptions = q._raw_columns
+        try:
+            column_descriptions = q.column_descriptions
+        except Exception:
+            column_descriptions = q._raw_columns
 
     dialect = get_bind(q, s).dialect
 
@@ -255,7 +258,7 @@ def process_args(after=False, before=False, page=None):
 def select_page(
     s, selectable, per_page=PER_PAGE_DEFAULT, after=False, before=False, page=None
 ):
-    """Get a page of results from a SQLAlchemy Core selectable.
+    """Get a page of results from a SQLAlchemy Core (or new-style ORM) selectable.
 
     Specify no more than one of the arguments ``page``, ``after`` or
     ``before``. If none of these are provided, the first page is returned.
@@ -281,7 +284,7 @@ def select_page(
 
 
 def get_page(query, per_page=PER_PAGE_DEFAULT, after=False, before=False, page=None):
-    """Get a page of results for an ORM query.
+    """Get a page of results for a Legacy ORM query.
 
     Specify no more than one of the arguments ``page``, ``after`` or
     ``before``. If none of these are provided, the first page is returned.
diff --git a/sqlakeyset/sqla.py b/sqlakeyset/sqla.py
index 8c72690..9f966e6 100644
--- a/sqlakeyset/sqla.py
+++ b/sqlakeyset/sqla.py
@@ -2,6 +2,9 @@ import sqlalchemy
 from packaging import version
 
 
+SQLA_VERSION = version.parse(sqlalchemy.__version__)
+
+
 def get_bind(q, s):
     try:
         # session
@@ -11,7 +14,48 @@ def get_bind(q, s):
         return s
 
 
-if version.parse(sqlalchemy.__version__) < version.parse("1.4.0b1"):
-    from .sqla13 import *  # noqa
+if SQLA_VERSION < version.parse("1.4.0b1"):
+    from .sqla13 import (
+        core_coerce_row,
+        core_result_type,
+        group_by_clauses,
+        order_by_clauses,
+        orm_coerce_row,
+        orm_query_keys,
+        orm_result_type,
+        orm_to_selectable,
+    )
+elif SQLA_VERSION < version.parse("2.0.0b1"):
+    from .sqla14 import (
+        core_coerce_row,
+        core_result_type,
+        group_by_clauses,
+        order_by_clauses,
+        orm_coerce_row,
+        orm_query_keys,
+        orm_result_type,
+        orm_to_selectable,
+    )
 else:
-    from .sqla14 import *  # noqa
+    from .sqla20 import (
+        core_coerce_row,
+        core_result_type,
+        group_by_clauses,
+        order_by_clauses,
+        orm_coerce_row,
+        orm_query_keys,
+        orm_result_type,
+        orm_to_selectable,
+    )
+
+__all__ = [
+    "core_coerce_row",
+    "core_result_type",
+    "get_bind",
+    "group_by_clauses",
+    "order_by_clauses",
+    "orm_coerce_row",
+    "orm_query_keys",
+    "orm_result_type",
+    "orm_to_selectable",
+]
diff --git a/sqlakeyset/sqla13.py b/sqlakeyset/sqla13.py
index 4f04e1f..069a904 100644
--- a/sqlakeyset/sqla13.py
+++ b/sqlakeyset/sqla13.py
@@ -66,3 +66,15 @@ def order_by_clauses(selectable):
 def group_by_clauses(selectable):
     """Extract the GROUP BY clause list from a select/query"""
     return selectable._group_by_clause
+
+
+__all__ = [
+    "core_coerce_row",
+    "core_result_type",
+    "group_by_clauses",
+    "order_by_clauses",
+    "orm_coerce_row",
+    "orm_query_keys",
+    "orm_result_type",
+    "orm_to_selectable",
+]
diff --git a/sqlakeyset/sqla14.py b/sqlakeyset/sqla14.py
index c2aad27..de367de 100644
--- a/sqlakeyset/sqla14.py
+++ b/sqlakeyset/sqla14.py
@@ -1,84 +1,31 @@
 """Methods for messing with the internals of SQLAlchemy >1.3 results."""
-from sqlalchemy.engine.result import result_tuple
-from sqlalchemy.engine.row import Row as _Row, LegacyRow as _LegacyRow
+from sqlalchemy.engine.row import LegacyRow as _LegacyRow
 
 from .constants import ORDER_COL_PREFIX
-
-
-def orm_query_keys(query):
-    """Given a SQLAlchemy ORM query, extract the list of column keys expected
-    in the result."""
-    return [c["name"] for c in query.column_descriptions]
-
-
-def _create_result_tuple(compile_state):
-    """Create a `_row_getter` function to use as `result_type`"""
-    # This is adapted from the internals of sqlalchemy.orm.loading
-    keys = [ent._label_name for ent in compile_state._entities]
-
-    keyed_tuple = result_tuple(
-        keys, [ent._extra_entities for ent in compile_state._entities]
-    )
-    return keyed_tuple
-
-
-def _is_single_entity(compile_state, query_context):
-    # This is copied from the internals of sqlalchemy.orm.loading
-    return (
-        not query_context.load_options._only_return_tuples
-        and len(compile_state._entities) == 1
-        and compile_state._entities[0].supports_single_entity
-    )
-
-
-def orm_result_type(query):
-    """Return the type constructor for rows that would be returned by a given
-    query; or the identity function for queries that return a single entity
-    rather than rows.
-
-    :param query: The query to inspect.
-    :type query: :class:`sqlalchemy.orm.query.Query`.
-    :returns: either a named tuple type or the identity."""
-
-    state = query._compile_state()
-    if _is_single_entity(state, query._compile_context()):
-        return lambda x: x[0]
-    return _create_result_tuple(state)
-
-
-def orm_coerce_row(row, extra_columns, result_type):
-    """Trim off the extra columns."""
-    # orm_get_page might have added some extra columns to the query in order
-    # to get the keys for the bookmark. Here, we split the rows back to the
-    # requested data and the ordering keys.
-    N = len(row) - len(extra_columns)
-    return result_type(row[:N])
-
-
-def core_result_type(selectable, s):
-    """Given a SQLAlchemy Core selectable and a connection/session, get the
-    type constructor for the result row type."""
-    # Unused in sqlalchemy 1.4: see core_coerce_row implementation
-    return None
-
-
-def result_keys(result):
-    return [k for k in result.keys if not k.startswith(ORDER_COL_PREFIX)]
-
-
-class Row(_Row):
-    def keys(self):
-        return result_keys(self._parent)
-
-
-class LegacyRow(_Row):
+from .sqla20 import (
+    Row,
+    core_result_type,
+    group_by_clauses,
+    order_by_clauses,
+    orm_coerce_row,
+    orm_query_keys,
+    orm_result_type,
+    orm_to_selectable,
+    result_keys,
+)
+
+
+class LegacyRow(_LegacyRow):
     def keys(self):
         return result_keys(self._parent)
 
 
 def core_coerce_row(row, extra_columns, result_type):
     """Trim off the extra columns and return as a correct-as-possible
-    sqlalchemy Row."""
+    sqlalchemy Row.
+
+    In SQLAlchemy 1.4 there is the possibility of the row being a transitional
+    LegacyRow, so we handle this case explicitly."""
     if not extra_columns:
         return row
     N = len(row) - len(extra_columns)
@@ -94,25 +41,20 @@ def core_coerce_row(row, extra_columns, result_type):
         {  # Strip out added OCs from the keymap:
             k: v
             for k, v in row._keymap.items()
-            if not v[1].startswith(ORDER_COL_PREFIX)
+            if not (isinstance(v[1], str) and v[1].startswith(ORDER_COL_PREFIX))
         },
         row._key_style,
         row._data[:N],
     )
 
 
-def orm_to_selectable(q):
-    """Normalize an ORM query into a selectable.
-
-    In SQLAlchemy 1.4, there is no distinction."""
-    return q
-
-
-def order_by_clauses(selectable):
-    """Extract the ORDER BY clause list from a select/query"""
-    return selectable._order_by_clauses
-
-
-def group_by_clauses(selectable):
-    """Extract the GROUP BY clause list from a select/query"""
-    return selectable._group_by_clauses
+__all__ = [
+    "core_coerce_row",
+    "core_result_type",
+    "group_by_clauses",
+    "order_by_clauses",
+    "orm_coerce_row",
+    "orm_query_keys",
+    "orm_result_type",
+    "orm_to_selectable",
+]
diff --git a/sqlakeyset/sqla20.py b/sqlakeyset/sqla20.py
new file mode 100644
index 0000000..a076f4b
--- /dev/null
+++ b/sqlakeyset/sqla20.py
@@ -0,0 +1,124 @@
+"""Methods for messing with the internals of SQLAlchemy 1.4/2.0 results."""
+from sqlalchemy.engine.result import result_tuple
+from sqlalchemy.engine.row import Row as _Row
+
+from .constants import ORDER_COL_PREFIX
+
+
+def orm_query_keys(query):
+    """Given a SQLAlchemy ORM query, extract the list of column keys expected
+    in the result."""
+    return [c["name"] for c in query.column_descriptions]
+
+
+def _create_result_tuple(compile_state):
+    """Create a `_row_getter` function to use as `result_type`"""
+    # This is adapted from the internals of sqlalchemy.orm.loading
+    keys = [ent._label_name for ent in compile_state._entities]
+
+    keyed_tuple = result_tuple(
+        keys, [ent._extra_entities for ent in compile_state._entities]
+    )
+    return keyed_tuple
+
+
+def _is_single_entity(compile_state, query_context):
+    # This is copied from the internals of sqlalchemy.orm.loading
+    return (
+        not query_context.load_options._only_return_tuples
+        and len(compile_state._entities) == 1
+        and compile_state._entities[0].supports_single_entity
+    )
+
+
+def orm_result_type(query):
+    """Return the type constructor for rows that would be returned by a given
+    query; or the identity function for queries that return a single entity
+    rather than rows.
+
+    :param query: The query to inspect.
+    :type query: :class:`sqlalchemy.orm.query.Query`.
+    :returns: either a named tuple type or the identity."""
+
+    state = query._compile_state()
+    if _is_single_entity(state, query._compile_context()):
+        return lambda x: x[0]
+    return _create_result_tuple(state)
+
+
+def orm_coerce_row(row, extra_columns, result_type):
+    """Trim off the extra columns."""
+    # orm_get_page might have added some extra columns to the query in order
+    # to get the keys for the bookmark. Here, we split the rows back to the
+    # requested data and the ordering keys.
+    N = len(row) - len(extra_columns)
+    return result_type(row[:N])
+
+
+def core_result_type(selectable, s):
+    """Given a SQLAlchemy Core selectable and a connection/session, get the
+    type constructor for the result row type."""
+    # Unused in sqlalchemy 1.4: see core_coerce_row implementation
+    return None
+
+
+def result_keys(result):
+    return [k for k in result.keys if not k.startswith(ORDER_COL_PREFIX)]
+
+
+class Row(_Row):
+    def keys(self):
+        return result_keys(self._parent)
+
+
+def orm_to_selectable(q):
+    """Normalize an ORM query into a selectable.
+
+    In SQLAlchemy 1.4, there is no distinction."""
+    return q
+
+
+def order_by_clauses(selectable):
+    """Extract the ORDER BY clause list from a select/query"""
+    return selectable._order_by_clauses
+
+
+def group_by_clauses(selectable):
+    """Extract the GROUP BY clause list from a select/query"""
+    return selectable._group_by_clauses
+
+
+def core_coerce_row(row, extra_columns, result_type):
+    """Trim off the extra columns and return as a correct-as-possible
+    sqlalchemy Row."""
+    if not extra_columns:
+        return row
+    N = len(row) - len(extra_columns)
+
+    cls = Row
+
+    return cls(
+        row._parent,
+        None,  # Processors are applied immediately in sqla1.4+
+        {  # Strip out added OCs from the keymap:
+            k: v
+            for k, v in row._keymap.items()
+            if not (isinstance(v[1], str) and v[1].startswith(ORDER_COL_PREFIX))
+        },
+        row._key_style,
+        row._data[:N],
+    )
+
+
+__all__ = [
+    "Row",
+    "core_coerce_row",
+    "core_result_type",
+    "group_by_clauses",
+    "order_by_clauses",
+    "orm_coerce_row",
+    "orm_query_keys",
+    "orm_result_type",
+    "orm_to_selectable",
+    "result_keys",
+]


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..2ce16be
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,282 @@
+import enum
+from datetime import timedelta
+from functools import partial
+from random import randrange
+
+import arrow
+import pytest
+from packaging import version
+from sqlalchemy import Column, Enum, ForeignKey, Integer, String, func, inspect
+from sqlalchemy import select as _select
+from sqlalchemy.ext.hybrid import hybrid_property
+from sqlalchemy.orm import column_property, relationship
+from sqlalchemy.types import TypeDecorator
+from sqlbag import S as _S
+from sqlbag import temporary_database
+
+from sqlakeyset.sqla import SQLA_VERSION
+from sqlakeyset import custom_bookmark_type
+
+
+# XXX monkeypatch until sqlalchemy_utils supports sqlalchemy 1.4
+try:
+    from sqlalchemy.orm.query import _ColumnEntity
+except ImportError:
+    import sqlalchemy.orm.query
+    from sqlalchemy.orm.context import _ColumnEntity
+
+    sqlalchemy.orm.query._ColumnEntity = _ColumnEntity
+
+from sqlalchemy_utils import ArrowType
+
+SQLA2 = SQLA_VERSION >= version.parse("1.4")
+
+if SQLA2:
+    from sqlalchemy.orm import declarative_base
+
+    select = _select
+    S = partial(_S, future=True)
+else:
+    from sqlalchemy.ext.declarative import declarative_base
+
+    def select(*args):
+        return _select(args)
+
+    S = _S
+
+
+class Base(declarative_base()):
+    __abstract__ = True
+
+    def __repr__(self):
+        try:
+            name = type(self).__name__
+            cols = inspect(type(self)).columns.keys()
+            colstr = ", ".join(f"{k}={getattr(self, k)!r}" for k in cols)
+            return f"{name}({colstr})"
+        except Exception:  # e.g. if instance is stale and detached
+            return super().__repr__()
+
+
+ECHO = False
+
+BOOK = "t_Book"
+
+custom_bookmark_type(arrow.Arrow, "da", deserializer=arrow.get)
+
+
+def randtime():
+    return arrow.now() - timedelta(seconds=randrange(86400))
+
+
+# Custom type to guard against double processing: see issue #47
+class MyInteger(float):
+    pass
+
+
+custom_bookmark_type(MyInteger, "mi")
+
+
+class DoubleResultProcessing(Exception):
+    pass
+
+
+class GuardDoubleResultProcessing(TypeDecorator):
+    impl = Integer
+    cache_ok = True
+
+    def process_result_value(self, value, dialect):
+        if isinstance(value, MyInteger):
+            raise DoubleResultProcessing(
+                "Result processor was called on an already processed value!"
+            )
+        return MyInteger(value)
+
+    def process_bind_param(self, value, dialect):
+        return float(value)
+
+
+class Colour(enum.Enum):
+    red = 0
+    green = 1
+    blue = 2
+
+
+custom_bookmark_type(
+    Colour,
+    "col",
+    serializer=lambda c: c.name,
+    deserializer=lambda s: Colour[s],
+)
+
+
+class Light(Base):
+    __tablename__ = "t_Light"
+    id = Column(Integer, primary_key=True)
+    intensity = Column(Integer, nullable=False)
+    colour = Column(Enum(Colour), nullable=False)
+    myint = Column(GuardDoubleResultProcessing, nullable=False)
+
+
+class Book(Base):
+    __tablename__ = BOOK
+    id = Column("book_id", Integer, primary_key=True)
+    name = Column(String(255), nullable=False)
+    a = Column(Integer)
+    b = Column(Integer, nullable=False)
+    c = Column(Integer, nullable=False)
+    d = Column(Integer, nullable=False)
+    author_id = Column(Integer, ForeignKey("author.id"))
+    prequel_id = Column(Integer, ForeignKey(id), nullable=True)
+    prequel = relationship("Book", remote_side=[id], backref="sequel", uselist=False)
+    published_at = Column(ArrowType, default=randtime, nullable=False)
+
+    popularity = column_property(b + c * d)
+
+    @hybrid_property
+    def score(self):
+        return self.b * self.c - self.d
+
+
+class Author(Base):
+    __tablename__ = "author"
+    id = Column(Integer, primary_key=True)
+    name = Column(String(255), nullable=False)
+    books = relationship("Book", backref="author")
+    info = Column(String(255), nullable=False)
+
+    @hybrid_property
+    def book_count(self):
+        return len(self.books)
+
+    @book_count.expression
+    def book_count(cls):
+        return (
+            select(func.count(Book.id))
+            .where(Book.author_id == cls.id)
+            .label("book_count")
+        )
+
+
+JoinedInheritanceBase = declarative_base()
+
+
+class Animal(JoinedInheritanceBase):
+    # These columns all have weird names to test we're not relying on defaults
+    id = Column("anim_id", Integer, primary_key=True)
+    category = Column("cat", String(255), nullable=False)
+    name = Column("nnom", String(255))
+    leg_count = Column("lc", Integer, nullable=False, default=0)
+
+    __tablename__ = "inh_animal"
+    __mapper_args__ = {
+        "polymorphic_on": "category",
+        "polymorphic_identity": "animal",
+    }
+
+
+class Invertebrate(Animal):
+    id = Column("invid", Integer, ForeignKey(Animal.id), primary_key=True)
+    __tablename__ = "inh_invertebrate"
+    __mapper_args__ = {
+        "polymorphic_identity": "invertebrate",
+    }
+
+
+class Vertebrate(Animal):
+    id = Column("random_column_name", Integer, ForeignKey(Animal.id), primary_key=True)
+    vertebra_count = Column(Integer, nullable=False, default=0)
+    __tablename__ = "inh_vertebrate"
+    __mapper_args__ = {
+        "polymorphic_identity": "vertebrate",
+    }
+
+
+class Arthropod(Invertebrate):
+    id = Column("unrelated", Integer, ForeignKey(Invertebrate.id), primary_key=True)
+    __tablename__ = "inh_arthropod"
+    __mapper_args__ = {
+        "polymorphic_identity": "arthropod",
+    }
+
+
+class Mammal(Vertebrate):
+    id = Column("mamamammal", Integer, ForeignKey(Vertebrate.id), primary_key=True)
+    nipple_count = Column(Integer, nullable=False, default=0)
+    __tablename__ = "inh_mammal"
+    __mapper_args__ = {
+        "polymorphic_identity": "mammal",
+    }
+
+
+def _dburl(request):
+    count = 10
+    data = []
+
+    for x in range(count):
+        b = Book(name="Book {}".format(x), a=x, b=x % 2, c=count - x, d=99)
+
+        if x == 1:
+            b.a = None
+            b.author = Author(name="Willy Shakespeare", info="Old timer")
+
+        data.append(b)
+
+    for x in range(count):
+        author = Author(
+            name="Author {}".format(x), info="Rank {}".format(count + 1 - x)
+        )
+        abooks = []
+        for y in range((2 * x) % 10):
+            b = Book(
+                name="Book {}-{}".format(x, y),
+                a=x + y,
+                b=(y * x) % 2,
+                c=count - x,
+                d=99 - y,
+            )
+            b.author = author
+            if y % 4 != 0:
+                b.prequel = abooks[(2 * y + 1) % len(abooks)]
+            abooks.append(b)
+            data.append(b)
+
+    data += [
+        Light(colour=Colour(i % 3), intensity=(i * 13) % 53, myint=i) for i in range(99)
+    ]
+
+    with temporary_database(request.param, host="localhost") as dburl:
+        with S(dburl) as s:
+            Base.metadata.create_all(s.connection())
+            s.add_all(data)
+        yield dburl
+
+
+SUPPORTED_ENGINES = ["sqlite", "postgresql", "mysql"]
+
+dburl = pytest.fixture(params=SUPPORTED_ENGINES)(_dburl)
+no_mysql_dburl = pytest.fixture(params=["sqlite", "postgresql"])(_dburl)
+pg_only_dburl = pytest.fixture(params=["postgresql"])(_dburl)
+
+
+@pytest.fixture(params=SUPPORTED_ENGINES)
+def joined_inheritance_dburl(request):
+    with temporary_database(request.param, host="localhost") as dburl:
+        with S(dburl) as s:
+            JoinedInheritanceBase.metadata.create_all(s.connection())
+            s.add_all(
+                [
+                    Mammal(
+                        name="Human", vertebra_count=33, leg_count=2, nipple_count=2
+                    ),
+                    Mammal(name="Dog", vertebra_count=36, leg_count=4, nipple_count=10),
+                    Invertebrate(name="Jellyfish"),
+                    Invertebrate(name="Jellyfish"),
+                    Arthropod(name="Spider", leg_count=8),
+                    Arthropod(name="Ant", leg_count=6),
+                    Arthropod(name="Scorpion", leg_count=8),
+                    Arthropod(name="Beetle", leg_count=6),
+                    Vertebrate(name="Snake", vertebra_count=300),
+                ]
+            )
+        yield dburl
diff --git a/tests/test_internals.py b/tests/test_internals.py
index 15119e8..c4a956e 100644
--- a/tests/test_internals.py
+++ b/tests/test_internals.py
@@ -1,7 +1,14 @@
+from packaging import version
 from pytest import mark, raises, warns
 from sqlalchemy import Column, Integer, String, asc, column, desc
 from sqlalchemy.orm import class_mapper
-from sqlalchemy.ext.declarative import declarative_base
+
+from sqlakeyset.sqla import SQLA_VERSION
+
+if SQLA_VERSION >= version.parse("1.4"):
+    from sqlalchemy.orm import declarative_base
+else:
+    from sqlalchemy.ext.declarative import declarative_base
 from sqlalchemy.sql.expression import nullslast
 from sqlalchemy.sql.operators import asc_op  # , desc_op
 
diff --git a/tests/test_paging.py b/tests/test_paging.py
index 93d654d..8abe285 100644
--- a/tests/test_paging.py
+++ b/tests/test_paging.py
@@ -1,295 +1,27 @@
-import enum
 import warnings
-from random import randrange
 from packaging import version
 
 import pytest
 import sqlalchemy
-from sqlalchemy.orm import sessionmaker
+from sqlalchemy.orm import sessionmaker, aliased, Bundle
 from sqlalchemy import (
-    select,
-    String,
-    Column,
-    Enum,
-    Integer,
-    ForeignKey,
-    column,
-    table,
     desc,
     func,
-    inspect,
 )
-from sqlalchemy.ext.declarative import declarative_base
-from sqlalchemy.ext.hybrid import hybrid_property
-from sqlalchemy.orm import relationship, aliased, column_property, Bundle
-from sqlalchemy.types import TypeDecorator
-from sqlbag import temporary_database, S
-
-import arrow
-from datetime import timedelta
-
-# XXX monkeypatch until sqlalchemy_utils supports sqlalchemy 1.4
-try:
-    from sqlalchemy.orm.query import _ColumnEntity
-except ImportError:
-    from sqlalchemy.orm.context import _ColumnEntity
-    import sqlalchemy.orm.query
-
-    sqlalchemy.orm.query._ColumnEntity = _ColumnEntity
-
-from sqlalchemy_utils import ArrowType
 
 from sqlakeyset import (
     get_page,
     select_page,
     serialize_bookmark,
     unserialize_bookmark,
-    custom_bookmark_type,
     InvalidPage,
 )
 from sqlakeyset.paging import process_args
-from sqlakeyset.columns import OC
+from conftest import Book, Author, ECHO, S, Animal, Vertebrate, Mammal, Light, select, JoinedInheritanceBase, Base, SQLA2
 
 warnings.simplefilter("error")
 
 
-class Base(declarative_base()):
-    __abstract__ = True
-
-    def __repr__(self):
-        try:
-            name = type(self).__name__
-            cols = inspect(type(self)).columns.keys()
-            colstr = ", ".join(f"{k}={getattr(self, k)!r}" for k in cols)
-            return f"{name}({colstr})"
-        except Exception:  # e.g. if instance is stale and detached
-            return super().__repr__()
-
-
-ECHO = False
-
-BOOK = "t_Book"
-
-custom_bookmark_type(arrow.Arrow, "da", deserializer=arrow.get)
-
-
-def randtime():
-    return arrow.now() - timedelta(seconds=randrange(86400))
-
-
-# Custom type to guard against double processing: see issue #47
-class MyInteger(float):
-    pass
-
-
-custom_bookmark_type(MyInteger, "mi")
-
-
-class DoubleResultProcessing(Exception):
-    pass
-
-
-class GuardDoubleResultProcessing(TypeDecorator):
-    impl = Integer
-    cache_ok = True
-
-    def process_result_value(self, value, dialect):
-        if isinstance(value, MyInteger):
-            raise DoubleResultProcessing(
-                "Result processor was called on an already processed value!"
-            )
-        return MyInteger(value)
-
-    def process_bind_param(self, value, dialect):
-        return float(value)
-
-
-class Colour(enum.Enum):
-    red = 0
-    green = 1
-    blue = 2
-
-
-custom_bookmark_type(
-    Colour,
-    "col",
-    serializer=lambda c: c.name,
-    deserializer=lambda s: Colour[s],
-)
-
-
-class Light(Base):
-    __tablename__ = "t_Light"
-    id = Column(Integer, primary_key=True)
-    intensity = Column(Integer, nullable=False)
-    colour = Column(Enum(Colour), nullable=False)
-    myint = Column(GuardDoubleResultProcessing, nullable=False)
-
-
-class Book(Base):
-    __tablename__ = BOOK
-    id = Column("book_id", Integer, primary_key=True)
-    name = Column(String(255), nullable=False)
-    a = Column(Integer)
-    b = Column(Integer, nullable=False)
-    c = Column(Integer, nullable=False)
-    d = Column(Integer, nullable=False)
-    author_id = Column(Integer, ForeignKey("author.id"))
-    prequel_id = Column(Integer, ForeignKey(id), nullable=True)
-    prequel = relationship("Book", remote_side=[id], backref="sequel", uselist=False)
-    published_at = Column(ArrowType, default=randtime, nullable=False)
-
-    popularity = column_property(b + c * d)
-
-    @hybrid_property
-    def score(self):
-        return self.b * self.c - self.d
-
-
-class Author(Base):
-    __tablename__ = "author"
-    id = Column(Integer, primary_key=True)
-    name = Column(String(255), nullable=False)
-    books = relationship("Book", backref="author")
-    info = Column(String(255), nullable=False)
-
-    @hybrid_property
-    def book_count(self):
-        return len(self.books)
-
-    @book_count.expression
-    def book_count(cls):
-        return (
-            select([func.count(Book.id)])
-            .where(Book.author_id == cls.id)
-            .label("book_count")
-        )
-
-
-JoinedInheritanceBase = declarative_base()
-
-
-class Animal(JoinedInheritanceBase):
-    # These columns all have weird names to test we're not relying on defaults
-    id = Column("anim_id", Integer, primary_key=True)
-    category = Column("cat", String(255), nullable=False)
-    name = Column("nnom", String(255))
-    leg_count = Column("lc", Integer, nullable=False, default=0)
-
-    __tablename__ = "inh_animal"
-    __mapper_args__ = {
-        "polymorphic_on": "category",
-        "polymorphic_identity": "animal",
-    }
-
-
-class Invertebrate(Animal):
-    id = Column("invid", Integer, ForeignKey(Animal.id), primary_key=True)
-    __tablename__ = "inh_invertebrate"
-    __mapper_args__ = {
-        "polymorphic_identity": "invertebrate",
-    }
-
-
-class Vertebrate(Animal):
-    id = Column("random_column_name", Integer, ForeignKey(Animal.id), primary_key=True)
-    vertebra_count = Column(Integer, nullable=False, default=0)
-    __tablename__ = "inh_vertebrate"
-    __mapper_args__ = {
-        "polymorphic_identity": "vertebrate",
-    }
-
-
-class Arthropod(Invertebrate):
-    id = Column("unrelated", Integer, ForeignKey(Invertebrate.id), primary_key=True)
-    __tablename__ = "inh_arthropod"
-    __mapper_args__ = {
-        "polymorphic_identity": "arthropod",
-    }
-
-
-class Mammal(Vertebrate):
-    id = Column("mamamammal", Integer, ForeignKey(Vertebrate.id), primary_key=True)
-    nipple_count = Column(Integer, nullable=False, default=0)
-    __tablename__ = "inh_mammal"
-    __mapper_args__ = {
-        "polymorphic_identity": "mammal",
-    }
-
-
-def _dburl(request):
-    count = 10
-    data = []
-
-    for x in range(count):
-        b = Book(name="Book {}".format(x), a=x, b=x % 2, c=count - x, d=99)
-
-        if x == 1:
-            b.a = None
-            b.author = Author(name="Willy Shakespeare", info="Old timer")
-
-        data.append(b)
-
-    for x in range(count):
-        author = Author(
-            name="Author {}".format(x), info="Rank {}".format(count + 1 - x)
-        )
-        abooks = []
-        for y in range((2 * x) % 10):
-            b = Book(
-                name="Book {}-{}".format(x, y),
-                a=x + y,
-                b=(y * x) % 2,
-                c=count - x,
-                d=99 - y,
-            )
-            b.author = author
-            if y % 4 != 0:
-                b.prequel = abooks[(2 * y + 1) % len(abooks)]
-            abooks.append(b)
-            data.append(b)
-
-    data += [
-        Light(colour=Colour(i % 3), intensity=(i * 13) % 53, myint=i) for i in range(99)
-    ]
-
-    with temporary_database(request.param, host="localhost") as dburl:
-        with S(dburl) as s:
-            Base.metadata.create_all(s.connection())
-            s.add_all(data)
-        yield dburl
-
-
-SUPPORTED_ENGINES = ["sqlite", "postgresql", "mysql"]
-
-dburl = pytest.fixture(params=SUPPORTED_ENGINES)(_dburl)
-no_mysql_dburl = pytest.fixture(params=["sqlite", "postgresql"])(_dburl)
-pg_only_dburl = pytest.fixture(params=["postgresql"])(_dburl)
-
-
-@pytest.fixture(params=SUPPORTED_ENGINES)
-def joined_inheritance_dburl(request):
-    with temporary_database(request.param, host="localhost") as dburl:
-        with S(dburl) as s:
-            JoinedInheritanceBase.metadata.create_all(s.connection())
-            s.add_all(
-                [
-                    Mammal(
-                        name="Human", vertebra_count=33, leg_count=2, nipple_count=2
-                    ),
-                    Mammal(name="Dog", vertebra_count=36, leg_count=4, nipple_count=10),
-                    Invertebrate(name="Jellyfish"),
-                    Invertebrate(name="Jellyfish"),
-                    Arthropod(name="Spider", leg_count=8),
-                    Arthropod(name="Ant", leg_count=6),
-                    Arthropod(name="Scorpion", leg_count=8),
-                    Arthropod(name="Beetle", leg_count=6),
-                    Vertebrate(name="Snake", vertebra_count=300),
-                ]
-            )
-        yield dburl
-
-
 def check_paging_orm(q):
     item_counts = range(1, 12)
 
@@ -457,10 +189,13 @@ def test_orm_column_property(dburl):
         check_paging_orm(q=q)
 
 
-def test_column_named_info(dburl):
+def test_orm_column_named_info(dburl):
     # See issue djrobstep#24
     with S(dburl, echo=ECHO) as s:
-        q = s.query(Author).from_self().order_by(Author.info, Author.id)
+        aa1 = aliased(Author)
+        aa2 = aliased(Author)
+        q = s.query(aa2).select_from(aa1).join(aa2, aa2.id == aa1.id).order_by(aa1.info, aa1.id)
+
         check_paging_orm(q=q)
 
 
@@ -595,14 +330,8 @@ def test_orm_joined_inheritance(joined_inheritance_dburl):
 
 
 def test_core(dburl):
-    spec = ["b", "d", "book_id", "c"]
-
-    cols = [column(each) for each in spec]
-    ob = [OC(x).uo for x in spec]
-
-    selectable = select(
-        cols, from_obj=[table("t_Book")], whereclause=column("d") == 99, order_by=ob
-    )
+    selectable = select(Book.b, Book.d, Book.id, Book.c).where(Book.d == 99)\
+        .order_by(Book.b, Book.d, Book.id, Book.c)
 
     with S(dburl, echo=ECHO) as s:
         check_paging_core(selectable=selectable, s=s)
@@ -614,18 +343,18 @@ def test_core(dburl):
 
 def test_core2(dburl):
     with S(dburl, echo=ECHO) as s:
-        sel = select([Book.score]).order_by(Book.id)
+        sel = select(Book.score).order_by(Book.id)
         check_paging_core(sel, s)
 
         sel = (
-            select([Book.score])
+            select(Book.score)
             .order_by(Author.id - Book.id, Book.id)
             .where(Author.id == Book.author_id)
         )
         check_paging_core(sel, s)
 
         sel = (
-            select([Book.author_id, func.count()])
+            select(Book.author_id, func.count())
             .group_by(Book.author_id)
             .order_by(func.sum(Book.popularity))
         )
@@ -633,7 +362,7 @@ def test_core2(dburl):
 
         v = func.sum(func.coalesce(Book.a, 0)) + func.min(Book.b)
         sel = (
-            select([Book.author_id, func.count(), v])
+            select(Book.author_id, func.count(), v)
             .group_by(Book.author_id)
             .order_by(v)
         )
@@ -642,7 +371,7 @@ def test_core2(dburl):
 
 def test_core_enum(dburl):
     with S(dburl, echo=ECHO) as s:
-        selectable = select([Light.id, Light.colour]).order_by(
+        selectable = select(Light.id, Light.colour).order_by(
             Light.intensity, Light.id
         )
         check_paging_core(selectable=selectable, s=s)
@@ -655,7 +384,7 @@ def test_core_enum(dburl):
 # order (as recommended by the MySQL documentation).
 def test_core_order_by_enum(no_mysql_dburl):
     with S(no_mysql_dburl, echo=ECHO) as s:
-        selectable = select([Light.id, Light.colour]).order_by(
+        selectable = select(Light.id, Light.colour).order_by(
             Light.colour, Light.intensity, Light.id
         )
         check_paging_core(selectable=selectable, s=s)
@@ -663,13 +392,13 @@ def test_core_order_by_enum(no_mysql_dburl):
 
 def test_core_result_processor(dburl):
     with S(dburl, echo=ECHO) as s:
-        selectable = select([Light.id, Light.myint]).order_by(Light.intensity, Light.id)
+        selectable = select(Light.id, Light.myint).order_by(Light.intensity, Light.id)
         check_paging_core(selectable=selectable, s=s)
 
 
 def test_core_order_by_result_processor(dburl):
     with S(dburl, echo=ECHO) as s:
-        selectable = select([Light.id]).order_by(Light.myint, Light.id)
+        selectable = select(Light.id).order_by(Light.myint, Light.id)
         check_paging_core(selectable=selectable, s=s)
 
 
@@ -749,11 +478,10 @@ def test_orm_custom_session_bind(dburl):
 
 
 def test_multiple_engines(dburl, joined_inheritance_dburl):
-
-    eng = sqlalchemy.create_engine(dburl)
-    eng2 = sqlalchemy.create_engine(joined_inheritance_dburl)
-    session_factory = sessionmaker()
-    Base.metadata.bind = eng
+    kw = {"future": True} if SQLA2 else {}
+    eng = sqlalchemy.create_engine(dburl, **kw)
+    eng2 = sqlalchemy.create_engine(joined_inheritance_dburl, **kw)
+    session_factory = sessionmaker(bind=eng, **kw)
     JoinedInheritanceBase.metadata.bind = eng2
 
     s = session_factory()
diff --git a/tests/test_paging_20_style.py b/tests/test_paging_20_style.py
new file mode 100644
index 0000000..9bb7570
--- /dev/null
+++ b/tests/test_paging_20_style.py
@@ -0,0 +1,175 @@
+"""SQLAlchemy 2-style ORM tests"""
+import pytest
+from packaging import version
+from sqlalchemy import desc, func, select
+from sqlalchemy.orm import aliased
+from conftest import (
+    ECHO,
+    SQLA_VERSION,
+    Author,
+    Book,
+    S,
+)
+from test_paging import check_paging_core
+
+if SQLA_VERSION < version.parse("1.4.0b1"):
+    pytest.skip(
+        "Legacy SQLAlchemy version, skipping new-style tests", allow_module_level=True
+    )
+
+
+def test_new_orm_query1(dburl):
+    spec = [desc(Book.b), Book.d, Book.id]
+
+    with S(dburl, echo=ECHO) as s:
+        q = select(Book, Author, Book.id).outerjoin(Author).order_by(*spec)
+        check_paging_core(q, s)
+
+
+def test_new_orm_query2(dburl):
+    with S(dburl, echo=ECHO) as s:
+        q = select(Book).order_by(Book.id, Book.name)
+        check_paging_core(q, s)
+
+
+def test_new_orm_query3(dburl):
+    with S(dburl, echo=ECHO) as s:
+        q = select(Book.id, Book.name.label("x")).order_by(Book.name, Book.id)
+        check_paging_core(q, s)
+
+
+def test_new_orm_query4(dburl):
+    with S(dburl, echo=ECHO) as s:
+        q = select(Book).order_by(Book.name)
+        check_paging_core(q, s)
+
+
+def test_new_orm_order_by_arrowtype(dburl):
+    with S(dburl, echo=ECHO) as s:
+        q = select(Book).order_by(Book.published_at, Book.id)
+        check_paging_core(q, s)
+
+
+def test_new_orm_implicit_join(dburl):
+    with S(dburl, echo=ECHO) as s:
+        q = (
+            select(Book)
+            .order_by(Author.name, Book.id)
+            .filter(Book.author_id == Author.id)
+        )
+        check_paging_core(q, s)
+
+
+def test_new_orm_hybrid_property(dburl):
+    with S(dburl, echo=ECHO) as s:
+        q = select(Book).order_by(Book.score, Book.id)
+        check_paging_core(q, s)
+        q = select(Book, Author).join(Book.author).order_by(Book.score, Book.id)
+        check_paging_core(q, s)
+        q = (
+            select(Book.score, Book, Author)
+            .join(Book.author)
+            .order_by(Book.score, Book.id)
+        )
+        check_paging_core(q, s)
+
+
+def test_new_orm_column_property(dburl):
+    with S(dburl, echo=ECHO) as s:
+        q = select(Book).order_by(Book.popularity, Book.id)
+        check_paging_core(q, s)
+        q = (
+            select(Book, Author)
+            .join(Book.author)
+            .order_by(Book.popularity.desc(), Book.id)
+        )
+        check_paging_core(q, s)
+
+
+def test_new_orm_column_named_info(dburl):
+    # See issue djrobstep#24
+    with S(dburl, echo=ECHO) as s:
+        aa1 = aliased(Author)
+        aa2 = aliased(Author)
+        q = (
+            select(aa2)
+            .select_from(aa1)
+            .join(aa2, aa2.id == aa1.id)
+            .order_by(aa1.info, aa1.id)
+        )
+
+        check_paging_core(q, s)
+
+
+def test_new_orm_correlated_subquery_hybrid(dburl):
+    with S(dburl, echo=ECHO) as s:
+        q = select(Author.id, Author.name).order_by(Author.book_count, Author.id)
+        check_paging_core(q, s)
+
+
+def test_new_orm_expression(dburl):
+    with S(dburl, echo=ECHO) as s:
+        key = func.coalesce(Book.a, 0) + Book.b
+        q = select(Book).order_by(key, Book.id)
+        check_paging_core(q, s)
+
+        q = select(Book).order_by(key.label("sort_by_me"), Book.id)
+        check_paging_core(q, s)
+
+
+def test_new_orm_aggregated(dburl):
+    count = func.count().label("count")
+    spec = [desc(count), desc(Author.name), Author.id]
+
+    with S(dburl, echo=ECHO) as s:
+        q = select(Author, count).join(Author.books).group_by(Author).order_by(*spec)
+        check_paging_core(q, s)
+
+
+def test_new_orm_subquery(dburl):
+    count = func.count().label("count")
+
+    with S(dburl, echo=ECHO) as s:
+        sq = (
+            select(Author.id, count)
+            .join(Author.books)
+            .group_by(Author.id)
+            .subquery("sq")
+        )
+        q = (
+            select(sq.c.count, Author)
+            .join(sq, sq.c.id == Author.id)
+            .order_by(desc(sq.c.count), Author.name, Author.id)
+        )
+        check_paging_core(q, s)
+
+
+def test_new_orm_recursive_cte(pg_only_dburl):
+    with S(pg_only_dburl, echo=ECHO) as s:
+        # Start with "origins": books that don't have prequels
+        seed = select(Book.id.label("id"), Book.id.label("origin")).filter(
+            Book.prequel is None
+        )
+
+        # Recurse by picking up sequels
+        sequel = aliased(Book, name="sequel")
+        recursive = seed.cte(recursive=True)
+        recursive = recursive.union(
+            select(sequel.id, recursive.c.origin).filter(
+                sequel.prequel_id == recursive.c.id
+            )
+        )
+
+        # Count total books per origin
+        count = func.count().label("count")
+        origin = recursive.c.origin.label("origin")
+        sq = select(origin, count).group_by(origin).cte(recursive=False)
+
+        # Join to full book table
+        q = (
+            select(sq.c.count, Book)
+            .filter(Book.id == sq.c.origin)
+            .order_by(sq.c.count.desc(), Book.id)
+        )
+
+        check_paging_core(q, s)