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.
Files changed (95) hide show
  1. flowmaticdb-1.0.0/PKG-INFO +1024 -0
  2. flowmaticdb-1.0.0/README.md +994 -0
  3. flowmaticdb-1.0.0/pyproject.toml +65 -0
  4. flowmaticdb-1.0.0/setup.cfg +4 -0
  5. flowmaticdb-1.0.0/src/flowmaticdb/__init__.py +27 -0
  6. flowmaticdb-1.0.0/src/flowmaticdb/_helpers.py +48 -0
  7. flowmaticdb-1.0.0/src/flowmaticdb/_query_with_params.py +67 -0
  8. flowmaticdb-1.0.0/src/flowmaticdb/adapters/__init__.py +11 -0
  9. flowmaticdb-1.0.0/src/flowmaticdb/adapters/_base.py +98 -0
  10. flowmaticdb-1.0.0/src/flowmaticdb/adapters/_mysql.py +190 -0
  11. flowmaticdb-1.0.0/src/flowmaticdb/adapters/_postgres.py +162 -0
  12. flowmaticdb-1.0.0/src/flowmaticdb/adapters/_sqlite.py +165 -0
  13. flowmaticdb-1.0.0/src/flowmaticdb/database/__init__.py +7 -0
  14. flowmaticdb-1.0.0/src/flowmaticdb/database/_abc.py +128 -0
  15. flowmaticdb-1.0.0/src/flowmaticdb/database/_database.py +112 -0
  16. flowmaticdb-1.0.0/src/flowmaticdb/database/_db.py +7 -0
  17. flowmaticdb-1.0.0/src/flowmaticdb/database/_table.py +106 -0
  18. flowmaticdb-1.0.0/src/flowmaticdb/dialects/__init__.py +13 -0
  19. flowmaticdb-1.0.0/src/flowmaticdb/dialects/_base.py +178 -0
  20. flowmaticdb-1.0.0/src/flowmaticdb/dialects/_mysql.py +266 -0
  21. flowmaticdb-1.0.0/src/flowmaticdb/dialects/_postgres.py +137 -0
  22. flowmaticdb-1.0.0/src/flowmaticdb/dialects/_sql_dialect.py +739 -0
  23. flowmaticdb-1.0.0/src/flowmaticdb/dialects/_sqlite.py +178 -0
  24. flowmaticdb-1.0.0/src/flowmaticdb/exceptions.py +17 -0
  25. flowmaticdb-1.0.0/src/flowmaticdb/migrations/__init__.py +7 -0
  26. flowmaticdb-1.0.0/src/flowmaticdb/migrations/_loader.py +62 -0
  27. flowmaticdb-1.0.0/src/flowmaticdb/migrations/_migration_abc.py +23 -0
  28. flowmaticdb-1.0.0/src/flowmaticdb/migrations/_migrator.py +102 -0
  29. flowmaticdb-1.0.0/src/flowmaticdb/migrations/_template.py +34 -0
  30. flowmaticdb-1.0.0/src/flowmaticdb/query/__init__.py +19 -0
  31. flowmaticdb-1.0.0/src/flowmaticdb/query/_alter_table.py +36 -0
  32. flowmaticdb-1.0.0/src/flowmaticdb/query/_condition.py +16 -0
  33. flowmaticdb-1.0.0/src/flowmaticdb/query/_condition_group.py +176 -0
  34. flowmaticdb-1.0.0/src/flowmaticdb/query/_condition_mixin.py +135 -0
  35. flowmaticdb-1.0.0/src/flowmaticdb/query/_create_table.py +35 -0
  36. flowmaticdb-1.0.0/src/flowmaticdb/query/_ddl_mixins.py +313 -0
  37. flowmaticdb-1.0.0/src/flowmaticdb/query/_delete.py +29 -0
  38. flowmaticdb-1.0.0/src/flowmaticdb/query/_drop_table.py +34 -0
  39. flowmaticdb-1.0.0/src/flowmaticdb/query/_having_mixin.py +257 -0
  40. flowmaticdb-1.0.0/src/flowmaticdb/query/_insert.py +29 -0
  41. flowmaticdb-1.0.0/src/flowmaticdb/query/_join.py +269 -0
  42. flowmaticdb-1.0.0/src/flowmaticdb/query/_joins_mixin.py +60 -0
  43. flowmaticdb-1.0.0/src/flowmaticdb/query/_on_conflict.py +10 -0
  44. flowmaticdb-1.0.0/src/flowmaticdb/query/_order_by.py +11 -0
  45. flowmaticdb-1.0.0/src/flowmaticdb/query/_query.py +70 -0
  46. flowmaticdb-1.0.0/src/flowmaticdb/query/_select.py +68 -0
  47. flowmaticdb-1.0.0/src/flowmaticdb/query/_simple_mixins.py +142 -0
  48. flowmaticdb-1.0.0/src/flowmaticdb/query/_union.py +15 -0
  49. flowmaticdb-1.0.0/src/flowmaticdb/query/_update.py +26 -0
  50. flowmaticdb-1.0.0/src/flowmaticdb/query/_where_mixin.py +257 -0
  51. flowmaticdb-1.0.0/src/flowmaticdb/query/enums/__init__.py +17 -0
  52. flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_chain.py +6 -0
  53. flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_condition.py +23 -0
  54. flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_join.py +10 -0
  55. flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_order_by_dir.py +6 -0
  56. flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_referential_action.py +10 -0
  57. flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_type.py +9 -0
  58. flowmaticdb-1.0.0/src/flowmaticdb/query/enums/_union.py +6 -0
  59. flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/__init__.py +20 -0
  60. flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_alias.py +33 -0
  61. flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_current_timestamp.py +19 -0
  62. flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_excluded.py +8 -0
  63. flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_expression.py +25 -0
  64. flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_identifier.py +22 -0
  65. flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_raw.py +22 -0
  66. flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_sql.py +19 -0
  67. flowmaticdb-1.0.0/src/flowmaticdb/query/expressions/_sub_query.py +27 -0
  68. flowmaticdb-1.0.0/src/flowmaticdb/result/__init__.py +14 -0
  69. flowmaticdb-1.0.0/src/flowmaticdb/result/_base.py +41 -0
  70. flowmaticdb-1.0.0/src/flowmaticdb/result/_mysql.py +71 -0
  71. flowmaticdb-1.0.0/src/flowmaticdb/result/_postgres.py +152 -0
  72. flowmaticdb-1.0.0/src/flowmaticdb/result/_result.py +29 -0
  73. flowmaticdb-1.0.0/src/flowmaticdb/result/_sqlite.py +51 -0
  74. flowmaticdb-1.0.0/src/flowmaticdb.egg-info/PKG-INFO +1024 -0
  75. flowmaticdb-1.0.0/src/flowmaticdb.egg-info/SOURCES.txt +93 -0
  76. flowmaticdb-1.0.0/src/flowmaticdb.egg-info/dependency_links.txt +1 -0
  77. flowmaticdb-1.0.0/src/flowmaticdb.egg-info/requires.txt +11 -0
  78. flowmaticdb-1.0.0/src/flowmaticdb.egg-info/top_level.txt +1 -0
  79. flowmaticdb-1.0.0/tests/test_alter_table_query.py +333 -0
  80. flowmaticdb-1.0.0/tests/test_conditions.py +69 -0
  81. flowmaticdb-1.0.0/tests/test_delete_query.py +18 -0
  82. flowmaticdb-1.0.0/tests/test_dialect_mysql.py +385 -0
  83. flowmaticdb-1.0.0/tests/test_dialect_postgres.py +139 -0
  84. flowmaticdb-1.0.0/tests/test_dialect_sql.py +292 -0
  85. flowmaticdb-1.0.0/tests/test_dialect_sqlite.py +110 -0
  86. flowmaticdb-1.0.0/tests/test_expressions.py +66 -0
  87. flowmaticdb-1.0.0/tests/test_insert_query.py +64 -0
  88. flowmaticdb-1.0.0/tests/test_integration_mysql.py +1197 -0
  89. flowmaticdb-1.0.0/tests/test_integration_postgres.py +911 -0
  90. flowmaticdb-1.0.0/tests/test_integration_sqlite.py +271 -0
  91. flowmaticdb-1.0.0/tests/test_joins.py +38 -0
  92. flowmaticdb-1.0.0/tests/test_query_with_params.py +259 -0
  93. flowmaticdb-1.0.0/tests/test_result_abstract.py +86 -0
  94. flowmaticdb-1.0.0/tests/test_select_query.py +113 -0
  95. 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