flowmaticdb 1.0.0__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.
- flowmaticdb-1.0.0/PKG-INFO +1024 -0
- flowmaticdb-1.0.0/README.md +994 -0
- flowmaticdb-1.0.0/pyproject.toml +65 -0
- flowmaticdb-1.0.0/setup.cfg +4 -0
- flowmaticdb-1.0.0/src/flowmaticdb/__init__.py +27 -0
- flowmaticdb-1.0.0/src/flowmaticdb/_helpers.py +48 -0
- flowmaticdb-1.0.0/src/flowmaticdb/_query_with_params.py +67 -0
- flowmaticdb-1.0.0/src/flowmaticdb/adapters/__init__.py +11 -0
- flowmaticdb-1.0.0/src/flowmaticdb/adapters/_base.py +98 -0
- flowmaticdb-1.0.0/src/flowmaticdb/adapters/_mysql.py +190 -0
- flowmaticdb-1.0.0/src/flowmaticdb/adapters/_postgres.py +162 -0
- flowmaticdb-1.0.0/src/flowmaticdb/adapters/_sqlite.py +165 -0
- flowmaticdb-1.0.0/src/flowmaticdb/database/__init__.py +7 -0
- flowmaticdb-1.0.0/src/flowmaticdb/database/_abc.py +128 -0
- flowmaticdb-1.0.0/src/flowmaticdb/database/_database.py +112 -0
- flowmaticdb-1.0.0/src/flowmaticdb/database/_db.py +7 -0
- flowmaticdb-1.0.0/src/flowmaticdb/database/_table.py +106 -0
- flowmaticdb-1.0.0/src/flowmaticdb/dialects/__init__.py +13 -0
- flowmaticdb-1.0.0/src/flowmaticdb/dialects/_base.py +178 -0
- flowmaticdb-1.0.0/src/flowmaticdb/dialects/_mysql.py +266 -0
- flowmaticdb-1.0.0/src/flowmaticdb/dialects/_postgres.py +137 -0
- flowmaticdb-1.0.0/src/flowmaticdb/dialects/_sql_dialect.py +739 -0
- flowmaticdb-1.0.0/src/flowmaticdb/dialects/_sqlite.py +178 -0
- flowmaticdb-1.0.0/src/flowmaticdb/exceptions.py +17 -0
- flowmaticdb-1.0.0/src/flowmaticdb/migrations/__init__.py +7 -0
- flowmaticdb-1.0.0/src/flowmaticdb/migrations/_loader.py +62 -0
- flowmaticdb-1.0.0/src/flowmaticdb/migrations/_migration_abc.py +23 -0
- flowmaticdb-1.0.0/src/flowmaticdb/migrations/_migrator.py +102 -0
- flowmaticdb-1.0.0/src/flowmaticdb/migrations/_template.py +34 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/__init__.py +19 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_alter_table.py +36 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_condition.py +16 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_condition_group.py +176 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_condition_mixin.py +135 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_create_table.py +35 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_ddl_mixins.py +313 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_delete.py +29 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_drop_table.py +34 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_having_mixin.py +257 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_insert.py +29 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_join.py +269 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_joins_mixin.py +60 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_on_conflict.py +10 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_order_by.py +11 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_query.py +70 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_select.py +68 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_simple_mixins.py +142 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_union.py +15 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_update.py +26 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/_where_mixin.py +257 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/enums/__init__.py +17 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_chain.py +6 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_condition.py +23 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_join.py +10 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_order_by_dir.py +6 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_referential_action.py +10 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_type.py +9 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_union.py +6 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/__init__.py +20 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_alias.py +33 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_current_timestamp.py +19 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_excluded.py +8 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_expression.py +25 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_identifier.py +22 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_raw.py +22 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_sql.py +19 -0
- flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_sub_query.py +27 -0
- flowmaticdb-1.0.0/src/flowmaticdb/result/__init__.py +14 -0
- flowmaticdb-1.0.0/src/flowmaticdb/result/_base.py +41 -0
- flowmaticdb-1.0.0/src/flowmaticdb/result/_mysql.py +71 -0
- flowmaticdb-1.0.0/src/flowmaticdb/result/_postgres.py +152 -0
- flowmaticdb-1.0.0/src/flowmaticdb/result/_result.py +29 -0
- flowmaticdb-1.0.0/src/flowmaticdb/result/_sqlite.py +51 -0
- flowmaticdb-1.0.0/src/flowmaticdb.egg-info/PKG-INFO +1024 -0
- flowmaticdb-1.0.0/src/flowmaticdb.egg-info/SOURCES.txt +93 -0
- flowmaticdb-1.0.0/src/flowmaticdb.egg-info/dependency_links.txt +1 -0
- flowmaticdb-1.0.0/src/flowmaticdb.egg-info/requires.txt +11 -0
- flowmaticdb-1.0.0/src/flowmaticdb.egg-info/top_level.txt +1 -0
- flowmaticdb-1.0.0/tests/test_alter_table_query.py +333 -0
- flowmaticdb-1.0.0/tests/test_conditions.py +69 -0
- flowmaticdb-1.0.0/tests/test_delete_query.py +18 -0
- flowmaticdb-1.0.0/tests/test_dialect_mysql.py +385 -0
- flowmaticdb-1.0.0/tests/test_dialect_postgres.py +139 -0
- flowmaticdb-1.0.0/tests/test_dialect_sql.py +292 -0
- flowmaticdb-1.0.0/tests/test_dialect_sqlite.py +110 -0
- flowmaticdb-1.0.0/tests/test_expressions.py +66 -0
- flowmaticdb-1.0.0/tests/test_insert_query.py +64 -0
- flowmaticdb-1.0.0/tests/test_integration_mysql.py +1197 -0
- flowmaticdb-1.0.0/tests/test_integration_postgres.py +911 -0
- flowmaticdb-1.0.0/tests/test_integration_sqlite.py +271 -0
- flowmaticdb-1.0.0/tests/test_joins.py +38 -0
- flowmaticdb-1.0.0/tests/test_query_with_params.py +259 -0
- flowmaticdb-1.0.0/tests/test_result_abstract.py +86 -0
- flowmaticdb-1.0.0/tests/test_select_query.py +113 -0
- flowmaticdb-1.0.0/tests/test_update_query.py +23 -0
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flowmaticdb
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A Python database abstraction layer supporting PostgreSQL, SQLite, and MySQL.
|
|
5
|
+
Author: Flowmatic, UniForceMusic
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Flowmatic-AI/python-prototyping-db
|
|
8
|
+
Project-URL: Repository, https://github.com/Flowmatic-AI/python-prototyping-db
|
|
9
|
+
Project-URL: Issues, https://github.com/Flowmatic-AI/python-prototyping-db/issues
|
|
10
|
+
Keywords: database,abstraction,sql,postgresql,sqlite,mysql
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Provides-Extra: postgres
|
|
23
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
|
|
24
|
+
Provides-Extra: mysql
|
|
25
|
+
Requires-Dist: mysql-connector-python>=9.0; extra == "mysql"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
28
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.1; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# Transparency
|
|
32
|
+
This package is a port of [PHP Sentience Database](https://github.com/Sentience-Framework/database)
|
|
33
|
+
|
|
34
|
+
It was ported using AI agents mostly powered by:
|
|
35
|
+
- Orchestrator: Deepseek V4 Flash (occational GLM 5.2 or Qwen 3.6 35B A3B)
|
|
36
|
+
- Sub agents: Qwen 3.6 35B A3B (occational Gemma 4 E2B)
|
|
37
|
+
|
|
38
|
+
The original PHP is made almost entirely by hand (except for the ExpressionF parsing). The workflow went as follows:
|
|
39
|
+
1. Copy sentience/database package to this directory
|
|
40
|
+
2. Let Deepseek V4 Flash explore the codebase and write a simple SQLite compatible port, with only CRUD queries, plan in PLAN.md
|
|
41
|
+
3. Let a new session with Deepseek V4 Flash as the orchestrator, and Qwen 3.6 35B A3B as subagent implement this first plan
|
|
42
|
+
4. Add DDL queries
|
|
43
|
+
5. Write Postgres implementation using the same setup
|
|
44
|
+
6. Write MySQL implementation using the same setup
|
|
45
|
+
7. Refine codebase
|
|
46
|
+
|
|
47
|
+
From a moral and environmental perspective, i've tried to use as much local AI as possible. The total token cost of this port is about $14 in Openrouter credits, most of which was used on GLM 5.2, even though Deepseek was the primary model used.
|
|
48
|
+
|
|
49
|
+
Coding agents work best if you give them a clear structure. In this case, having a human crafted package as an example, in a language with similar features, provied to be a great task for these models.
|
|
50
|
+
|
|
51
|
+
# flowmaticdb — Python Database Abstraction
|
|
52
|
+
|
|
53
|
+
A multi-dialect database abstraction layer for Python, supporting **PostgreSQL**, **SQLite**, and **MySQL**. Ported from the PHP library `sentience/database`.
|
|
54
|
+
|
|
55
|
+
flowmaticdb gives you a fluent query builder API, driver-level adapters, dialect-aware SQL generation, and a unified result abstraction — all with strict type hints and zero magic strings.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install flowmaticdb
|
|
63
|
+
# Or with dev dependencies:
|
|
64
|
+
pip install "flowmaticdb[dev]"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from flowmaticdb.database import DB
|
|
69
|
+
|
|
70
|
+
# Connect to any supported database
|
|
71
|
+
db = DB.connect_sqlite(":memory:")
|
|
72
|
+
# db = DB.connect_postgresql("mydb", host="localhost", user="postgres")
|
|
73
|
+
# db = DB.connect_mysql("mydb", host="localhost", user="root")
|
|
74
|
+
|
|
75
|
+
# Fluent query building
|
|
76
|
+
result = (
|
|
77
|
+
db.select("users")
|
|
78
|
+
.columns(["id", "name", "email"])
|
|
79
|
+
.where_equals("active", True)
|
|
80
|
+
.where_greater_than("age", 18)
|
|
81
|
+
.order_by_asc("name")
|
|
82
|
+
.limit(10)
|
|
83
|
+
.execute()
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Fetch results
|
|
87
|
+
for row in result.fetch_dicts():
|
|
88
|
+
print(row["name"], row["email"])
|
|
89
|
+
|
|
90
|
+
first = result.fetch_dict() # Single row or None
|
|
91
|
+
count = result.scalar() # First column of first row
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Supported Databases
|
|
97
|
+
|
|
98
|
+
| Database | Connection Method | Adapter | Dialect | Required Driver |
|
|
99
|
+
|----------|-------------------|---------|---------|----------------|
|
|
100
|
+
| SQLite | `DB.connect_sqlite()` | `SQLiteAdapter` | `SQLiteDialect` | Built-in (`sqlite3`) |
|
|
101
|
+
| PostgreSQL | `DB.connect_postgresql()` | `PsycopgAdapter` | `PostgresqlDialect` | `psycopg[binary]>=3.1` |
|
|
102
|
+
| MySQL | `DB.connect_mysql()` | `MySQLAdapter` | `MySQLDialect` | `mysql-connector-python` |
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Connecting to a Database
|
|
107
|
+
|
|
108
|
+
### SQLite
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from flowmaticdb.database import DB
|
|
112
|
+
|
|
113
|
+
# In-memory
|
|
114
|
+
db = DB.connect_sqlite(":memory:")
|
|
115
|
+
|
|
116
|
+
# File-based
|
|
117
|
+
db = DB.connect_sqlite("/path/to/database.sqlite")
|
|
118
|
+
|
|
119
|
+
# With options
|
|
120
|
+
db = DB.connect_sqlite("mydb.db", options={
|
|
121
|
+
"read_only": False,
|
|
122
|
+
"journal_mode": "WAL",
|
|
123
|
+
"foreign_keys": 1,
|
|
124
|
+
"busy_timeout": 5000,
|
|
125
|
+
"encoding": "UTF-8",
|
|
126
|
+
})
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### PostgreSQL
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
db = DB.connect_postgresql(
|
|
133
|
+
"mydb",
|
|
134
|
+
host="localhost",
|
|
135
|
+
port=5432,
|
|
136
|
+
user="postgres",
|
|
137
|
+
password="secret",
|
|
138
|
+
options={
|
|
139
|
+
"sslmode": "require",
|
|
140
|
+
"search_path": "public",
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### MySQL
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
db = DB.connect_mysql(
|
|
149
|
+
"mydb",
|
|
150
|
+
host="localhost",
|
|
151
|
+
port=3306,
|
|
152
|
+
user="root",
|
|
153
|
+
password="secret",
|
|
154
|
+
options={
|
|
155
|
+
"charset": "utf8mb4",
|
|
156
|
+
"connect_timeout": 10,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Debug Callback
|
|
162
|
+
|
|
163
|
+
All connection methods accept a `debug_callback` for query logging:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
def debug(sql: str, duration: float, error: str | None):
|
|
167
|
+
print(f"[{duration:.4f}s] {sql}")
|
|
168
|
+
if error:
|
|
169
|
+
print(f" ERROR: {error}")
|
|
170
|
+
|
|
171
|
+
db = DB.connect_sqlite(":memory:", debug_callback=debug)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Query Building
|
|
177
|
+
|
|
178
|
+
All query builders return `Self` for seamless method chaining.
|
|
179
|
+
|
|
180
|
+
### SELECT
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
# Basic select
|
|
184
|
+
db.select("users").execute()
|
|
185
|
+
|
|
186
|
+
# With columns
|
|
187
|
+
db.select("users").columns(["id", "name"]).execute()
|
|
188
|
+
|
|
189
|
+
# Alias the table
|
|
190
|
+
db.select_table("users", "u").columns(["u.id", "u.name"]).execute()
|
|
191
|
+
|
|
192
|
+
# Sub-query as source
|
|
193
|
+
sub = db.select("active_users").columns(["id"])
|
|
194
|
+
db.select_sub_query(sub, "a").execute()
|
|
195
|
+
|
|
196
|
+
# Change table (fluent)
|
|
197
|
+
q = db.select("users")
|
|
198
|
+
q.table("admins").execute()
|
|
199
|
+
|
|
200
|
+
# Count
|
|
201
|
+
count: int = db.select("users").where_equals("active", True).count()
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### INSERT
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
# Single row
|
|
208
|
+
db.insert("users").values({"name": "Alice", "age": 30}).execute()
|
|
209
|
+
|
|
210
|
+
# Multiple rows
|
|
211
|
+
db.insert("users").values(
|
|
212
|
+
{"name": "Bob", "age": 25},
|
|
213
|
+
{"name": "Charlie", "age": 35},
|
|
214
|
+
).execute()
|
|
215
|
+
|
|
216
|
+
# With RETURNING (PostgreSQL / SQLite ≥ 3.35)
|
|
217
|
+
result = db.insert("users").values({"name": "Dave"}).returning(["id"]).execute()
|
|
218
|
+
new_id = result.scalar()
|
|
219
|
+
|
|
220
|
+
# ON CONFLICT (PostgreSQL / SQLite ≥ 3.24)
|
|
221
|
+
db.insert("users").values({"name": "Alice"}).on_conflict_do_nothing("name").execute()
|
|
222
|
+
db.insert("users").values({"name": "Alice", "age": 31}).on_conflict_do_update(
|
|
223
|
+
"name", {"age": 31}
|
|
224
|
+
).execute()
|
|
225
|
+
|
|
226
|
+
# Get last insert ID
|
|
227
|
+
db.insert("users").values({"name": "Eve"}).last_insert_id("id").execute()
|
|
228
|
+
last_id = db.last_insert_id()
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### UPDATE
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
db.update("users").updates({"age": 26}).where_equals("name", "Bob").execute()
|
|
235
|
+
|
|
236
|
+
# With RETURNING
|
|
237
|
+
result = (
|
|
238
|
+
db.update("users")
|
|
239
|
+
.updates({"age": 27})
|
|
240
|
+
.where_equals("name", "Bob")
|
|
241
|
+
.returning(["id", "age"])
|
|
242
|
+
.execute()
|
|
243
|
+
)
|
|
244
|
+
updated = result.fetch_dict()
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### DELETE
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
db.delete("users").where_equals("name", "Alice").execute()
|
|
251
|
+
|
|
252
|
+
# Change table
|
|
253
|
+
q = db.delete("users")
|
|
254
|
+
q.table("old_users").execute()
|
|
255
|
+
|
|
256
|
+
# With RETURNING
|
|
257
|
+
result = db.delete("users").where_less_than("age", 18).returning(["id"]).execute()
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### CREATE TABLE
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
# Using convenience methods
|
|
264
|
+
db.create_table("users").if_not_exists() \
|
|
265
|
+
.identity("id") \
|
|
266
|
+
.string("name", not_null=True) \
|
|
267
|
+
.integer("age") \
|
|
268
|
+
.boolean("active", default=True) \
|
|
269
|
+
.date_time("created_at") \
|
|
270
|
+
.execute()
|
|
271
|
+
|
|
272
|
+
# Using raw column definitions
|
|
273
|
+
db.create_table("posts").if_not_exists() \
|
|
274
|
+
.column("id", TypeEnum.INT, not_null=True) \
|
|
275
|
+
.column("title", TypeEnum.STRING, not_null=True) \
|
|
276
|
+
.column("body", "TEXT") \
|
|
277
|
+
.primary_keys("id") \
|
|
278
|
+
.execute()
|
|
279
|
+
|
|
280
|
+
# With constraints
|
|
281
|
+
db.create_table("orders").if_not_exists() \
|
|
282
|
+
.identity("id") \
|
|
283
|
+
.integer("user_id") \
|
|
284
|
+
.string("status") \
|
|
285
|
+
.unique_constraint(["status", "user_id"], name="uq_orders_status_user") \
|
|
286
|
+
.foreign_key_constraint(
|
|
287
|
+
"user_id", "users", "id",
|
|
288
|
+
referential_actions=["ON DELETE CASCADE"],
|
|
289
|
+
) \
|
|
290
|
+
.execute()
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### ALTER TABLE
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
# Add columns
|
|
297
|
+
db.alter_table("users") \
|
|
298
|
+
.add_string("email", size=255) \
|
|
299
|
+
.add_int("score", not_null=True, default=0) \
|
|
300
|
+
.execute()
|
|
301
|
+
|
|
302
|
+
# Rename / drop columns
|
|
303
|
+
db.alter_table("users") \
|
|
304
|
+
.rename_column("name", "full_name") \
|
|
305
|
+
.drop_column("temp_field") \
|
|
306
|
+
.execute()
|
|
307
|
+
|
|
308
|
+
# Add constraints
|
|
309
|
+
db.alter_table("users") \
|
|
310
|
+
.add_unique_constraint(["email"], name="uq_users_email") \
|
|
311
|
+
.add_foreign_key_constraint("role_id", "roles", "id") \
|
|
312
|
+
.execute()
|
|
313
|
+
|
|
314
|
+
# Drop constraints
|
|
315
|
+
db.alter_table("users") \
|
|
316
|
+
.drop_constraint("uq_users_email") \
|
|
317
|
+
.execute()
|
|
318
|
+
|
|
319
|
+
# Raw alter
|
|
320
|
+
db.alter_table("users").alter("ALTER COLUMN age SET NOT NULL").execute()
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### DROP TABLE
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
db.drop_table("posts").execute()
|
|
327
|
+
db.drop_table("posts").if_exists().execute()
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## WHERE Conditions
|
|
333
|
+
|
|
334
|
+
Every condition method has four variants:
|
|
335
|
+
|
|
336
|
+
| Variant | Example |
|
|
337
|
+
|---------|---------|
|
|
338
|
+
| `where_*` | `where_equals("name", "Alice")` |
|
|
339
|
+
| `or_where_*` | `or_where_equals("name", "Bob")` |
|
|
340
|
+
| `where_not_*` | `where_not_equals("status", "banned")` |
|
|
341
|
+
| `or_where_not_*` | `or_where_not_equals("role", "admin")` |
|
|
342
|
+
|
|
343
|
+
### Available Conditions
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
# Comparison
|
|
347
|
+
.where_equals("name", "Alice")
|
|
348
|
+
.where_not_equals("status", "banned")
|
|
349
|
+
.where_less_than("age", 18)
|
|
350
|
+
.where_less_than_or_equals("age", 65)
|
|
351
|
+
.where_greater_than("score", 100)
|
|
352
|
+
.where_greater_than_or_equals("score", 0)
|
|
353
|
+
|
|
354
|
+
# Null checks
|
|
355
|
+
.where_is_null("deleted_at")
|
|
356
|
+
.where_is_not_null("email")
|
|
357
|
+
|
|
358
|
+
# Pattern matching
|
|
359
|
+
.where_like("name", "Alice%") # SQL LIKE
|
|
360
|
+
.where_not_like("email", "%@spam.com")
|
|
361
|
+
.where_starts_with("username", "admin") # LIKE 'admin%'
|
|
362
|
+
.where_ends_with("filename", ".pdf") # LIKE '%.pdf'
|
|
363
|
+
.where_contains("bio", "engineer") # LIKE '%engineer%'
|
|
364
|
+
.where_not_contains("bio", "spam") # NOT LIKE '%spam%'
|
|
365
|
+
|
|
366
|
+
# File globbing (SQLite)
|
|
367
|
+
.where_glob("path", "*.txt")
|
|
368
|
+
.where_not_glob("path", "*.tmp")
|
|
369
|
+
|
|
370
|
+
# Set membership
|
|
371
|
+
.where_in("id", [1, 2, 3])
|
|
372
|
+
.where_not_in("role", ["guest", "anon"])
|
|
373
|
+
|
|
374
|
+
# Range
|
|
375
|
+
.where_between("age", 18, 65)
|
|
376
|
+
.where_not_between("age", 0, 17)
|
|
377
|
+
|
|
378
|
+
# Empty string
|
|
379
|
+
.where_empty("middle_name")
|
|
380
|
+
.where_not_empty("full_name")
|
|
381
|
+
|
|
382
|
+
# Regex
|
|
383
|
+
.where_regex("email", r"^[a-z]+@")
|
|
384
|
+
.where_not_regex("email", r"^test@")
|
|
385
|
+
|
|
386
|
+
# Subquery existence
|
|
387
|
+
sub = db.select("orders").columns(["user_id"])
|
|
388
|
+
.where_exists(sub)
|
|
389
|
+
.where_not_exists(sub)
|
|
390
|
+
|
|
391
|
+
# Grouped conditions
|
|
392
|
+
.where_group(lambda g: (
|
|
393
|
+
g.where_equals("plan", "premium")
|
|
394
|
+
.or_where_group(lambda g2: (
|
|
395
|
+
g2.where_equals("plan", "free")
|
|
396
|
+
.where_less_than("trial_days", 30)
|
|
397
|
+
))
|
|
398
|
+
))
|
|
399
|
+
.where_not_group(lambda g: g.where_equals("role", "internal"))
|
|
400
|
+
|
|
401
|
+
# Raw SQL conditions
|
|
402
|
+
.where_raw("EXTRACT(YEAR FROM created_at) = ?", [2026])
|
|
403
|
+
.or_where_raw("last_login IS NOT NULL")
|
|
404
|
+
|
|
405
|
+
# Custom operator
|
|
406
|
+
.where_operator("json_data", "@>", '{"vip": true}')
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## HAVING Conditions
|
|
412
|
+
|
|
413
|
+
Exactly the same methods as WHERE, prefixed with `having_*` / `or_having_*`:
|
|
414
|
+
|
|
415
|
+
```python
|
|
416
|
+
db.select("users") \
|
|
417
|
+
.columns(["plan", "count(*)"]) \
|
|
418
|
+
.group_by(["plan"]) \
|
|
419
|
+
.having_greater_than("count(*)", 5) \
|
|
420
|
+
.having_between("avg(age)", 18, 65) \
|
|
421
|
+
.having_group(lambda g: g.where_equals("plan", "enterprise")) \
|
|
422
|
+
.execute()
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## JOINs
|
|
428
|
+
|
|
429
|
+
```python
|
|
430
|
+
from flowmaticdb import raw, identifier
|
|
431
|
+
|
|
432
|
+
query = db.select("users").columns(["users.id", "posts.title"])
|
|
433
|
+
|
|
434
|
+
# INNER JOIN with ON conditions
|
|
435
|
+
join = query.inner_join("posts", "p") # Returns Join object
|
|
436
|
+
join.on(["users", "id"], ["p", "user_id"]) # ON users.id = p.user_id
|
|
437
|
+
join.or_on(["p", "status"], ["'published'"]) # OR p.status = 'published'
|
|
438
|
+
|
|
439
|
+
# LEFT JOIN
|
|
440
|
+
query.left_join("comments", "c").on(["p", "id"], ["c", "post_id"])
|
|
441
|
+
|
|
442
|
+
# CROSS JOIN
|
|
443
|
+
query.cross_join("sessions")
|
|
444
|
+
|
|
445
|
+
# LATERAL joins
|
|
446
|
+
query.left_join_lateral(sub_query, "sq")
|
|
447
|
+
query.inner_join_lateral(sub_query, "sq")
|
|
448
|
+
query.cross_join_lateral(sub_query, "sq")
|
|
449
|
+
|
|
450
|
+
# Raw join SQL (e.g. for aggregates)
|
|
451
|
+
query.join(raw("LEFT JOIN (SELECT user_id, count(*) AS cnt FROM orders GROUP BY user_id) AS o ON o.user_id = users.id"))
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Join ON Conditions
|
|
455
|
+
|
|
456
|
+
`Join` objects support all the same condition methods as WHERE:
|
|
457
|
+
|
|
458
|
+
```python
|
|
459
|
+
join = query.inner_join("orders")
|
|
460
|
+
join.where_equals(["orders", "user_id"], ["users", "id"])
|
|
461
|
+
join.where_greater_than("orders.total", 100)
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
466
|
+
## DISTINCT, GROUP BY, ORDER BY, LIMIT, OFFSET
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
db.select("users") \
|
|
470
|
+
.distinct() # DISTINCT
|
|
471
|
+
.distinct(["category"]) # DISTINCT ON (PostgreSQL only)
|
|
472
|
+
.group_by(["plan", "status"]) \
|
|
473
|
+
.order_by_asc("name") \
|
|
474
|
+
.order_by_desc("created_at") # Multiple orderings
|
|
475
|
+
.limit(50) \
|
|
476
|
+
.offset(10) \
|
|
477
|
+
.execute()
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## UNION / UNION ALL
|
|
483
|
+
|
|
484
|
+
```python
|
|
485
|
+
active = db.select("users").where_equals("active", True)
|
|
486
|
+
archived = db.select("archived_users")
|
|
487
|
+
|
|
488
|
+
db.select("users") \
|
|
489
|
+
.columns(["id", "name"]) \
|
|
490
|
+
.union(active) \
|
|
491
|
+
.union_all(archived) \
|
|
492
|
+
.execute()
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
## Transactions
|
|
498
|
+
|
|
499
|
+
```python
|
|
500
|
+
# Explicit transaction
|
|
501
|
+
db.begin_transaction()
|
|
502
|
+
try:
|
|
503
|
+
db.insert("users").values({"name": "Alice"}).execute()
|
|
504
|
+
db.insert("users").values({"name": "Bob"}).execute()
|
|
505
|
+
db.commit_transaction()
|
|
506
|
+
except Exception:
|
|
507
|
+
db.rollback_transaction()
|
|
508
|
+
|
|
509
|
+
# With context-manager-style callback
|
|
510
|
+
def work(database):
|
|
511
|
+
database.insert("users").values({"name": "Charlie"}).execute()
|
|
512
|
+
database.insert("users").values({"name": "Dave"}).execute()
|
|
513
|
+
|
|
514
|
+
db.transaction(work) # Auto commit/rollback
|
|
515
|
+
|
|
516
|
+
# Savepoints for nested transactions
|
|
517
|
+
db.begin_transaction()
|
|
518
|
+
db.begin_transaction("savepoint_1")
|
|
519
|
+
db.commit_transaction("savepoint_1")
|
|
520
|
+
db.rollback_transaction() # Rolls back main transaction
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
## Working with Results
|
|
526
|
+
|
|
527
|
+
All `execute()` calls return a `ResultABC` object.
|
|
528
|
+
|
|
529
|
+
### Fetching Data
|
|
530
|
+
|
|
531
|
+
```python
|
|
532
|
+
result = db.select("users").execute()
|
|
533
|
+
|
|
534
|
+
# Single row
|
|
535
|
+
row: dict | None = result.fetch_dict()
|
|
536
|
+
|
|
537
|
+
# All rows
|
|
538
|
+
rows: list[dict] = result.fetch_dicts()
|
|
539
|
+
|
|
540
|
+
# First column of first row
|
|
541
|
+
val: Any = result.scalar()
|
|
542
|
+
val = result.scalar("name") # Named column
|
|
543
|
+
|
|
544
|
+
# Column metadata
|
|
545
|
+
cols: dict[str, str] = result.columns() # {"id": "integer", "name": "text", ...}
|
|
546
|
+
|
|
547
|
+
# Hydrate into objects
|
|
548
|
+
class User:
|
|
549
|
+
def __init__(self):
|
|
550
|
+
self.id = 0
|
|
551
|
+
self.name = ""
|
|
552
|
+
|
|
553
|
+
user = result.fetch_object(User) # Single
|
|
554
|
+
users = result.fetch_objects(User) # List
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
### Snapshotting a Result
|
|
558
|
+
|
|
559
|
+
Freeze a live cursor result into an in-memory `Result`:
|
|
560
|
+
|
|
561
|
+
```python
|
|
562
|
+
from flowmaticdb.result import snapshot_result
|
|
563
|
+
|
|
564
|
+
live_result = db.select("users").execute()
|
|
565
|
+
snapshot = snapshot_result(live_result) # Can be iterated repeatedly
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### Result Methods Summary
|
|
569
|
+
|
|
570
|
+
| Method | Returns | Description |
|
|
571
|
+
|--------|---------|-------------|
|
|
572
|
+
| `fetch_dict()` | `dict \| None` | Next row as dict, or `None` |
|
|
573
|
+
| `fetch_dicts()` | `list[dict]` | All remaining rows |
|
|
574
|
+
| `scalar(column=None)` | `Any` | First value of next row |
|
|
575
|
+
| `fetch_object(cls, args)` | `object \| None` | Hydrate next row into object |
|
|
576
|
+
| `fetch_objects(cls, args)` | `list[object]` | Hydrate all rows into objects |
|
|
577
|
+
| `columns()` | `dict[str, str]` | Column name → type mapping |
|
|
578
|
+
|
|
579
|
+
---
|
|
580
|
+
|
|
581
|
+
## Table API
|
|
582
|
+
|
|
583
|
+
High-level table wrapper for common patterns:
|
|
584
|
+
|
|
585
|
+
```python
|
|
586
|
+
from flowmaticdb.database import Table
|
|
587
|
+
|
|
588
|
+
# Create a table reference
|
|
589
|
+
table = Table(db, db.dialect, "users")
|
|
590
|
+
|
|
591
|
+
# Shortcuts
|
|
592
|
+
table.select() # SELECT *
|
|
593
|
+
table.select(["id", "name"]) # SELECT id, name
|
|
594
|
+
table.insert({"name": "Alice"}) # INSERT
|
|
595
|
+
table.update({"age": 30}) # UPDATE ... (add WHERE separately)
|
|
596
|
+
table.delete() # DELETE ... (add WHERE separately)
|
|
597
|
+
|
|
598
|
+
# Smart operations
|
|
599
|
+
table.select_or_insert(["name"], ["Alice"]) # SELECT first, INSERT if not found
|
|
600
|
+
table.insert_or_ignore(["name"], ["Bob"]) # INSERT ... ON CONFLICT DO NOTHING
|
|
601
|
+
table.insert_or_update(
|
|
602
|
+
["name"], ["Charlie"],
|
|
603
|
+
conflict="name",
|
|
604
|
+
updates={"age": 40},
|
|
605
|
+
) # INSERT ... ON CONFLICT DO UPDATE
|
|
606
|
+
|
|
607
|
+
# DDL
|
|
608
|
+
table.create(lambda q: q.identity("id").string("name"))
|
|
609
|
+
table.create_if_not_exists(...)
|
|
610
|
+
table.drop()
|
|
611
|
+
table.drop_if_exists()
|
|
612
|
+
table.truncate()
|
|
613
|
+
|
|
614
|
+
# Introspection
|
|
615
|
+
table.columns() # list[str] — column names
|
|
616
|
+
table.is_empty() # bool
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
---
|
|
620
|
+
|
|
621
|
+
## Expressions
|
|
622
|
+
|
|
623
|
+
Import module-level factory functions:
|
|
624
|
+
|
|
625
|
+
```python
|
|
626
|
+
from flowmaticdb import raw, identifier, alias, expression, sub_query, current_timestamp, now
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
### Available Expressions
|
|
630
|
+
|
|
631
|
+
| Expression | Purpose | Example |
|
|
632
|
+
|-----------|---------|---------|
|
|
633
|
+
| `raw(sql)` | Raw SQL snippet | `raw("COUNT(*) AS cnt")` |
|
|
634
|
+
| `identifier(name)` | Escaped identifier | `identifier(["schema", "table"])` |
|
|
635
|
+
| `alias(expr, alias)` | `expr AS alias` | `alias("users", "u")` |
|
|
636
|
+
| `expression(sql, params)` | SQL with positional params | `expression("? + ?", [1, 2])` |
|
|
637
|
+
| `sub_query(query, alias)` | `(SELECT ...) AS alias` | `sub_query(select_q, "sq")` |
|
|
638
|
+
| `current_timestamp()` | `CURRENT_TIMESTAMP` | `current_timestamp()` |
|
|
639
|
+
| `now()` | `datetime.now(UTC)` | `now()` |
|
|
640
|
+
|
|
641
|
+
```python
|
|
642
|
+
db.select(raw("COUNT(*) AS cnt")).table("users").execute()
|
|
643
|
+
|
|
644
|
+
# Schema-qualified table reference
|
|
645
|
+
db.select(identifier(["public", "users"])).execute()
|
|
646
|
+
|
|
647
|
+
# Alias in joins
|
|
648
|
+
join = query.inner_join(alias("users", "u"))
|
|
649
|
+
join.on(identifier(["u", "id"]), identifier(["posts", "user_id"]))
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
---
|
|
653
|
+
|
|
654
|
+
## EXPLAIN Queries
|
|
655
|
+
|
|
656
|
+
```python
|
|
657
|
+
plan = db.select("users").where_equals("name", "Alice").explain()
|
|
658
|
+
for row in plan:
|
|
659
|
+
print(row)
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
---
|
|
663
|
+
|
|
664
|
+
## Raw Query Execution
|
|
665
|
+
|
|
666
|
+
For one-off SQL that doesn't need the query builder:
|
|
667
|
+
|
|
668
|
+
```python
|
|
669
|
+
# DDL (no parameters)
|
|
670
|
+
db.exec("CREATE TABLE temp (id INTEGER PRIMARY KEY)")
|
|
671
|
+
|
|
672
|
+
# DML with parameters
|
|
673
|
+
from flowmaticdb import QueryWithParams
|
|
674
|
+
qwp = QueryWithParams(query="SELECT * FROM users WHERE name = ?", params=["Alice"])
|
|
675
|
+
result = db.query_with_params(qwp)
|
|
676
|
+
rows = result.fetch_dicts()
|
|
677
|
+
|
|
678
|
+
# Prepared statement shortcut
|
|
679
|
+
result = db.prepared("SELECT * FROM users WHERE age > ? AND active = ?", [18, True])
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
---
|
|
683
|
+
|
|
684
|
+
## QueryWithParams
|
|
685
|
+
|
|
686
|
+
The core data structure that travels from query builders through dialects to adapters:
|
|
687
|
+
|
|
688
|
+
```python
|
|
689
|
+
from flowmaticdb import QueryWithParams
|
|
690
|
+
|
|
691
|
+
qwp = QueryWithParams(query="SELECT * FROM users WHERE age > ?", params=[18])
|
|
692
|
+
|
|
693
|
+
# Convert %s placeholders to ? positional
|
|
694
|
+
qwp2 = qwp.percent_s_to_question_marks()
|
|
695
|
+
|
|
696
|
+
# Interpolate values into SQL string (for debugging / emulation)
|
|
697
|
+
sql = qwp.to_sql(dialect)
|
|
698
|
+
# Returns: SELECT * FROM users WHERE age > 18
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
---
|
|
702
|
+
|
|
703
|
+
## Exception Hierarchy
|
|
704
|
+
|
|
705
|
+
```
|
|
706
|
+
DatabaseError
|
|
707
|
+
├── AdapterError — Adapter-level issues (connection, configuration)
|
|
708
|
+
├── DriverError — Driver/connection errors
|
|
709
|
+
├── QueryError — Query building errors (e.g., unsupported SQL feature)
|
|
710
|
+
└── QueryWithParamsError — Parameterized query errors
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
```python
|
|
714
|
+
from flowmaticdb.exceptions import DatabaseError, QueryError
|
|
715
|
+
|
|
716
|
+
try:
|
|
717
|
+
db.select("users").execute()
|
|
718
|
+
except QueryError as e:
|
|
719
|
+
print(f"Query error: {e}")
|
|
720
|
+
except DatabaseError as e:
|
|
721
|
+
print(f"Database error: {e}")
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
---
|
|
725
|
+
|
|
726
|
+
## Dialect-Specific Behavior
|
|
727
|
+
|
|
728
|
+
### PostgreSQL
|
|
729
|
+
|
|
730
|
+
| Feature | Support | Details |
|
|
731
|
+
|---------|---------|---------|
|
|
732
|
+
| `DISTINCT ON` | ✅ | `distinct(["col1", "col2"])` |
|
|
733
|
+
| `ON CONFLICT` | ✅ | Native (≥ 9.5) |
|
|
734
|
+
| `RETURNING` | ✅ | Native (≥ 8.2) |
|
|
735
|
+
| `ILIKE` | ✅ | Case-insensitive LIKE |
|
|
736
|
+
| `LATERAL` | ✅ | (≥ 9.3) |
|
|
737
|
+
| Regex | ✅ | `regexp_like()` (≥ 15) or `~`/`!~` operators |
|
|
738
|
+
| `GENERATED BY DEFAULT AS IDENTITY` | ✅ | (≥ 17, or falls back to `SERIAL`) |
|
|
739
|
+
| Native boolean | ✅ | `BOOLEAN` type |
|
|
740
|
+
| Datetime | ✅ | Microsecond precision: `%Y-%m-%d %H:%M:%S.%f` |
|
|
741
|
+
|
|
742
|
+
### SQLite
|
|
743
|
+
|
|
744
|
+
| Feature | Support | Details |
|
|
745
|
+
|---------|---------|---------|
|
|
746
|
+
| `ON CONFLICT` | ✅ | (≥ 3.24.0) |
|
|
747
|
+
| `RETURNING` | ✅ | (≥ 3.35.0) |
|
|
748
|
+
| `GLOB` | ✅ | Native file globbing |
|
|
749
|
+
| `REGEXP` | ✅ | Via `regexp_like()` or `REGEXP` operator |
|
|
750
|
+
| `ALTER COLUMN` | ❌ | Raises `QueryError` |
|
|
751
|
+
| `DROP COLUMN` | ❌ | Raises `QueryError` (pre-3.35.0; newer versions support it — check dialect option) |
|
|
752
|
+
| Named constraints | ❌ | Names stripped from constraints |
|
|
753
|
+
| Auto-increment | ✅ | `INTEGER PRIMARY KEY AUTOINCREMENT` |
|
|
754
|
+
| Case-insensitive LIKE | ✅ | Default SQLite behavior |
|
|
755
|
+
|
|
756
|
+
### MySQL
|
|
757
|
+
|
|
758
|
+
| Feature | Support | Details |
|
|
759
|
+
|---------|---------|---------|
|
|
760
|
+
| `ON DUPLICATE KEY` | ✅ | Via `on_conflict_do_update()` |
|
|
761
|
+
| `RETURNING` | ❌ | Not supported; emulation not implemented |
|
|
762
|
+
| Auto-increment | ✅ | `AUTO_INCREMENT` |
|
|
763
|
+
| Placeholders | ✅ | `?` → `%s` conversion for connector |
|
|
764
|
+
|
|
765
|
+
### General ANSI (SQLDialect base)
|
|
766
|
+
|
|
767
|
+
- `LIMIT` / `OFFSET` — Standard ANSI syntax
|
|
768
|
+
- `LIMIT ? OFFSET ?` — Parameterized
|
|
769
|
+
- No native `ON CONFLICT`, `RETURNING`, `DISTINCT ON`, or `LATERAL`
|
|
770
|
+
- No `GLOB` support
|
|
771
|
+
- Regex raises `QueryError`
|
|
772
|
+
|
|
773
|
+
---
|
|
774
|
+
|
|
775
|
+
## Architecture
|
|
776
|
+
|
|
777
|
+
```
|
|
778
|
+
┌────────────────────────────────────────────────────┐
|
|
779
|
+
│ User Code │
|
|
780
|
+
│ DB.connect_*() → Database → Query Builders │
|
|
781
|
+
└──────────────────┬─────────────────────────────────┘
|
|
782
|
+
│
|
|
783
|
+
┌────────┴────────┐
|
|
784
|
+
▼ ▼
|
|
785
|
+
┌──────────┐ ┌──────────────┐
|
|
786
|
+
│ Dialects │ │ Adapters │
|
|
787
|
+
│ ──────── │ │ ────────── │
|
|
788
|
+
│ SQL gen │ │ Connection │
|
|
789
|
+
│ + types │ │ + execution │
|
|
790
|
+
└────┬─────┘ └──────┬───────┘
|
|
791
|
+
│ │
|
|
792
|
+
▼ ▼
|
|
793
|
+
┌──────────┐ ┌──────────────┐
|
|
794
|
+
│ Query │ │ Result │
|
|
795
|
+
│ Builders │ │ ────────── │
|
|
796
|
+
│ ──────── │ │ fetch_dict() │
|
|
797
|
+
│ Fluent │ │ fetch_dicts()│
|
|
798
|
+
│ chaining │ │ scalar() │
|
|
799
|
+
└──────────┘ └──────────────┘
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
### Four Pillars
|
|
803
|
+
|
|
804
|
+
1. **Dialects** — Database-specific SQL generation
|
|
805
|
+
- `DialectABC` — Abstract base
|
|
806
|
+
- `SQLDialect` — ANSI SQL (~713 lines; overridable in subclasses)
|
|
807
|
+
- `PostgresqlDialect` — PostgreSQL overrides
|
|
808
|
+
- `SQLiteDialect` — SQLite overrides
|
|
809
|
+
- `MySQLDialect` — MySQL overrides
|
|
810
|
+
|
|
811
|
+
2. **Adapters** — Connection wrappers
|
|
812
|
+
- `AdapterABC` — Abstract base
|
|
813
|
+
- `SQLiteAdapter` — Wraps `sqlite3.Connection`
|
|
814
|
+
- `PsycopgAdapter` — Wraps `psycopg.Connection`
|
|
815
|
+
- `MySQLAdapter` — Wraps `mysql.connector.Connection`
|
|
816
|
+
|
|
817
|
+
3. **Query Builders** — Fluent SQL construction
|
|
818
|
+
- `SelectQuery` — SELECT with WHERE/HAVING/JOINs/GROUP BY/ORDER BY/LIMIT/OFFSET/UNION
|
|
819
|
+
- `InsertQuery` — INSERT with ON CONFLICT/RETURNING
|
|
820
|
+
- `UpdateQuery` — UPDATE with WHERE/RETURNING
|
|
821
|
+
- `DeleteQuery` — DELETE with WHERE/RETURNING
|
|
822
|
+
- `CreateTableQuery` — CREATE TABLE with columns, keys, constraints
|
|
823
|
+
- `AlterTableQuery` — ALTER TABLE (add/rename/drop columns, constraints)
|
|
824
|
+
- `DropTableQuery` — DROP TABLE
|
|
825
|
+
|
|
826
|
+
4. **Results** — Unified result set
|
|
827
|
+
- `ResultABC` — Abstract base
|
|
828
|
+
- `Result` — In-memory result (snapshot)
|
|
829
|
+
- `SQLite3Result` — Wraps `sqlite3.Cursor`
|
|
830
|
+
- `PsycopgResult` — Wraps psycopg cursor
|
|
831
|
+
- `MySQLResult` — Wraps `mysql.connector.cursor`
|
|
832
|
+
|
|
833
|
+
### Mixin Architecture
|
|
834
|
+
|
|
835
|
+
Query builders use Python multiple inheritance for composable behavior:
|
|
836
|
+
|
|
837
|
+
| Mixin | Used By | Methods |
|
|
838
|
+
|-------|---------|---------|
|
|
839
|
+
| `WhereMixin` | Select, Update, Delete | `where_*`, `or_where_*` (40+ methods) |
|
|
840
|
+
| `HavingMixin` | Select | `having_*`, `or_having_*` (40+ methods) |
|
|
841
|
+
| `JoinsMixin` | Select | `left_join()`, `inner_join()`, `cross_join()`, etc. |
|
|
842
|
+
| `ColumnsMixin` | Select | `columns()` |
|
|
843
|
+
| `DistinctMixin` | Select | `distinct()` |
|
|
844
|
+
| `GroupByMixin` | Select | `group_by()` |
|
|
845
|
+
| `OrderByMixin` | Select | `order_by_asc()`, `order_by_desc()` |
|
|
846
|
+
| `LimitMixin` | Select | `limit()` |
|
|
847
|
+
| `OffsetMixin` | Select | `offset()` |
|
|
848
|
+
| `UnionMixin` | Select | `union()`, `union_all()` |
|
|
849
|
+
| `ValuesMixin` | Insert | `values()` |
|
|
850
|
+
| `UpdatesMixin` | Update | `updates()` |
|
|
851
|
+
| `ReturningMixin` | Insert, Update, Delete | `returning()` |
|
|
852
|
+
| `OnConflictMixin` | Insert | `on_conflict_do_nothing()`, `on_conflict_do_update()` |
|
|
853
|
+
| `LastInsertIdMixin` | Insert | `last_insert_id()` |
|
|
854
|
+
| `ColumnsDefinitionMixin` | CreateTable | `column()`, `integer()`, `string()`, `boolean()`, etc. |
|
|
855
|
+
| `AltersMixin` | AlterTable | `add_column()`, `rename_column()`, `drop_column()`, etc. |
|
|
856
|
+
| `ConstraintsMixin` | CreateTable | `unique_constraint()`, `foreign_key_constraint()` |
|
|
857
|
+
| `PrimaryKeysMixin` | CreateTable | `primary_keys()` |
|
|
858
|
+
| `IfNotExistsMixin` | CreateTable | `if_not_exists()` |
|
|
859
|
+
| `IfExistsMixin` | DropTable | `if_exists()` |
|
|
860
|
+
|
|
861
|
+
---
|
|
862
|
+
|
|
863
|
+
## Enums Reference
|
|
864
|
+
|
|
865
|
+
```python
|
|
866
|
+
from flowmaticdb.query.enums import ConditionEnum
|
|
867
|
+
# =, <>, <, <=, >, >=, BETWEEN, NOT BETWEEN, LIKE, NOT LIKE,
|
|
868
|
+
# GLOB, NOT GLOB, IN, NOT IN, REGEX, NOT REGEX, EXISTS, NOT EXISTS, RAW
|
|
869
|
+
|
|
870
|
+
from flowmaticdb.query.enums import ChainEnum
|
|
871
|
+
# AND, OR
|
|
872
|
+
|
|
873
|
+
from flowmaticdb.query.enums import JoinEnum
|
|
874
|
+
# LEFT JOIN, LEFT JOIN LATERAL, INNER JOIN, INNER JOIN LATERAL,
|
|
875
|
+
# CROSS JOIN, CROSS JOIN LATERAL
|
|
876
|
+
|
|
877
|
+
from flowmaticdb.query.enums import OrderByDirectionEnum
|
|
878
|
+
# ASC, DESC
|
|
879
|
+
|
|
880
|
+
from flowmaticdb.query.enums import UnionEnum
|
|
881
|
+
# UNION, UNION ALL
|
|
882
|
+
|
|
883
|
+
from flowmaticdb.query.enums import TypeEnum
|
|
884
|
+
# BOOL, INT, FLOAT, STRING, DATETIME
|
|
885
|
+
|
|
886
|
+
from flowmaticdb.query.enums import ReferentialActionEnum
|
|
887
|
+
# ON_UPDATE_NO_ACTION, ON_UPDATE_SET_NULL, ON_UPDATE_CASCADE,
|
|
888
|
+
# ON_DELETE_NO_ACTION, ON_DELETE_SET_NULL, ON_DELETE_CASCADE
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
---
|
|
892
|
+
|
|
893
|
+
## Import Notes
|
|
894
|
+
|
|
895
|
+
A leading underscore on a module name marks it as a private implementation detail — never import from it directly. Each package's public API is exactly its `__init__.py` `__all__`; import from the package instead. (`flowmaticdb.exceptions` is the one deliberate exception, kept as a public module by convention.)
|
|
896
|
+
|
|
897
|
+
- `PsycopgAdapter`, `MySQLAdapter` — Import from `flowmaticdb.adapters`, NOT a submodule
|
|
898
|
+
- `PsycopgResult`, `MySQLResult` — Import from `flowmaticdb.result`, NOT a submodule
|
|
899
|
+
- `raw()`, `identifier()`, `alias()`, `expression()`, `sub_query()`, `current_timestamp()`, `now()` — Module-level functions, imported from `flowmaticdb`
|
|
900
|
+
- `snapshot_result()` — Import from `flowmaticdb.result`
|
|
901
|
+
|
|
902
|
+
```python
|
|
903
|
+
from flowmaticdb.adapters import PsycopgAdapter, MySQLAdapter
|
|
904
|
+
from flowmaticdb.result import PsycopgResult, MySQLResult, snapshot_result
|
|
905
|
+
from flowmaticdb import raw, identifier, alias, expression, sub_query, current_timestamp, now
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
---
|
|
909
|
+
|
|
910
|
+
## Qualified Column References
|
|
911
|
+
|
|
912
|
+
Use two-element lists for schema-qualified or table-qualified column names:
|
|
913
|
+
|
|
914
|
+
```python
|
|
915
|
+
# Correct: table-qualified
|
|
916
|
+
.where_equals(["users", "name"], "Alice")
|
|
917
|
+
|
|
918
|
+
# Correct: schema-qualified
|
|
919
|
+
.where_equals(["public", "users", "name"], "Alice")
|
|
920
|
+
|
|
921
|
+
# Correct: using identifier()
|
|
922
|
+
.where_equals(identifier(["users", "name"]), "Alice")
|
|
923
|
+
|
|
924
|
+
# WRONG: "users.name" is treated as a single identifier
|
|
925
|
+
# and escaped as "users.name" (non-existent column)
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
For raw JOIN clauses and aggregate expressions, use `raw()`:
|
|
929
|
+
|
|
930
|
+
```python
|
|
931
|
+
query.join(raw("LEFT JOIN orders o ON o.user_id = users.id"))
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
Schema-qualified table references work with plain lists:
|
|
935
|
+
|
|
936
|
+
```python
|
|
937
|
+
db.insert(["public", "users"]).values({"name": "Alice"}).execute()
|
|
938
|
+
db.delete(["schema", "table"]).where_equals("id", 1).execute()
|
|
939
|
+
db.update(["schema", "table"]).updates({"name": "Bob"}).execute()
|
|
940
|
+
db.create_table(["schema", "table"]).identity("id").string("name").execute()
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
---
|
|
944
|
+
|
|
945
|
+
## Database-Specific Notes
|
|
946
|
+
|
|
947
|
+
### Placeholder Conversion
|
|
948
|
+
|
|
949
|
+
All dialects emit `?` as the placeholder. Each adapter converts to its driver's native format:
|
|
950
|
+
- **PostgreSQL**: `?` → `%s` via `question_marks_to_percent_s()` (psycopg expects `%s`)
|
|
951
|
+
- **MySQL**: `?` → `%s` via `question_marks_to_percent_s()` (mysql-connector expects `%s`)
|
|
952
|
+
- **SQLite**: `%s` → `?` via `percent_s_to_question_marks()` (SQLite uses `?` natively; handles user-provided `%s`)
|
|
953
|
+
|
|
954
|
+
Both conversion methods use `REGEX_PATTERN` to skip placeholders inside quoted strings and comments.
|
|
955
|
+
|
|
956
|
+
### DDL vs DML
|
|
957
|
+
|
|
958
|
+
- **DDL** (CREATE, ALTER, DROP, BEGIN, COMMIT): Use `adapter.exec(sql)` — no parameter binding
|
|
959
|
+
- **DML** (SELECT, INSERT, UPDATE, DELETE): Use `adapter.query_with_params(dialect, qwp)` — uses parameterized queries
|
|
960
|
+
|
|
961
|
+
---
|
|
962
|
+
|
|
963
|
+
## Development
|
|
964
|
+
|
|
965
|
+
### Setup
|
|
966
|
+
|
|
967
|
+
```bash
|
|
968
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
969
|
+
pip install -r requirements.txt
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
### Running Tests
|
|
973
|
+
|
|
974
|
+
```bash
|
|
975
|
+
# All 191 tests
|
|
976
|
+
python3 -m pytest
|
|
977
|
+
|
|
978
|
+
# Unit tests only (no database needed)
|
|
979
|
+
python3 -m pytest tests/test_dialect_sql.py
|
|
980
|
+
python3 -m pytest tests/test_select_query.py
|
|
981
|
+
|
|
982
|
+
# SQLite integration (in-memory, no setup)
|
|
983
|
+
python3 -m pytest tests/test_integration_sqlite.py
|
|
984
|
+
|
|
985
|
+
# PostgreSQL integration (requires Docker)
|
|
986
|
+
docker compose up -d postgres
|
|
987
|
+
python3 -m pytest tests/test_integration_postgres.py
|
|
988
|
+
|
|
989
|
+
# MySQL integration (requires Docker)
|
|
990
|
+
docker compose up -d mysql
|
|
991
|
+
python3 -m pytest tests/test_integration_mysql.py
|
|
992
|
+
|
|
993
|
+
# Single test
|
|
994
|
+
python3 -m pytest tests/test_dialect_sql.py -k "test_select"
|
|
995
|
+
|
|
996
|
+
# Type checking
|
|
997
|
+
python3 -m mypy src/flowmaticdb
|
|
998
|
+
|
|
999
|
+
# Linting
|
|
1000
|
+
python3 -m ruff check src/flowmaticdb/ tests/
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
### Run Demo
|
|
1004
|
+
|
|
1005
|
+
```bash
|
|
1006
|
+
python3 main.py
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
Connects to MySQL by default. Edit `main.py` to switch to SQLite or PostgreSQL.
|
|
1010
|
+
|
|
1011
|
+
---
|
|
1012
|
+
|
|
1013
|
+
## Requirements
|
|
1014
|
+
|
|
1015
|
+
- Python ≥ 3.11
|
|
1016
|
+
- `psycopg[binary]>=3.1` (PostgreSQL adapter — optional)
|
|
1017
|
+
- `mysql-connector-python` (MySQL adapter — optional)
|
|
1018
|
+
- SQLite uses the standard library (`sqlite3`)
|
|
1019
|
+
|
|
1020
|
+
---
|
|
1021
|
+
|
|
1022
|
+
## License
|
|
1023
|
+
|
|
1024
|
+
MIT
|