flowmaticdb 2.2.2__tar.gz → 2.3.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-2.2.2 → flowmaticdb-2.3.0}/PKG-INFO +75 -3
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/README.md +74 -2
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/pyproject.toml +1 -1
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/__init__.py +2 -0
- flowmaticdb-2.3.0/src/flowmaticdb/_json.py +36 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/adapters/_postgres.py +42 -5
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/adapters/_sqlite.py +81 -3
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/dialects/_base.py +8 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/dialects/_mysql.py +3 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/dialects/_postgres.py +23 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/dialects/_sql_dialect.py +21 -1
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_ddl_mixins.py +4 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_type.py +1 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/__init__.py +3 -1
- flowmaticdb-2.3.0/src/flowmaticdb/query/expressions/_postgres_array.py +33 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/result/_mysql.py +25 -3
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/result/_sqlite.py +11 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb.egg-info/PKG-INFO +75 -3
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb.egg-info/SOURCES.txt +3 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_integration_mysql.py +61 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_integration_postgres.py +169 -4
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_integration_sqlite.py +108 -0
- flowmaticdb-2.3.0/tests/test_json_and_datetime_types.py +134 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/setup.cfg +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/_exceptions.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/_helpers.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/_query_with_params.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/adapters/__init__.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/adapters/_base.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/adapters/_mysql.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/database/__init__.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/database/_abc.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/database/_database.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/database/_db.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/database/_table.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/dialects/__init__.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/dialects/_sqlite.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/migrations/__init__.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/migrations/_loader.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/migrations/_migration_abc.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/migrations/_migrator.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/migrations/_template.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/py.typed +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/__init__.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_alter_table.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_condition.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_condition_group.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_condition_mixin.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_create_table.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_delete.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_drop_table.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_having_mixin.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_insert.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_join.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_joins_mixin.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_on_conflict.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_order_by.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_query.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_select.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_simple_mixins.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_union.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_update.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_where_mixin.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/__init__.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_add_column.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_add_foreign_key_constraint.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_add_primary_keys.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_add_unique_constraint.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_alter.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_alter_column.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_column.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_constraint.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_drop_column.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_drop_constraint.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_foreign_key_constraint.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_raw_alter.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_raw_constraint.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_rename_column.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_unique_constraint.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/__init__.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_chain.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_condition.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_join.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_order_by_dir.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_referential_action.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_union.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_alias.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_current_timestamp.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_excluded.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_expression.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_identifier.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_raw.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_sql.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_sub_query.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/result/__init__.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/result/_base.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/result/_postgres.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/result/_result.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb.egg-info/dependency_links.txt +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb.egg-info/requires.txt +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb.egg-info/top_level.txt +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_adapters_port.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_alter_table_query.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_conditions.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_conditions_port.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_database_port.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_ddl_port.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_delete_query.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_dialect_mysql.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_dialect_postgres.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_dialect_sql.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_dialect_sql_port.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_dialect_sqlite.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_dialect_subclasses_port.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_expressions.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_helpers_port.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_insert_query.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_joins.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_query_core_port.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_query_with_params.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_result_abstract.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_select_query.py +0 -0
- {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_update_query.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flowmaticdb
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: A Python database abstraction layer supporting PostgreSQL, SQLite, and MySQL.
|
|
5
5
|
Author: Flowmatic, UniForceMusic
|
|
6
6
|
License: MIT
|
|
@@ -273,6 +273,7 @@ db.create_table("users").if_not_exists() \
|
|
|
273
273
|
.integer("age") \
|
|
274
274
|
.boolean("active", default=True) \
|
|
275
275
|
.datetime("created_at") \
|
|
276
|
+
.json("preferences") \
|
|
276
277
|
.execute()
|
|
277
278
|
|
|
278
279
|
# Using raw column definitions
|
|
@@ -651,6 +652,7 @@ from flowmaticdb import raw, identifier, alias, expression, sub_query, current_t
|
|
|
651
652
|
| `sub_query(query, alias)` | `(SELECT ...) AS alias` | `sub_query(select_q, "sq")` |
|
|
652
653
|
| `current_timestamp()` | `CURRENT_TIMESTAMP` | `current_timestamp()` |
|
|
653
654
|
| `now()` | `datetime.now(UTC)` | `now()` |
|
|
655
|
+
| `PostgresArray(values)` | Bind a list as a PostgreSQL array instead of JSON | `PostgresArray([1, 2, 3])` |
|
|
654
656
|
|
|
655
657
|
```python
|
|
656
658
|
db.select(raw("COUNT(*) AS cnt")).table("users").execute()
|
|
@@ -737,6 +739,69 @@ except DatabaseError as e:
|
|
|
737
739
|
|
|
738
740
|
---
|
|
739
741
|
|
|
742
|
+
## Datetime and JSON Values
|
|
743
|
+
|
|
744
|
+
`datetime` objects and JSON documents are serialized on the way into the database and deserialized on the way back out, on every adapter.
|
|
745
|
+
|
|
746
|
+
```python
|
|
747
|
+
from datetime import datetime, timezone
|
|
748
|
+
|
|
749
|
+
db.create_table("events").if_not_exists() \
|
|
750
|
+
.identity("id") \
|
|
751
|
+
.datetime("happened_at") \
|
|
752
|
+
.json("payload") \
|
|
753
|
+
.execute()
|
|
754
|
+
|
|
755
|
+
db.insert("events").values({
|
|
756
|
+
"happened_at": datetime.now(timezone.utc),
|
|
757
|
+
"payload": {"kind": "signup", "tags": ["a", "b"]},
|
|
758
|
+
}).execute()
|
|
759
|
+
|
|
760
|
+
row = db.select("events").execute().fetch_dict()
|
|
761
|
+
row["happened_at"] # datetime
|
|
762
|
+
row["payload"] # dict
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
`TypeEnum.JSON` (the `.json()` column builder) maps to the best type the server has: `JSONB` on PostgreSQL ≥ 9.4, `JSON` on PostgreSQL ≥ 9.2, MySQL ≥ 5.7.8, MariaDB ≥ 10.2.7 and SQLite, and `TEXT` on anything older. `TypeEnum.DATETIME` maps to `TIMESTAMPTZ`, `DATETIME(6)` and `DATETIME` respectively.
|
|
766
|
+
|
|
767
|
+
Nested values `json` does not know are rendered rather than raising: `datetime`, `date` and `time` become ISO-8601 strings, `Decimal` becomes a string (a float would lose precision). A JSON column holding text that is not valid JSON is handed back as that text instead of failing the fetch.
|
|
768
|
+
|
|
769
|
+
How each driver is wired up:
|
|
770
|
+
|
|
771
|
+
| Driver | Datetime | JSON |
|
|
772
|
+
|--------|----------|------|
|
|
773
|
+
| psycopg | Native, both directions | Serialized on the way in; psycopg decodes `json`/`jsonb` on the way out |
|
|
774
|
+
| asyncpg | Bound natively, reconciled against the placeholder's declared type | `json`/`jsonb` codecs registered on connect, both directions |
|
|
775
|
+
| mysql.connector | Native, both directions | Serialized on the way in; `MySQLResult` decodes columns the server reports as `json` |
|
|
776
|
+
| sqlite3 | `DATETIME`/`TIMESTAMP`/`DATE` adapters and converters registered by `SQLiteAdapter` | `JSON`/`JSONB` adapters and converters |
|
|
777
|
+
|
|
778
|
+
SQLite stores only primitives, so `SQLiteAdapter` registers custom datatypes with the `sqlite3` module and opens its connections with `detect_types=sqlite3.PARSE_DECLTYPES`. Conversion is keyed off the column's *declared* type, so a `DATETIME` or `JSON` table column is converted while an expression (`count(*)`, a computed alias) has no declared type and is returned as-is. Datetimes are written as full ISO-8601, so microseconds and UTC offsets survive the round trip.
|
|
779
|
+
|
|
780
|
+
### PostgreSQL arrays — `PostgresArray`
|
|
781
|
+
|
|
782
|
+
A bare `list` is a JSON document on **every** dialect, PostgreSQL included. PostgreSQL also has a real array type, but nothing in the value itself says which of the two is meant, so the array reading is opt-in — wrap the value in `PostgresArray`:
|
|
783
|
+
|
|
784
|
+
```python
|
|
785
|
+
from flowmaticdb import PostgresArray
|
|
786
|
+
|
|
787
|
+
db.insert("rows").values({
|
|
788
|
+
"id": 1,
|
|
789
|
+
"actual_json_column": [1, 2, 3, 4],
|
|
790
|
+
"postgres_array_column": PostgresArray([5, 6, 7, 8]),
|
|
791
|
+
}).execute()
|
|
792
|
+
|
|
793
|
+
# Also works anywhere else a value is bound, e.g. the array containment operators
|
|
794
|
+
db.select("rows").where_operator("tags", "@>", PostgresArray(["a", "b"])).execute()
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
Both PostgreSQL drivers behave identically here: a bare list aimed at an array column is sent as JSON and rejected, rather than quietly being taken as an array. Element types are left to the driver, so `PostgresArray([datetime(...)])` binds as `timestamptz[]`, not `text[]`.
|
|
798
|
+
|
|
799
|
+
Dialects with no array type unwrap `PostgresArray` back to JSON, so a query written for PostgreSQL still runs against SQLite and MySQL.
|
|
800
|
+
|
|
801
|
+
Reading is unaffected — an array column always comes back as a plain `list`.
|
|
802
|
+
|
|
803
|
+
---
|
|
804
|
+
|
|
740
805
|
## Dialect-Specific Behavior
|
|
741
806
|
|
|
742
807
|
### PostgreSQL
|
|
@@ -751,7 +816,9 @@ except DatabaseError as e:
|
|
|
751
816
|
| Regex | ✅ | `regexp_like()` (≥ 15) or `~`/`!~` operators |
|
|
752
817
|
| `GENERATED BY DEFAULT AS IDENTITY` | ✅ | (≥ 17, or falls back to `SERIAL`) |
|
|
753
818
|
| Native boolean | ✅ | `BOOLEAN` type |
|
|
754
|
-
| Datetime | ✅ | Microsecond precision: `%Y-%m-%d %H:%M:%S.%f` |
|
|
819
|
+
| Datetime | ✅ | Microsecond precision: `%Y-%m-%d %H:%M:%S.%f`; `TypeEnum.DATETIME` → `TIMESTAMPTZ` |
|
|
820
|
+
| JSON | ✅ | `TypeEnum.JSON` → `JSONB` (≥ 9.4) or `JSON` (≥ 9.2); a bare `list`/`dict` is a document |
|
|
821
|
+
| Arrays | ✅ | Opt-in via `PostgresArray([...])` — see [PostgreSQL arrays](#postgresql-arrays--postgresarray) |
|
|
755
822
|
|
|
756
823
|
### SQLite
|
|
757
824
|
|
|
@@ -766,6 +833,8 @@ except DatabaseError as e:
|
|
|
766
833
|
| Named constraints | ❌ | Names stripped from constraints |
|
|
767
834
|
| Auto-increment | ✅ | `INTEGER PRIMARY KEY AUTOINCREMENT` |
|
|
768
835
|
| Case-insensitive LIKE | ✅ | Default SQLite behavior |
|
|
836
|
+
| Datetime | ✅ | Custom `DATETIME`/`TIMESTAMP`/`DATE` datatype via `sqlite3` adapters and converters |
|
|
837
|
+
| JSON | ✅ | Custom `JSON`/`JSONB` datatype via `sqlite3` adapters and converters |
|
|
769
838
|
|
|
770
839
|
### MySQL
|
|
771
840
|
|
|
@@ -775,6 +844,8 @@ except DatabaseError as e:
|
|
|
775
844
|
| `RETURNING` | ❌ | Not supported; emulation not implemented |
|
|
776
845
|
| Auto-increment | ✅ | `AUTO_INCREMENT` |
|
|
777
846
|
| Placeholders | ✅ | `?` → `%s` conversion for connector |
|
|
847
|
+
| Datetime | ✅ | `TypeEnum.DATETIME` → `DATETIME(size)`, fsp clamped to 6 |
|
|
848
|
+
| JSON | ✅ | `TypeEnum.JSON` → `JSON` (MySQL ≥ 5.7.8, MariaDB ≥ 10.2.7), else `TEXT` |
|
|
778
849
|
|
|
779
850
|
### General ANSI (SQLDialect base)
|
|
780
851
|
|
|
@@ -895,7 +966,7 @@ from flowmaticdb.query.enums import UnionEnum
|
|
|
895
966
|
# UNION, UNION ALL
|
|
896
967
|
|
|
897
968
|
from flowmaticdb.query.enums import TypeEnum
|
|
898
|
-
# BOOL, INT, FLOAT, STRING, DATETIME
|
|
969
|
+
# BOOL, INT, FLOAT, STRING, DATETIME, JSON
|
|
899
970
|
|
|
900
971
|
from flowmaticdb.query.enums import ReferentialActionEnum
|
|
901
972
|
# ON_UPDATE_NO_ACTION, ON_UPDATE_SET_NULL, ON_UPDATE_CASCADE,
|
|
@@ -909,6 +980,7 @@ from flowmaticdb.query.enums import ReferentialActionEnum
|
|
|
909
980
|
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. This holds without exception, including the exception classes and helper functions, which live in `_exceptions.py` and `_helpers.py` and are re-exported from `flowmaticdb`.
|
|
910
981
|
|
|
911
982
|
- `PsycopgAdapter`, `MySQLAdapter` — Import from `flowmaticdb.adapters`, NOT a submodule
|
|
983
|
+
- `PostgresArray` — Re-exported from the top-level package: `from flowmaticdb import PostgresArray` (it also lives in `flowmaticdb.query.expressions`)
|
|
912
984
|
- `PsycopgResult`, `MySQLResult` — Import from `flowmaticdb.result`, NOT a submodule
|
|
913
985
|
- `raw()`, `identifier()`, `alias()`, `expression()`, `sub_query()`, `current_timestamp()`, `now()` — Module-level functions, imported from `flowmaticdb`
|
|
914
986
|
- `snapshot_result()` — Import from `flowmaticdb.result`
|
|
@@ -237,6 +237,7 @@ db.create_table("users").if_not_exists() \
|
|
|
237
237
|
.integer("age") \
|
|
238
238
|
.boolean("active", default=True) \
|
|
239
239
|
.datetime("created_at") \
|
|
240
|
+
.json("preferences") \
|
|
240
241
|
.execute()
|
|
241
242
|
|
|
242
243
|
# Using raw column definitions
|
|
@@ -615,6 +616,7 @@ from flowmaticdb import raw, identifier, alias, expression, sub_query, current_t
|
|
|
615
616
|
| `sub_query(query, alias)` | `(SELECT ...) AS alias` | `sub_query(select_q, "sq")` |
|
|
616
617
|
| `current_timestamp()` | `CURRENT_TIMESTAMP` | `current_timestamp()` |
|
|
617
618
|
| `now()` | `datetime.now(UTC)` | `now()` |
|
|
619
|
+
| `PostgresArray(values)` | Bind a list as a PostgreSQL array instead of JSON | `PostgresArray([1, 2, 3])` |
|
|
618
620
|
|
|
619
621
|
```python
|
|
620
622
|
db.select(raw("COUNT(*) AS cnt")).table("users").execute()
|
|
@@ -701,6 +703,69 @@ except DatabaseError as e:
|
|
|
701
703
|
|
|
702
704
|
---
|
|
703
705
|
|
|
706
|
+
## Datetime and JSON Values
|
|
707
|
+
|
|
708
|
+
`datetime` objects and JSON documents are serialized on the way into the database and deserialized on the way back out, on every adapter.
|
|
709
|
+
|
|
710
|
+
```python
|
|
711
|
+
from datetime import datetime, timezone
|
|
712
|
+
|
|
713
|
+
db.create_table("events").if_not_exists() \
|
|
714
|
+
.identity("id") \
|
|
715
|
+
.datetime("happened_at") \
|
|
716
|
+
.json("payload") \
|
|
717
|
+
.execute()
|
|
718
|
+
|
|
719
|
+
db.insert("events").values({
|
|
720
|
+
"happened_at": datetime.now(timezone.utc),
|
|
721
|
+
"payload": {"kind": "signup", "tags": ["a", "b"]},
|
|
722
|
+
}).execute()
|
|
723
|
+
|
|
724
|
+
row = db.select("events").execute().fetch_dict()
|
|
725
|
+
row["happened_at"] # datetime
|
|
726
|
+
row["payload"] # dict
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
`TypeEnum.JSON` (the `.json()` column builder) maps to the best type the server has: `JSONB` on PostgreSQL ≥ 9.4, `JSON` on PostgreSQL ≥ 9.2, MySQL ≥ 5.7.8, MariaDB ≥ 10.2.7 and SQLite, and `TEXT` on anything older. `TypeEnum.DATETIME` maps to `TIMESTAMPTZ`, `DATETIME(6)` and `DATETIME` respectively.
|
|
730
|
+
|
|
731
|
+
Nested values `json` does not know are rendered rather than raising: `datetime`, `date` and `time` become ISO-8601 strings, `Decimal` becomes a string (a float would lose precision). A JSON column holding text that is not valid JSON is handed back as that text instead of failing the fetch.
|
|
732
|
+
|
|
733
|
+
How each driver is wired up:
|
|
734
|
+
|
|
735
|
+
| Driver | Datetime | JSON |
|
|
736
|
+
|--------|----------|------|
|
|
737
|
+
| psycopg | Native, both directions | Serialized on the way in; psycopg decodes `json`/`jsonb` on the way out |
|
|
738
|
+
| asyncpg | Bound natively, reconciled against the placeholder's declared type | `json`/`jsonb` codecs registered on connect, both directions |
|
|
739
|
+
| mysql.connector | Native, both directions | Serialized on the way in; `MySQLResult` decodes columns the server reports as `json` |
|
|
740
|
+
| sqlite3 | `DATETIME`/`TIMESTAMP`/`DATE` adapters and converters registered by `SQLiteAdapter` | `JSON`/`JSONB` adapters and converters |
|
|
741
|
+
|
|
742
|
+
SQLite stores only primitives, so `SQLiteAdapter` registers custom datatypes with the `sqlite3` module and opens its connections with `detect_types=sqlite3.PARSE_DECLTYPES`. Conversion is keyed off the column's *declared* type, so a `DATETIME` or `JSON` table column is converted while an expression (`count(*)`, a computed alias) has no declared type and is returned as-is. Datetimes are written as full ISO-8601, so microseconds and UTC offsets survive the round trip.
|
|
743
|
+
|
|
744
|
+
### PostgreSQL arrays — `PostgresArray`
|
|
745
|
+
|
|
746
|
+
A bare `list` is a JSON document on **every** dialect, PostgreSQL included. PostgreSQL also has a real array type, but nothing in the value itself says which of the two is meant, so the array reading is opt-in — wrap the value in `PostgresArray`:
|
|
747
|
+
|
|
748
|
+
```python
|
|
749
|
+
from flowmaticdb import PostgresArray
|
|
750
|
+
|
|
751
|
+
db.insert("rows").values({
|
|
752
|
+
"id": 1,
|
|
753
|
+
"actual_json_column": [1, 2, 3, 4],
|
|
754
|
+
"postgres_array_column": PostgresArray([5, 6, 7, 8]),
|
|
755
|
+
}).execute()
|
|
756
|
+
|
|
757
|
+
# Also works anywhere else a value is bound, e.g. the array containment operators
|
|
758
|
+
db.select("rows").where_operator("tags", "@>", PostgresArray(["a", "b"])).execute()
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
Both PostgreSQL drivers behave identically here: a bare list aimed at an array column is sent as JSON and rejected, rather than quietly being taken as an array. Element types are left to the driver, so `PostgresArray([datetime(...)])` binds as `timestamptz[]`, not `text[]`.
|
|
762
|
+
|
|
763
|
+
Dialects with no array type unwrap `PostgresArray` back to JSON, so a query written for PostgreSQL still runs against SQLite and MySQL.
|
|
764
|
+
|
|
765
|
+
Reading is unaffected — an array column always comes back as a plain `list`.
|
|
766
|
+
|
|
767
|
+
---
|
|
768
|
+
|
|
704
769
|
## Dialect-Specific Behavior
|
|
705
770
|
|
|
706
771
|
### PostgreSQL
|
|
@@ -715,7 +780,9 @@ except DatabaseError as e:
|
|
|
715
780
|
| Regex | ✅ | `regexp_like()` (≥ 15) or `~`/`!~` operators |
|
|
716
781
|
| `GENERATED BY DEFAULT AS IDENTITY` | ✅ | (≥ 17, or falls back to `SERIAL`) |
|
|
717
782
|
| Native boolean | ✅ | `BOOLEAN` type |
|
|
718
|
-
| Datetime | ✅ | Microsecond precision: `%Y-%m-%d %H:%M:%S.%f` |
|
|
783
|
+
| Datetime | ✅ | Microsecond precision: `%Y-%m-%d %H:%M:%S.%f`; `TypeEnum.DATETIME` → `TIMESTAMPTZ` |
|
|
784
|
+
| JSON | ✅ | `TypeEnum.JSON` → `JSONB` (≥ 9.4) or `JSON` (≥ 9.2); a bare `list`/`dict` is a document |
|
|
785
|
+
| Arrays | ✅ | Opt-in via `PostgresArray([...])` — see [PostgreSQL arrays](#postgresql-arrays--postgresarray) |
|
|
719
786
|
|
|
720
787
|
### SQLite
|
|
721
788
|
|
|
@@ -730,6 +797,8 @@ except DatabaseError as e:
|
|
|
730
797
|
| Named constraints | ❌ | Names stripped from constraints |
|
|
731
798
|
| Auto-increment | ✅ | `INTEGER PRIMARY KEY AUTOINCREMENT` |
|
|
732
799
|
| Case-insensitive LIKE | ✅ | Default SQLite behavior |
|
|
800
|
+
| Datetime | ✅ | Custom `DATETIME`/`TIMESTAMP`/`DATE` datatype via `sqlite3` adapters and converters |
|
|
801
|
+
| JSON | ✅ | Custom `JSON`/`JSONB` datatype via `sqlite3` adapters and converters |
|
|
733
802
|
|
|
734
803
|
### MySQL
|
|
735
804
|
|
|
@@ -739,6 +808,8 @@ except DatabaseError as e:
|
|
|
739
808
|
| `RETURNING` | ❌ | Not supported; emulation not implemented |
|
|
740
809
|
| Auto-increment | ✅ | `AUTO_INCREMENT` |
|
|
741
810
|
| Placeholders | ✅ | `?` → `%s` conversion for connector |
|
|
811
|
+
| Datetime | ✅ | `TypeEnum.DATETIME` → `DATETIME(size)`, fsp clamped to 6 |
|
|
812
|
+
| JSON | ✅ | `TypeEnum.JSON` → `JSON` (MySQL ≥ 5.7.8, MariaDB ≥ 10.2.7), else `TEXT` |
|
|
742
813
|
|
|
743
814
|
### General ANSI (SQLDialect base)
|
|
744
815
|
|
|
@@ -859,7 +930,7 @@ from flowmaticdb.query.enums import UnionEnum
|
|
|
859
930
|
# UNION, UNION ALL
|
|
860
931
|
|
|
861
932
|
from flowmaticdb.query.enums import TypeEnum
|
|
862
|
-
# BOOL, INT, FLOAT, STRING, DATETIME
|
|
933
|
+
# BOOL, INT, FLOAT, STRING, DATETIME, JSON
|
|
863
934
|
|
|
864
935
|
from flowmaticdb.query.enums import ReferentialActionEnum
|
|
865
936
|
# ON_UPDATE_NO_ACTION, ON_UPDATE_SET_NULL, ON_UPDATE_CASCADE,
|
|
@@ -873,6 +944,7 @@ from flowmaticdb.query.enums import ReferentialActionEnum
|
|
|
873
944
|
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. This holds without exception, including the exception classes and helper functions, which live in `_exceptions.py` and `_helpers.py` and are re-exported from `flowmaticdb`.
|
|
874
945
|
|
|
875
946
|
- `PsycopgAdapter`, `MySQLAdapter` — Import from `flowmaticdb.adapters`, NOT a submodule
|
|
947
|
+
- `PostgresArray` — Re-exported from the top-level package: `from flowmaticdb import PostgresArray` (it also lives in `flowmaticdb.query.expressions`)
|
|
876
948
|
- `PsycopgResult`, `MySQLResult` — Import from `flowmaticdb.result`, NOT a submodule
|
|
877
949
|
- `raw()`, `identifier()`, `alias()`, `expression()`, `sub_query()`, `current_timestamp()`, `now()` — Module-level functions, imported from `flowmaticdb`
|
|
878
950
|
- `snapshot_result()` — Import from `flowmaticdb.result`
|
|
@@ -11,11 +11,13 @@ from flowmaticdb._helpers import (
|
|
|
11
11
|
sub_query,
|
|
12
12
|
)
|
|
13
13
|
from flowmaticdb._query_with_params import QueryWithParams
|
|
14
|
+
from flowmaticdb.query.expressions import PostgresArray
|
|
14
15
|
|
|
15
16
|
__all__ = [
|
|
16
17
|
"AdapterError",
|
|
17
18
|
"DatabaseError",
|
|
18
19
|
"DriverError",
|
|
20
|
+
"PostgresArray",
|
|
19
21
|
"QueryError",
|
|
20
22
|
"QueryWithParams",
|
|
21
23
|
"QueryWithParamsError",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from datetime import time as time_of_day
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _encode_fallback(value: Any) -> Any:
|
|
11
|
+
"""Render the values a database document commonly carries but ``json`` does
|
|
12
|
+
not know: temporals as ISO-8601, decimals as strings (a float would silently
|
|
13
|
+
lose precision)."""
|
|
14
|
+
if isinstance(value, (datetime, date, time_of_day)):
|
|
15
|
+
return value.isoformat()
|
|
16
|
+
if isinstance(value, Decimal):
|
|
17
|
+
return str(value)
|
|
18
|
+
raise TypeError(f"object of type {type(value).__name__} is not JSON serializable")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def encode_json(value: Any) -> str:
|
|
22
|
+
return json.dumps(value, default=_encode_fallback)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def decode_json(value: Any) -> Any:
|
|
26
|
+
if isinstance(value, (bytes, bytearray)):
|
|
27
|
+
value = value.decode("utf-8")
|
|
28
|
+
if not isinstance(value, str):
|
|
29
|
+
return value
|
|
30
|
+
try:
|
|
31
|
+
return json.loads(value)
|
|
32
|
+
except json.JSONDecodeError:
|
|
33
|
+
# A JSON column can hold text written outside this library (or by a
|
|
34
|
+
# driver that already decoded it); hand it back rather than failing the
|
|
35
|
+
# whole fetch.
|
|
36
|
+
return value
|
|
@@ -10,8 +10,10 @@ from datetime import time as time_of_day
|
|
|
10
10
|
from typing import TYPE_CHECKING, Any, TypeVar
|
|
11
11
|
|
|
12
12
|
from flowmaticdb import AdapterError
|
|
13
|
+
from flowmaticdb._json import decode_json, encode_json
|
|
13
14
|
from flowmaticdb._query_with_params import REGEX_PATTERN
|
|
14
15
|
from flowmaticdb.adapters._base import AdapterABC
|
|
16
|
+
from flowmaticdb.query.expressions import PostgresArray
|
|
15
17
|
from flowmaticdb.result import AsyncpgResult, PsycopgResult, ResultABC
|
|
16
18
|
|
|
17
19
|
if TYPE_CHECKING:
|
|
@@ -47,6 +49,9 @@ def _placeholders_to_dollar_signs(query: str) -> str:
|
|
|
47
49
|
# Placeholder types PostgreSQL resolves to a string.
|
|
48
50
|
_TEXT_TYPE_OIDS = frozenset({18, 19, 25, 705, 1042, 1043})
|
|
49
51
|
|
|
52
|
+
# Placeholder types the json/jsonb codecs registered in _open() encode.
|
|
53
|
+
_JSON_TYPE_OIDS = frozenset({114, 3802})
|
|
54
|
+
|
|
50
55
|
|
|
51
56
|
def _cast_param(dialect: DialectABC, value: Any) -> Any:
|
|
52
57
|
# asyncpg binds in the binary protocol against the type PostgreSQL declared for
|
|
@@ -55,12 +60,18 @@ def _cast_param(dialect: DialectABC, value: Any) -> Any:
|
|
|
55
60
|
# reconciled against the real placeholder type in _adapt_params().
|
|
56
61
|
if isinstance(value, (datetime, date, time_of_day)):
|
|
57
62
|
return value
|
|
63
|
+
# Documents and arrays are left native for the same reason: whether a dict or
|
|
64
|
+
# list has to be rendered depends on the placeholder type, which is only
|
|
65
|
+
# known once the statement is prepared.
|
|
66
|
+
if isinstance(value, (dict, list, PostgresArray)):
|
|
67
|
+
return value
|
|
58
68
|
return dialect.cast_to_driver(value)
|
|
59
69
|
|
|
60
70
|
|
|
61
71
|
def _adapt_params(dialect: DialectABC, parameter_types: Sequence[Any], params: list[Any]) -> list[Any]:
|
|
62
|
-
"""Render temporal values bound to a text placeholder, using the
|
|
63
|
-
statement's parameter types — the only point where the target type
|
|
72
|
+
"""Render temporal and document values bound to a text placeholder, using the
|
|
73
|
+
prepared statement's parameter types — the only point where the target type
|
|
74
|
+
is known.
|
|
64
75
|
|
|
65
76
|
psycopg can hand PostgreSQL a datetime for a text column and let the server
|
|
66
77
|
render it; asyncpg encodes binary against the declared type and refuses, so
|
|
@@ -77,8 +88,20 @@ def _adapt_params(dialect: DialectABC, parameter_types: Sequence[Any], params: l
|
|
|
77
88
|
|
|
78
89
|
|
|
79
90
|
def _adapt_param(dialect: DialectABC, oid: int, value: Any) -> Any:
|
|
80
|
-
if
|
|
81
|
-
|
|
91
|
+
if isinstance(value, PostgresArray):
|
|
92
|
+
# Explicitly an array: asyncpg binds a native list as one.
|
|
93
|
+
return list(value.values)
|
|
94
|
+
|
|
95
|
+
if isinstance(value, (datetime, date, time_of_day)):
|
|
96
|
+
return dialect.cast_datetime(value) if oid in _TEXT_TYPE_OIDS else value
|
|
97
|
+
|
|
98
|
+
if isinstance(value, (dict, list)):
|
|
99
|
+
# A document reaches the json/jsonb codecs untouched and is rendered
|
|
100
|
+
# anywhere else. That also means a bare list bound to an array column is
|
|
101
|
+
# handed over as JSON and rejected, rather than quietly becoming an
|
|
102
|
+
# array -- the same outcome as psycopg, which cannot tell them apart.
|
|
103
|
+
return value if oid in _JSON_TYPE_OIDS else dialect.cast_json(value)
|
|
104
|
+
|
|
82
105
|
return value
|
|
83
106
|
|
|
84
107
|
|
|
@@ -345,7 +368,21 @@ class AsyncpgAdapter(AdapterABC):
|
|
|
345
368
|
async def _open(self, connect_options: dict[str, Any]) -> AsyncpgConnection:
|
|
346
369
|
import asyncpg
|
|
347
370
|
|
|
348
|
-
|
|
371
|
+
connection: AsyncpgConnection = await asyncpg.connect(**connect_options)
|
|
372
|
+
|
|
373
|
+
# asyncpg hands json/jsonb over as raw text unless a codec says otherwise.
|
|
374
|
+
# These make dicts and lists bind to a json column and come back decoded,
|
|
375
|
+
# matching what psycopg does out of the box.
|
|
376
|
+
for type_name in ("json", "jsonb"):
|
|
377
|
+
await connection.set_type_codec(
|
|
378
|
+
type_name,
|
|
379
|
+
encoder=encode_json,
|
|
380
|
+
decoder=decode_json,
|
|
381
|
+
schema="pg_catalog",
|
|
382
|
+
format="text",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return connection
|
|
349
386
|
|
|
350
387
|
async def _fetch(self, query: str) -> AsyncpgResult:
|
|
351
388
|
statement = await self._connection.prepare(query)
|
|
@@ -3,8 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
import sqlite3
|
|
4
4
|
import time
|
|
5
5
|
from collections.abc import Callable
|
|
6
|
+
from datetime import date, datetime
|
|
6
7
|
from typing import TYPE_CHECKING, Any
|
|
7
8
|
|
|
9
|
+
from flowmaticdb._json import decode_json, encode_json
|
|
8
10
|
from flowmaticdb.adapters._base import AdapterABC
|
|
9
11
|
from flowmaticdb.result import ResultABC, SQLite3Result
|
|
10
12
|
|
|
@@ -13,6 +15,73 @@ if TYPE_CHECKING:
|
|
|
13
15
|
from flowmaticdb.dialects import DialectABC
|
|
14
16
|
|
|
15
17
|
|
|
18
|
+
def _adapt_datetime(value: datetime) -> str:
|
|
19
|
+
"""ISO-8601 with a space separator: keeps microseconds and the UTC offset
|
|
20
|
+
(both of which the dialect's SQL-literal format drops) while staying
|
|
21
|
+
readable to SQLite's own date/time functions."""
|
|
22
|
+
return value.isoformat(sep=" ")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _adapt_date(value: date) -> str:
|
|
26
|
+
return value.isoformat()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _adapt_json(value: dict[str, Any] | list[Any]) -> str:
|
|
30
|
+
return encode_json(value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _convert_datetime(value: bytes) -> datetime | str:
|
|
34
|
+
text = value.decode("utf-8")
|
|
35
|
+
try:
|
|
36
|
+
return datetime.fromisoformat(text)
|
|
37
|
+
except ValueError:
|
|
38
|
+
# A DATETIME column can hold anything SQLite accepted -- an epoch
|
|
39
|
+
# number or a partial date written by another tool. Return it as text
|
|
40
|
+
# rather than failing the fetch.
|
|
41
|
+
return text
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _convert_date(value: bytes) -> date | str:
|
|
45
|
+
text = value.decode("utf-8")
|
|
46
|
+
try:
|
|
47
|
+
return date.fromisoformat(text)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _convert_json(value: bytes) -> Any:
|
|
53
|
+
return decode_json(value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _register_types() -> None:
|
|
57
|
+
"""Teach ``sqlite3`` the DATETIME/DATE and JSON column types SQLite itself
|
|
58
|
+
does not have, in both directions.
|
|
59
|
+
|
|
60
|
+
The ``sqlite3`` registry is a process-wide module singleton, so this is
|
|
61
|
+
global state -- but the converters only fire on connections opened with
|
|
62
|
+
``PARSE_DECLTYPES``, which is exactly the connections this adapter opens.
|
|
63
|
+
Registering is idempotent (each call overwrites the same registry entry),
|
|
64
|
+
so it runs per connect instead of at import time.
|
|
65
|
+
"""
|
|
66
|
+
sqlite3.register_adapter(datetime, _adapt_datetime)
|
|
67
|
+
sqlite3.register_adapter(date, _adapt_date)
|
|
68
|
+
sqlite3.register_adapter(dict, _adapt_json)
|
|
69
|
+
sqlite3.register_adapter(list, _adapt_json)
|
|
70
|
+
sqlite3.register_converter("DATETIME", _convert_datetime)
|
|
71
|
+
sqlite3.register_converter("TIMESTAMP", _convert_datetime)
|
|
72
|
+
sqlite3.register_converter("DATE", _convert_date)
|
|
73
|
+
sqlite3.register_converter("JSON", _convert_json)
|
|
74
|
+
sqlite3.register_converter("JSONB", _convert_json)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _cast_param(dialect: DialectABC, value: Any) -> Any:
|
|
78
|
+
"""Temporals and documents are left native so the registered ``sqlite3``
|
|
79
|
+
adapters above serialize them; everything else goes through the dialect."""
|
|
80
|
+
if isinstance(value, (datetime, date, dict, list)):
|
|
81
|
+
return value
|
|
82
|
+
return dialect.cast_to_driver(value)
|
|
83
|
+
|
|
84
|
+
|
|
16
85
|
class SQLiteAdapter(AdapterABC):
|
|
17
86
|
def __init__(
|
|
18
87
|
self,
|
|
@@ -32,14 +101,23 @@ class SQLiteAdapter(AdapterABC):
|
|
|
32
101
|
self._connect()
|
|
33
102
|
|
|
34
103
|
def _connect(self) -> None:
|
|
104
|
+
_register_types()
|
|
105
|
+
|
|
35
106
|
db_name = self._database_name
|
|
36
107
|
read_only = self._options.get("read_only", False)
|
|
37
108
|
|
|
109
|
+
# PARSE_DECLTYPES is what routes a column declared DATETIME or JSON
|
|
110
|
+
# through the converters registered above. Columns that are expressions
|
|
111
|
+
# rather than table columns have no declared type and stay untouched.
|
|
38
112
|
if read_only:
|
|
39
113
|
uri = f"file:{db_name}?mode=ro"
|
|
40
|
-
self._connection = sqlite3.connect(
|
|
114
|
+
self._connection = sqlite3.connect(
|
|
115
|
+
uri, uri=True, isolation_level=None, detect_types=sqlite3.PARSE_DECLTYPES
|
|
116
|
+
)
|
|
41
117
|
else:
|
|
42
|
-
self._connection = sqlite3.connect(
|
|
118
|
+
self._connection = sqlite3.connect(
|
|
119
|
+
db_name, isolation_level=None, detect_types=sqlite3.PARSE_DECLTYPES
|
|
120
|
+
)
|
|
43
121
|
|
|
44
122
|
self._connection.row_factory = sqlite3.Row
|
|
45
123
|
|
|
@@ -112,7 +190,7 @@ class SQLiteAdapter(AdapterABC):
|
|
|
112
190
|
) -> ResultABC:
|
|
113
191
|
query_with_params = query_with_params.percent_s_to_question_marks()
|
|
114
192
|
sql = query_with_params.query
|
|
115
|
-
params = [dialect
|
|
193
|
+
params = [_cast_param(dialect, param) for param in query_with_params.params]
|
|
116
194
|
|
|
117
195
|
start = time.time()
|
|
118
196
|
error: str | None = None
|
|
@@ -169,6 +169,10 @@ class DialectABC(ABC):
|
|
|
169
169
|
def cast_datetime(self, value: Any) -> str:
|
|
170
170
|
...
|
|
171
171
|
|
|
172
|
+
@abstractmethod
|
|
173
|
+
def cast_json(self, value: Any) -> str:
|
|
174
|
+
...
|
|
175
|
+
|
|
172
176
|
@abstractmethod
|
|
173
177
|
def cast_to_driver(self, value: Any) -> Any:
|
|
174
178
|
...
|
|
@@ -181,6 +185,10 @@ class DialectABC(ABC):
|
|
|
181
185
|
def parse_datetime(self, value: Any) -> Any:
|
|
182
186
|
...
|
|
183
187
|
|
|
188
|
+
@abstractmethod
|
|
189
|
+
def parse_json(self, value: Any) -> Any:
|
|
190
|
+
...
|
|
191
|
+
|
|
184
192
|
@abstractmethod
|
|
185
193
|
def type(self, type_enum: TypeEnum, bits: int | None = None) -> str:
|
|
186
194
|
...
|
|
@@ -51,6 +51,9 @@ class MySQLDialect(SQLDialect):
|
|
|
51
51
|
self.lateral = (not self._is_mariadb) and self._version >= 80014
|
|
52
52
|
self.on_conflict = True if self._is_mariadb else self._version >= 40100
|
|
53
53
|
self.returning = self._is_mariadb and self._version >= 100500
|
|
54
|
+
# MariaDB spells JSON as a LONGTEXT alias from 10.2.7; MySQL has a real
|
|
55
|
+
# native JSON type from 5.7.8. Older servers fall back to TEXT.
|
|
56
|
+
self.json = self._version >= 100207 if self._is_mariadb else self._version >= 50708
|
|
54
57
|
|
|
55
58
|
def begin_transaction(self, name: str | None = None) -> QueryWithParams:
|
|
56
59
|
return QueryWithParams(query="START TRANSACTION")
|
|
@@ -10,6 +10,7 @@ from flowmaticdb.dialects._sql_dialect import SQLDialect
|
|
|
10
10
|
from flowmaticdb.query import Condition, OnConflict
|
|
11
11
|
from flowmaticdb.query.ddl import Column
|
|
12
12
|
from flowmaticdb.query.enums import ConditionEnum, TypeEnum
|
|
13
|
+
from flowmaticdb.query.expressions import PostgresArray
|
|
13
14
|
|
|
14
15
|
_TZ_OFFSET_RE = re.compile(r"([+-]\d{2})$")
|
|
15
16
|
|
|
@@ -48,6 +49,8 @@ class PostgresqlDialect(SQLDialect):
|
|
|
48
49
|
self.on_conflict = v >= 90500
|
|
49
50
|
self.generated_by_default_as_identity = v >= 170000
|
|
50
51
|
self.returning = v >= 80200
|
|
52
|
+
self.json = v >= 90200
|
|
53
|
+
self.jsonb = v >= 90400
|
|
51
54
|
|
|
52
55
|
def _build_on_conflict(
|
|
53
56
|
self,
|
|
@@ -118,8 +121,26 @@ class PostgresqlDialect(SQLDialect):
|
|
|
118
121
|
if isinstance(value, bool):
|
|
119
122
|
return "TRUE" if value else "FALSE"
|
|
120
123
|
|
|
124
|
+
if isinstance(value, PostgresArray):
|
|
125
|
+
if not value.values:
|
|
126
|
+
return "'{}'"
|
|
127
|
+
items = ", ".join(self.cast_to_query(item) for item in value.values)
|
|
128
|
+
return f"ARRAY[{items}]"
|
|
129
|
+
|
|
121
130
|
return super().cast_to_query(value)
|
|
122
131
|
|
|
132
|
+
def cast_to_driver(self, value: Any) -> Any:
|
|
133
|
+
# PostgreSQL is the one supported engine with a real array type, and both
|
|
134
|
+
# drivers bind a Python list as one -- which is exactly why the array
|
|
135
|
+
# reading has to be asked for. A bare list stays a JSON document (base
|
|
136
|
+
# behaviour); only PostgresArray is unwrapped for the driver to bind as
|
|
137
|
+
# an array. Element types are left alone so the driver types them
|
|
138
|
+
# natively (a list of datetimes becomes timestamptz[], not text[]).
|
|
139
|
+
if isinstance(value, PostgresArray):
|
|
140
|
+
return list(value.values)
|
|
141
|
+
|
|
142
|
+
return super().cast_to_driver(value)
|
|
143
|
+
|
|
123
144
|
def cast_bool(self, value: bool) -> bool | int:
|
|
124
145
|
return value
|
|
125
146
|
|
|
@@ -150,4 +171,6 @@ class PostgresqlDialect(SQLDialect):
|
|
|
150
171
|
return "DOUBLE PRECISION" if size > 32 else "REAL"
|
|
151
172
|
if type_enum == TypeEnum.DATETIME:
|
|
152
173
|
return "TIMESTAMPTZ"
|
|
174
|
+
if type_enum == TypeEnum.JSON and self.jsonb:
|
|
175
|
+
return "JSONB"
|
|
153
176
|
return super().type(type_enum, bits)
|