flowmaticdb 2.2.0__tar.gz → 2.2.2__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 (122) hide show
  1. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/PKG-INFO +25 -13
  2. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/README.md +20 -12
  3. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/pyproject.toml +6 -1
  4. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/adapters/_postgres.py +1 -1
  5. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/dialects/_postgres.py +23 -2
  6. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_join.py +3 -3
  7. flowmaticdb-2.2.2/src/flowmaticdb/query/_joins_mixin.py +106 -0
  8. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb.egg-info/PKG-INFO +25 -13
  9. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb.egg-info/requires.txt +5 -0
  10. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_conditions_port.py +20 -17
  11. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_dialect_postgres.py +1 -1
  12. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_integration_mysql.py +5 -8
  13. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_integration_postgres.py +3 -3
  14. flowmaticdb-2.2.2/tests/test_joins.py +80 -0
  15. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_select_query.py +1 -2
  16. flowmaticdb-2.2.0/src/flowmaticdb/query/_joins_mixin.py +0 -114
  17. flowmaticdb-2.2.0/tests/test_joins.py +0 -37
  18. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/setup.cfg +0 -0
  19. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/__init__.py +0 -0
  20. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/_exceptions.py +0 -0
  21. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/_helpers.py +0 -0
  22. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/_query_with_params.py +0 -0
  23. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/adapters/__init__.py +0 -0
  24. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/adapters/_base.py +0 -0
  25. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/adapters/_mysql.py +0 -0
  26. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/adapters/_sqlite.py +0 -0
  27. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/database/__init__.py +0 -0
  28. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/database/_abc.py +0 -0
  29. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/database/_database.py +0 -0
  30. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/database/_db.py +0 -0
  31. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/database/_table.py +0 -0
  32. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/dialects/__init__.py +0 -0
  33. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/dialects/_base.py +0 -0
  34. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/dialects/_mysql.py +0 -0
  35. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/dialects/_sql_dialect.py +0 -0
  36. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/dialects/_sqlite.py +0 -0
  37. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/migrations/__init__.py +0 -0
  38. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/migrations/_loader.py +0 -0
  39. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/migrations/_migration_abc.py +0 -0
  40. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/migrations/_migrator.py +0 -0
  41. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/migrations/_template.py +0 -0
  42. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/py.typed +0 -0
  43. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/__init__.py +0 -0
  44. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_alter_table.py +0 -0
  45. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_condition.py +0 -0
  46. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_condition_group.py +0 -0
  47. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_condition_mixin.py +0 -0
  48. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_create_table.py +0 -0
  49. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_ddl_mixins.py +0 -0
  50. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_delete.py +0 -0
  51. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_drop_table.py +0 -0
  52. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_having_mixin.py +0 -0
  53. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_insert.py +0 -0
  54. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_on_conflict.py +0 -0
  55. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_order_by.py +0 -0
  56. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_query.py +0 -0
  57. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_select.py +0 -0
  58. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_simple_mixins.py +0 -0
  59. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_union.py +0 -0
  60. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_update.py +0 -0
  61. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/_where_mixin.py +0 -0
  62. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/__init__.py +0 -0
  63. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_add_column.py +0 -0
  64. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_add_foreign_key_constraint.py +0 -0
  65. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_add_primary_keys.py +0 -0
  66. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_add_unique_constraint.py +0 -0
  67. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_alter.py +0 -0
  68. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_alter_column.py +0 -0
  69. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_column.py +0 -0
  70. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_constraint.py +0 -0
  71. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_drop_column.py +0 -0
  72. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_drop_constraint.py +0 -0
  73. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_foreign_key_constraint.py +0 -0
  74. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_raw_alter.py +0 -0
  75. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_raw_constraint.py +0 -0
  76. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_rename_column.py +0 -0
  77. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/ddl/_unique_constraint.py +0 -0
  78. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/enums/__init__.py +0 -0
  79. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/enums/_chain.py +0 -0
  80. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/enums/_condition.py +0 -0
  81. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/enums/_join.py +0 -0
  82. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/enums/_order_by_dir.py +0 -0
  83. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/enums/_referential_action.py +0 -0
  84. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/enums/_type.py +0 -0
  85. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/enums/_union.py +0 -0
  86. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/expressions/__init__.py +0 -0
  87. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/expressions/_alias.py +0 -0
  88. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/expressions/_current_timestamp.py +0 -0
  89. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/expressions/_excluded.py +0 -0
  90. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/expressions/_expression.py +0 -0
  91. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/expressions/_identifier.py +0 -0
  92. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/expressions/_raw.py +0 -0
  93. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/expressions/_sql.py +0 -0
  94. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/query/expressions/_sub_query.py +0 -0
  95. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/result/__init__.py +0 -0
  96. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/result/_base.py +0 -0
  97. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/result/_mysql.py +0 -0
  98. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/result/_postgres.py +0 -0
  99. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/result/_result.py +0 -0
  100. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb/result/_sqlite.py +0 -0
  101. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb.egg-info/SOURCES.txt +0 -0
  102. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb.egg-info/dependency_links.txt +0 -0
  103. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/src/flowmaticdb.egg-info/top_level.txt +0 -0
  104. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_adapters_port.py +0 -0
  105. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_alter_table_query.py +0 -0
  106. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_conditions.py +0 -0
  107. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_database_port.py +0 -0
  108. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_ddl_port.py +0 -0
  109. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_delete_query.py +0 -0
  110. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_dialect_mysql.py +0 -0
  111. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_dialect_sql.py +0 -0
  112. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_dialect_sql_port.py +0 -0
  113. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_dialect_sqlite.py +0 -0
  114. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_dialect_subclasses_port.py +0 -0
  115. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_expressions.py +0 -0
  116. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_helpers_port.py +0 -0
  117. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_insert_query.py +0 -0
  118. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_integration_sqlite.py +0 -0
  119. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_query_core_port.py +0 -0
  120. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_query_with_params.py +0 -0
  121. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/tests/test_result_abstract.py +0 -0
  122. {flowmaticdb-2.2.0 → flowmaticdb-2.2.2}/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.0
