Corrige LE vrai bug : un nom de champ mot-réservé SQL (ex. "order") faisait disparaître l'objet entier

Reproduit à l'identique le cas signalé (objet "dialog" avec les champs
order/spiker/text/level_id/parcour_id) : le champ "order" est un mot
réservé SQL — "CREATE TABLE dialog (order INTEGER, ...)" plante avec
"OperationalError: near \"order\": syntax error". Comme ce crash survient
APRÈS l'INSERT de la ligne _definitions mais AVANT le commit(), rien
n'était jamais persisté : l'objet ENTIER disparaissait, malgré des champs
parfaitement remplis — d'où "j'ai tout rempli comme il faut et aucun objet
n'est créé". Mon précédent correctif (champ "Relation" sans cible) était
réel mais ne couvrait pas ce cas précis.

Cause de fond : chaque nom de colonne (dérivé du nom de champ tapé par
l'utilisateur, via slugify) était interpolé TEL QUEL dans du SQL brut
(CREATE TABLE, INSERT, UPDATE, ALTER TABLE ADD/DROP/RENAME COLUMN) sans
jamais être encadré de guillemets — n'importe quel nom de champ qui soit
aussi un mot réservé SQLite (order, group, index, select, where, table,
key, default, check, references, unique...) déclenchait exactement le
même crash-et-perte-de-transaction, dans n'importe laquelle de ces
opérations.

Correctif général (pas un simple contournement pour "order") :
db/quote_ident.py encadre tout identifiant de colonne de guillemets
doubles (forme standard SQL, supportée par SQLite) — appliqué partout où
un nom de colonne utilisateur est interpolé dans du SQL brut :
create_definition, add_field_to_definition, delete_field, update_field
(RENAME COLUMN), insert_row, update_row, update_row_field,
rows_referencing. Les noms de TABLE n'ont pas besoin de cette protection
(table_name_for.py les préfixe toujours "obj_", donc jamais un mot réservé
à eux seuls).

Trois nouveaux tests (tests/test_reserved_sql_keyword_field_names.py) :
création avec un champ "order" + insertion/lecture/mise à jour d'une
ligne, renommage d'un champ vers/depuis un mot réservé ("group"), ajout
d'un champ "select" à un objet existant — les trois confirmés en échec
sur l'ancien code (même erreur reproduite) puis au vert avec le
correctif. 136 tests au vert au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-27 19:27:09 +02:00
co-authored by Claude Sonnet 5
parent c14f7e9ba5
commit 5c069ae1fe
10 changed files with 108 additions and 10 deletions
@@ -0,0 +1,74 @@
"""Régression : un nom de champ qui est un mot réservé SQL (ex. "order")
faisait planter TOUTE requête SQL brute qui le mentionnait telle quelle
(CREATE TABLE, INSERT, UPDATE, ALTER TABLE...) — ex. "OperationalError:
near \"order\": syntax error" dans create_definition(). Comme le crash
survient APRÈS l'INSERT de la ligne _definitions mais AVANT le commit(),
rien n'était jamais persisté : l'objet entier disparaissait, sans le
moindre message d'erreur — vécu comme "le panneau recharge la page sans
créer d'objet" même avec des champs parfaitement remplis. Corrigé en
encadrant chaque nom de colonne de guillemets doubles (db/quote_ident.py)
partout où il est interpolé dans du SQL brut."""
import db
def test_object_created_with_a_reserved_keyword_field_name(client, game):
resp = client.post(f"/game/{game}/objects/new", data={
"object_name": "dialog",
"field_name[]": ["order", "spiker", "text"],
"field_type[]": ["nombre_entier", "texte", "texte_long"],
"field_relation[]": ["", "", ""],
"field_required[]": ["1", "1", "1"],
"field_min[]": ["1", "", ""],
"field_max[]": ["100", "", ""],
}, follow_redirects=False)
assert resp.status_code == 302
definitions = db.list_definitions(game)
assert len(definitions) == 1
definition = db.get_definition(game, definitions[0]["id"])
assert [f["name"] for f in definition["fields"]] == ["order", "spiker", "text"]
# Insertion, lecture ET mise à jour d'une ligne doivent aussi fonctionner
# (insert_row.py / update_row.py / list_rows.py mentionnent aussi "order").
client.post(f"/game/{game}/objects/{definition['id']}/data/new",
data={"order": "5", "spiker": "Bob", "text": "hello"})
rows = db.list_rows(game, definition)
assert len(rows) == 1
assert rows[0]["order"] == 5
client.post(f"/game/{game}/objects/{definition['id']}/data/{rows[0]['id']}/edit",
data={"order": "9", "spiker": "Bob", "text": "hello"})
rows = db.list_rows(game, definition)
assert rows[0]["order"] == 9
def test_field_renamed_to_and_from_a_reserved_keyword(client, game):
resp = client.post(f"/game/{game}/objects/new", data={
"object_name": "chose", "field_name[]": ["nom"], "field_type[]": ["texte"],
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
}, follow_redirects=False)
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
field_id = db.get_definition(game, def_id)["fields"][0]["id"]
resp = client.post(f"/game/{game}/objects/{def_id}/fields/{field_id}/edit",
data={"field_name": "group", "field_required": "0"}, follow_redirects=False)
assert resp.status_code == 302
assert db.get_definition(game, def_id)["fields"][0]["name"] == "group"
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"group": "valeur"})
rows = db.list_rows(game, db.get_definition(game, def_id))
assert rows[0]["group"] == "valeur"
def test_added_field_with_a_reserved_keyword_name(client, game):
resp = client.post(f"/game/{game}/objects/new", data={
"object_name": "chose", "field_name[]": ["nom"], "field_type[]": ["texte"],
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
}, follow_redirects=False)
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
resp = client.post(f"/game/{game}/objects/{def_id}/fields/add",
data={"field_name": "select", "field_type": "texte"}, follow_redirects=False)
assert resp.status_code == 302
names = [f["name"] for f in db.get_definition(game, def_id)["fields"]]
assert "select" in names