# Buggy code
def _merge_cursor_description(
    self,
    context,
    cursor_description,
    result_columns,
    num_ctx_cols,
    cols_are_ordered,
    textual_ordered,
    loose_column_name_matching,
):
    """Merge a cursor.description with compiled result column information.

    There are at least four separate strategies used here, selected
    depending on the type of SQL construct used to start with.

    The most common case is that of the compiled SQL expression construct,
    which generated the column names present in the raw SQL string and
    which has the identical number of columns as were reported by
    cursor.description.  In this case, we assume a 1-1 positional mapping
    between the entries in cursor.description and the compiled object.
    This is also the most performant case as we disregard extracting /
    decoding the column names present in cursor.description since we
    already have the desired name we generated in the compiled SQL
    construct.

    The next common case is that of the completely raw string SQL,
    such as passed to connection.execute().  In this case we have no
    compiled construct to work with, so we extract and decode the
    names from cursor.description and index those as the primary
    result row target keys.

    The remaining fairly common case is that of the textual SQL
    that includes at least partial column information; this is when
    we use a :class:`_expression.TextualSelect` construct.
    This construct may have
    unordered or ordered column information.  In the ordered case, we
    merge the cursor.description and the compiled construct's information
    positionally, and warn if there are additional description names
    present, however we still decode the names in cursor.description
    as we don't have a guarantee that the names in the columns match
    on these.   In the unordered case, we match names in cursor.description
    to that of the compiled construct based on name matching.
    In both of these cases, the cursor.description names and the column
    expression objects and names are indexed as result row target keys.

    The final case is much less common, where we have a compiled
    non-textual SQL expression construct, but the number of columns
    in cursor.description doesn't match what's in the compiled
    construct.  We make the guess here that there might be textual
    column expressions in the compiled construct that themselves include
    a comma in them causing them to split.  We do the same name-matching
    as with textual non-ordered columns.

    The name-matched system of merging is the same as that used by
    SQLAlchemy for all cases up through te 0.9 series.   Positional
    matching for compiled SQL expressions was introduced in 1.0 as a
    major performance feature, and positional matching for textual
    :class:`_expression.TextualSelect` objects in 1.1.
    As name matching is no longer
    a common case, it was acceptable to factor it into smaller generator-
    oriented methods that are easier to understand, but incur slightly
    more performance overhead.

    """

    case_sensitive = context.dialect.case_sensitive

    if (
        num_ctx_cols
        and cols_are_ordered
        and not textual_ordered
        and num_ctx_cols == len(cursor_description)
    ):
        self._keys = [elem[0] for elem in result_columns]
        # pure positional 1-1 case; doesn't need to read
        # the names from cursor.description

        # this metadata is safe to cache because we are guaranteed
        # to have the columns in the same order for new executions
        self._safe_for_cache = True
        return [
            (
                idx,
                rmap_entry[RM_OBJECTS],
                rmap_entry[RM_NAME].lower()
                if not case_sensitive
                else rmap_entry[RM_NAME],
                rmap_entry[RM_RENDERED_NAME],
                context.get_result_processor(
                    rmap_entry[RM_TYPE],
                    rmap_entry[RM_RENDERED_NAME],
                    cursor_description[idx][1],
                ),
                None,
            )
            for idx, rmap_entry in enumerate(result_columns)
        ]
    else:
        # name-based or text-positional cases, where we need
        # to read cursor.description names

        if textual_ordered:
            self._safe_for_cache = True
            # textual positional case
            raw_iterator = self._merge_textual_cols_by_position(
                context, cursor_description, result_columns
            )
        elif num_ctx_cols:
            # compiled SQL with a mismatch of description cols
            # vs. compiled cols, or textual w/ unordered columns
            # the order of columns can change if the query is
            # against a "select *", so not safe to cache
            self._safe_for_cache = False
            raw_iterator = self._merge_cols_by_name(
                context,
                cursor_description,
                result_columns,
                loose_column_name_matching,
            )
        else:
            # no compiled SQL, just a raw string, order of columns
            # can change for "select *"
            self._safe_for_cache = False
            raw_iterator = self._merge_cols_by_none(context, cursor_description)

        return [
            (
                idx,
                obj,
                cursor_colname,
                cursor_colname,
                context.get_result_processor(mapped_type, cursor_colname, coltype),
                untranslated,
            )
            for (
                idx,
                cursor_colname,
                mapped_type,
                coltype,
                obj,
                untranslated,
            ) in raw_iterator
        ]


