]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
fix construct_params() for render_postcompile; add new API
authorMike Bayer <mike_mp@zzzcomputing.com>
Thu, 8 Dec 2022 16:00:31 +0000 (11:00 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Thu, 8 Dec 2022 22:37:25 +0000 (17:37 -0500)
The :meth:`.SQLCompiler.construct_params` method, as well as the
:attr:`.SQLCompiler.params` accessor, will now return the
exact parameters that correspond to a compiled statement that used
the ``render_postcompile`` parameter to compile.   Previously,
the method returned a parameter structure that by itself didn't correspond
to either the original parameters or the expanded ones.

Passing a new dictionary of parameters to
:meth:`.SQLCompiler.construct_params` for a :class:`.SQLCompiler` that was
constructed with ``render_postcompile`` is now disallowed; instead, to make
a new SQL string and parameter set for an alternate set of parameters, a
new method :meth:`.SQLCompiler.construct_expanded_state` is added which
will produce a new expanded form for the given parameter set, using the
:class:`.ExpandedState` container which includes a new SQL statement
and new parameter dictionary, as well as a positional parameter tuple.

Fixes: #6114
Change-Id: I9874905bb90f86799b82b244d57369558b18fd93

doc/build/changelog/unreleased_20/6114.rst [new file with mode: 0644]
doc/build/core/internals.rst
lib/sqlalchemy/sql/compiler.py
lib/sqlalchemy/testing/assertions.py
test/sql/test_compiler.py

diff --git a/doc/build/changelog/unreleased_20/6114.rst b/doc/build/changelog/unreleased_20/6114.rst
new file mode 100644 (file)
index 0000000..e773d92
--- /dev/null
@@ -0,0 +1,20 @@
+.. change::
+    :tags: bug, sql
+    :tickets: 6114
+
+    The :meth:`.SQLCompiler.construct_params` method, as well as the
+    :attr:`.SQLCompiler.params` accessor, will now return the
+    exact parameters that correspond to a compiled statement that used
+    the ``render_postcompile`` parameter to compile.   Previously,
+    the method returned a parameter structure that by itself didn't correspond
+    to either the original parameters or the expanded ones.
+
+    Passing a new dictionary of parameters to
+    :meth:`.SQLCompiler.construct_params` for a :class:`.SQLCompiler` that was
+    constructed with ``render_postcompile`` is now disallowed; instead, to make
+    a new SQL string and parameter set for an alternate set of parameters, a
+    new method :meth:`.SQLCompiler.construct_expanded_state` is added which
+    will produce a new expanded form for the given parameter set, using the
+    :class:`.ExpandedState` container which includes a new SQL statement
+    and new parameter dictionary, as well as a positional parameter tuple.
+
index 9a19ccc13a85a376d88c4a77d3171f73acb376d7..e3769b3422e1cd7517d7a419837cbad1003a5e7f 100644 (file)
@@ -43,6 +43,9 @@ Some key internal constructs are listed here.
 .. autoclass:: sqlalchemy.engine.ExecutionContext
     :members:
 
+.. autoclass:: sqlalchemy.sql.compiler.ExpandedState
+    :members:
+
 
 .. autoclass:: sqlalchemy.sql.compiler.GenericTypeCompiler
     :members:
index d7358ad3befbb749a66bb06290f17a57c39142c0..7aa89869e45e7599c2080543ba8c146e9847f118 100644 (file)
@@ -404,13 +404,53 @@ class ExpandedState(NamedTuple):
     will be rendered into the SQL statement at execution time, rather than
     being passed as separate parameters to the driver.
 
+    To create an :class:`.ExpandedState` instance, use the
+    :meth:`.SQLCompiler.construct_expanded_state` method on any
+    :class:`.SQLCompiler` instance.
+
     """
 
     statement: str
-    additional_parameters: _CoreSingleExecuteParams
+    """String SQL statement with parameters fully expanded"""
+
+    parameters: _CoreSingleExecuteParams
+    """Parameter dictionary with parameters fully expanded.
+
+    For a statement that uses named parameters, this dictionary will map
+    exactly to the names in the statement.  For a statement that uses
+    positional parameters, the :attr:`.ExpandedState.positional_parameters`
+    will yield a tuple with the positional parameter set.
+
+    """
+
     processors: Mapping[str, _BindProcessorType[Any]]
+    """mapping of bound value processors"""
+
     positiontup: Optional[Sequence[str]]
+    """Sequence of string names indicating the order of positional
+    parameters"""
+
     parameter_expansion: Mapping[str, List[str]]
+    """Mapping representing the intermediary link from original parameter
+    name to list of "expanded" parameter names, for those parameters that
+    were expanded."""
+
+    @property
+    def positional_parameters(self) -> Tuple[Any, ...]:
+        """Tuple of positional parameters, for statements that were compiled
+        using a positional paramstyle.
+
+        """
+        if self.positiontup is None:
+            raise exc.InvalidRequestError(
+                "statement does not use a positional paramstyle"
+            )
+        return tuple(self.parameters[key] for key in self.positiontup)
+
+    @property
+    def additional_parameters(self) -> _CoreSingleExecuteParams:
+        """synonym for :attr:`.ExpandedState.parameters`."""
+        return self.parameters
 
 
 class _InsertManyValues(NamedTuple):
@@ -956,8 +996,30 @@ class SQLCompiler(Compiled):
     """
     whether to render out POSTCOMPILE params during the compile phase.
 
+    This attribute is used only for end-user invocation of stmt.compile();
+    it's never used for actual statement execution, where instead the
+    dialect internals access and render the internal postcompile structure
+    directly.
+
+    """
+
+    _post_compile_expanded_state: Optional[ExpandedState] = None
+    """When render_postcompile is used, the ``ExpandedState`` used to create
+    the "expanded" SQL is assigned here, and then used by the ``.params``
+    accessor and ``.construct_params()`` methods for their return values.
+
+    .. versionadded:: 2.0.0b5
+
     """
 
+    _pre_expanded_string: Optional[str] = None
+    """Stores the original string SQL before 'post_compile' is applied,
+    for cases where 'post_compile' were used.
+
+    """
+
+    _pre_expanded_positiontup: Optional[List[str]] = None
+
     _insertmanyvalues: Optional[_InsertManyValues] = None
 
     _insert_crud_params: Optional[crud._CrudParamSequence] = None
@@ -1164,7 +1226,14 @@ class SQLCompiler(Compiled):
                     self._process_positional()
 
             if self._render_postcompile:
-                self._process_parameters_for_postcompile(_populate_self=True)
+                parameters = self.construct_params(
+                    escape_names=False,
+                    _no_postcompile=True,
+                )
+
+                self._process_parameters_for_postcompile(
+                    parameters, _populate_self=True
+                )
 
     @property
     def insert_single_values_expr(self) -> Optional[str]:
@@ -1481,6 +1550,29 @@ class SQLCompiler(Compiled):
     def sql_compiler(self):
         return self
 
+    def construct_expanded_state(
+        self,
+        params: Optional[_CoreSingleExecuteParams] = None,
+        escape_names: bool = True,
+    ) -> ExpandedState:
+        """Return a new :class:`.ExpandedState` for a given parameter set.
+
+        For queries that use "expanding" or other late-rendered parameters,
+        this method will provide for both the finalized SQL string as well
+        as the parameters that would be used for a particular parameter set.
+
+        .. versionadded:: 2.0.0b5
+
+        """
+        parameters = self.construct_params(
+            params,
+            escape_names=escape_names,
+            _no_postcompile=True,
+        )
+        return self._process_parameters_for_postcompile(
+            parameters,
+        )
+
     def construct_params(
         self,
         params: Optional[_CoreSingleExecuteParams] = None,
@@ -1488,12 +1580,26 @@ class SQLCompiler(Compiled):
         escape_names: bool = True,
         _group_number: Optional[int] = None,
         _check: bool = True,
+        _no_postcompile: bool = False,
     ) -> _MutableCoreSingleExecuteParams:
         """return a dictionary of bind parameter keys and values"""
 
+        if self._render_postcompile and not _no_postcompile:
+            assert self._post_compile_expanded_state is not None
+            if not params:
+                return dict(self._post_compile_expanded_state.parameters)
+            else:
+                raise exc.InvalidRequestError(
+                    "can't construct new parameters when render_postcompile "
+                    "is used; the statement is hard-linked to the original "
+                    "parameters.  Use construct_expanded_state to generate a "
+                    "new statement and parameters."
+                )
+
         has_escaped_names = escape_names and bool(self.escaped_bind_names)
 
         if extracted_parameters:
+
             # related the bound parameters collected in the original cache key
             # to those collected in the incoming cache key.  They will not have
             # matching names but they will line up positionally in the same
@@ -1520,6 +1626,7 @@ class SQLCompiler(Compiled):
             resolved_extracted = None
 
         if params:
+
             pd = {}
             for bindparam, name in self.bind_names.items():
                 escaped_name = (
@@ -1593,6 +1700,7 @@ class SQLCompiler(Compiled):
                     pd[escaped_name] = value_param.effective_value
                 else:
                     pd[escaped_name] = value_param.value
+
             return pd
 
     @util.memoized_instancemethod
@@ -1649,7 +1757,7 @@ class SQLCompiler(Compiled):
 
     def _process_parameters_for_postcompile(
         self,
-        parameters: Optional[_MutableCoreSingleExecuteParams] = None,
+        parameters: _MutableCoreSingleExecuteParams,
         _populate_self: bool = False,
     ) -> ExpandedState:
         """handle special post compile parameters.
@@ -1665,16 +1773,22 @@ class SQLCompiler(Compiled):
 
         """
 
-        if parameters is None:
-            parameters = self.construct_params(escape_names=False)
-
         expanded_parameters = {}
-        positiontup: Optional[List[str]]
+        new_positiontup: Optional[List[str]]
+
+        pre_expanded_string = self._pre_expanded_string
+        if pre_expanded_string is None:
+            pre_expanded_string = self.string
 
         if self.positional:
-            positiontup = []
+            new_positiontup = []
+
+            pre_expanded_positiontup = self._pre_expanded_positiontup
+            if pre_expanded_positiontup is None:
+                pre_expanded_positiontup = self.positiontup
+
         else:
-            positiontup = None
+            new_positiontup = pre_expanded_positiontup = None
 
         processors = self._bind_processors
         single_processors = cast(
@@ -1698,8 +1812,8 @@ class SQLCompiler(Compiled):
 
         numeric_positiontup: Optional[List[str]] = None
 
-        if self.positional and self.positiontup is not None:
-            names: Iterable[str] = self.positiontup
+        if self.positional and pre_expanded_positiontup is not None:
+            names: Iterable[str] = pre_expanded_positiontup
             if self._numeric_binds:
                 numeric_positiontup = []
         else:
@@ -1772,16 +1886,16 @@ class SQLCompiler(Compiled):
                         numeric_positiontup.extend(
                             name for name, _ in to_update
                         )
-                    elif positiontup is not None:
+                    elif new_positiontup is not None:
                         # to_update has escaped names, but that's ok since
                         # these are new names, that aren't in the
                         # escaped_bind_names dict.
-                        positiontup.extend(name for name, _ in to_update)
+                        new_positiontup.extend(name for name, _ in to_update)
                     expanded_parameters[name] = [
                         expand_key for expand_key, _ in to_update
                     ]
-            elif positiontup is not None:
-                positiontup.append(name)
+            elif new_positiontup is not None:
+                new_positiontup.append(name)
 
         def process_expanding(m):
             key = m.group(1)
@@ -1799,11 +1913,11 @@ class SQLCompiler(Compiled):
             return expr
 
         statement = re.sub(
-            self._post_compile_pattern, process_expanding, self.string
+            self._post_compile_pattern, process_expanding, pre_expanded_string
         )
 
         if numeric_positiontup is not None:
-            assert positiontup is not None
+            assert new_positiontup is not None
             param_pos = {
                 key: f"{self._numeric_binds_identifier_char}{num}"
                 for num, key in enumerate(
@@ -1814,13 +1928,13 @@ class SQLCompiler(Compiled):
             statement = self._pyformat_pattern.sub(
                 lambda m: param_pos[m.group(1)], statement
             )
-            positiontup.extend(numeric_positiontup)
+            new_positiontup.extend(numeric_positiontup)
 
         expanded_state = ExpandedState(
             statement,
             parameters,
             new_processors,
-            positiontup,
+            new_positiontup,
             expanded_parameters,
         )
 
@@ -1828,24 +1942,15 @@ class SQLCompiler(Compiled):
             # this is for the "render_postcompile" flag, which is not
             # otherwise used internally and is for end-user debugging and
             # special use cases.
+            self._pre_expanded_string = pre_expanded_string
+            self._pre_expanded_positiontup = pre_expanded_positiontup
             self.string = expanded_state.statement
-            self._bind_processors.update(expanded_state.processors)
-            self.positiontup = list(expanded_state.positiontup or ())
-            self.post_compile_params = frozenset()
-            for key in expanded_state.parameter_expansion:
-                bind = self.binds.pop(key)
-
-                if TYPE_CHECKING:
-                    assert bind.value is not None
-
-                self.bind_names.pop(bind)
-                for value, expanded_key in zip(
-                    bind.value, expanded_state.parameter_expansion[key]
-                ):
-                    self.binds[expanded_key] = new_param = bind._with_value(
-                        value
-                    )
-                    self.bind_names[new_param] = expanded_key
+            self.positiontup = (
+                list(expanded_state.positiontup or ())
+                if self.positional
+                else None
+            )
+            self._post_compile_expanded_state = expanded_state
 
         return expanded_state
 
index 790a72ec847c68d6ce352eddc88a717237d1162b..5adda0dadca2c25e351ba77657153066e1daf162 100644 (file)
@@ -636,10 +636,30 @@ class AssertsCompiledSQL:
         eq_(cc, result, "%r != %r on dialect %r" % (cc, result, dialect))
 
         if checkparams is not None:
-            eq_(c.construct_params(params), checkparams)
+            if render_postcompile:
+                expanded_state = c.construct_expanded_state(
+                    params, escape_names=False
+                )
+                eq_(expanded_state.parameters, checkparams)
+            else:
+                eq_(c.construct_params(params), checkparams)
         if checkpositional is not None:
-            p = c.construct_params(params, escape_names=False)
-            eq_(tuple([p[x] for x in c.positiontup]), checkpositional)
+            if render_postcompile:
+                expanded_state = c.construct_expanded_state(
+                    params, escape_names=False
+                )
+                eq_(
+                    tuple(
+                        [
+                            expanded_state.parameters[x]
+                            for x in expanded_state.positiontup
+                        ]
+                    ),
+                    checkpositional,
+                )
+            else:
+                p = c.construct_params(params, escape_names=False)
+                eq_(tuple([p[x] for x in c.positiontup]), checkpositional)
         if check_prefetch is not None:
             eq_(c.prefetch, check_prefetch)
         if check_literal_execute is not None:
index d342b924838561931ebf2fff9a308c38c9a60407..39971fd76669616e970bc6340c3d7a097137495f 100644 (file)
@@ -8,8 +8,11 @@ styling and coherent test organization.
 
 """
 
+from __future__ import annotations
+
 import datetime
 import decimal
+from typing import TYPE_CHECKING
 
 from sqlalchemy import alias
 from sqlalchemy import and_
@@ -93,12 +96,18 @@ from sqlalchemy.testing import expect_raises
 from sqlalchemy.testing import expect_raises_message
 from sqlalchemy.testing import fixtures
 from sqlalchemy.testing import is_
+from sqlalchemy.testing import is_none
 from sqlalchemy.testing import is_true
 from sqlalchemy.testing import mock
 from sqlalchemy.testing import ne_
 from sqlalchemy.testing.schema import pep435_enum
 from sqlalchemy.types import UserDefinedType
 
+
+if TYPE_CHECKING:
+    from sqlalchemy import Select
+
+
 table1 = table(
     "mytable",
     column("myid", Integer),
@@ -4458,7 +4467,7 @@ class BindParameterTest(AssertsCompiledSQL, fixtures.TestBase):
             "WHERE t.x = '10') AS anon_1 UNION SELECT "
             "(SELECT 1 FROM t WHERE t.x = '10') AS anon_1) AS anon_2",
         )
-        eq_(compiled.construct_params(), {"param_1": "10"})
+        eq_(compiled.construct_params(_no_postcompile=True), {"param_1": "10"})
 
     def test_construct_params_repeated_postcompile_params_two(self):
         """test for :ticket:`6202` two - same param name used twice
@@ -4491,7 +4500,7 @@ class BindParameterTest(AssertsCompiledSQL, fixtures.TestBase):
             "FROM t WHERE t.x = '10') AS anon_1 UNION SELECT "
             "(SELECT 1 FROM t WHERE t.x = '10') AS anon_3) AS anon_2",
         )
-        eq_(compiled.construct_params(), {"param_1": "10"})
+        eq_(compiled.construct_params(_no_postcompile=True), {"param_1": "10"})
 
     def test_construct_params_positional_plain_repeated(self):
         t = table("t", column("x"))
@@ -4515,7 +4524,10 @@ class BindParameterTest(AssertsCompiledSQL, fixtures.TestBase):
             "UNION SELECT (SELECT 1 FROM t WHERE t.x = %s AND t.x = '12') "
             "AS anon_1) AS anon_2",
         )
-        eq_(compiled.construct_params(), {"param_1": "10", "param_2": "12"})
+        eq_(
+            compiled.construct_params(_no_postcompile=True),
+            {"param_1": "10", "param_2": "12"},
+        )
         eq_(compiled.positiontup, ["param_1", "param_1"])
 
     def test_tuple_clauselist_in(self):
@@ -4958,22 +4970,13 @@ class BindParameterTest(AssertsCompiledSQL, fixtures.TestBase):
             table1.c.name.in_(bindparam(paramname, value=["a", "b"]))
         )
 
-        # NOTE: below the rendered params are just what
-        # render_postcompile will do right now
-        # if you run construct_params().  render_postcompile mode
-        # is not actually used by the execution internals, it's for
-        # user-facing compilation code.  So this is likely a
-        # current limitation of construct_params() which is not
-        # doing the full blown postcompile; just assert that's
-        # what it does for now.  it likely should be corrected
-        # to make more sense.
         if paramstyle.qmark:
             self.assert_compile(
                 stmt,
                 "SELECT mytable.myid FROM mytable "
                 "WHERE mytable.name IN (?, ?)",
-                params={paramname: ["y", "z"]},
-                checkpositional=(["y", "z"], ["y", "z"]),
+                params={paramname: ["y", "z", "q"]},
+                checkpositional=("y", "z", "q"),
                 dialect="sqlite",
                 render_postcompile=True,
             )
@@ -4982,8 +4985,8 @@ class BindParameterTest(AssertsCompiledSQL, fixtures.TestBase):
                 stmt,
                 "SELECT mytable.myid FROM mytable "
                 "WHERE mytable.name IN (:1, :2)",
-                params={paramname: ["y", "z"]},
-                checkpositional=(["y", "z"], ["y", "z"]),
+                params={paramname: ["y", "z", "q"]},
+                checkpositional=("y", "z", "q"),
                 dialect=sqlite.dialect(paramstyle="numeric"),
                 render_postcompile=True,
             )
@@ -4994,8 +4997,8 @@ class BindParameterTest(AssertsCompiledSQL, fixtures.TestBase):
                 "(:%s_1, :%s_2)" % (expected, expected),
                 params={paramname: ["y", "z"]},
                 checkparams={
-                    "%s_1" % expected: ["y", "z"],
-                    "%s_2" % expected: ["y", "z"],
+                    "%s_1" % expected: "y",
+                    "%s_2" % expected: "z",
                 },
                 dialect="default",
                 render_postcompile=True,
@@ -5071,16 +5074,6 @@ class BindParameterTest(AssertsCompiledSQL, fixtures.TestBase):
             .where(table1.c.myid == 9)
         ).order_by("myid")
 
-        # NOTE: below the rendered params are just what
-        # render_postcompile will do right now
-        # if you run construct_params().  render_postcompile mode
-        # is not actually used by the execution internals, it's for
-        # user-facing compilation code.  So this is likely a
-        # current limitation of construct_params() which is not
-        # doing the full blown postcompile; just assert that's
-        # what it does for now.  it likely should be corrected
-        # to make more sense.
-
         if paramstyle.qmark:
             self.assert_compile(
                 stmt,
@@ -5095,16 +5088,7 @@ class BindParameterTest(AssertsCompiledSQL, fixtures.TestBase):
                 "mytable.name IN (?)) "
                 "AND mytable.myid = ? ORDER BY myid",
                 params={"uname": ["y", "z"], "uname2": ["a"]},
-                checkpositional=(
-                    ["y", "z"],
-                    ["y", "z"],
-                    ["a"],
-                    8,
-                    ["y", "z"],
-                    ["y", "z"],
-                    ["a"],
-                    9,
-                ),
+                checkpositional=("y", "z", "a", 8, "y", "z", "a", 9),
                 dialect="sqlite",
                 render_postcompile=True,
             )
@@ -5122,7 +5106,7 @@ class BindParameterTest(AssertsCompiledSQL, fixtures.TestBase):
                 "mytable.name IN (:5)) "
                 "AND mytable.myid = :2 ORDER BY myid",
                 params={"uname": ["y", "z"], "uname2": ["a"]},
-                checkpositional=(8, 9, ["y", "z"], ["y", "z"], ["a"]),
+                checkpositional=(8, 9, "y", "z", "a"),
                 dialect=sqlite.dialect(paramstyle="numeric"),
                 render_postcompile=True,
             )
@@ -5141,13 +5125,11 @@ class BindParameterTest(AssertsCompiledSQL, fixtures.TestBase):
                 "AND mytable.myid = :myid_2 ORDER BY myid",
                 params={"uname": ["y", "z"], "uname2": ["a"]},
                 checkparams={
-                    "uname": ["y", "z"],
-                    "uname2": ["a"],
-                    "uname_1": ["y", "z"],
-                    "uname_2": ["y", "z"],
-                    "uname2_1": ["a"],
                     "myid_1": 8,
                     "myid_2": 9,
+                    "uname_1": "y",
+                    "uname_2": "z",
+                    "uname2_1": "a",
                 },
                 dialect="default",
                 render_postcompile=True,
@@ -5171,6 +5153,369 @@ class BindParameterTest(AssertsCompiledSQL, fixtures.TestBase):
         )
 
 
+class CompileUXTest(fixtures.TestBase):
+    """tests focused on calling stmt.compile() directly, user cases"""
+
+    @testing.fixture
+    def render_postcompile_fixture(self):
+        return (
+            select(func.count(1))
+            .where(column("q") == "x")
+            .where(column("z").in_([1, 2, 3]))
+            .where(column("z_tuple").in_([(1, "a"), (2, "b"), (3, "c")]))
+            .where(
+                column("y").op("foobar")(
+                    bindparam(
+                        "key", value=[("1", "2"), ("3", "4")], expanding=True
+                    )
+                )
+            )
+        )
+
+    def test_render_postcompile_default_stmt(self, render_postcompile_fixture):
+        stmt = render_postcompile_fixture
+
+        compiled = stmt.compile(compile_kwargs={"render_postcompile": True})
+        eq_ignore_whitespace(
+            compiled.string,
+            "SELECT count(:count_2) AS count_1 WHERE q = :q_1 AND z "
+            "IN (:z_1_1, :z_1_2, :z_1_3) AND z_tuple IN "
+            "((:z_tuple_1_1_1, :z_tuple_1_1_2), "
+            "(:z_tuple_1_2_1, :z_tuple_1_2_2), "
+            "(:z_tuple_1_3_1, :z_tuple_1_3_2)) "
+            "AND (y foobar ((:key_1_1, :key_1_2), (:key_2_1, :key_2_2)))",
+        )
+
+    def test_render_postcompile_named_parameters(
+        self, render_postcompile_fixture
+    ):
+        stmt = render_postcompile_fixture
+
+        compiled = stmt.compile(compile_kwargs={"render_postcompile": True})
+        is_none(compiled.positiontup)
+        eq_(
+            compiled.construct_params(),
+            {
+                "count_2": 1,
+                "q_1": "x",
+                "z_1_1": 1,
+                "z_1_2": 2,
+                "z_1_3": 3,
+                "z_tuple_1_1_1": 1,
+                "z_tuple_1_1_2": "a",
+                "z_tuple_1_2_1": 2,
+                "z_tuple_1_2_2": "b",
+                "z_tuple_1_3_1": 3,
+                "z_tuple_1_3_2": "c",
+                "key_1_1": "1",
+                "key_1_2": "2",
+                "key_2_1": "3",
+                "key_2_2": "4",
+            },
+        )
+
+    def test_render_postcompile_no_new_params(
+        self, render_postcompile_fixture
+    ):
+        stmt = render_postcompile_fixture
+
+        compiled = stmt.compile(compile_kwargs={"render_postcompile": True})
+        params = {"q_1": "g"}
+        with expect_raises_message(
+            exc.InvalidRequestError,
+            "can't construct new parameters when render_postcompile is used; "
+            "the statement is hard-linked to the original parameters.",
+        ):
+            compiled.construct_params(params)
+
+    @testing.variation("render_postcompile", [True, False])
+    def test_new_expanded_state_no_params(
+        self, render_postcompile_fixture: Select, render_postcompile
+    ):
+        stmt = render_postcompile_fixture
+
+        compiled = stmt.compile(
+            compile_kwargs={"render_postcompile": render_postcompile}
+        )
+        is_none(compiled.positiontup)
+
+        es = compiled.construct_expanded_state()
+
+        is_none(compiled.positiontup)
+
+        eq_ignore_whitespace(
+            es.statement,
+            "SELECT count(:count_2) AS count_1 WHERE q = :q_1 AND z "
+            "IN (:z_1_1, :z_1_2, :z_1_3) AND z_tuple IN "
+            "((:z_tuple_1_1_1, :z_tuple_1_1_2), "
+            "(:z_tuple_1_2_1, :z_tuple_1_2_2), "
+            "(:z_tuple_1_3_1, :z_tuple_1_3_2)) "
+            "AND (y foobar ((:key_1_1, :key_1_2), (:key_2_1, :key_2_2)))",
+        )
+
+        eq_(
+            es.parameters,
+            {
+                "count_2": 1,
+                "q_1": "x",
+                "z_1_1": 1,
+                "z_1_2": 2,
+                "z_1_3": 3,
+                "z_tuple_1_1_1": 1,
+                "z_tuple_1_1_2": "a",
+                "z_tuple_1_2_1": 2,
+                "z_tuple_1_2_2": "b",
+                "z_tuple_1_3_1": 3,
+                "z_tuple_1_3_2": "c",
+                "key_1_1": "1",
+                "key_1_2": "2",
+                "key_2_1": "3",
+                "key_2_2": "4",
+            },
+        )
+
+    @testing.variation("render_postcompile", [True, False])
+    @testing.variation("positional", [True, False])
+    def test_accessor_no_params(self, render_postcompile, positional):
+        stmt = select(column("q"))
+
+        positional_dialect = default.DefaultDialect(
+            paramstyle="qmark" if positional else "pyformat"
+        )
+        compiled = stmt.compile(
+            dialect=positional_dialect,
+            compile_kwargs={"render_postcompile": render_postcompile},
+        )
+        if positional:
+            eq_(compiled.positiontup, [])
+        else:
+            is_none(compiled.positiontup)
+        eq_(compiled.params, {})
+        eq_(compiled.construct_params(), {})
+
+        es = compiled.construct_expanded_state()
+        if positional:
+            eq_(es.positiontup, [])
+            eq_(es.positional_parameters, ())
+        else:
+            is_none(es.positiontup)
+            with expect_raises_message(
+                exc.InvalidRequestError,
+                "statement does not use a positional paramstyle",
+            ):
+                es.positional_parameters
+        eq_(es.parameters, {})
+
+        eq_ignore_whitespace(
+            es.statement,
+            "SELECT q",
+        )
+
+    @testing.variation("render_postcompile", [True, False])
+    def test_new_expanded_state_new_params(
+        self, render_postcompile_fixture: Select, render_postcompile
+    ):
+        stmt = render_postcompile_fixture
+
+        compiled = stmt.compile(
+            compile_kwargs={"render_postcompile": render_postcompile}
+        )
+        is_none(compiled.positiontup)
+
+        es = compiled.construct_expanded_state(
+            {
+                "z_tuple_1": [("q", "z", "p"), ("g", "h", "i")],
+                "key": ["a", "b"],
+            }
+        )
+        is_none(compiled.positiontup)
+
+        eq_ignore_whitespace(
+            es.statement,
+            "SELECT count(:count_2) AS count_1 WHERE q = :q_1 AND z IN "
+            "(:z_1_1, :z_1_2, :z_1_3) AND z_tuple IN "
+            "((:z_tuple_1_1_1, :z_tuple_1_1_2, :z_tuple_1_1_3), "
+            "(:z_tuple_1_2_1, :z_tuple_1_2_2, :z_tuple_1_2_3)) AND "
+            "(y foobar (:key_1, :key_2))",
+        )
+
+        eq_(
+            es.parameters,
+            {
+                "count_2": 1,
+                "q_1": "x",
+                "z_1_1": 1,
+                "z_1_2": 2,
+                "z_1_3": 3,
+                "z_tuple_1_1_1": "q",
+                "z_tuple_1_1_2": "z",
+                "z_tuple_1_1_3": "p",
+                "z_tuple_1_2_1": "g",
+                "z_tuple_1_2_2": "h",
+                "z_tuple_1_2_3": "i",
+                "key_1": "a",
+                "key_2": "b",
+            },
+        )
+
+    @testing.variation("render_postcompile", [True, False])
+    @testing.variation("paramstyle", ["qmark", "numeric"])
+    def test_new_expanded_state_new_positional_params(
+        self,
+        render_postcompile_fixture: Select,
+        render_postcompile,
+        paramstyle,
+    ):
+        stmt = render_postcompile_fixture
+        positional_dialect = default.DefaultDialect(paramstyle=paramstyle.name)
+
+        compiled = stmt.compile(
+            dialect=positional_dialect,
+            compile_kwargs={"render_postcompile": render_postcompile},
+        )
+
+        if render_postcompile:
+            eq_(
+                compiled.positiontup,
+                [
+                    "count_2",
+                    "q_1",
+                    "z_1_1",
+                    "z_1_2",
+                    "z_1_3",
+                    "z_tuple_1_1_1",
+                    "z_tuple_1_1_2",
+                    "z_tuple_1_2_1",
+                    "z_tuple_1_2_2",
+                    "z_tuple_1_3_1",
+                    "z_tuple_1_3_2",
+                    "key_1_1",
+                    "key_1_2",
+                    "key_2_1",
+                    "key_2_2",
+                ],
+            )
+        else:
+            eq_(
+                compiled.positiontup,
+                ["count_2", "q_1", "z_1", "z_tuple_1", "key"],
+            )
+        es = compiled.construct_expanded_state(
+            {
+                "z_tuple_1": [("q", "z", "p"), ("g", "h", "i")],
+                "key": ["a", "b"],
+            }
+        )
+        if paramstyle.qmark:
+            eq_ignore_whitespace(
+                es.statement,
+                "SELECT count(?) AS count_1 WHERE q = ? "
+                "AND z IN (?, ?, ?) AND "
+                "z_tuple IN ((?, ?, ?), (?, ?, ?)) AND (y foobar (?, ?))",
+            )
+        elif paramstyle.numeric:
+            eq_ignore_whitespace(
+                es.statement,
+                "SELECT count(:1) AS count_1 WHERE q = :2 AND z IN "
+                "(:3, :4, :5) AND z_tuple "
+                "IN ((:6, :7, :8), (:9, :10, :11)) AND (y foobar (:12, :13))",
+            )
+        else:
+            paramstyle.fail()
+
+        eq_(
+            es.parameters,
+            {
+                "count_2": 1,
+                "q_1": "x",
+                "z_1_1": 1,
+                "z_1_2": 2,
+                "z_1_3": 3,
+                "z_tuple_1_1_1": "q",
+                "z_tuple_1_1_2": "z",
+                "z_tuple_1_1_3": "p",
+                "z_tuple_1_2_1": "g",
+                "z_tuple_1_2_2": "h",
+                "z_tuple_1_2_3": "i",
+                "key_1": "a",
+                "key_2": "b",
+            },
+        )
+        eq_(
+            es.positiontup,
+            [
+                "count_2",
+                "q_1",
+                "z_1_1",
+                "z_1_2",
+                "z_1_3",
+                "z_tuple_1_1_1",
+                "z_tuple_1_1_2",
+                "z_tuple_1_1_3",
+                "z_tuple_1_2_1",
+                "z_tuple_1_2_2",
+                "z_tuple_1_2_3",
+                "key_1",
+                "key_2",
+            ],
+        )
+        eq_(
+            es.positional_parameters,
+            (1, "x", 1, 2, 3, "q", "z", "p", "g", "h", "i", "a", "b"),
+        )
+
+    def test_render_postcompile_positional_parameters(
+        self, render_postcompile_fixture
+    ):
+        stmt = render_postcompile_fixture
+
+        positional_dialect = default.DefaultDialect(paramstyle="qmark")
+        compiled = stmt.compile(
+            dialect=positional_dialect,
+            compile_kwargs={"render_postcompile": True},
+        )
+        eq_(
+            compiled.construct_params(),
+            {
+                "count_2": 1,
+                "q_1": "x",
+                "z_1_1": 1,
+                "z_1_2": 2,
+                "z_1_3": 3,
+                "z_tuple_1_1_1": 1,
+                "z_tuple_1_1_2": "a",
+                "z_tuple_1_2_1": 2,
+                "z_tuple_1_2_2": "b",
+                "z_tuple_1_3_1": 3,
+                "z_tuple_1_3_2": "c",
+                "key_1_1": "1",
+                "key_1_2": "2",
+                "key_2_1": "3",
+                "key_2_2": "4",
+            },
+        )
+        eq_(
+            compiled.positiontup,
+            [
+                "count_2",
+                "q_1",
+                "z_1_1",
+                "z_1_2",
+                "z_1_3",
+                "z_tuple_1_1_1",
+                "z_tuple_1_1_2",
+                "z_tuple_1_2_1",
+                "z_tuple_1_2_2",
+                "z_tuple_1_3_1",
+                "z_tuple_1_3_2",
+                "key_1_1",
+                "key_1_2",
+                "key_2_1",
+                "key_2_2",
+            ],
+        )
+
+
 class UnsupportedTest(fixtures.TestBase):
     def test_unsupported_element_str_visit_name(self):
         from sqlalchemy.sql.expression import ClauseElement