Repository: adamchainz/django-mysql

Issue:
INDEX query hints
#69 added the basic hints, but this does not include the `USE/IGNORE/FORCE INDEX [FOR {JOIN|ORDER BY|GROUP BY}]` family of hints. These are a bit harder to inject, since the `FROM tablename` bit would have to be parsed, plus they will be a bit less user friendly since the index names aren't kept anywhere in Djangoland, but they should be achievable.



Discussion:
Article about wanting them: https://wearespindle.com/articles/optimizing-like-mad/

Django ticket saying they won't be added to core since they aren't generic enough: https://code.djangoproject.com/ticket/11003


Resolving patch:
diff --git a/django_mysql/models/query.py b/django_mysql/models/query.py
--- a/django_mysql/models/query.py
+++ b/django_mysql/models/query.py
@@ -106,6 +106,58 @@ def sql_cache(self):
     def sql_no_cache(self):
         return self.extra(where=["/*QueryRewrite':SQL_NO_CACHE*/1"])
 
+    def use_index(self, *index_names, **kwargs):
+        kwargs['hint'] = 'USE'
+        return self._index_hint(*index_names, **kwargs)
+
+    def force_index(self, *index_names, **kwargs):
+        kwargs['hint'] = 'FORCE'
+        return self._index_hint(*index_names, **kwargs)
+
+    def ignore_index(self, *index_names, **kwargs):
+        kwargs['hint'] = 'IGNORE'
+        return self._index_hint(*index_names, **kwargs)
+
+    def _index_hint(self, *index_names, **kwargs):
+        hint = kwargs.pop('hint')
+        table_name = kwargs.pop('table_name', None)
+        for_ = kwargs.pop('for_', None)
+        if kwargs:
+            raise ValueError(
+                "{}_index accepts only 'for_' and 'table_name' as keyword "
+                "arguments"
+                .format(hint.lower())
+            )
+
+        if hint != 'USE' and not len(index_names):
+            raise ValueError(
+                "{}_index requires at least one index name"
+                .format(hint.lower())
+            )
+
+        if table_name is None:
+            table_name = self.model._meta.db_table
+
+        if for_ in ('JOIN', 'ORDER BY', 'GROUP BY'):
+            for_bit = 'FOR {} '.format(for_)
+        elif for_ is None:
+            for_bit = ''
+        else:
+            raise ValueError("for_ must be one of: None, 'JOIN', 'ORDER BY', "
+                             "'GROUP BY'")
+
+        if len(index_names) == 0:
+            indexes = "NONE"
+        else:
+            indexes = "`" + "`,`".join(index_names) + "`"
+
+        hint = (
+            "/*QueryRewrite':index=`{table_name}` {hint} {for_bit}{indexes}*/1"
+            .format(table_name=table_name, hint=hint, for_bit=for_bit,
+                    indexes=indexes)
+        )
+        return self.extra(where=[hint])
+
     # Features handled by extra classes/functions
 
     def iter_smart(self, **kwargs):
diff --git a/django_mysql/rewrite_query.py b/django_mysql/rewrite_query.py
--- a/django_mysql/rewrite_query.py
+++ b/django_mysql/rewrite_query.py
@@ -20,24 +20,53 @@
 
 # Regex to match a rewrite rule
 query_rewrite_re = re.compile(r"/\*QueryRewrite':(.*?)\*/")
+# Regex to parse an index hint into a tuple
+index_rule_re = re.compile(
+    r"""
+    index=
+    (?P<table_name>`[^`]+`)
+    \ # space
+    (?P<rule>USE|IGNORE|FORCE)
+    \ # space
+    (
+        FOR
+        \ # space
+        (?P<for_what>JOIN|ORDER\ BY|GROUP\ BY)
+        \ # space
+    )?
+    (?P<index_names>(`[^`]+`(,`[^`]+`)*)|NONE)
+    """,
+    re.VERBOSE
+)
 
 
 def rewrite_query(sql):
     comments = []
     hints = []
+    index_hints = []
     for match in query_rewrite_re.findall(sql):
         if match in SELECT_HINT_TOKENS:
             hints.append(match)
         elif match.startswith('label='):
             comments.append(match[6:])