# Traceback (sqlalchemy.exc.InvalidRequestError)
2020-09-05 14:00:42,711 INFO sqlalchemy.engine.Engine SELECT users.id, teams.id
FROM users LEFT OUTER JOIN teams ON teams.id = users.team_id
2020-09-05 14:00:42,711 INFO sqlalchemy.engine.Engine [generated in 0.00025s] {}
1
2020-09-05 14:00:42,712 INFO sqlalchemy.engine.Engine SELECT users.id, teams.id
FROM users LEFT OUTER JOIN teams ON teams.id = users.team_id
2020-09-05 14:00:42,712 INFO sqlalchemy.engine.Engine [cached since 0.001864s ago] {}
Traceback (most recent call last):
File "test_cache.py", line 28, in <module>
print(e.execute(build_query()).first()[users.c.id])
File "sqlalchemy/lib/sqlalchemy/engine/cursor.py", line 587, in _raise_for_ambiguous_column_name
raise exc.InvalidRequestError(
sqlalchemy.exc.InvalidRequestError: Ambiguous column name 'id' in result set column descriptions

# Fixed code
def _merge_cursor_description(
    self,
    context,
    cursor_description,
    result_columns,
    num_ctx_cols,
    cols_are_ordered,
    textual_ordered,
    loose_column_name_matching,
):
    """Merge a cursor.description with compiled result column information.

    There are at least four separate strategies used here, selected
    depending on the type of SQL construct used to start with.

    The most common case is that of the compiled SQL expression construct,
    which generated the column names present in the raw SQL string and
    which has the identical number of columns as were reported by
    cursor.description.  In this case, we assume a 1-1 positional mapping
    between the entries in cursor.description and the compiled object.
    This is also the most performant case as we disregard extracting /
    decoding the column names present in cursor.description since we
    already have the desired name we generated in the compiled SQL
    construct.

    The next common case is that of the completely raw string SQL,
    such as passed to connection.execute().  In this case we have no
    compiled construct to work with, so we extract and decode the
    names from cursor.description and index those as the primary
    result row target keys.

    The remaining fairly common case is that of the textual SQL
    that includes at least partial column information; this is when
    we use a :class:`_expression.TextualSelect` construct.
    This construct may have
    unordered or ordered column information.  In the ordered case, we
    merge the cursor.description and the compiled construct's information
    positionally, and warn if there are additional description names
    present, however we still decode the names in cursor.description
    as we don't have a guarantee that the names in the columns match
    on these.   In the unordered case, we match names in cursor.description
    to that of the compiled construct based on name matching.
    In both of these cases, the cursor.description names and the column
    expression objects and names are indexed as result row target keys.

    The final case is much less common, where we have a compiled
    non-textual SQL expression construct, but the number of columns
    in cursor.description doesn't match what's in the compiled
    construct.  We make the guess here that there might be textual
    column expressions in the compiled construct that themselves include
    a comma in them causing them to split.  We do the same name-matching
    as with textual non-ordered columns.

    The name-matched system of merging is the same as that used by
    SQLAlchemy for all cases up through te 0.9 series.   Positional
    matching for compiled SQL expressions was introduced in 1.0 as a
    major performance feature, and positional matching for textual
    :class:`_expression.TextualSelect` objects in 1.1.
    As name matching is no longer
    a common case, it was acceptable to factor it into smaller generator-
    oriented methods that are easier to understand, but incur slightly
    more performance overhead.

    """

    case_sensitive = context.dialect.case_sensitive

    if (
        num_ctx_cols
        and cols_are_ordered
        and not textual_ordered
        and num_ctx_cols == len(cursor_description)
    ):
        self._keys = [elem[0] for elem in result_columns]
        # pure positional 1-1 case; doesn't need to read
        # the names from cursor.description

        # this metadata is safe to cache because we are guaranteed
        # to have the columns in the same order for new executions
        self._safe_for_cache = True
        return [
            (
                idx,
                idx,
                rmap_entry[RM_OBJECTS],
                rmap_entry[RM_NAME].lower()
                if not case_sensitive
                else rmap_entry[RM_NAME],
                rmap_entry[RM_RENDERED_NAME],
                context.get_result_processor(
                    rmap_entry[RM_TYPE],
                    rmap_entry[RM_RENDERED_NAME],
                    cursor_description[idx][1],
                ),
                None,
            )
            for idx, rmap_entry in enumerate(result_columns)
        ]
    else:
        # name-based or text-positional cases, where we need
        # to read cursor.description names

        if textual_ordered:
            self._safe_for_cache = True
            # textual positional case
            raw_iterator = self._merge_textual_cols_by_position(
                context, cursor_description, result_columns
            )
        elif num_ctx_cols:
            # compiled SQL with a mismatch of description cols
            # vs. compiled cols, or textual w/ unordered columns
            # the order of columns can change if the query is
            # against a "select *", so not safe to cache
            self._safe_for_cache = False
            raw_iterator = self._merge_cols_by_name(
                context,
                cursor_description,
                result_columns,
                loose_column_name_matching,
            )
        else:
            # no compiled SQL, just a raw string, order of columns
            # can change for "select *"
            self._safe_for_cache = False
            raw_iterator = self._merge_cols_by_none(context, cursor_description)

        return [
            (
                idx,
                ridx,
                obj,
                cursor_colname,
                cursor_colname,
                context.get_result_processor(mapped_type, cursor_colname, coltype),
                untranslated,
            )
            for (
                idx,
                ridx,
                cursor_colname,
                mapped_type,
                coltype,
                obj,
                untranslated,
            ) in raw_iterator
        ]