3
+ Version: 2.2.2
4
4
  Summary: A Python database abstraction layer supporting PostgreSQL, SQLite, and MySQL.
5
5
  Author: Flowmatic, UniForceMusic
6
6
  License: MIT
@@ -29,6 +29,10 @@ Provides-Extra: dev
29
29
  Requires-Dist: pytest>=7.0; extra == "dev"
30
30
  Requires-Dist: mypy>=1.0; extra == "dev"
31
31
  Requires-Dist: ruff>=0.1; extra == "dev"
32
+ Provides-Extra: all
33
+ Requires-Dist: asyncpg>=0.29; extra == "all"
34
+ Requires-Dist: mysql-connector-python>=9.0; extra == "all"
35
+ Requires-Dist: psycopg[binary]>=3.1; extra == "all"
32
36
 
33
37
  # Transparency
34
38
  This package is a port of [PHP Sentience Database](https://github.com/Sentience-Framework/database)
@@ -433,21 +437,26 @@ from flowmaticdb import raw, identifier
433
437
 
434
438
  query = db.select("users").columns(["users.id", "posts.title"])
435
439
 
436
- # INNER JOIN with ON conditions
437
- join = query.inner_join("posts", "p") # Returns Join object
438
- join.on(["users", "id"], ["p", "user_id"]) # ON users.id = p.user_id
439
- join.or_on(["p", "status"], ["'published'"]) # OR p.status = 'published'
440
+ # INNER JOIN with ON conditions — the callback receives the Join,
441
+ # every join method returns the query so you can keep chaining
442
+ query.inner_join_table(
443
+ "posts",
444
+ lambda join: join
445
+ .on(["users", "id"], ["p", "user_id"]) # ON users.id = p.user_id
446
+ .or_on(["p", "status"], ["'published'"]), # OR p.status = 'published'
447
+ "p",
448
+ )
440
449
 
441
450
  # LEFT JOIN
442
- query.left_join("comments", "c").on(["p", "id"], ["c", "post_id"])
451
+ query.left_join_table("comments", lambda join: join.on(["p", "id"], ["c", "post_id"]), "c")
443
452
 
444
- # CROSS JOIN
453
+ # CROSS JOIN (never takes ON conditions)
445
454
  query.cross_join("sessions")
446
455
 
447
456
  # LATERAL joins
448
- query.left_join_lateral(sub_query, "sq")
449
- query.inner_join_lateral(sub_query, "sq")
450
- query.cross_join_lateral(sub_query, "sq")
457
+ query.left_join_lateral_sub_query(sub_query, "sq")
458
+ query.inner_join_lateral_sub_query(sub_query, "sq")
459
+ query.cross_join_lateral_sub_query(sub_query, "sq")
451
460
 
452
461
  # Raw join SQL (e.g. for aggregates)
453
462
  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"))
@@ -458,9 +467,12 @@ query.join(raw("LEFT JOIN (SELECT user_id, count(*) AS cnt FROM orders GROUP BY
458
467
  `Join` objects support all the same condition methods as WHERE:
459
468
 
460
469
  ```python
461
- join = query.inner_join("orders")
462
- join.where_equals(["orders", "user_id"], ["users", "id"])
463
- join.where_greater_than("orders.total", 100)
470
+ query.inner_join(
471
+ "orders",
472
+ lambda join: join
473
+ .where_equals(["orders", "user_id"], ["users", "id"])
474
+ .where_greater_than("orders.total", 100),
475
+ )
464
476
  ```
465
477
 
466
478
  ---
@@ -401,21 +401,26 @@ from flowmaticdb import raw, identifier
401
401
 
402
402
  query = db.select("users").columns(["users.id", "posts.title"])
403
403
 
404
- # INNER JOIN with ON conditions
405
- join = query.inner_join("posts", "p") # Returns Join object
406
- join.on(["users", "id"], ["p", "user_id"]) # ON users.id = p.user_id
407
- join.or_on(["p", "status"], ["'published'"]) # OR p.status = 'published'
404
+ # INNER JOIN with ON conditions — the callback receives the Join,
405
+ # every join method returns the query so you can keep chaining
406
+ query.inner_join_table(
407
+ "posts",
408
+ lambda join: join
409
+ .on(["users", "id"], ["p", "user_id"]) # ON users.id = p.user_id
410
+ .or_on(["p", "status"], ["'published'"]), # OR p.status = 'published'
411
+ "p",
412
+ )
408
413
 
409
414
  # LEFT JOIN
410
- query.left_join("comments", "c").on(["p", "id"], ["c", "post_id"])
415
+ query.left_join_table("comments", lambda join: join.on(["p", "id"], ["c", "post_id"]), "c")
411
416
 
412
- # CROSS JOIN
417
+ # CROSS JOIN (never takes ON conditions)
413
418
  query.cross_join("sessions")
414
419
 
415
420
  # LATERAL joins
416
- query.left_join_lateral(sub_query, "sq")
417
- query.inner_join_lateral(sub_query, "sq")
418
- query.cross_join_lateral(sub_query, "sq")
421
+ query.left_join_lateral_sub_query(sub_query, "sq")
422
+ query.inner_join_lateral_sub_query(sub_query, "sq")
423
+ query.cross_join_lateral_sub_query(sub_query, "sq")
419
424
 
420
425
  # Raw join SQL (e.g. for aggregates)
421
426
  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"))
@@ -426,9 +431,12 @@ query.join(raw("LEFT JOIN (SELECT user_id, count(*) AS cnt FROM orders GROUP BY
426
431
  `Join` objects support all the same condition methods as WHERE:
427
432
 
428
433
  ```python
429
- join = query.inner_join("orders")
430
- join.where_equals(["orders", "user_id"], ["users", "id"])
431
- join.where_greater_than("orders.total", 100)
434
+ query.inner_join(
435
+ "orders",
436
+ lambda join: join
437
+ .where_equals(["orders", "user_id"], ["users", "id"])
438
+ .where_greater_than("orders.total", 100),
439
+ )
432
440
  ```
433
441
 
434
442
  ---
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "flowmaticdb"
7
- version = "2.2.0"
7
+ version = "2.2.2"
8
8
  description = "A Python database abstraction layer supporting PostgreSQL, SQLite, and MySQL."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -50,6 +50,11 @@ dev = [
50
50
  "mypy>=1.0",
51
51
  "ruff>=0.1",
52
52
  ]
53
+ all = [
54
+ "asyncpg>=0.29",
55
+ "mysql-connector-python>=9.0",
56
+ "psycopg[binary]>=3.1",
57
+ ]
53
58
 
54
59
  [tool.setuptools.packages.find]
55
60
  where = ["src"]
@@ -9,7 +9,7 @@ from datetime import date, datetime
9
9
  from datetime import time as time_of_day
10
10
  from typing import TYPE_CHECKING, Any, TypeVar
11
11
 
12
- from flowmaticdb._exceptions import AdapterError
12
+ from flowmaticdb import AdapterError
13
13
  from flowmaticdb._query_with_params import REGEX_PATTERN
14
14
  from flowmaticdb.adapters._base import AdapterABC
15
15
  from flowmaticdb.result import AsyncpgResult, PsycopgResult, ResultABC
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import dataclasses
4
4
  import re
5
5
  from collections.abc import Mapping
6
+ from datetime import UTC, datetime
6
7
  from typing import Any, ClassVar
7
8
 
8
9
  from flowmaticdb.dialects._sql_dialect import SQLDialect
@@ -12,6 +13,10 @@ from flowmaticdb.query.enums import ConditionEnum, TypeEnum
12
13
 
13
14
  _TZ_OFFSET_RE = re.compile(r"([+-]\d{2})$")
14
15
 
16
+ # TIMESTAMP WITHOUT TIME ZONE columns and pre-timestamptz data still come back
17
+ # without an offset; those are read as UTC.
18
+ _NAIVE_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f"
19
+
15
20
 
16
21
  class PostgresqlDialect(SQLDialect):
17
22
  escape_chars: ClassVar[Mapping[str, str]] = {
@@ -33,7 +38,7 @@ class PostgresqlDialect(SQLDialect):
33
38
  self.on_conflict = True
34
39
  self.returning = True
35
40
  self.lateral = True
36
- self.datetime_format = "%Y-%m-%d %H:%M:%S.%f"
41
+ self.datetime_format = "%Y-%m-%d %H:%M:%S.%f%z"
37
42
  self._version_gate()
38
43
 
39
44
  def _version_gate(self) -> None:
@@ -118,9 +123,25 @@ class PostgresqlDialect(SQLDialect):
118
123
  def cast_bool(self, value: bool) -> bool | int:
119
124
  return value
120
125
 
126
+ def cast_datetime(self, value: Any) -> str:
127
+ # %z renders nothing for a naive datetime, which would defeat the point
128
+ # of a timestamptz column -- read those as UTC, matching how naive
129
+ # strings are parsed back.
130
+ if isinstance(value, datetime) and value.tzinfo is None:
131
+ value = value.replace(tzinfo=UTC)
132
+ return super().cast_datetime(value)
133
+
121
134
  def parse_datetime(self, value: Any) -> Any:
122
135
  if isinstance(value, str):
123
136
  value = _TZ_OFFSET_RE.sub(r"\1:00", value)
137
+ try:
138
+ return datetime.strptime(value, self.datetime_format)
139
+ except ValueError:
140
+ pass
141
+ try:
142
+ return datetime.strptime(value, _NAIVE_DATETIME_FORMAT).replace(tzinfo=UTC)
143
+ except ValueError:
144
+ pass
124
145
  return super().parse_datetime(value)
125
146
 
126
147
  def type(self, type_enum: TypeEnum, bits: int | None = None) -> str:
@@ -128,5 +149,5 @@ class PostgresqlDialect(SQLDialect):
128
149
  if type_enum == TypeEnum.FLOAT:
129
150
  return "DOUBLE PRECISION" if size > 32 else "REAL"
130
151
  if type_enum == TypeEnum.DATETIME:
131
- return "TIMESTAMP"
152
+ return "TIMESTAMPTZ"
132
153
  return super().type(type_enum, bits)
@@ -8,7 +8,7 @@ from flowmaticdb.query._condition import Condition
8
8
  from flowmaticdb.query._condition_group import ConditionGroupABC
9
9
  from flowmaticdb.query._condition_mixin import ConditionMixin
10
10
  from flowmaticdb.query.enums import ChainEnum, JoinEnum
11
- from flowmaticdb.query.expressions import SqlABC
11
+ from flowmaticdb.query.expressions import SqlABC, Identifier
12
12
 
13
13
  if TYPE_CHECKING:
14
14
  from flowmaticdb.query._select import SelectQuery
@@ -264,9 +264,9 @@ class Join(ConditionMixin):
264
264
  return self
265
265
 
266
266
  def on(self, left: list[str], right: list[str]) -> Self:
267
- self._equals(self.conditions, left, right, cast=True)
267
+ self._equals(self.conditions, left, Identifier(right))
268
268
  return self
269
269
 
270
270
  def or_on(self, left: list[str], right: list[str]) -> Self:
271
- self._equals(self.conditions, left, right, cast=True, chain=ChainEnum.OR)
271
+ self._equals(self.conditions, left, Identifier(right), chain=ChainEnum.OR)
272
272
  return self
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import TYPE_CHECKING, Any, Self
5
+
6
+ from flowmaticdb.query._join import Join
7
+ from flowmaticdb.query.enums import JoinEnum
8
+ from flowmaticdb.query.expressions import Alias, SqlABC, SubQuery
9
+
10
+ if TYPE_CHECKING:
11
+ from flowmaticdb.query._select import SelectQuery
12
+
13
+
14
+ class JoinsMixin:
15
+ joins: list[Any]
16
+
17
+ def left_join(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None) -> Self:
18
+ return self._add_join(JoinEnum.LEFT_JOIN, table, on)
19
+
20
+ def left_join_table(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None, alias: str | None = None) -> Self:
21
+ return self.left_join(Alias(table, alias) if alias else table, on)
22
+
23
+ def left_join_sub_query(self, query: SelectQuery, alias: str, on: Callable[[Join], Join | None] | None = None) -> Self:
24
+ return self.left_join(SubQuery(query, alias), on)
25
+
26
+ def left_join_lateral(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None) -> Self:
27
+ return self._add_join(JoinEnum.LEFT_JOIN_LATERAL, table, on)
28
+
29
+ def left_join_lateral_table(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None, alias: str | None = None) -> Self:
30
+ return self.left_join_lateral(Alias(table, alias) if alias else table, on)
31
+
32
+ def left_join_lateral_sub_query(self, query: SelectQuery, alias: str, on: Callable[[Join], Join | None] | None = None) -> Self:
33
+ return self.left_join_lateral(SubQuery(query, alias), on)
34
+
35
+ def inner_join(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None) -> Self:
36
+ return self._add_join(JoinEnum.INNER_JOIN, table, on)
37
+
38
+ def inner_join_table(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None, alias: str | None = None) -> Self:
39
+ return self.inner_join(Alias(table, alias) if alias else table, on)
40
+
41
+ def inner_join_sub_query(self, query: SelectQuery, alias: str, on: Callable[[Join], Join | None] | None = None) -> Self:
42
+ return self.inner_join(SubQuery(query, alias), on)
43
+
44
+ def inner_join_lateral(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None) -> Self:
45
+ return self._add_join(JoinEnum.INNER_JOIN_LATERAL, table, on)
46
+
47
+ def inner_join_lateral_table(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None, alias: str | None = None) -> Self:
48
+ return self.inner_join_lateral(Alias(table, alias) if alias else table, on)
49
+
50
+ def inner_join_lateral_sub_query(self, query: SelectQuery, alias: str, on: Callable[[Join], Join | None] | None = None) -> Self:
51
+ return self.inner_join_lateral(SubQuery(query, alias), on)
52
+
53
+ def cross_join(self, table: str | list[str] | SqlABC) -> Self:
54
+ return self._add_join(JoinEnum.CROSS_JOIN, table, None)
55
+
56
+ def cross_join_table(self, table: str | list[str] | SqlABC, alias: str | None = None) -> Self:
57
+ return self.cross_join(Alias(table, alias) if alias else table)
58
+
59
+ def cross_join_sub_query(self, query: SelectQuery, alias: str) -> Self:
60
+ return self.cross_join(SubQuery(query, alias))
61
+
62
+ def cross_join_lateral(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None) -> Self:
63
+ return self._add_join(JoinEnum.CROSS_JOIN_LATERAL, table, on)
64
+
65
+ def cross_join_lateral_table(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None, alias: str | None = None) -> Self:
66
+ return self.cross_join_lateral(Alias(table, alias) if alias else table, on)
67
+
68
+ def cross_join_lateral_sub_query(self, query: SelectQuery, alias: str, on: Callable[[Join], Join | None] | None = None) -> Self:
69
+ return self.cross_join_lateral(SubQuery(query, alias), on)
70
+
71
+ def outer_apply(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None) -> Self:
72
+ return self._add_join(JoinEnum.LEFT_JOIN_LATERAL, table, on)
73
+
74
+ def outer_apply_table(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None, alias: str | None = None) -> Self:
75
+ return self.outer_apply(Alias(table, alias) if alias else table, on)
76
+
77
+ def outer_apply_sub_query(self, query: SelectQuery, alias: str, on: Callable[[Join], Join | None] | None = None) -> Self:
78
+ return self.outer_apply(SubQuery(query, alias), on)
79
+
80
+ def cross_apply(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None) -> Self:
81
+ return self._add_join(JoinEnum.INNER_JOIN_LATERAL, table, on)
82
+
83
+ def cross_apply_table(self, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None = None, alias: str | None = None) -> Self:
84
+ return self.cross_apply(Alias(table, alias) if alias else table, on)
85
+
86
+ def cross_apply_sub_query(self, query: SelectQuery, alias: str, on: Callable[[Join], Join | None] | None = None) -> Self:
87
+ return self.cross_apply(SubQuery(query, alias), on)
88
+
89
+ def join(self, sql: Any) -> Self:
90
+ self.joins.append(sql)
91
+ return self
92
+
93
+ def _add_join(self, join_type: JoinEnum, table: str | list[str] | SqlABC, on: Callable[[Join], Join | None] | None) -> Self:
94
+ join: Any = Join(join=join_type, table=table)
95
+
96
+ if on:
97
+ returned = on(join)
98
+ if returned is not None:
99
+ join = returned
100
+
101
+ if not isinstance(join, Join):
102
+ return self
103
+
104
+ self.joins.append(join)
105
+
106
+ return self
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowmaticdb
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: A Python database abstraction layer supporting PostgreSQL, SQLite, and MySQL.
5
5
  Author: Flowmatic, UniForceMusic
6
6
  License: MIT
@@ -29,6 +29,10 @@ Provides-Extra: dev
29
29
  Requires-Dist: pytest>=7.0; extra == "dev"
30
30
  Requires-Dist: mypy>=1.0; extra == "dev"
31
31
  Requires-Dist: ruff>=0.1; extra == "dev"
32
+ Provides-Extra: all
33
+ Requires-Dist: asyncpg>=0.29; extra == "all"
34
+ Requires-Dist: mysql-connector-python>=9.0; extra == "all"
35
+ Requires-Dist: psycopg[binary]>=3.1; extra == "all"
32
36
 
33
37
  # Transparency
34
38
  This package is a port of [PHP Sentience Database](https://github.com/Sentience-Framework/database)
@@ -433,21 +437,26 @@ from flowmaticdb import raw, identifier
433
437
 
434
438
  query = db.select("users").columns(["users.id", "posts.title"])
435
439
 
436
- # INNER JOIN with ON conditions
437
- join = query.inner_join("posts", "p") # Returns Join object
438
- join.on(["users", "id"], ["p", "user_id"]) # ON users.id = p.user_id
439
- join.or_on(["p", "status"], ["'published'"]) # OR p.status = 'published'
440
+ # INNER JOIN with ON conditions — the callback receives the Join,
441
+ # every join method returns the query so you can keep chaining
442
+ query.inner_join_table(
443
+ "posts",
444
+ lambda join: join
445
+ .on(["users", "id"], ["p", "user_id"]) # ON users.id = p.user_id
446
+ .or_on(["p", "status"], ["'published'"]), # OR p.status = 'published'
447
+ "p",
448
+ )
440
449
 
441
450
  # LEFT JOIN
442
- query.left_join("comments", "c").on(["p", "id"], ["c", "post_id"])
451
+ query.left_join_table("comments", lambda join: join.on(["p", "id"], ["c", "post_id"]), "c")
443
452
 
444
- # CROSS JOIN
453
+ # CROSS JOIN (never takes ON conditions)
445
454
  query.cross_join("sessions")
446
455
 
447
456
  # LATERAL joins
448
- query.left_join_lateral(sub_query, "sq")
449
- query.inner_join_lateral(sub_query, "sq")
450
- query.cross_join_lateral(sub_query, "sq")
457
+ query.left_join_lateral_sub_query(sub_query, "sq")
458
+ query.inner_join_lateral_sub_query(sub_query, "sq")
459
+ query.cross_join_lateral_sub_query(sub_query, "sq")
451
460
 
452
461
  # Raw join SQL (e.g. for aggregates)
453
462
  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"))
@@ -458,9 +467,12 @@ query.join(raw("LEFT JOIN (SELECT user_id, count(*) AS cnt FROM orders GROUP BY
458
467
  `Join` objects support all the same condition methods as WHERE:
459
468
 
460
469
  ```python
461
- join = query.inner_join("orders")
462
- join.where_equals(["orders", "user_id"], ["users", "id"])
463
- join.where_greater_than("orders.total", 100)
470
+ query.inner_join(
471
+ "orders",
472
+ lambda join: join
473
+ .where_equals(["orders", "user_id"], ["users", "id"])
474
+ .where_greater_than("orders.total", 100),
475
+ )
464
476
  ```
465
477
 
466
478
  ---
@@ -1,4 +1,9 @@
1
1
 
2
+ [all]
3
+ asyncpg>=0.29
4
+ mysql-connector-python>=9.0
5
+ psycopg[binary]>=3.1
6
+
2
7
  [asyncpg]
3
8
  asyncpg>=0.29
4
9
 
@@ -148,42 +148,45 @@ def test_join_or_where_not_empty_chains_with_or_not_and() -> None:
148
148
 
149
149
  def test_select_has_full_join_api(sql_dialect: SQLDialect, mock_db) -> None:
150
150
  q = SelectQuery(sql_dialect, "users", database=mock_db)
151
- assert isinstance(q.inner_join_table("posts"), Join)
152
- assert isinstance(q.cross_join_table("posts"), Join)
153
- assert isinstance(q.left_join_lateral("posts"), Join)
154
- assert isinstance(q.inner_join_lateral("posts"), Join)
155
- assert isinstance(q.cross_join_lateral("posts"), Join)
156
- assert isinstance(q.outer_apply("posts"), Join)
157
- assert isinstance(q.cross_apply("posts"), Join)
151
+ assert q.inner_join_table("posts") is q
152
+ assert q.cross_join_table("posts") is q
153
+ assert q.left_join_lateral("posts") is q
154
+ assert q.inner_join_lateral("posts") is q
155
+ assert q.cross_join_lateral("posts") is q
156
+ assert q.outer_apply("posts") is q
157
+ assert q.cross_apply("posts") is q
158
+ assert len(q.joins) == 7
159
+ assert all(isinstance(j, Join) for j in q.joins)
158
160
 
159
161
 
160
162
  def test_outer_apply_maps_to_left_join_lateral(sql_dialect: SQLDialect, mock_db) -> None:
161
163
  q = SelectQuery(sql_dialect, "users", database=mock_db)
162
- j = q.outer_apply("posts")
163
- assert j.join == JoinEnum.LEFT_JOIN_LATERAL
164
+ q.outer_apply("posts")
165
+ assert q.joins[0].join == JoinEnum.LEFT_JOIN_LATERAL
164
166
 
165
167
 
166
168
  def test_cross_apply_maps_to_inner_join_lateral(sql_dialect: SQLDialect, mock_db) -> None:
167
169
  q = SelectQuery(sql_dialect, "users", database=mock_db)
168
- j = q.cross_apply("posts")
169
- assert j.join == JoinEnum.INNER_JOIN_LATERAL
170
+ q.cross_apply("posts")
171
+ assert q.joins[0].join == JoinEnum.INNER_JOIN_LATERAL
170
172
 
171
173
 
172
174
  def test_left_join_lateral_takes_raw_table_not_subquery_only(sql_dialect: SQLDialect, mock_db) -> None:
173
175
  q = SelectQuery(sql_dialect, "users", database=mock_db)
174
- j = q.left_join_lateral("posts", "p")
175
- assert j.join == JoinEnum.LEFT_JOIN_LATERAL
176
+ q.left_join_lateral_table("posts", alias="p")
177
+ assert q.joins[0].join == JoinEnum.LEFT_JOIN_LATERAL
176
178
 
177
179
 
178
180
  def test_left_join_lateral_sub_query_still_available(sql_dialect: SQLDialect, mock_db) -> None:
179
181
  inner = SelectQuery(sql_dialect, "posts", database=mock_db)
180
182
  q = SelectQuery(sql_dialect, "users", database=mock_db)
181
- j = q.left_join_lateral_sub_query(inner, "p")
182
- assert j.join == JoinEnum.LEFT_JOIN_LATERAL
183
+ q.left_join_lateral_sub_query(inner, "p")
184
+ assert q.joins[0].join == JoinEnum.LEFT_JOIN_LATERAL
183
185
 
184
186
 
185
187
  def test_inner_join_sub_query_and_cross_join_sub_query_exist(sql_dialect: SQLDialect, mock_db) -> None:
186
188
  inner = SelectQuery(sql_dialect, "posts", database=mock_db)
187
189
  q = SelectQuery(sql_dialect, "users", database=mock_db)
188
- assert isinstance(q.inner_join_sub_query(inner, "p"), Join)
189
- assert isinstance(q.cross_join_sub_query(inner, "p"), Join)
190
+ assert q.inner_join_sub_query(inner, "p") is q
191
+ assert q.cross_join_sub_query(inner, "p") is q
192
+ assert [j.join for j in q.joins] == [JoinEnum.INNER_JOIN, JoinEnum.CROSS_JOIN]
@@ -118,7 +118,7 @@ def test_pg_type_mapping(pg_dialect: PostgresqlDialect) -> None:
118
118
  assert pg_dialect.type(TypeEnum.INT) == "INTEGER"
119
119
  assert pg_dialect.type(TypeEnum.FLOAT) == "REAL"
120
120
  assert pg_dialect.type(TypeEnum.FLOAT, 64) == "DOUBLE PRECISION"
121
- assert pg_dialect.type(TypeEnum.DATETIME) == "TIMESTAMP"
121
+ assert pg_dialect.type(TypeEnum.DATETIME) == "TIMESTAMPTZ"
122
122
 
123
123
 
124
124
  def test_pg_like_case_sensitive_by_default(pg_dialect: PostgresqlDialect) -> None:
@@ -395,8 +395,7 @@ def test_mysql_joins(
395
395
 
396
396
  q: SelectQuery = SelectQuery(dialect, "users", database=db)
397
397
  q.columns(["name", "title"])
398
- j = q.inner_join("posts")
399
- j.on(["users", "id"], ["posts", "user_id"])
398
+ q.inner_join("posts", lambda join: join.on(["users", "id"], ["posts", "user_id"]))
400
399
  q.order_by_asc("title")
401
400
 
402
401
  qwp: QueryWithParams = q.to_query_with_params()
@@ -418,8 +417,7 @@ def test_mysql_joins(
418
417
 
419
418
  q2: SelectQuery = SelectQuery(dialect, "users", database=db)
420
419
  q2.columns(["name", "title"])
421
- lj = q2.left_join("posts")
422
- lj.on(["users", "id"], ["posts", "user_id"])
420
+ q2.left_join("posts", lambda join: join.on(["users", "id"], ["posts", "user_id"]))
423
421
  q2.order_by_asc("name")
424
422
 
425
423
  qwp2: QueryWithParams = q2.to_query_with_params()
@@ -445,8 +443,8 @@ def test_mysql_joins(
445
443
 
446
444
  q4: SelectQuery = SelectQuery(dialect, "users", database=db)
447
445
  q4.columns(["name", "title"])
448
- cj = q4.cross_join("posts")
449
- assert cj.conditions == []
446
+ q4.cross_join("posts")
447
+ assert q4.joins[0].conditions == []
450
448
 
451
449
  qwp4: QueryWithParams = q4.to_query_with_params()
452
450
  result4: ResultABC = adapter.query_with_params(dialect, qwp4)
@@ -987,8 +985,7 @@ def test_mysql_giant_select(
987
985
  ]
988
986
  )
989
987
 
990
- inner = q.inner_join("posts")
991
- inner.on(["users", "id"], ["posts", "user_id"])
988
+ q.inner_join("posts", lambda join: join.on(["users", "id"], ["posts", "user_id"]))
992
989
 
993
990
  q.where_greater_than(["users", "age"], 18)
994
991
  q.where_in(["users", "id"], [1, 2, 3])
@@ -271,14 +271,14 @@ def test_postgres_joins(pg_adapter: PsycopgAdapter, pg_dialect: PostgresqlDialec
271
271
 
272
272
  q = SelectQuery(dialect, "j_users", database=db)
273
273
  q.columns([identifier(["j_users", "name"]), identifier(["j_posts", "title"])])
274
- q.inner_join("j_posts").on(["j_users", "id"], ["j_posts", "user_id"])
274
+ q.inner_join("j_posts", lambda join: join.on(["j_users", "id"], ["j_posts", "user_id"]))
275
275
  rows: list[dict[str, Any]] = adapter.query_with_params(dialect, q.to_query_with_params()).fetch_dicts()
276
276
  assert len(rows) == 2
277
277
  assert all(r["name"] == "Alice" for r in rows)
278
278
 
279
279
  q = SelectQuery(dialect, "j_users", database=db)
280
280
  q.columns([identifier(["j_users", "name"]), identifier(["j_posts", "title"])])
281
- q.left_join("j_posts").on(["j_users", "id"], ["j_posts", "user_id"])
281
+ q.left_join("j_posts", lambda join: join.on(["j_users", "id"], ["j_posts", "user_id"]))
282
282
  rows = adapter.query_with_params(dialect, q.to_query_with_params()).fetch_dicts()
283
283
  names: dict[str, Any] = {r["name"]: r["title"] for r in rows}
284
284
  assert names["Bob"] is None
@@ -663,7 +663,7 @@ def test_postgres_giant_select(pg_adapter: PsycopgAdapter, pg_dialect: Postgresq
663
663
 
664
664
  q = SelectQuery(dialect, Alias("g_users", "u"), database=db)
665
665
  q.columns([identifier(["u", "name"]), expression('count("p"."id") AS post_count')])
666
- q.inner_join("g_posts", "p").on(["u", "id"], ["p", "user_id"])
666
+ q.inner_join_table("g_posts", lambda join: join.on(["u", "id"], ["p", "user_id"]), "p")
667
667
  q.where_greater_than_or_equals("age", 25)
668
668
  q.where_in("name", ["Alice", "Bob"])
669
669
  q.where_between("age", 20, 40)
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from flowmaticdb.dialects import SQLDialect
6
+ from flowmaticdb.query import Join, SelectQuery
7
+ from flowmaticdb.query.enums import ChainEnum, JoinEnum
8
+ from flowmaticdb.query.expressions import Alias
9
+
10
+
11
+ def test_join_creation() -> None:
12
+ j = Join(join=JoinEnum.LEFT_JOIN, table="posts")
13
+ assert j.join == JoinEnum.LEFT_JOIN
14
+ assert j.table == "posts"
15
+ assert j.conditions == []
16
+
17
+
18
+ def test_join_on_condition() -> None:
19
+ j = Join(join=JoinEnum.INNER_JOIN, table="posts")
20
+ j.on("users.id", "posts.user_id")
21
+ assert len(j.conditions) == 1
22
+ assert j.conditions[0].chain == ChainEnum.AND
23
+
24
+
25
+ def test_join_or_on_condition() -> None:
26
+ j = Join(join=JoinEnum.LEFT_JOIN, table="posts")
27
+ j.or_on("a", "b")
28
+ assert j.conditions[0].chain == ChainEnum.OR
29
+
30
+
31
+ def test_multiple_join_conditions() -> None:
32
+ j = Join(join=JoinEnum.LEFT_JOIN, table="posts")
33
+ j.on("users.id", "posts.user_id")
34
+ j.on("users.deleted", 0)
35
+ assert len(j.conditions) == 2
36
+
37
+
38
+ def test_all_join_types() -> None:
39
+ for join_type in JoinEnum:
40
+ j = Join(join=join_type, table="t")
41
+ assert j.join == join_type
42
+
43
+
44
+ def test_join_returns_query_for_chaining(sql_dialect: SQLDialect, mock_db) -> None:
45
+ q = SelectQuery(sql_dialect, "users", database=mock_db)
46
+ returned = q.inner_join("posts").left_join("comments").where_equals("active", 1)
47
+ assert returned is q
48
+ assert [j.join for j in q.joins] == [JoinEnum.INNER_JOIN, JoinEnum.LEFT_JOIN]
49
+
50
+
51
+ def test_join_callback_receives_join(sql_dialect: SQLDialect, mock_db) -> None:
52
+ q = SelectQuery(sql_dialect, "users", database=mock_db)
53
+ q.inner_join("posts", lambda join: join.on(["users", "id"], ["posts", "user_id"]))
54
+ assert len(q.joins) == 1
55
+ assert len(q.joins[0].conditions) == 1
56
+
57
+
58
+ def test_join_callback_returning_none_keeps_join(sql_dialect: SQLDialect, mock_db) -> None:
59
+ def on(join: Join) -> None:
60
+ join.on(["users", "id"], ["posts", "user_id"])
61
+
62
+ q = SelectQuery(sql_dialect, "users", database=mock_db)
63
+ q.inner_join("posts", on)
64
+ assert len(q.joins) == 1
65
+ assert len(q.joins[0].conditions) == 1
66
+
67
+
68
+ def test_join_callback_returning_non_join_cancels_join(sql_dialect: SQLDialect, mock_db) -> None:
69
+ def on(join: Join) -> Any:
70
+ return False
71
+
72
+ q = SelectQuery(sql_dialect, "users", database=mock_db)
73
+ q.inner_join("posts", on)
74
+ assert q.joins == []
75
+
76
+
77
+ def test_join_table_alias_wraps_table(sql_dialect: SQLDialect, mock_db) -> None:
78
+ q = SelectQuery(sql_dialect, "users", database=mock_db)
79
+ q.left_join_table("posts", None, "p")
80
+ assert isinstance(q.joins[0].table, Alias)
@@ -107,7 +107,6 @@ def test_select_where_group(sql_dialect: SQLDialect, mock_db) -> None:
107
107
 
108
108
  def test_select_join(sql_dialect: SQLDialect, mock_db) -> None:
109
109
  q: SelectQuery = SelectQuery(sql_dialect, "users", database=mock_db)
110
- j = q.left_join("posts")
111
- j.on("users.id", "posts.user_id")
110
+ q.left_join("posts", lambda join: join.on("users.id", "posts.user_id"))
112
111
  qwp: QueryWithParams = q.to_query_with_params()
113
112
  assert "LEFT JOIN" in qwp.query