+        elif match.startswith('index='):
+            # Extra parsing
+            index_match = index_rule_re.match(match)
+            if index_match:
+                index_hints.append((
+                    index_match.group('table_name'),
+                    index_match.group('rule'),
+                    index_match.group('index_names'),
+                    index_match.group('for_what'),
+                ))
 
         # Silently fail on unrecognized rewrite requests
 
     # Delete all rewrite comments
     sql = query_rewrite_re.sub('', sql)
 
-    if comments or hints:  # If nothing to do, don't bother
-        sql = modify_sql(sql, comments, hints)
+    if comments or hints or index_hints:  # If nothing to do, don't bother
+        sql = modify_sql(sql, comments, hints, index_hints)
 
     return sql
 
@@ -86,7 +115,7 @@ def rewrite_query(sql):
 )
 
 
-def modify_sql(sql, add_comments, add_hints):
+def modify_sql(sql, add_comments, add_hints, add_index_hints):
     """
     Parse the start of the SQL, injecting each string in add_comments in
     individual SQL comments after the first keyword, and adding the named
@@ -123,6 +152,41 @@ def modify_sql(sql, add_comments, add_hints):
                 if existing is not None:
                     tokens.append(existing.rstrip())
 
-    # Add the remainder of the statement and join it all up
-    tokens.append(sql[match.end():])
+    # Maybe rewrite the remainder of the statement for index hints
+    remainder = sql[match.end():]
+
+    if tokens[0] == "SELECT" and add_index_hints:
+        for index_hint in add_index_hints:
+            remainder = modify_sql_index_hints(remainder, *index_hint)
+
+    # Join everything
+    tokens.append(remainder)
     return ' '.join(tokens)
+
+
+table_spec_re_template = r'''
+    \b(?P<operator>FROM|JOIN)
+    \s+
+    {table_name}
+    \s+
+'''
+
+replacement_template = (
+    r'\g<operator> {table_name} '
+    r'{rule} INDEX {for_section}({index_names}) '
+)
+
+
+def modify_sql_index_hints(sql, table_name, rule, index_names, for_what):
+    table_spec_re = table_spec_re_template.format(table_name=table_name)
+    if for_what:
+        for_section = 'FOR {} '.format(for_what)
+    else:
+        for_section = ''
+    replacement = replacement_template.format(
+        table_name=table_name,
+        rule=rule,
+        for_section=for_section,
+        index_names=('' if index_names == 'NONE' else index_names)
+    )
+    return re.sub(table_spec_re, replacement, sql, count=1, flags=re.VERBOSE)
diff --git a/django_mysql/utils.py b/django_mysql/utils.py
--- a/django_mysql/utils.py
+++ b/django_mysql/utils.py
@@ -4,10 +4,13 @@
 import os
 import pty
 import time
+from collections import defaultdict
 from contextlib import contextmanager
 from subprocess import PIPE, Popen, call
 from threading import Lock, Thread
 
+from django.db import DEFAULT_DB_ALIAS, connections
+from django.utils import six
 from django.utils.six.moves.queue import Empty, Queue
 
 
@@ -219,3 +222,44 @@ def run(self):
 def collapse_spaces(string):
     bits = string.replace('\n', ' ').split(' ')
     return " ".join(filter(None, bits))
+
+
+def index_name(model, *field_names, **kwargs):
+    """
+    Returns the name of the index existing on field_names, or raises KeyError
+    if no such index exists.
+    """
+    if not len(field_names):
+        raise ValueError("At least one field name required")
+    using = kwargs.pop('using', DEFAULT_DB_ALIAS)
+    if len(kwargs):
+        raise ValueError("The only supported keyword argument is 'using'")
+
+    existing_fields = {field.name: field for field in model._meta.fields}
+    fields = [existing_fields[name] for name in field_names
+              if name in existing_fields]
+
+    if len(fields) != len(field_names):
+        unfound_names = set(field_names) - set(field.name for field in fields)
+        raise ValueError("Fields do not exist: " + ",".join(unfound_names))
+    column_names = tuple(field.column for field in fields)
+
+    with connections[using].cursor() as cursor:
+        cursor.execute(
+            """SELECT `INDEX_NAME`, `SEQ_IN_INDEX`, `COLUMN_NAME`
+               FROM INFORMATION_SCHEMA.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND
+                     TABLE_NAME = %s AND
+                     COLUMN_NAME IN %s
+               ORDER BY `INDEX_NAME`, `SEQ_IN_INDEX` ASC""",
+            (model._meta.db_table, column_names)
+        )
+        indexes = defaultdict(list)
+        for index_name, _, column_name in cursor.fetchall():
+            indexes[index_name].append(column_name)
+
+    indexes_by_columns = {tuple(v): k for k, v in six.iteritems(indexes)}
+    try:
+        return indexes_by_columns[column_names]
+    except KeyError:
+        raise KeyError("There is no index on (" + ",".join(field_names) + ")")


Test patch:
diff --git a/tests/django_mysql_tests/models.py b/tests/django_mysql_tests/models.py
--- a/tests/django_mysql_tests/models.py
+++ b/tests/django_mysql_tests/models.py
@@ -130,7 +130,7 @@ class AuthorMultiIndex(Model):
     class Meta(object):
         index_together = ('name', 'country')
 
-    name = CharField(max_length=32)
+    name = CharField(max_length=32, db_index=True)
     country = CharField(max_length=32)
 
     def __unicode__(self):
diff --git a/tests/django_mysql_tests/test_handler.py b/tests/django_mysql_tests/test_handler.py
--- a/tests/django_mysql_tests/test_handler.py
+++ b/tests/django_mysql_tests/test_handler.py
@@ -5,6 +5,7 @@
 from django.test import TestCase
 
 from django_mysql.models.handler import Handler
+from django_mysql.utils import index_name
 from django_mysql_tests.models import (
     Author, AuthorHugeName, AuthorMultiIndex, NameAuthor, VanillaAuthor
 )
@@ -311,8 +312,7 @@ def setUp(self):
                                                       country='Scotland')
         self.smith2 = AuthorMultiIndex.objects.create(name='John Smith',
                                                       country='England')
-        self.index_name = [name for name in get_index_names(AuthorMultiIndex)
-                           if name != "PRIMARY"][0]
+        self.index_name = index_name(AuthorMultiIndex, 'name', 'country')
 
     def test_read_all(self):
         with AuthorMultiIndex.objects.handler() as handler:
diff --git a/tests/django_mysql_tests/test_models.py b/tests/django_mysql_tests/test_models.py
--- a/tests/django_mysql_tests/test_models.py
+++ b/tests/django_mysql_tests/test_models.py
@@ -10,11 +10,13 @@
 from django.test.utils import override_settings
 
 from django_mysql.models import ApproximateInt, SmartIterator
-from django_mysql.utils import have_program
+from django_mysql.utils import have_program, index_name
 from django_mysql_tests.models import (
-    Author, AuthorExtra, Book, NameAuthor, VanillaAuthor
+    Author, AuthorExtra, AuthorMultiIndex, Book, NameAuthor, VanillaAuthor
+)
+from django_mysql_tests.utils import (
+    CaptureLastQuery, captured_stdout, used_indexes
 )
-from django_mysql_tests.utils import CaptureLastQuery, captured_stdout
 
 
 class ApproximateCountTests(TestCase):
@@ -211,6 +213,117 @@ def test_complex_query_2(self):
                                .filter(books__in=subq))
         assert cap.query.startswith("SELECT STRAIGHT_JOIN ")
 
+    def test_use_index(self):
+        name_idx = index_name(Author, 'name')
+        with CaptureLastQuery() as cap:
+            list(Author.objects.filter(name__gt='')
+                               .use_index(name_idx))
+        assert ('USE INDEX (`' + name_idx + '`)') in cap.query
+        used = used_indexes(cap.query)
+        assert len(used) == 0 or name_idx in used
+
+    def test_use_index_primary(self):
+        with CaptureLastQuery() as cap:
+            list(Author.objects.use_index('PRIMARY'))
+        assert ('USE INDEX (`PRIMARY`)') in cap.query
+        used = used_indexes(cap.query)
+        assert len(used) == 0 or 'PRIMARY' in used
+
+    def test_force_index(self):
+        name_idx = index_name(Author, 'name')
+        with CaptureLastQuery() as cap:
+            list(Author.objects.filter(name__gt='')
+                               .force_index(name_idx))
+        assert ('FORCE INDEX (`' + name_idx + '`)') in cap.query
+        assert name_idx in used_indexes(cap.query)
+
+    def test_force_index_primary(self):
+        with CaptureLastQuery() as cap:
+            list(Author.objects.force_index('PRIMARY'))
+        assert ('FORCE INDEX (`PRIMARY`)') in cap.query
+        used = used_indexes(cap.query)
+        assert len(used) == 0 or 'PRIMARY' in used
+
+    def test_ignore_index(self):
+        name_idx = index_name(Author, 'name')
+        with CaptureLastQuery() as cap:
+            list(Author.objects.filter(name__gt='').ignore_index(name_idx))
+        assert ('IGNORE INDEX (`' + name_idx + '`)') in cap.query
+        assert name_idx not in used_indexes(cap.query)
+
+    def test_ignore_index_multiple(self):
+        name_idx = index_name(AuthorMultiIndex, 'name')
+        name_country_idx = index_name(AuthorMultiIndex, 'name', 'country')
+        with CaptureLastQuery() as cap:
+            list(AuthorMultiIndex.objects
+                                 .filter(name__gt='')
+                                 .ignore_index(name_idx, name_country_idx))
+        assert (
+            'IGNORE INDEX (`' + name_idx + '`,`' + name_country_idx + '`)'
+            in cap.query
+        )
+        used = used_indexes(cap.query)
+        assert name_idx not in used
+        assert name_country_idx not in used
+
+    def test_ignore_index_primary(self):
+        with CaptureLastQuery() as cap:
+            list(Author.objects.filter(name__gt='').ignore_index('PRIMARY'))
+        assert ('IGNORE INDEX (`PRIMARY`)') in cap.query
+        assert 'PRIMARY' not in used_indexes(cap.query)
+
+    def test_force_index_at_least_one(self):
+        with pytest.raises(ValueError) as excinfo:
+            Author.objects.force_index()
+        assert (
+            str(excinfo.value) ==
+            "force_index requires at least one index name"
+        )
+
+    def test_force_index_invalid_for(self):
+        with pytest.raises(ValueError) as excinfo:
+            Author.objects.force_index('a', for_='INVALID')
+        assert "for_ must be one of" in str(excinfo.value)
+
+    def test_force_index_invalid_kwarg(self):
+        with pytest.raises(ValueError) as excinfo:
+            Author.objects.force_index('a', nonexistent=True)
+        assert (
+            "force_index accepts only 'for_' and 'table_name' as keyword "
+            "arguments" in
+            str(excinfo.value)
+        )
+
+    def test_index_hint_force_order_by(self):
+        name_idx = index_name(Author, 'name')
+        with CaptureLastQuery() as cap:
+            list(Author.objects.force_index(name_idx, for_='ORDER BY')
+                               .order_by('name'))
+
+        assert ('FORCE INDEX FOR ORDER BY (`' + name_idx + "`)") in cap.query
+        assert name_idx in used_indexes(cap.query)
+
+    def test_use_index_none(self):
+        with CaptureLastQuery() as cap:
+            list(Author.objects.values_list('name').distinct().use_index())
+        assert 'USE INDEX () ' in cap.query
+        assert used_indexes(cap.query) == set()
+
+    def test_use_index_table_name(self):
+        extra_table = 'django_mysql_tests_authorextra'
+        with CaptureLastQuery() as cap:
+            list(Author.objects
+                       .select_related('authorextra')
+                       .use_index('PRIMARY', table_name=extra_table))
+        assert '`' + extra_table + '` USE INDEX (`PRIMARY`) ' in cap.query
+
+    def test_force_index_table_name_doesnt_exist_ignored(self):
+        with CaptureLastQuery() as cap:
+            list(Author.objects
+                       .select_related('authorextra')
+                       .force_index('PRIMARY', table_name='nonexistent'))
+        assert ' FORCE INDEX ' not in cap.query
+
 
 class SmartIteratorTests(TestCase):
 
diff --git a/tests/django_mysql_tests/test_rewrite_query.py b/tests/django_mysql_tests/test_rewrite_query.py
--- a/tests/django_mysql_tests/test_rewrite_query.py
+++ b/tests/django_mysql_tests/test_rewrite_query.py
@@ -207,6 +207,109 @@ def test_bad_query_not_rewritten(self):
             "WHERE (1)"
         )
 
+    def test_index_hint_use(self):
+        assert (
+            rewrite_query(
+                "SELECT col_a FROM `sometable` WHERE "
+                "(/*QueryRewrite':index=`sometable` USE `col_a_idx`*/1)"
+            ) ==
+            "SELECT col_a FROM `sometable` USE INDEX (`col_a_idx`) WHERE (1)"
+        )
+
+    def test_index_ignore(self):
+        assert (
+            rewrite_query(
+                "SELECT col_a FROM `sometable` WHERE "
+                "(/*QueryRewrite':index=`sometable` IGNORE `col_a_idx`*/1)"
+            ) ==
+            "SELECT col_a FROM `sometable` IGNORE INDEX (`col_a_idx`) "
+            "WHERE (1)"
+        )
+
+    def test_index_force(self):
+        assert (
+            rewrite_query(
+                "SELECT col_a FROM `sometable` WHERE "
+                "(/*QueryRewrite':index=`sometable` FORCE `col_a_idx`*/1)"
+            ) ==
+            "SELECT col_a FROM `sometable` FORCE INDEX (`col_a_idx`) "
+            "WHERE (1)"
+        )
+
+    def test_index_nonsense_does_nothing(self):
+        assert (
+            rewrite_query(
+                "SELECT col_a FROM `sometable` WHERE "
+                "(/*QueryRewrite':index=`sometable` MAHOGANY `col_a_idx`*/1)"
+            ) ==
+            "SELECT col_a FROM `sometable` WHERE (1)"
+        )
+
+    def test_index_hint_use_secondary(self):
+        assert (
+            rewrite_query(
+                "SELECT col_a, col_b FROM `sometable` INNER JOIN `othertable` "
+                " WHERE (/*QueryRewrite':index=`othertable` USE `myindex`*/1)"
+            ) ==
+            "SELECT col_a, col_b FROM `sometable` INNER JOIN `othertable` "
+            "USE INDEX (`myindex`) WHERE (1)"
+        )
+
+    def test_index_hint_multiple_indexes(self):
+        assert (
+            rewrite_query(
+                "SELECT col_a FROM `tabl` WHERE "
+                "(/*QueryRewrite':index=`tabl` IGNORE `idx1`,`idx2`*/1)"
+            ) ==
+            "SELECT col_a FROM `tabl` IGNORE INDEX (`idx1`,`idx2`) WHERE (1)"
+        )
+
+    def test_index_hint_multiple_hints(self):
+        assert (
+            rewrite_query(
+                "SELECT col_a FROM `sometable` "
+                "WHERE (/*QueryRewrite':index=`sometable` IGNORE `idx1`*/1) "
+                "AND (/*QueryRewrite':index=`sometable` IGNORE `idx2`*/1)"
+            ) ==
+            "SELECT col_a FROM `sometable` IGNORE INDEX (`idx2`) "
+            "IGNORE INDEX (`idx1`) WHERE (1) AND (1)"
+        )
+
+    def test_index_hint_for_join(self):
+        assert (
+            rewrite_query(
+                "SELECT `sometable`.col_a, `sometable2`.col_b "
+                "FROM `sometable` NATURAL JOIN `sometable2` "
+                "WHERE (/*QueryRewrite':index=`sometable` IGNORE FOR JOIN "
+                "`idx`*/1)"
+            ) ==
+            "SELECT `sometable`.col_a, `sometable2`.col_b FROM `sometable` "
+            "IGNORE INDEX FOR JOIN (`idx`) "
+            "NATURAL JOIN `sometable2` WHERE (1)"
+        )
+
+    def test_index_hint_for_group_by(self):
+        assert (
+            rewrite_query(
+                "SELECT col_a, SUM(col_b) FROM `sometable` "
+                "WHERE (/*QueryRewrite':index=`sometable` FORCE FOR GROUP BY "
+                "`idx`*/1) GROUP BY col_a"
+            ) ==
+            "SELECT col_a, SUM(col_b) FROM `sometable` FORCE INDEX FOR GROUP "
+            "BY (`idx`) WHERE (1) GROUP BY col_a"
+        )
+
+    def test_index_hint_for_order_by(self):
+        assert (
+            rewrite_query(
+                "SELECT col_a FROM `sometable` "
+                "WHERE (/*QueryRewrite':index=`sometable` USE FOR ORDER BY "
+                "`idx` */1) ORDER BY col_a"
+            ) ==
+            "SELECT col_a FROM `sometable` USE INDEX FOR ORDER BY (`idx`) "
+            "WHERE (1) ORDER BY col_a"
+        )
+
     def test_it_is_monkey_patched(self):
         with CaptureLastQuery() as cap, connection.cursor() as cursor:
             cursor.execute("SELECT 1 FROM DUAL "
diff --git a/tests/django_mysql_tests/test_utils.py b/tests/django_mysql_tests/test_utils.py
--- a/tests/django_mysql_tests/test_utils.py
+++ b/tests/django_mysql_tests/test_utils.py
@@ -4,11 +4,15 @@
 from time import sleep
 from unittest import skipUnless
 
-from django.test import SimpleTestCase
+import pytest
+from django.test import SimpleTestCase, TestCase
+from django.utils import six
 
 from django_mysql.utils import (
-    PTFingerprintThread, WeightedAverageRate, have_program, pt_fingerprint
+    PTFingerprintThread, WeightedAverageRate, have_program, index_name,
+    pt_fingerprint
 )
+from django_mysql_tests.models import Author, AuthorMultiIndex
 
 
 class WeightedAverageRateTests(SimpleTestCase):
@@ -97,3 +101,57 @@ def test_the_thread_shuts_on_time_out(self):
         sleep(0.2)
         assert PTFingerprintThread.the_thread is None
         PTFingerprintThread.PROCESS_LIFETIME = 60
+
+
+class IndexNameTests(TestCase):
+    def test_requires_field_names(self):
+        with pytest.raises(ValueError) as excinfo:
+            index_name(Author)
+        assert (
+            "At least one field name required" in
+            six.text_type(excinfo.value)
+        )
+
+    def test_requires_real_field_names(self):
+        with pytest.raises(ValueError) as excinfo:
+            index_name(Author, 'nonexistent')
+        assert (
+            "Fields do not exist: nonexistent" in
+            six.text_type(excinfo.value)
+        )
+
+    def test_invalid_kwarg(self):
+        with pytest.raises(ValueError) as excinfo:
+            index_name(Author, 'name', nonexistent_kwarg=True)
+        assert (
+            "The only supported keyword argument is 'using'" in
+            six.text_type(excinfo.value)
+        )
+
+    def test_primary_key(self):
+        assert index_name(Author, 'id') == 'PRIMARY'
+
+    def test_primary_key_using_other(self):
+        assert index_name(Author, 'id', using='other') == 'PRIMARY'
+
+    def test_secondary_single_field(self):
+        name = index_name(Author, 'name')
+        assert name.startswith('django_mysql_tests_author_')
+
+    def test_index_does_not_exist(self):
+        with pytest.raises(KeyError) as excinfo:
+            index_name(Author, 'bio')
+        assert "There is no index on (bio)" in six.text_type(excinfo.value)
+
+    def test_secondary_multiple_fields(self):
+        name = index_name(AuthorMultiIndex, 'name', 'country')
+        assert name.startswith('django_mysql_tests_authormultiindex')
+
+    def test_secondary_multiple_fields_non_existent_reversed_existent(self):
+        # Checks that order is preserved
+        with pytest.raises(KeyError):
+            index_name(AuthorMultiIndex, 'country', 'name')
+
+    def test_secondary_multiple_fields_non_existent(self):
+        with pytest.raises(KeyError):
+            index_name(AuthorMultiIndex, 'country', 'id')
diff --git a/tests/django_mysql_tests/utils.py b/tests/django_mysql_tests/utils.py
--- a/tests/django_mysql_tests/utils.py
+++ b/tests/django_mysql_tests/utils.py
@@ -1,7 +1,7 @@
 import sys
 from contextlib import contextmanager
 
-from django.db import connection
+from django.db import DEFAULT_DB_ALIAS, connection, connections
 from django.test.utils import CaptureQueriesContext
 from django.utils import six
 
@@ -62,3 +62,12 @@ def __exit__(self, a, b, c):
     @property
     def query(self):
         return self.capturer.captured_queries[-1]['sql']
+
+
+def used_indexes(query, using=DEFAULT_DB_ALIAS):
+    """
+    Given SQL 'query', run EXPLAIN and return the names of the indexes used
+    """
+    with connections[using].cursor() as cursor:
+        cursor.execute("EXPLAIN " + query)
+        return {row[5] for row in cursor.fetchall() if row[5]}