# Exécuter une requête depuis Python : execute, paramètres, commit et fetch<no value>

## Qu'allons-nous apprendre ?

Jusqu'à présent, nous avons écrit du SQL « à la main » dans Database Client. Maintenant, nous le relions au code, en suivant le parcours sur lequel insiste le programme :

```text
Python  →  Connection  →  Query  →  Database  →  Result  →  Python
(values)   (open)         (SQL +    (runs it)     (rows)     (tuples,
                           params)                            ints, strs)
```

- **`execute`** avec des **paramètres** (`%s`).
- **L'injection SQL** (SQL Injection) : le danger, et pourquoi les paramètres le résolvent.
- **`commit`** : enregistrer les modifications.
- **`lastrowid`** : l'id que la base a donné à la nouvelle ligne.
- **`fetchone` / `fetchall`** sur de vraies données.

## Pourquoi en avons-nous besoin ?

Dans une API, les valeurs viennent **de l'utilisateur** : un nom, un âge et un e-mail du body d'un POST. Le code doit les mettre dans la requête **en toute sécurité**, enregistrer, et renvoyer ce qui a été enregistré, y compris le nouvel id (`201 Created` + l'étudiant). Tout cela se passe dans cette leçon, avec du vrai code.

## Explication à partir des bases

### `execute(sql, params)`

```python
cursor.execute(
    "INSERT INTO students (name, age, email) VALUES (%s, %s, %s)",
    ("Dana", 24, "dana@mail.com"),
)
```

- Dans le SQL, on écrit **`%s`** à la place de chaque valeur (un **emplacement**, Placeholder). Dans MySQL, c'est toujours `%s` — **pour tous les types**, les nombres aussi.
- Les valeurs sont passées **séparément**, sous forme de **tuple**, **dans le même ordre** que les emplacements.
- Le pilote (`mysql.connector`) les met en place **en toute sécurité** : les chaînes comme des chaînes, et les nombres comme des nombres. Pas besoin de guillemets, ni de conversion.

### L'injection SQL — pourquoi ne pas construire du SQL par concaténation

Il paraît « simple » de construire la requête nous-mêmes :

```python
name = input("Student name to search: ")
sql = "SELECT id, name FROM students WHERE name = '" + name + "'"
```

Si l'utilisateur tape `Dana`, on obtient la requête `... WHERE name = 'Dana'`. Très bien. Mais s'il tape :

```text
x' OR '1'='1
```

On obtient la requête :

```sql
SELECT id, name FROM students WHERE name = 'x' OR '1'='1'
```

`'1'='1'` est toujours vrai, donc **tous les étudiants sont renvoyés**. L'utilisateur a **changé le sens de la requête** par son entrée. C'est l'**injection SQL**, l'une des failles de sécurité les plus courantes et les plus dangereuses au monde. Par elle, on pénètre dans des bases de données, on vole des mots de passe, et on supprime des tables.

**Avec `%s` et des paramètres**, le pilote traite l'entrée **toujours comme une valeur**, et jamais comme une partie du SQL. L'utilisateur peut taper ce qu'il veut, et la base cherchera simplement un étudiant dont le nom est cette chaîne étrange.

### Comment `%s` protège-t-il exactement ?

Avec la concaténation, l'attaque fonctionne à cause d'**une seule apostrophe** : l'apostrophe que l'utilisateur a tapée après `x` **ferme** notre texte, et tout ce qui suit (`OR '1'='1'`) est déjà lu comme une partie de la commande.

Avec `%s`, le pilote prépare la valeur avant de l'envoyer : il ajoute une **barre oblique inverse** (`\`) devant chaque apostrophe de l'entrée. Une barre oblique inverse devant une apostrophe dit à MySQL : « c'est une apostrophe ordinaire à l'intérieur du texte, pas la fin du texte ».

| Étape | Ce que l'on voit |
|---|---|
| L'utilisateur tape | `x' OR '1'='1` |
| Le pilote ajoute `\` devant chaque apostrophe | `x\' OR \'1\'=\'1` |
| Ce que reçoit MySQL | `SELECT name FROM students WHERE name = 'x\' OR \'1\'=\'1'` |
| Comment MySQL le lit | **Un seul** texte : un nom long et étrange — `x' OR '1'='1` |

L'apostrophe de l'utilisateur **ne peut plus fermer** le texte, donc toute l'entrée reste à l'intérieur comme un seul nom. Il n'y a pas de `OR` devenu partie de la commande, et MySQL cherche un étudiant portant ce nom — et n'en trouve pas (`[]`).

**Et pourquoi ne pas le faire soi-même ?** Parce qu'il est facile de manquer un cas : un guillemet double, une barre oblique inverse dans l'entrée, un encodage spécial. Le pilote sait tous les gérer, à chaque fois — c'est pourquoi c'est toujours `%s`.

{{< box "warn" "Une règle d'or" >}}
Ne construisez **jamais** du SQL avec `+` à partir de valeurs venant de l'extérieur. **Toujours `%s` et des paramètres.** Il n'y a pas d'exception.
{{< /box >}}

{{< box "analogy" "Analogie : un formulaire avec des cases" >}}
Concaténer des chaînes = laisser un inconnu écrire librement sur votre chèque, y compris sur la ligne du montant. Les paramètres = un chèque imprimé avec une case dédiée au nom, et ce qui est écrit dans la case n'est **que le nom** : on ne peut pas « déborder » et changer le montant.
{{< /box >}}

### `commit` — enregistrer vraiment

Les commandes qui **modifient** des données (`INSERT`, `UPDATE`, `DELETE`) entrent d'abord dans une **transaction** (Transaction) ouverte. La modification est visible dans la connexion courante, mais elle n'est **pas enregistrée définitivement** tant que l'on n'appelle pas **`connection.commit()`**. Si vous fermez la connexion sans commit, **les modifications sont jetées**.

- `SELECT` n'a pas besoin de commit, car il ne modifie rien.
- `CREATE TABLE` est enregistré dans MySQL **immédiatement**, même sans commit.

### `lastrowid` et `rowcount`

Après `execute`, le curseur contient des informations sur ce qui s'est passé :

| Attribut | Ce qu'il contient | Usage |
|---|---|---|
| `cursor.lastrowid` | L'id que la base a donné à **la dernière ligne insérée** | Renvoyer le nouvel élément d'un POST |
| `cursor.rowcount` | **Combien de lignes ont changé** dans un INSERT/UPDATE/DELETE | Découvrir un « introuvable » dans un UPDATE/DELETE (plus loin dans ce thème) |

## Syntaxe

```python
connection = get_connection()
try:
    cursor = connection.cursor()

    # change data: execute + commit
    cursor.execute("INSERT INTO t (a, b) VALUES (%s, %s)", (value_a, value_b))
    connection.commit()
    new_id = cursor.lastrowid

    # read data: execute + fetch
    cursor.execute("SELECT a, b FROM t WHERE b > %s", (10,))
    rows = cursor.fetchall()
    cursor.execute("SELECT a, b FROM t WHERE id = %s", (new_id,))
    row = cursor.fetchone()
finally:
    connection.close()
```

**Remarquez le `(10,)`** : un tuple d'**un seul** élément doit avoir une virgule (souvenez-vous des tuples !). `(10)` n'est que le nombre 10, et `execute` échouera.

## Premier exemple de code

`week-4/school-db/students_demo.py` :

```python
import mysql.connector

def get_connection():
    return mysql.connector.connect(
        host="localhost", user="root", password="kodkod", database="school")

connection = get_connection()
try:
    cursor = connection.cursor()
    cursor.execute(
        """
        CREATE TABLE IF NOT EXISTS students (
            id INT AUTO_INCREMENT PRIMARY KEY,
            name VARCHAR(100) NOT NULL,
            age INT NOT NULL,
            email VARCHAR(100) NOT NULL UNIQUE
        )
        """
    )

    cursor.execute(
        "INSERT INTO students (name, age, email) VALUES (%s, %s, %s)",
        ("Dana", 24, "dana@mail.com"),
    )
    connection.commit()
    print("Inserted student with id", cursor.lastrowid)

    cursor.execute("SELECT id, name, age, email FROM students")
    print("All rows:", cursor.fetchall())
finally:
    connection.close()
```

**Sortie (la première exécution, sur une table vide) :**

```text
Inserted student with id 1
All rows: [(1, 'Dana', 24, 'dana@mail.com')]
```

## Analyse du code ligne par ligne

| Code | Étape du parcours |
|---|---|
| `get_connection()` | **Connexion** : l'ouverture. |
| `cursor = connection.cursor()` | Le curseur à travers lequel nous exécuterons toutes les requêtes. |
| `"""..."""` | **Une chaîne sur plusieurs lignes** (triples guillemets). Pratique pour un long SQL. |
| `execute(CREATE TABLE ...)` | La création de la table, si elle n'existe pas. |
| `execute("INSERT ... VALUES (%s, %s, %s)", ("Dana", 24, "dana@mail.com"))` | Une **requête** avec 3 emplacements et 3 valeurs. **Python → base de données.** |
| `connection.commit()` | **L'enregistrement.** Sans lui, l'étudiante disparaîtrait à la fermeture. |
| `cursor.lastrowid` | L'id donné par la base : 1. |
| `execute("SELECT ...")` + `fetchall()` | **Résultat → Python** : une liste de tuples. |
| `finally: connection.close()` | La fermeture, toujours. |

**Relancez-le :**

```text
Traceback (most recent call last):
  ...
mysql.connector.errors.IntegrityError: 1062 (23000): Duplicate entry 'dana@mail.com' for key 'students.email'
```

À la deuxième exécution, l'INSERT de Dana échoue, car l'e-mail existe déjà, et la base a imposé `UNIQUE`. (Et malgré l'erreur, la connexion **a été fermée**, grâce à `finally`.) Plus loin dans ce thème, nous gérerons correctement cette erreur. En attendant, pour repartir de zéro : dans Database Client, exécutez `DROP TABLE students;`.

## Un autre exemple

**Ajouter plusieurs étudiants, des recherches avec paramètres, et une démonstration de l'injection SQL.**

```python
import mysql.connector

connection = mysql.connector.connect(
    host="localhost", user="root", password="kodkod", database="school")
try:
    cursor = connection.cursor()
    cursor.execute("DROP TABLE IF EXISTS students")
    cursor.execute(
        "CREATE TABLE students (id INT AUTO_INCREMENT PRIMARY KEY, "
        "name VARCHAR(100) NOT NULL, age INT NOT NULL)")
    for name, age in [("Dana", 24), ("Avi", 31), ("Noa", 22)]:
        cursor.execute("INSERT INTO students (name, age) VALUES (%s, %s)", (name, age))
    connection.commit()

    cursor.execute("SELECT id, name, age FROM students WHERE id = %s", (2,))
    print("By id 2:", cursor.fetchone())

    cursor.execute("SELECT name FROM students WHERE age > %s ORDER BY age", (23,))
    print("Older than 23:", cursor.fetchall())

    cursor.execute("SELECT id, name FROM students WHERE id = %s", (99,))
    print("By id 99:", cursor.fetchone())

    attack = "x' OR '1'='1"

    unsafe_sql = "SELECT name FROM students WHERE name = '" + attack + "'"
    print("UNSAFE query:", unsafe_sql)
    cursor.execute(unsafe_sql)
    print("UNSAFE result:", cursor.fetchall())

    cursor.execute("SELECT name FROM students WHERE name = %s", (attack,))
    print("SAFE result:", cursor.fetchall())
finally:
    connection.close()
```

**Sortie :**

```text
By id 2: (2, 'Avi', 31)
Older than 23: [('Dana',), ('Avi',)]
By id 99: None
UNSAFE query: SELECT name FROM students WHERE name = 'x' OR '1'='1'
UNSAFE result: [('Dana',), ('Avi',), ('Noa',)]
SAFE result: []
```

**Analyse :**

- `DROP TABLE IF EXISTS students` — supprime la table, si elle existe, pour que chaque exécution reparte de zéro. (Pratique pour les démonstrations. On ne fait **pas** cela dans du vrai code !)
- **Un INSERT dans une boucle, et un seul commit à la fin** — les trois insertions sont enregistrées ensemble.
- **`fetchone()` sur un id existant** → un tuple. **Sur un id qui n'existe pas** → **`None`**. C'est le « introuvable » qui deviendra un 404.
- **`(2,)`**, **`(23,)`** — un tuple d'une seule valeur, avec une virgule.
- **La version non sûre** a renvoyé **tous les étudiants**, car l'entrée a modifié la requête elle-même.
- **La version sûre** a cherché un étudiant dont le nom est exactement `x' OR '1'='1`, et il n'y en a pas, donc `[]`. **L'attaque a échoué.**

## Erreur courante

**Erreur 1 : oublier `commit`.**

```python
cursor.execute("INSERT INTO students (name, age) VALUES (%s, %s)", ("Tom", 27))
connection.close()
```

Il n'y a pas d'erreur, et **Tom n'a pas été enregistré**. Fermer sans commit = annuler les modifications. C'est l'une des erreurs les plus déroutantes, car « tout a fonctionné ».

**Erreur 2 : un seul paramètre sans virgule.**

```python
cursor.execute("SELECT name FROM students WHERE id = %s", (2))
```

```text
mysql.connector.errors.ProgrammingError: Could not process parameters: int(2), it must be of type list, tuple or dict
```

`(2)` est un `int`, pas un tuple. → `(2,)`.

**Erreur 3 : un nombre de paramètres qui ne correspond pas.**

```python
cursor.execute("INSERT INTO students (name, age) VALUES (%s, %s)", ("Tom",))
```

```text
mysql.connector.errors.ProgrammingError: Not enough parameters for the SQL statement
```

**Erreur 4 : `%s` entre guillemets.**

```python
cursor.execute("SELECT * FROM students WHERE name = '%s'", ("Dana",))
```

```text
mysql.connector.errors.ProgrammingError: 1064 (42000): You have an error in your SQL syntax; ... near 'Dana''' at line 1
```

Le pilote ajoute déjà les guillemets lui-même, et maintenant il y en a deux paires. **`%s` toujours sans guillemets.**

**Erreur 5 : `%s` à la place du nom d'une table ou d'une colonne.** `"SELECT * FROM %s"` ne fonctionne pas : les emplacements sont **uniquement pour les valeurs** — le pilote transforme `students` en `'students'` (une chaîne), et c'est une erreur de syntaxe. Les noms des tables et des colonnes s'écrivent dans le code, de façon fixe.

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## Exercices

Les exercices n'utilisent que ce qui a été vu dans cette leçon et les précédentes. Essayez d'abord seul, et n'ouvrez la solution qu'ensuite.

### Exercice 1: execute avec des paramètres

Écrivez une fonction `add_student(connection, name, age)` qui ajoute un étudiant avec des **paramètres** (`%s`), enregistre, et renvoie le nouvel `id`.

```python
cursor = connection.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS students "
               "(id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, age INT NOT NULL)")
print(add_student(connection, "Dana", 24))
print(add_student(connection, "Avi", 31))
cursor.execute("SELECT * FROM students")
print(cursor.fetchall())
```

**Résultat attendu :**

```text
1
2
[(1, 'Dana', 24), (2, 'Avi', 31)]
```

{{< solution >}}
```python
import mysql.connector


def add_student(connection, name, age):
    cursor = connection.cursor()
    cursor.execute(
        "INSERT INTO students (name, age) VALUES (%s, %s)", (name, age))
    connection.commit()
    return cursor.lastrowid


connection = mysql.connector.connect(
    host="localhost", user="root", password="kodkod", database="school")
cursor = connection.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS students "
               "(id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, age INT NOT NULL)")
print(add_student(connection, "Dana", 24))
print(add_student(connection, "Avi", 31))
cursor.execute("SELECT * FROM students")
print(cursor.fetchall())
connection.close()
```

```text
1
2
[(1, 'Dana', 24), (2, 'Avi', 31)]
```

- Les `%s` sont des **emplacements**, et les valeurs sont passées séparément sous forme de tuple. (Dans MySQL, on écrit `%s` pour tous les types — les nombres aussi.)
- `cursor.lastrowid` — l'`id` que la base a donné à la nouvelle ligne.
{{< /solution >}}

### Exercice 2: L'injection SQL

La table contient deux étudiants — Dana et Avi. Créez-la ainsi :

```py
import mysql.connector

connection = mysql.connector.connect(
    host="localhost", user="root", password="kodkod", database="school")
cursor = connection.cursor()
cursor.execute("DROP TABLE IF EXISTS students")   # start clean, so ids start from 1
cursor.execute("CREATE TABLE students (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100))")
cursor.execute("INSERT INTO students (name) VALUES ('Dana'), ('Avi')")
connection.commit()
connection.close()
```

Un code qui construit une requête à partir d'une saisie de l'utilisateur :

```text
name = "x' OR '1'='1"
sql = "SELECT name FROM students WHERE name = '" + name + "'"
```

1. À quoi ressemblera la commande créée, et que renverra-t-elle ?
2. Montrez la différence en code : exécutez la requête par concaténation et affichez `Unsafe:` et le résultat, puis la même requête avec `%s` et affichez `Safe:` et le résultat.

**Résultat attendu :**

```text
Unsafe: [('Dana',), ('Avi',)]
Safe: []
```

{{< solution >}}
1. La commande est `SELECT name FROM students WHERE name = 'x' OR '1'='1'` — et la condition `'1'='1'` est toujours vraie, elle renvoie donc **tous les étudiants**. La saisie de l'utilisateur est devenue une partie de la **commande**.
2.

```python
import mysql.connector

connection = mysql.connector.connect(
    host="localhost", user="root", password="kodkod", database="school")
cursor = connection.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS students "
               "(id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100))")
cursor.execute("INSERT INTO students (name) VALUES ('Dana'), ('Avi')")
connection.commit()

name = "x' OR '1'='1"

sql = "SELECT name FROM students WHERE name = '" + name + "'"
cursor.execute(sql)
print("Unsafe:", cursor.fetchall())

cursor.execute("SELECT name FROM students WHERE name = %s", (name,))
print("Safe:", cursor.fetchall())
connection.close()
```

```text
Unsafe: [('Dana',), ('Avi',)]
Safe: []
```

Avec `%s`, la valeur est passée **séparément** — le pilote la traite comme un nom étrange, jamais comme une commande. **Ne construisez jamais de SQL en concaténant une saisie.**
{{< /solution >}}

### Exercice 3: Le commit oublié

```text
connection = mysql.connector.connect(...)
cursor = connection.cursor()
cursor.execute("INSERT INTO students (name, age) VALUES (%s, %s)", ("Noa", 22))
connection.close()
```

Le programme s'est exécuté sans erreur, mais Noa n'est pas dans la base. Pourquoi ?

Montrez-le en code : partez d'une table `students` vide (avec `name` et `age`). Insérez Noa **sans** `commit`, fermez, et dans une nouvelle connexion comptez combien de lignes il y a dans la table. Puis insérez-la à nouveau **avec** `commit`, et comptez à nouveau.

**Résultat attendu :**

```text
Without commit: 0 rows
With commit: 1 rows
```

{{< solution >}}
Sans `commit()`, la modification reste **temporaire**, et `close()` l'annule silencieusement.

```python
import mysql.connector


def connect():
    return mysql.connector.connect(
        host="localhost", user="root", password="kodkod", database="school")


def count_rows():
    connection = connect()
    try:
        cursor = connection.cursor()
        cursor.execute("SELECT * FROM students")
        return len(cursor.fetchall())
    finally:
        connection.close()


setup = connect()
setup.cursor().execute("CREATE TABLE IF NOT EXISTS students (name VARCHAR(100), age INT)")
setup.close()

connection = connect()
cursor = connection.cursor()
cursor.execute("INSERT INTO students (name, age) VALUES (%s, %s)", ("Noa", 22))
connection.close()
print("Without commit:", count_rows(), "rows")

connection = connect()
cursor = connection.cursor()
cursor.execute("INSERT INTO students (name, age) VALUES (%s, %s)", ("Noa", 22))
connection.commit()
connection.close()
print("With commit:", count_rows(), "rows")
```

```text
Without commit: 0 rows
With commit: 1 rows
```

`CREATE TABLE` est enregistré immédiatement, mais chaque `INSERT`, `UPDATE` et `DELETE` attend le `commit`.
{{< /solution >}}

### Exercice 4: Une recherche sûre par id

La table contient une étudiante — Dana, 24 ans. Créez-la ainsi :

```py
import mysql.connector

connection = mysql.connector.connect(
    host="localhost", user="root", password="kodkod", database="school")
cursor = connection.cursor()
cursor.execute("DROP TABLE IF EXISTS students")   # start clean, so ids start from 1
cursor.execute("CREATE TABLE students (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100), age INT)")
cursor.execute("INSERT INTO students (name, age) VALUES ('Dana', 24)")
connection.commit()
connection.close()
```

Écrivez `find_by_id(student_id)` qui ouvre une connexion, cherche un étudiant avec un paramètre, et ferme toujours. Elle renvoie la ligne ou `None`.

```python
print(find_by_id(1))
print(find_by_id(99))
```

**Résultat attendu :**

```text
(1, 'Dana', 24)
None
```

{{< solution >}}
```python
import mysql.connector


def get_connection():
    return mysql.connector.connect(
        host="localhost", user="root", password="kodkod", database="school")


def find_by_id(student_id):
    connection = get_connection()
    try:
        cursor = connection.cursor()
        cursor.execute("SELECT * FROM students WHERE id = %s", (student_id,))
        return cursor.fetchone()
    finally:
        connection.close()


setup = get_connection()
cursor = setup.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS students "
               "(id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100), age INT)")
cursor.execute("INSERT INTO students (name, age) VALUES ('Dana', 24)")
setup.commit()
setup.close()

print(find_by_id(1))
print(find_by_id(99))
```

```text
(1, 'Dana', 24)
None
```

Remarquez le `(student_id,)` — un tuple d'**un seul** élément a besoin d'une virgule. Sans la virgule, ce ne sont que des parenthèses autour d'un nombre.
{{< /solution >}}

### Exercice 5: Le parcours d'une requête

Remettez dans l'ordre les étapes de l'exécution d'une requête depuis Python :

- (a) `connection.commit()` — seulement si l'on a modifié des données
- (b) `mysql.connector.connect(...)` puis `connection.cursor()`
- (c) `connection.close()`
- (d) `cursor.execute(sql, params)`
- (e) `cursor.fetchone()` / `fetchall()` — seulement si l'on a lu des données

{{< solution >}}
L'ordre : **(b), (d), puis (e) ou (a), et à la fin (c)**

1. **connect + cursor** — ouvrir une connexion et créer un curseur.
2. **execute** — exécuter la requête avec les paramètres.
3. Si c'est une lecture (`SELECT`) — **fetch** des résultats. Si c'est une modification (`INSERT`, `UPDATE`, `DELETE`) — **commit**.
4. **close** — fermer, dans `finally`.
{{< /solution >}}

<!-- exercises:end -->

## Résumé

- Le parcours : **Python → connexion → requête (avec `%s` et des paramètres) → base de données → résultat → Python**.
- `cursor.execute(sql, params)` — les valeurs **uniquement** par `%s` et un tuple. Un tuple d'une seule valeur : `(x,)`.
- **L'injection SQL** = une entrée qui modifie la requête. Ne concaténez **jamais** dans du SQL des valeurs venant de l'extérieur.
- Modifier des données (INSERT/UPDATE/DELETE) → **`commit()`**, sinon la modification est jetée à la fermeture.
- `cursor.lastrowid` = le nouvel id. `fetchone()` = une ligne ou `None`. `fetchall()` = une liste de tuples.
- Une fonction pour chaque action, avec sa propre connexion et `try/finally`.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

## Documentation officielle

- [MySQLCursor.execute()](https://dev.mysql.com/doc/connector-python/en/connector-python-api-mysqlcursor-execute.html)
- [Inserting Data Using Connector/Python](https://dev.mysql.com/doc/connector-python/en/connector-python-example-cursor-transaction.html)
- [Querying Data Using Connector/Python](https://dev.mysql.com/doc/connector-python/en/connector-python-example-cursor-select.html)

<!-- docs:end -->
