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.
Files changed (123) hide show
  1. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/PKG-INFO +75 -3
  2. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/README.md +74 -2
  3. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/pyproject.toml +1 -1
  4. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/__init__.py +2 -0
  5. flowmaticdb-2.3.0/src/flowmaticdb/_json.py +36 -0
  6. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/adapters/_postgres.py +42 -5
  7. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/adapters/_sqlite.py +81 -3
  8. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/dialects/_base.py +8 -0
  9. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/dialects/_mysql.py +3 -0
  10. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/dialects/_postgres.py +23 -0
  11. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/dialects/_sql_dialect.py +21 -1
  12. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_ddl_mixins.py +4 -0
  13. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_type.py +1 -0
  14. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/__init__.py +3 -1
  15. flowmaticdb-2.3.0/src/flowmaticdb/query/expressions/_postgres_array.py +33 -0
  16. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/result/_mysql.py +25 -3
  17. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/result/_sqlite.py +11 -0
  18. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb.egg-info/PKG-INFO +75 -3
  19. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb.egg-info/SOURCES.txt +3 -0
  20. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_integration_mysql.py +61 -0
  21. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_integration_postgres.py +169 -4
  22. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_integration_sqlite.py +108 -0
  23. flowmaticdb-2.3.0/tests/test_json_and_datetime_types.py +134 -0
  24. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/setup.cfg +0 -0
  25. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/_exceptions.py +0 -0
  26. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/_helpers.py +0 -0
  27. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/_query_with_params.py +0 -0
  28. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/adapters/__init__.py +0 -0
  29. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/adapters/_base.py +0 -0
  30. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/adapters/_mysql.py +0 -0
  31. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/database/__init__.py +0 -0
  32. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/database/_abc.py +0 -0
  33. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/database/_database.py +0 -0
  34. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/database/_db.py +0 -0
  35. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/database/_table.py +0 -0
  36. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/dialects/__init__.py +0 -0
  37. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/dialects/_sqlite.py +0 -0
  38. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/migrations/__init__.py +0 -0
  39. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/migrations/_loader.py +0 -0
  40. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/migrations/_migration_abc.py +0 -0
  41. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/migrations/_migrator.py +0 -0
  42. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/migrations/_template.py +0 -0
  43. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/py.typed +0 -0
  44. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/__init__.py +0 -0
  45. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_alter_table.py +0 -0
  46. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_condition.py +0 -0
  47. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_condition_group.py +0 -0
  48. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_condition_mixin.py +0 -0
  49. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_create_table.py +0 -0
  50. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_delete.py +0 -0
  51. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_drop_table.py +0 -0
  52. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_having_mixin.py +0 -0
  53. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_insert.py +0 -0
  54. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_join.py +0 -0
  55. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_joins_mixin.py +0 -0
  56. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_on_conflict.py +0 -0
  57. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_order_by.py +0 -0
  58. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_query.py +0 -0
  59. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_select.py +0 -0
  60. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_simple_mixins.py +0 -0
  61. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_union.py +0 -0
  62. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_update.py +0 -0
  63. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/_where_mixin.py +0 -0
  64. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/__init__.py +0 -0
  65. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_add_column.py +0 -0
  66. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_add_foreign_key_constraint.py +0 -0
  67. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_add_primary_keys.py +0 -0
  68. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_add_unique_constraint.py +0 -0
  69. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_alter.py +0 -0
  70. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_alter_column.py +0 -0
  71. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_column.py +0 -0
  72. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_constraint.py +0 -0
  73. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_drop_column.py +0 -0
  74. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_drop_constraint.py +0 -0
  75. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_foreign_key_constraint.py +0 -0
  76. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_raw_alter.py +0 -0
  77. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_raw_constraint.py +0 -0
  78. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_rename_column.py +0 -0
  79. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/ddl/_unique_constraint.py +0 -0
  80. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/__init__.py +0 -0
  81. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_chain.py +0 -0
  82. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_condition.py +0 -0
  83. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_join.py +0 -0
  84. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_order_by_dir.py +0 -0
  85. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_referential_action.py +0 -0
  86. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/enums/_union.py +0 -0
  87. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_alias.py +0 -0
  88. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_current_timestamp.py +0 -0
  89. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_excluded.py +0 -0
  90. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_expression.py +0 -0
  91. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_identifier.py +0 -0
  92. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_raw.py +0 -0
  93. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_sql.py +0 -0
  94. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/query/expressions/_sub_query.py +0 -0
  95. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/result/__init__.py +0 -0
  96. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/result/_base.py +0 -0
  97. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/result/_postgres.py +0 -0
  98. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb/result/_result.py +0 -0
  99. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb.egg-info/dependency_links.txt +0 -0
  100. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb.egg-info/requires.txt +0 -0
  101. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/src/flowmaticdb.egg-info/top_level.txt +0 -0
  102. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_adapters_port.py +0 -0
  103. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_alter_table_query.py +0 -0
  104. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_conditions.py +0 -0
  105. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_conditions_port.py +0 -0
  106. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_database_port.py +0 -0
  107. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_ddl_port.py +0 -0
  108. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_delete_query.py +0 -0
  109. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_dialect_mysql.py +0 -0
  110. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_dialect_postgres.py +0 -0
  111. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_dialect_sql.py +0 -0
  112. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_dialect_sql_port.py +0 -0
  113. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_dialect_sqlite.py +0 -0
  114. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_dialect_subclasses_port.py +0 -0
  115. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_expressions.py +0 -0
  116. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_helpers_port.py +0 -0
  117. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_insert_query.py +0 -0
  118. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_joins.py +0 -0
  119. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_query_core_port.py +0 -0
  120. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_query_with_params.py +0 -0
  121. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_result_abstract.py +0 -0
  122. {flowmaticdb-2.2.2 → flowmaticdb-2.3.0}/tests/test_select_query.py +0 -0
  123. {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.2.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`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "flowmaticdb"
7
- version = "2.2.2"
7
+ version = "2.3.0"
8
8
  description = "A Python database abstraction layer supporting PostgreSQL, SQLite, and MySQL."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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 prepared
63
- statement's parameter types — the only point where the target type is known.
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 oid in _TEXT_TYPE_OIDS and isinstance(value, (datetime, date, time_of_day)):
81
- return dialect.cast_datetime(value)
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
- return await asyncpg.connect(**connect_options)
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(uri, uri=True, isolation_level=None)
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(db_name, isolation_level=None)
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.cast_to_driver(param) for param in query_with_params.params]
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)