1. 程式人生 > >【轉】Python mysql 引數替換的坑

【轉】Python mysql 引數替換的坑

文章轉自 https://blog.xupeng.me/2013/09/25/mysqldb-args-processing/ 感謝原作者分享

 

前幾天又有同事掉進了給 SQL 的 IN 條件傳參的坑,就像 SELECT col1, col2 FROM table1 WHERE id IN (1, 2, 3) 這類 SQL,如果是一個可變的列表作為 IN 的引數,那這個引數應該怎麼傳呢?

我見過至少這麼幾種:

1
2
id_list = [1, 2, 3]
cursor.execute('SELECT col1, col2 FROM table1 WHERE id IN (%s)', id_list)

這種方式是語法錯誤的,原因是 MySQLdb 做字串格式化時佔位符和引數個數不匹配。

1
2
id_list = [1, 2, 3]
cursor.execute('SELECT col1, col2 FROM table1 WHERE id IN (%s)', (id_list,))

這種方式語法是正確的,但語義是錯誤的,因為生成的 SQL 是 SELECT col1, col2 FROM table1 WHERE id IN ((‘1’, ‘2’, ‘3’))

1
2
3
id_list = [1, 2, 3]
id_list = ','.join([str(i) for i in id_list])
cursor.execute('SELECT col1, col2 FROM table1 WHERE id IN (%s)', id_list)

這種方式語義也是錯誤的,因為生成的 SQL 是 SELECT col1, col2 FROM table1 WHERE id IN (‘1,2,3’)

這三種是第一次使用 MySQLdb 給 IN 傳參時犯的最多的錯誤,大多數人遇到第一種錯和掉進後兩個坑之後,轉而採用了下面的方式:

1
2
3
id_list = [1, 2, 3]
id_list = ','.join([str(i) for i in id_list])
cursor.execute('SELECT col1, col2 FROM table1 WHERE id IN (%s)' % id_list)

這個方式對於可信的引數(比如自己生成的列表:range(1, 10, 2))來說可以用,但由於引數未經 escape,對於從使用者端接受的不可信引數來說,存在 SQL 注入的風險。

嚴防 SQL 注入的問題時刻都不能鬆懈,於是就有了這樣的改進版本:

1
2
3
id_list = [1, 2, 3]
id_list = ','.join([str(cursor.connection.literal(i)) for i in id_list])
cursor.execute('SELECT col1, col2 FROM table1 WHERE id IN (%s)' % id_list)

這個方式控制了 SQL 注入問題的滋生,但由於 cursor.connection.literal 是內部介面,並不推薦從外部使用。

然後就有了這樣的方式:

1
2
3
id_list = [1, 2, 3]
arg_list = ','.join(['%s'] * len(id_list))
cursor.execute('SELECT col1, col2 FROM table1 WHERE id IN (%s)' % arg_list, id_list)

這個方式是先生成與引數個數相同的 %s 佔位,拼出 ‘SELECT col1, col2 FROM table1 WHERE id IN (%s,%s,%s)’ 這樣的 SQL,然後使用安全的方式來傳參。

就是想傳一個引數而已,怎麼會這麼麻煩呢?觸令喪慘!

更正:以下劃線內容為未經充分測試的錯誤結論,僅做記錄:

一直以為 MySQLdb 是不支援給 IN 傳參的,直到這次又有同事掉坑我才讀了 MySQLdb escape 部分的程式碼,然後發現,MySQLdb 是在很多型別的 Python object 和 SQL 支援的型別之間做自動轉換的,比如 MySQLdb 會對 list 和 tuple 內的元素逐個進行 escape,生成一個 tuple,因此這才是正確的給 IN 傳參的方式:

1
2
id_list = [1, 2, 3]
cursor.execute('SELECT col1, col2 FROM table1 WHERE id IN %s', (id_list,))

可以把 MySQLdb 處理引數的過程簡化描述為:

  1. 對引數 (id_list,) 做 escape 得到 ((‘1’, ‘2’, ‘3’),)
  2. 用 escape 過的引數對 SQL 進行格式化:’SELECT col1, col2 FROM table1 WHERE id IN %s’ % ((‘1’, ‘2’, ‘3’),),得到完整 SQL:’SELECT col1, col2 FROM table1 WHERE id IN (‘1’, ‘2’, ‘3’)

整理一下口訣:IN 的引數和其他引數一樣,是一個整體,就要不要對屬於引數一部分的 () 念念不忘了……

總結一下評論中對這個方法提出的問題:

  1. 如果引數列表只有一個元素,比如 cursor.execute('SELECT col1, col2 FROM table1 WHERE id IN %s', ([1],)),生成的 SQL 是 SELECT col1, col2 FROM table1 WHERE id IN ('1',),是語法錯誤的
  2. 對列表內元素做 esacpe 時增加的引號會被留下,如果列表元素是字串,結果會是錯誤的,比如 cursor.execute('SELECT col1, col2 FROM table1 WHERE id IN %s', (["1", "2"],))生成的 SQL 是 SELECT col1, col2 FROM table1 WHERE id IN ("'1'", "'2'"),而對於數字引數恰好能正確工作的原因是,在執行 SQL 時如果列定義是 int 而傳參為字串,MySQL 會做隱式型別轉換(Type Conversion in Expression Evaluation)。

MySQLdb 支援對各種型別的 Python object 進行轉換和 escape,感興趣的同學可以看看 MySQLdb.converters 和 _mysql.c 中 *_escape* 系列的函式,另外 MySQLdb 也支援自定義轉換規則,參見 MySQLdb.connect 的 conv 引數。