sqlqb 0.1.4__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,8 @@ clean:
14
14
 
15
15
  .PHONY: lint
16
16
  lint:
17
- uv run ruff check .
17
+ uv run ruff check src/ tests/
18
+ uv run ruff format --check src/ tests/
18
19
 
19
20
 
20
21
  .PHONY: test
sqlqb-0.2.1/PKG-INFO ADDED
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.5
2
+ Name: sqlqb
3
+ Version: 0.2.1
4
+ Summary: a simple sql query builder
5
+ Requires-Python: >=3.12
@@ -1,16 +1,16 @@
1
1
  [project]
2
2
  name = "sqlqb"
3
- version = "0.1.4"
4
- description = "A simple sql query builder"
3
+ dynamic = ["version"]
4
+ description = "a simple sql query builder"
5
5
  readme = "README.md"
6
6
  authors = []
7
- requires-python = ">=3.12"
7
+ requires-python = ">= 3.12"
8
8
  dependencies = []
9
9
 
10
10
  [dependency-groups]
11
11
  dev = [
12
- "pytest>=9.0.3",
13
12
  "pytest-cov>=7.1.0",
13
+ "pytest>=9.0.3",
14
14
  "ruff>=0.15.17",
15
15
  ]
16
16
  pymysql = [
@@ -31,7 +31,9 @@ source = "vcs"
31
31
  testpaths = ["tests"]
32
32
 
33
33
  [tool.ruff]
34
- line-length = 999
34
+ line-length = 320
35
35
 
36
36
  [tool.ruff.lint]
37
- ignore = ["E501"] # E501 is the line-too-long rule
37
+ ignore = [
38
+ "E501", # E501 is the line-too-long rule
39
+ ]
@@ -1,6 +1,6 @@
1
- from .select import Select
2
- from .insert import Insert
3
1
  from .delete import Delete
2
+ from .insert import Insert
3
+ from .select import Select
4
4
  from .update import Update
5
5
 
6
- __all__ = ["Select", "Insert", "Delete", "Update"]
6
+ __all__ = ["Delete", "Insert", "Select", "Update"]
@@ -0,0 +1,36 @@
1
+ import io
2
+
3
+
4
+ class Delete:
5
+ def __init__(self):
6
+ self.__table: str | None = None
7
+ self.__wheres: list[str] = []
8
+ self.__params: dict = {}
9
+
10
+ def From(self, table: str) -> "Delete":
11
+ self.__table = table
12
+ return self
13
+
14
+ def Where(self, condition: str, **kwargs) -> "Delete":
15
+ self.__wheres.append(condition)
16
+ self.__params.update(kwargs)
17
+ return self
18
+
19
+ @property
20
+ def params(self) -> dict:
21
+ return self.__params
22
+
23
+ @property
24
+ def sql(self) -> str:
25
+ if not self.__table or not str(self.__table).strip():
26
+ raise ValueError("error: table name must not be empty")
27
+ sql = io.StringIO()
28
+ sql.write("DELETE FROM ")
29
+ sql.write(self.__table)
30
+ if self.__wheres:
31
+ sql.write(" WHERE ")
32
+ sql.write(" AND ".join(self.__wheres))
33
+ return sql.getvalue()
34
+
35
+ def __str__(self) -> str:
36
+ return self.sql
@@ -4,7 +4,7 @@ import io
4
4
  class Insert:
5
5
  def __init__(self):
6
6
  self.__table: str | None = None
7
- self.__rows: list[dict] = []
7
+ self.__values: list[dict] = []
8
8
 
9
9
  def Into(self, table: str) -> "Insert":
10
10
  if not table or not table.strip():
@@ -15,30 +15,28 @@ class Insert:
15
15
  def Values(self, **kwargs) -> "Insert":
16
16
  if not kwargs:
17
17
  raise ValueError("error: specify at least one column value")
18
- self.__rows.append(kwargs)
18
+ self.__values.append(kwargs)
19
19
  return self
20
20
 
21
21
  @property
22
22
  def params(self) -> list:
23
- if not self.__rows:
24
- return []
25
- return [v for row in self.__rows for v in row.values()]
23
+ return self.__values
26
24
 
27
25
  @property
28
26
  def sql(self) -> str:
29
27
  if not self.__table:
30
28
  raise ValueError("error: no table specified")
31
- if not self.__rows:
29
+ if not self.__values:
32
30
  raise ValueError("error: no values specified")
33
- columns = list(self.__rows[0].keys())
31
+ columns = self.__values[0].keys()
34
32
  sql = io.StringIO()
35
33
  sql.write("INSERT INTO ")
36
34
  sql.write(self.__table)
37
35
  sql.write(" (")
38
36
  sql.write(", ".join(columns))
39
- sql.write(") VALUES ")
40
- placeholder = "(" + ", ".join(["?"] * len(columns)) + ")"
41
- sql.write(", ".join([placeholder] * len(self.__rows)))
37
+ sql.write(") VALUES (")
38
+ sql.write(", ".join([f":{column}" for column in columns])) # TODO this assumes :style parameters
39
+ sql.write(")")
42
40
  return sql.getvalue()
43
41
 
44
42
  def __str__(self) -> str:
@@ -3,21 +3,20 @@ import io
3
3
 
4
4
  class Select:
5
5
  def __init__(self, *args: str):
6
- self.__columns: list[str] = list(args)
6
+ self.__columns: tuple[str] = args
7
7
  self.__table: str | None = None
8
- self.__wheres: list[str] = []
9
- self.__params: list = []
10
- self.__named_params: dict = {}
11
8
  self.__joins: list[str] = []
9
+ self.__wheres: list[str] = []
12
10
  self.__orderby: list[tuple[str, str]] = []
13
11
  self.__groupby: list[str] = []
14
12
  self.__limit: int | None = None
15
13
  self.__offset: int | None = None
14
+ self.__params: dict = {}
16
15
 
17
16
  def Columns(self, *columns: str) -> "Select":
18
17
  if not columns:
19
18
  raise ValueError("error: specify at least one column")
20
- self.__columns = list(columns)
19
+ self.__columns = columns
21
20
  return self
22
21
 
23
22
  def From(self, table: str) -> "Select":
@@ -26,32 +25,14 @@ class Select:
26
25
  self.__table = table
27
26
  return self
28
27
 
29
- def Join(self, join: str, *args, **kwargs) -> "Select":
30
- if args and kwargs:
31
- raise ValueError("error: cannot mix positional and named params")
32
- if args and self.__named_params:
33
- raise ValueError("error: cannot mix positional and named params")
34
- if kwargs and self.__params:
35
- raise ValueError("error: cannot mix positional and named params")
28
+ def Join(self, join: str, **kwargs) -> "Select":
36
29
  self.__joins.append(join)
37
- if kwargs:
38
- self.__named_params.update(kwargs)
39
- else:
40
- self.__params += list(args)
30
+ self.__params.update(kwargs)
41
31
  return self
42
32
 
43
- def Where(self, condition: str, *args, **kwargs) -> "Select":
44
- if args and kwargs:
45
- raise ValueError("error: cannot mix positional and named params")
46
- if args and self.__named_params:
47
- raise ValueError("error: cannot mix positional and named params")
48
- if kwargs and self.__params:
49
- raise ValueError("error: cannot mix positional and named params")
33
+ def Where(self, condition: str, **kwargs) -> "Select":
50
34
  self.__wheres.append(condition)
51
- if kwargs:
52
- self.__named_params.update(kwargs)
53
- else:
54
- self.__params += list(args)
35
+ self.__params.update(kwargs)
55
36
  return self
56
37
 
57
38
  def OrderBy(self, column: str, direction: str = "ASC") -> "Select":
@@ -73,19 +54,18 @@ class Select:
73
54
  return self
74
55
 
75
56
  @property
76
- def params(self) -> list | dict:
77
- if self.__named_params:
78
- return self.__named_params
57
+ def params(self) -> dict:
79
58
  return self.__params
80
59
 
81
60
  @property
82
61
  def sql(self) -> str:
62
+ if not self.__table or not str(self.__table).strip():
63
+ raise ValueError("error: table name must not be empty")
83
64
  sql = io.StringIO()
84
65
  sql.write("SELECT ")
85
66
  sql.write(", ".join(self.__columns or "*"))
86
- if self.__table:
87
- sql.write(" FROM ")
88
- sql.write(self.__table)
67
+ sql.write(" FROM ")
68
+ sql.write(self.__table)
89
69
  if self.__joins:
90
70
  sql.write(" ")
91
71
  sql.write(" ".join(self.__joins))
@@ -3,22 +3,10 @@ from sqlalchemy import text
3
3
  from sqlalchemy.engine import Connection as _Connection
4
4
  from sqlalchemy.engine import Engine as _Engine
5
5
 
6
- from sqlqb import Select as _Select
6
+ from sqlqb import Delete as _Delete
7
7
  from sqlqb import Insert as _Insert
8
+ from sqlqb import Select as _Select
8
9
  from sqlqb import Update as _Update
9
- from sqlqb import Delete as _Delete
10
-
11
-
12
- def _bind(sql: str, params: list | dict) -> tuple[str, dict]:
13
- """Convert params for SQLAlchemy text(): pass dicts through, replace ? with :p0/:p1/... for lists."""
14
- if isinstance(params, dict):
15
- return sql, params
16
- named: dict = {}
17
- for i, val in enumerate(params):
18
- key = f"p{i}"
19
- sql = sql.replace("?", f":{key}", 1)
20
- named[key] = val
21
- return sql, named
22
10
 
23
11
 
24
12
  class Select(_Select):
@@ -27,16 +15,13 @@ class Select(_Select):
27
15
  self.__connection = connection
28
16
 
29
17
  def execute(self):
30
- sql, params = _bind(self.sql, self.params)
31
- return self.__connection.execute(text(sql), params)
18
+ return self.__connection.execute(text(self.sql), self.params)
32
19
 
33
20
  def fetchone(self):
34
- sql, params = _bind(self.sql, self.params)
35
21
  row = self.execute().mappings().fetchone()
36
22
  return dict(row) if row is not None else None
37
23
 
38
24
  def fetchall(self) -> list:
39
- sql, params = _bind(self.sql, self.params)
40
25
  return [dict(row) for row in self.execute().mappings()]
41
26
 
42
27
 
@@ -46,8 +31,7 @@ class Insert(_Insert):
46
31
  self.__connection = connection
47
32
 
48
33
  def execute(self) -> int:
49
- sql, params = _bind(self.sql, self.params)
50
- return self.__connection.execute(text(sql), params).rowcount
34
+ return self.__connection.execute(text(self.sql), self.params).rowcount
51
35
 
52
36
 
53
37
  class Update(_Update):
@@ -56,8 +40,7 @@ class Update(_Update):
56
40
  self.__connection = connection
57
41
 
58
42
  def execute(self) -> int:
59
- sql, params = _bind(self.sql, self.params)
60
- return self.__connection.execute(text(sql), params).rowcount
43
+ return self.__connection.execute(text(self.sql), self.params).rowcount
61
44
 
62
45
 
63
46
  class Delete(_Delete):
@@ -66,8 +49,7 @@ class Delete(_Delete):
66
49
  self.__connection = connection
67
50
 
68
51
  def execute(self) -> int:
69
- sql, params = _bind(self.sql, self.params)
70
- return self.__connection.execute(text(sql), params).rowcount
52
+ return self.__connection.execute(text(self.sql), self.params).rowcount
71
53
 
72
54
 
73
55
  class Connection(_Connection):
@@ -2,10 +2,10 @@ from sqlite3 import Connection as _Connection
2
2
  from sqlite3 import Cursor as _Cursor
3
3
  from sqlite3 import connect as _connect
4
4
 
5
- from sqlqb import Select as _Select
5
+ from sqlqb import Delete as _Delete
6
6
  from sqlqb import Insert as _Insert
7
+ from sqlqb import Select as _Select
7
8
  from sqlqb import Update as _Update
8
- from sqlqb import Delete as _Delete
9
9
 
10
10
 
11
11
  class Select(_Select):
@@ -29,7 +29,12 @@ class Insert(_Insert):
29
29
  self.__connection = connection
30
30
 
31
31
  def execute(self) -> int:
32
- return self.__connection.execute(self.sql, self.params).rowcount
32
+ if len(self.params) == 1:
33
+ return self.__connection.execute(self.sql, self.params[0]).rowcount
34
+ return self.__connection.executemany(self.sql, self.params).rowcount
35
+
36
+ def executemany(self) -> int:
37
+ return self.execute()
33
38
 
34
39
 
35
40
  class Update(_Update):
@@ -0,0 +1,43 @@
1
+ import io
2
+
3
+
4
+ class Update:
5
+ def __init__(self, table: str):
6
+ if not table or not table.strip():
7
+ raise ValueError("error: table name must not be empty")
8
+ self.__table = table
9
+ self.__sets: dict[str, object] = {}
10
+ self.__wheres: list[str] = []
11
+ self.__params: dict[str, object] = {}
12
+
13
+ def Set(self, **kwargs) -> "Update":
14
+ if not kwargs:
15
+ raise ValueError("error: specify at least one column value")
16
+ self.__sets.update(kwargs)
17
+ return self
18
+
19
+ def Where(self, condition: str, **kwargs) -> "Update":
20
+ self.__wheres.append(condition)
21
+ self.__params.update(kwargs)
22
+ return self
23
+
24
+ @property
25
+ def params(self) -> dict:
26
+ return self.__sets | self.__params
27
+
28
+ @property
29
+ def sql(self) -> str:
30
+ if not self.__sets:
31
+ raise ValueError("error: no SET values specified")
32
+ sql = io.StringIO()
33
+ sql.write("UPDATE ")
34
+ sql.write(self.__table)
35
+ sql.write(" SET ")
36
+ sql.write(", ".join([f"{col} = :{col}" for col in self.__sets]))
37
+ if self.__wheres:
38
+ sql.write(" WHERE ")
39
+ sql.write(" AND ".join(self.__wheres))
40
+ return sql.getvalue()
41
+
42
+ def __str__(self) -> str:
43
+ return self.sql
@@ -0,0 +1,62 @@
1
+ import pytest
2
+
3
+ from sqlqb import Delete
4
+
5
+
6
+ class TestDeleteBasic:
7
+ def test_delete_all(self):
8
+ q = Delete().From("users")
9
+ assert q.sql == "DELETE FROM users"
10
+
11
+ def test_str_equals_sql(self):
12
+ q = Delete().From("users")
13
+ assert str(q) == q.sql
14
+
15
+ def test_no_table_raises(self):
16
+ with pytest.raises(ValueError):
17
+ _ = Delete().sql
18
+
19
+ def test_empty_table_raises(self):
20
+ with pytest.raises(ValueError):
21
+ _ = Delete().From("").sql
22
+
23
+ def test_blank_table_raises(self):
24
+ with pytest.raises(ValueError):
25
+ _ = Delete().From(" ").sql
26
+
27
+ def test_params_empty_by_default(self):
28
+ q = Delete().From("users")
29
+ assert q.params == {}
30
+
31
+
32
+ class TestDeleteWhere:
33
+ def test_single_where(self):
34
+ q = Delete().From("users").Where("id = :id", id=1)
35
+ assert q.sql == "DELETE FROM users WHERE id = :id"
36
+ assert q.params == {"id": 1}
37
+
38
+ def test_multiple_wheres_joined_with_and(self):
39
+ q = Delete().From("users").Where("active = :active", active=False).Where("role = :role", role="guest")
40
+ assert q.sql == "DELETE FROM users WHERE active = :active AND role = :role"
41
+ assert q.params == {"active": False, "role": "guest"}
42
+
43
+ def test_where_no_params(self):
44
+ q = Delete().From("sessions").Where("expired_at IS NOT NULL")
45
+ assert q.sql == "DELETE FROM sessions WHERE expired_at IS NOT NULL"
46
+ assert q.params == {}
47
+
48
+ def test_no_where_absent(self):
49
+ q = Delete().From("users")
50
+ assert "WHERE" not in q.sql
51
+
52
+
53
+ class TestDeleteChaining:
54
+ def test_method_chaining_returns_delete(self):
55
+ q = Delete()
56
+ assert q.From("t") is q
57
+ assert q.Where("1=1") is q
58
+
59
+ def test_full_query(self):
60
+ q = Delete().From("logs").Where("level = :level", level="debug")
61
+ assert q.sql == "DELETE FROM logs WHERE level = :level"
62
+ assert q.params == {"level": "debug"}
@@ -6,7 +6,7 @@ from sqlqb import Insert
6
6
  class TestInsertBasic:
7
7
  def test_single_row(self):
8
8
  q = Insert().Into("users").Values(name="Alice", email="alice@example.com")
9
- assert q.sql == "INSERT INTO users (name, email) VALUES (?, ?)"
9
+ assert q.sql == "INSERT INTO users (name, email) VALUES (:name, :email)"
10
10
 
11
11
  def test_str_equals_sql(self):
12
12
  q = Insert().Into("users").Values(name="Alice")
@@ -14,17 +14,17 @@ class TestInsertBasic:
14
14
 
15
15
  def test_params_single_row(self):
16
16
  q = Insert().Into("users").Values(name="Alice", email="alice@example.com")
17
- assert q.params == ["Alice", "alice@example.com"]
17
+ assert q.params == [{"name": "Alice", "email": "alice@example.com"}]
18
18
 
19
19
  def test_multiple_rows(self):
20
20
  q = Insert().Into("users").Values(name="Alice").Values(name="Bob")
21
- assert q.sql == "INSERT INTO users (name) VALUES (?), (?)"
22
- assert q.params == ["Alice", "Bob"]
21
+ assert q.sql == "INSERT INTO users (name) VALUES (:name)"
22
+ assert q.params == [{"name": "Alice"}, {"name": "Bob"}]
23
23
 
24
24
  def test_multiple_columns_multiple_rows(self):
25
25
  q = Insert().Into("orders").Values(user_id=1, total=99).Values(user_id=2, total=50)
26
- assert q.sql == "INSERT INTO orders (user_id, total) VALUES (?, ?), (?, ?)"
27
- assert q.params == [1, 99, 2, 50]
26
+ assert q.sql == "INSERT INTO orders (user_id, total) VALUES (:user_id, :total)"
27
+ assert q.params == [{"user_id": 1, "total": 99}, {"user_id": 2, "total": 50}]
28
28
 
29
29
  def test_method_chaining_returns_insert(self):
30
30
  q = Insert()
@@ -33,23 +33,23 @@ class TestInsertBasic:
33
33
 
34
34
  def test_no_table_raises(self):
35
35
  with pytest.raises(ValueError):
36
- Insert().Values(name="Alice").sql
36
+ _ = Insert().Values(name="Alice").sql
37
37
 
38
38
  def test_no_values_raises(self):
39
39
  with pytest.raises(ValueError):
40
- Insert().Into("users").sql
40
+ _ = Insert().Into("users").sql
41
41
 
42
42
  def test_empty_table_raises(self):
43
43
  with pytest.raises(ValueError):
44
- Insert().Into("")
44
+ _ = Insert().Into("")
45
45
 
46
46
  def test_blank_table_raises(self):
47
47
  with pytest.raises(ValueError):
48
- Insert().Into(" ")
48
+ _ = Insert().Into(" ")
49
49
 
50
50
  def test_empty_values_raises(self):
51
51
  with pytest.raises(ValueError):
52
- Insert().Into("users").Values()
52
+ _ = Insert().Into("users").Values()
53
53
 
54
54
  def test_params_empty_before_values(self):
55
55
  q = Insert().Into("users")
@@ -57,4 +57,4 @@ class TestInsertBasic:
57
57
 
58
58
  def test_various_value_types(self):
59
59
  q = Insert().Into("events").Values(user_id=42, active=True, score=3.14, label=None)
60
- assert q.params == [42, True, 3.14, None]
60
+ assert q.params == [{"user_id": 42, "active": True, "score": 3.14, "label": None}]
@@ -1,10 +1,11 @@
1
- import pytest
2
1
  from unittest.mock import MagicMock, patch
3
2
 
4
- from sqlqb import pymysql
5
- from sqlqb.pymysql import Select, Insert, Update, Delete, Connection
3
+ import pytest
6
4
  from pymysql.cursors import DictCursor
7
5
 
6
+ from sqlqb import pymysql
7
+ from sqlqb.pymysql import Connection, Delete, Insert, Select, Update, _to_pymysql
8
+
8
9
 
9
10
  @pytest.fixture
10
11
  def mock_conn():
@@ -33,9 +34,10 @@ class TestSelect:
33
34
 
34
35
  def test_execute_calls_cursor_execute_with_sql_and_params(self, mock_conn):
35
36
  conn, cursor = mock_conn
36
- q = Select(conn, "name").From("users").Where("id = ?", 1)
37
+ q = Select(conn, "name").From("users").Where("id = :id", id=1)
37
38
  q.execute()
38
- cursor.execute.assert_called_once_with(q.sql, q.params)
39
+ expected_sql, expected_params = _to_pymysql(q.sql, q.params)
40
+ cursor.execute.assert_called_once_with(expected_sql, expected_params)
39
41
 
40
42
  def test_execute_does_not_use_context_manager(self, mock_conn):
41
43
  conn, cursor = mock_conn
@@ -45,20 +47,21 @@ class TestSelect:
45
47
  def test_fetchone_returns_result(self, mock_conn):
46
48
  conn, cursor = mock_conn
47
49
  cursor.fetchone.return_value = {"id": 1, "name": "Alice"}
48
- result = Select(conn, "id", "name").From("users").Where("id = ?", 1).fetchone()
50
+ result = Select(conn, "id", "name").From("users").Where("id = :id", id=1).fetchone()
49
51
  assert result == {"id": 1, "name": "Alice"}
50
52
 
51
53
  def test_fetchone_returns_none_when_no_row(self, mock_conn):
52
54
  conn, cursor = mock_conn
53
55
  cursor.fetchone.return_value = None
54
- result = Select(conn, "id").From("users").Where("id = ?", 999).fetchone()
56
+ result = Select(conn, "id").From("users").Where("id = :id", id=999).fetchone()
55
57
  assert result is None
56
58
 
57
59
  def test_fetchone_calls_cursor_execute(self, mock_conn):
58
60
  conn, cursor = mock_conn
59
- q = Select(conn, "name").From("users").Where("id = ?", 1)
61
+ q = Select(conn, "name").From("users").Where("id = :id", id=1)
60
62
  q.fetchone()
61
- cursor.execute.assert_called_once_with(q.sql, q.params)
63
+ expected_sql, expected_params = _to_pymysql(q.sql, q.params)
64
+ cursor.execute.assert_called_once_with(expected_sql, expected_params)
62
65
 
63
66
  def test_fetchone_uses_context_manager(self, mock_conn):
64
67
  conn, cursor = mock_conn
@@ -90,14 +93,15 @@ class TestSelect:
90
93
 
91
94
  def test_multiple_where_params_passed_correctly(self, mock_conn):
92
95
  conn, cursor = mock_conn
93
- q = Select(conn, "name").From("users").Where("age > ?", 18).Where("active = ?", True)
96
+ q = Select(conn, "name").From("users").Where("age > :age", age=18).Where("active = :active", active=True)
94
97
  q.fetchall()
95
- cursor.execute.assert_called_once_with(q.sql, [18, True])
98
+ expected_sql, expected_params = _to_pymysql(q.sql, q.params)
99
+ cursor.execute.assert_called_once_with(expected_sql, expected_params)
96
100
 
97
101
  def test_chaining_with_order_and_limit(self, mock_conn):
98
102
  conn, cursor = mock_conn
99
103
  cursor.fetchall.return_value = [{"name": "Alice"}]
100
- result = Select(conn, "name").From("users").Where("age >= ?", 18).OrderBy("name").Limit(1).fetchall()
104
+ result = Select(conn, "name").From("users").Where("age >= :age", age=18).OrderBy("name").Limit(1).fetchall()
101
105
  assert result == [{"name": "Alice"}]
102
106
 
103
107
 
@@ -130,14 +134,15 @@ class TestUpdate:
130
134
  def test_execute_returns_rowcount(self, mock_conn):
131
135
  conn, cursor = mock_conn
132
136
  cursor.rowcount = 1
133
- count = Update(conn, "users").Set(name="Alice").Where("id = ?", 1).execute()
137
+ count = Update(conn, "users").Set(name="Alice").Where("id = :id", id=1).execute()
134
138
  assert count == 1
135
139
 
136
140
  def test_execute_calls_cursor_execute(self, mock_conn):
137
141
  conn, cursor = mock_conn
138
- q = Update(conn, "users").Set(name="Alice").Where("id = ?", 1)
142
+ q = Update(conn, "users").Set(name="Alice").Where("id = :id", id=1)
139
143
  q.execute()
140
- cursor.execute.assert_called_once_with(q.sql, q.params)
144
+ expected_sql, expected_params = _to_pymysql(q.sql, q.params)
145
+ cursor.execute.assert_called_once_with(expected_sql, expected_params)
141
146
 
142
147
  def test_execute_uses_context_manager(self, mock_conn):
143
148
  conn, cursor = mock_conn
@@ -153,9 +158,10 @@ class TestUpdate:
153
158
  def test_execute_multiple_set_columns(self, mock_conn):
154
159
  conn, cursor = mock_conn
155
160
  cursor.rowcount = 1
156
- q = Update(conn, "users").Set(name="Bob", age=20).Where("id = ?", 2)
161
+ q = Update(conn, "users").Set(name="Bob", age=20).Where("id = :id", id=2)
157
162
  count = q.execute()
158
- cursor.execute.assert_called_once_with(q.sql, q.params)
163
+ expected_sql, expected_params = _to_pymysql(q.sql, q.params)
164
+ cursor.execute.assert_called_once_with(expected_sql, expected_params)
159
165
  assert count == 1
160
166
 
161
167
 
@@ -163,18 +169,19 @@ class TestDelete:
163
169
  def test_execute_returns_rowcount(self, mock_conn):
164
170
  conn, cursor = mock_conn
165
171
  cursor.rowcount = 1
166
- count = Delete(conn).From("users").Where("id = ?", 1).execute()
172
+ count = Delete(conn).From("users").Where("id = :id", id=1).execute()
167
173
  assert count == 1
168
174
 
169
175
  def test_execute_calls_cursor_execute(self, mock_conn):
170
176
  conn, cursor = mock_conn
171
- q = Delete(conn).From("users").Where("id = ?", 1)
177
+ q = Delete(conn).From("users").Where("id = :id", id=1)
172
178
  q.execute()
173
- cursor.execute.assert_called_once_with(q.sql, q.params)
179
+ expected_sql, expected_params = _to_pymysql(q.sql, q.params)
180
+ cursor.execute.assert_called_once_with(expected_sql, expected_params)
174
181
 
175
182
  def test_execute_uses_context_manager(self, mock_conn):
176
183
  conn, cursor = mock_conn
177
- Delete(conn).From("users").Where("id = ?", 1).execute()
184
+ Delete(conn).From("users").Where("id = :id", id=1).execute()
178
185
  cursor.__enter__.assert_called_once()
179
186
 
180
187
  def test_execute_no_where_returns_all_rowcount(self, mock_conn):
@@ -183,13 +190,20 @@ class TestDelete:
183
190
  count = Delete(conn).From("users").execute()
184
191
  assert count == 3
185
192
 
186
- def test_execute_with_limit(self, mock_conn):
187
- conn, cursor = mock_conn
188
- cursor.rowcount = 2
189
- q = Delete(conn).From("users").Limit(2)
190
- count = q.execute()
191
- cursor.execute.assert_called_once_with(q.sql, q.params)
192
- assert count == 2
193
+
194
+ class TestToPymysql:
195
+ def test_dict_params_converts_placeholders(self):
196
+ sql, params = _to_pymysql(
197
+ "SELECT * FROM t WHERE id = :id AND name = :name",
198
+ {"id": 1, "name": "Alice"},
199
+ )
200
+ assert sql == "SELECT * FROM t WHERE id = %(id)s AND name = %(name)s"
201
+ assert params == {"id": 1, "name": "Alice"}
202
+
203
+ def test_dict_params_single_placeholder(self):
204
+ sql, params = _to_pymysql("DELETE FROM t WHERE id = :id", {"id": 42})
205
+ assert sql == "DELETE FROM t WHERE id = %(id)s"
206
+ assert params == {"id": 42}
193
207
 
194
208
 
195
209
  class TestConnection: