iceaxe 0.2.1__tar.gz → 0.2.3.dev1__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 (65) hide show
  1. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/PKG-INFO +1 -1
  2. iceaxe-0.2.3.dev1/iceaxe/__tests__/conf_models.py +81 -0
  3. iceaxe-0.2.3.dev1/iceaxe/__tests__/mountaineer/dependencies/test_core.py +73 -0
  4. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/test_comparison.py +3 -3
  5. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/test_field.py +5 -5
  6. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/test_session.py +179 -0
  7. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/field.py +8 -2
  8. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/mountaineer/dependencies/core.py +7 -2
  9. iceaxe-0.2.3.dev1/iceaxe/py.typed +0 -0
  10. iceaxe-0.2.3.dev1/iceaxe/schemas/__init__.py +0 -0
  11. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/session.py +120 -20
  12. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/pyproject.toml +1 -1
  13. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/setup.py +3 -1
  14. iceaxe-0.2.1/iceaxe/__tests__/conf_models.py +0 -41
  15. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/LICENSE +0 -0
  16. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/README.md +0 -0
  17. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/build.py +0 -0
  18. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/.DS_Store +0 -0
  19. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__init__.py +0 -0
  20. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/__init__.py +0 -0
  21. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/benchmarks/__init__.py +0 -0
  22. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/benchmarks/test_select.py +0 -0
  23. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/conftest.py +0 -0
  24. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/migrations/__init__.py +0 -0
  25. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/migrations/conftest.py +0 -0
  26. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/migrations/test_action_sorter.py +0 -0
  27. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/migrations/test_generator.py +0 -0
  28. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/migrations/test_generics.py +0 -0
  29. {iceaxe-0.2.1/iceaxe/__tests__/schemas → iceaxe-0.2.3.dev1/iceaxe/__tests__/mountaineer}/__init__.py +0 -0
  30. {iceaxe-0.2.1/iceaxe/schemas → iceaxe-0.2.3.dev1/iceaxe/__tests__/mountaineer/dependencies}/__init__.py +0 -0
  31. /iceaxe-0.2.1/iceaxe/py.typed → /iceaxe-0.2.3.dev1/iceaxe/__tests__/schemas/__init__.py +0 -0
  32. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/schemas/test_actions.py +0 -0
  33. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/schemas/test_cli.py +0 -0
  34. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/schemas/test_db_memory_serializer.py +0 -0
  35. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/schemas/test_db_serializer.py +0 -0
  36. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/schemas/test_db_stubs.py +0 -0
  37. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/test_base.py +0 -0
  38. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/__tests__/test_queries.py +0 -0
  39. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/base.py +0 -0
  40. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/comparison.py +0 -0
  41. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/functions.py +0 -0
  42. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/generics.py +0 -0
  43. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/io.py +0 -0
  44. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/logging.py +0 -0
  45. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/migrations/__init__.py +0 -0
  46. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/migrations/action_sorter.py +0 -0
  47. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/migrations/cli.py +0 -0
  48. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/migrations/client_io.py +0 -0
  49. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/migrations/generator.py +0 -0
  50. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/migrations/migration.py +0 -0
  51. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/migrations/migrator.py +0 -0
  52. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/mountaineer/__init__.py +0 -0
  53. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/mountaineer/cli.py +0 -0
  54. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/mountaineer/config.py +0 -0
  55. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/mountaineer/dependencies/__init__.py +0 -0
  56. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/postgres.py +0 -0
  57. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/queries.py +0 -0
  58. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/queries_str.py +0 -0
  59. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/schemas/actions.py +0 -0
  60. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/schemas/cli.py +0 -0
  61. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/schemas/db_memory_serializer.py +0 -0
  62. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/schemas/db_serializer.py +0 -0
  63. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/schemas/db_stubs.py +0 -0
  64. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/session_optimized.pyx +0 -0
  65. {iceaxe-0.2.1 → iceaxe-0.2.3.dev1}/iceaxe/typing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: iceaxe
3
- Version: 0.2.1
3
+ Version: 0.2.3.dev1
4
4
  Summary: A modern, fast ORM for Python.
5
5
  Author: Pierce Freeman
6
6
  Author-email: pierce@freeman.vc
@@ -0,0 +1,81 @@
1
+ from contextlib import contextmanager
2
+ from enum import StrEnum
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pyinstrument import Profiler
7
+
8
+ from iceaxe.base import Field, TableBase
9
+
10
+
11
+ class UserDemo(TableBase):
12
+ id: int = Field(primary_key=True, default=None)
13
+ name: str
14
+ email: str
15
+
16
+
17
+ class ArtifactDemo(TableBase):
18
+ id: int = Field(primary_key=True, default=None)
19
+ title: str
20
+ user_id: int = Field(foreign_key="userdemo.id")
21
+
22
+
23
+ class ComplexDemo(TableBase):
24
+ id: int = Field(primary_key=True, default=None)
25
+ string_list: list[str]
26
+ json_data: dict[str, str] = Field(is_json=True)
27
+
28
+
29
+ class Employee(TableBase):
30
+ id: int = Field(primary_key=True, default=None)
31
+ email: str = Field(unique=True)
32
+ first_name: str
33
+ last_name: str
34
+ department: str
35
+ salary: float
36
+
37
+
38
+ class Department(TableBase):
39
+ id: int = Field(primary_key=True, default=None)
40
+ name: str = Field(unique=True)
41
+ budget: float
42
+ location: str
43
+
44
+
45
+ class ProjectAssignment(TableBase):
46
+ id: int = Field(primary_key=True, default=None)
47
+ employee_id: int = Field(foreign_key="employee.id")
48
+ project_name: str
49
+ role: str
50
+ start_date: str
51
+
52
+
53
+ class EmployeeStatus(StrEnum):
54
+ ACTIVE = "active"
55
+ INACTIVE = "inactive"
56
+ ON_LEAVE = "on_leave"
57
+
58
+
59
+ class EmployeeMetadata(TableBase):
60
+ id: int = Field(primary_key=True, default=None)
61
+ employee_id: int = Field(foreign_key="employee.id")
62
+ status: EmployeeStatus
63
+ tags: list[str] = Field(is_json=True)
64
+ additional_info: dict[str, Any] = Field(is_json=True)
65
+
66
+
67
+ @contextmanager
68
+ def run_profile(request):
69
+ TESTS_ROOT = Path.cwd()
70
+ PROFILE_ROOT = TESTS_ROOT / ".profiles"
71
+
72
+ # Turn profiling on
73
+ profiler = Profiler()
74
+ profiler.start()
75
+
76
+ yield # Run test
77
+
78
+ profiler.stop()
79
+ PROFILE_ROOT.mkdir(exist_ok=True)
80
+ results_file = PROFILE_ROOT / f"{request.node.name}.html"
81
+ profiler.write_html(results_file)
@@ -0,0 +1,73 @@
1
+ from unittest.mock import AsyncMock, MagicMock, patch
2
+
3
+ import asyncpg
4
+ import pytest
5
+ from mountaineer import CoreDependencies
6
+
7
+ from iceaxe.mountaineer.config import DatabaseConfig
8
+ from iceaxe.mountaineer.dependencies.core import get_db_connection
9
+ from iceaxe.session import DBConnection
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def mock_db_connect():
14
+ conn = AsyncMock(spec=asyncpg.Connection)
15
+ conn.close = AsyncMock()
16
+
17
+ with patch("asyncpg.connect", new_callable=AsyncMock) as mock:
18
+ mock.return_value = conn
19
+ yield mock
20
+
21
+
22
+ @pytest.fixture
23
+ def mock_config():
24
+ return DatabaseConfig(
25
+ POSTGRES_HOST="test-host",
26
+ POSTGRES_PORT=5432,
27
+ POSTGRES_USER="test-user",
28
+ POSTGRES_PASSWORD="test-pass",
29
+ POSTGRES_DB="test-db",
30
+ )
31
+
32
+
33
+ @pytest.fixture
34
+ def mock_connection():
35
+ conn = AsyncMock(spec=asyncpg.Connection)
36
+ conn.close = AsyncMock()
37
+ return conn
38
+
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_get_db_connection_closes_after_yield(
42
+ mock_config: DatabaseConfig,
43
+ mock_connection: AsyncMock,
44
+ mock_db_connect: AsyncMock,
45
+ ):
46
+ mock_get_config = MagicMock(return_value=mock_config)
47
+ CoreDependencies.get_config_with_type = mock_get_config
48
+
49
+ mock_db_connect.return_value = mock_connection
50
+
51
+ # Get the generator
52
+ db_gen = get_db_connection(mock_config)
53
+
54
+ # Get the connection
55
+ connection = await anext(db_gen) # noqa: F821
56
+
57
+ assert isinstance(connection, DBConnection)
58
+ assert connection.conn == mock_connection
59
+ mock_db_connect.assert_called_once_with(
60
+ host=mock_config.POSTGRES_HOST,
61
+ port=mock_config.POSTGRES_PORT,
62
+ user=mock_config.POSTGRES_USER,
63
+ password=mock_config.POSTGRES_PASSWORD,
64
+ database=mock_config.POSTGRES_DB,
65
+ )
66
+
67
+ # Simulate the end of the generator's scope
68
+ try:
69
+ await db_gen.aclose()
70
+ except StopAsyncIteration:
71
+ pass
72
+
73
+ mock_connection.close.assert_called_once()
@@ -4,7 +4,7 @@ import pytest
4
4
 
5
5
  from iceaxe.base import TableBase
6
6
  from iceaxe.comparison import ComparisonType, FieldComparison
7
- from iceaxe.field import DBFieldClassDefinition, FieldInfo
7
+ from iceaxe.field import DBFieldClassDefinition, DBFieldInfo
8
8
 
9
9
 
10
10
  def test_comparison_type_enum():
@@ -24,7 +24,7 @@ def test_comparison_type_enum():
24
24
  @pytest.fixture
25
25
  def db_field():
26
26
  return DBFieldClassDefinition(
27
- root_model=TableBase, key="test_key", field_definition=FieldInfo()
27
+ root_model=TableBase, key="test_key", field_definition=DBFieldInfo()
28
28
  )
29
29
 
30
30
 
@@ -137,7 +137,7 @@ def test_compare(db_field: DBFieldClassDefinition):
137
137
  3.14,
138
138
  complex(1, 2),
139
139
  DBFieldClassDefinition(
140
- root_model=TableBase, key="other_key", field_definition=FieldInfo()
140
+ root_model=TableBase, key="other_key", field_definition=DBFieldInfo()
141
141
  ),
142
142
  ],
143
143
  )
@@ -4,13 +4,13 @@ import pytest
4
4
 
5
5
  from iceaxe.base import TableBase
6
6
  from iceaxe.comparison import ComparisonType, FieldComparison
7
- from iceaxe.field import DBFieldClassDefinition, FieldInfo
7
+ from iceaxe.field import DBFieldClassDefinition, DBFieldInfo
8
8
 
9
9
 
10
10
  @pytest.fixture
11
11
  def db_field():
12
12
  return DBFieldClassDefinition(
13
- root_model=TableBase, key="test_key", field_definition=FieldInfo()
13
+ root_model=TableBase, key="test_key", field_definition=DBFieldInfo()
14
14
  )
15
15
 
16
16
 
@@ -107,7 +107,7 @@ def test_compare(db_field: DBFieldClassDefinition):
107
107
  3.14,
108
108
  complex(1, 2),
109
109
  DBFieldClassDefinition(
110
- root_model=TableBase, key="other_key", field_definition=FieldInfo()
110
+ root_model=TableBase, key="other_key", field_definition=DBFieldInfo()
111
111
  ),
112
112
  ],
113
113
  )
@@ -132,8 +132,8 @@ def test_comparison_with_different_types(db_field: DBFieldClassDefinition, value
132
132
 
133
133
  def test_db_field_class_definition_instantiation():
134
134
  field_def = DBFieldClassDefinition(
135
- root_model=TableBase, key="test_key", field_definition=FieldInfo()
135
+ root_model=TableBase, key="test_key", field_definition=DBFieldInfo()
136
136
  )
137
137
  assert field_def.root_model == TableBase
138
138
  assert field_def.key == "test_key"
139
- assert isinstance(field_def.field_definition, FieldInfo)
139
+ assert isinstance(field_def.field_definition, DBFieldInfo)
@@ -512,3 +512,182 @@ async def test_db_connection_insert_update_enum(db_connection: DBConnection):
512
512
  result = await db_connection.conn.fetch("SELECT * FROM enumdemo")
513
513
  assert len(result) == 1
514
514
  assert result[0]["value"] == "b"
515
+
516
+
517
+ #
518
+ # Upsert
519
+ #
520
+
521
+
522
+ @pytest.mark.asyncio
523
+ async def test_upsert_basic_insert(db_connection: DBConnection):
524
+ """
525
+ Test basic insert when no conflict exists
526
+
527
+ """
528
+ await db_connection.conn.execute(
529
+ """
530
+ ALTER TABLE userdemo
531
+ ADD CONSTRAINT email_unique UNIQUE (email)
532
+ """
533
+ )
534
+
535
+ user = UserDemo(name="John Doe", email="john@example.com")
536
+ result = await db_connection.upsert(
537
+ [user],
538
+ conflict_fields=(UserDemo.email,),
539
+ returning_fields=(UserDemo.id, UserDemo.name, UserDemo.email),
540
+ )
541
+
542
+ assert result is not None
543
+ assert len(result) == 1
544
+ assert result[0][1] == "John Doe"
545
+ assert result[0][2] == "john@example.com"
546
+
547
+ # Verify in database
548
+ db_result = await db_connection.conn.fetch("SELECT * FROM userdemo")
549
+ assert len(db_result) == 1
550
+ assert db_result[0][1] == "John Doe"
551
+
552
+
553
+ @pytest.mark.asyncio
554
+ async def test_upsert_update_on_conflict(db_connection: DBConnection):
555
+ """
556
+ Test update when conflict exists
557
+
558
+ """
559
+ await db_connection.conn.execute(
560
+ """
561
+ ALTER TABLE userdemo
562
+ ADD CONSTRAINT email_unique UNIQUE (email)
563
+ """
564
+ )
565
+
566
+ # First insert
567
+ user = UserDemo(name="John Doe", email="john@example.com")
568
+ await db_connection.insert([user])
569
+
570
+ # Attempt upsert with same email but different name
571
+ new_user = UserDemo(name="Johnny Doe", email="john@example.com")
572
+ result = await db_connection.upsert(
573
+ [new_user],
574
+ conflict_fields=(UserDemo.email,),
575
+ update_fields=(UserDemo.name,),
576
+ returning_fields=(UserDemo.id, UserDemo.name, UserDemo.email),
577
+ )
578
+
579
+ assert result is not None
580
+ assert len(result) == 1
581
+ assert result[0][1] == "Johnny Doe"
582
+
583
+ # Verify only one record exists
584
+ db_result = await db_connection.conn.fetch("SELECT * FROM userdemo")
585
+ assert len(db_result) == 1
586
+ assert db_result[0]["name"] == "Johnny Doe"
587
+
588
+
589
+ @pytest.mark.asyncio
590
+ async def test_upsert_do_nothing_on_conflict(db_connection: DBConnection):
591
+ """
592
+ Test DO NOTHING behavior when no update_fields specified
593
+
594
+ """
595
+ await db_connection.conn.execute(
596
+ """
597
+ ALTER TABLE userdemo
598
+ ADD CONSTRAINT email_unique UNIQUE (email)
599
+ """
600
+ )
601
+
602
+ # First insert
603
+ user = UserDemo(name="John Doe", email="john@example.com")
604
+ await db_connection.insert([user])
605
+
606
+ # Attempt upsert with same email but different name
607
+ new_user = UserDemo(name="Johnny Doe", email="john@example.com")
608
+ result = await db_connection.upsert(
609
+ [new_user],
610
+ conflict_fields=(UserDemo.email,),
611
+ returning_fields=(UserDemo.id, UserDemo.name, UserDemo.email),
612
+ )
613
+
614
+ # Should return empty list as no update was performed
615
+ assert result == []
616
+
617
+ # Verify original record unchanged
618
+ db_result = await db_connection.conn.fetch("SELECT * FROM userdemo")
619
+ assert len(db_result) == 1
620
+ assert db_result[0][1] == "John Doe"
621
+
622
+
623
+ @pytest.mark.asyncio
624
+ async def test_upsert_multiple_objects(db_connection: DBConnection):
625
+ """
626
+ Test upserting multiple objects at once
627
+
628
+ """
629
+ await db_connection.conn.execute(
630
+ """
631
+ ALTER TABLE userdemo
632
+ ADD CONSTRAINT email_unique UNIQUE (email)
633
+ """
634
+ )
635
+
636
+ users = [
637
+ UserDemo(name="John Doe", email="john@example.com"),
638
+ UserDemo(name="Jane Doe", email="jane@example.com"),
639
+ ]
640
+ result = await db_connection.upsert(
641
+ users,
642
+ conflict_fields=(UserDemo.email,),
643
+ returning_fields=(UserDemo.name, UserDemo.email),
644
+ )
645
+
646
+ assert result is not None
647
+ assert len(result) == 2
648
+ assert {r[1] for r in result} == {"john@example.com", "jane@example.com"}
649
+
650
+
651
+ @pytest.mark.asyncio
652
+ async def test_upsert_empty_list(db_connection: DBConnection):
653
+ await db_connection.conn.execute(
654
+ """
655
+ ALTER TABLE userdemo
656
+ ADD CONSTRAINT email_unique UNIQUE (email)
657
+ """
658
+ )
659
+
660
+ """Test upserting an empty list"""
661
+ result = await db_connection.upsert(
662
+ [], conflict_fields=(UserDemo.email,), returning_fields=(UserDemo.id,)
663
+ )
664
+ assert result is None
665
+
666
+
667
+ @pytest.mark.asyncio
668
+ async def test_upsert_multiple_conflict_fields(db_connection: DBConnection):
669
+ """
670
+ Test upserting with multiple conflict fields
671
+
672
+ """
673
+ await db_connection.conn.execute(
674
+ """
675
+ ALTER TABLE userdemo
676
+ ADD CONSTRAINT email_unique UNIQUE (name, email)
677
+ """
678
+ )
679
+
680
+ users = [
681
+ UserDemo(name="John Doe", email="john@example.com"),
682
+ UserDemo(name="John Doe", email="john@example.com"),
683
+ UserDemo(name="Jane Doe", email="jane@example.com"),
684
+ ]
685
+ result = await db_connection.upsert(
686
+ users,
687
+ conflict_fields=(UserDemo.name, UserDemo.email),
688
+ returning_fields=(UserDemo.name, UserDemo.email),
689
+ )
690
+
691
+ assert result is not None
692
+ assert len(result) == 2
693
+ assert {r[1] for r in result} == {"john@example.com", "jane@example.com"}
@@ -1,3 +1,4 @@
1
+ from json import dumps as json_dumps
1
2
  from typing import (
2
3
  TYPE_CHECKING,
3
4
  Any,
@@ -92,6 +93,11 @@ class DBFieldInfo(FieldInfo):
92
93
  **field._attributes_set, # type: ignore
93
94
  )
94
95
 
96
+ def to_db_value(self, value: Any):
97
+ if self.is_json:
98
+ return json_dumps(value)
99
+ return value
100
+
95
101
 
96
102
  def __get_db_field(_: Callable[Concatenate[Any, P], Any] = PydanticField): # type: ignore
97
103
  """
@@ -136,13 +142,13 @@ def __get_db_field(_: Callable[Concatenate[Any, P], Any] = PydanticField): # ty
136
142
  class DBFieldClassDefinition(ComparisonBase):
137
143
  root_model: Type["TableBase"]
138
144
  key: str
139
- field_definition: FieldInfo
145
+ field_definition: DBFieldInfo
140
146
 
141
147
  def __init__(
142
148
  self,
143
149
  root_model: Type["TableBase"],
144
150
  key: str,
145
- field_definition: FieldInfo,
151
+ field_definition: DBFieldInfo,
146
152
  ):
147
153
  self.root_model = root_model
148
154
  self.key = key
@@ -3,6 +3,8 @@ Optional compatibility layer for `mountaineer` dependency access.
3
3
 
4
4
  """
5
5
 
6
+ from typing import AsyncGenerator
7
+
6
8
  import asyncpg
7
9
  from mountaineer import CoreDependencies, Depends
8
10
 
@@ -14,7 +16,7 @@ async def get_db_connection(
14
16
  config: DatabaseConfig = Depends(
15
17
  CoreDependencies.get_config_with_type(DatabaseConfig)
16
18
  ),
17
- ) -> DBConnection:
19
+ ) -> AsyncGenerator[DBConnection, None]:
18
20
  conn = await asyncpg.connect(
19
21
  host=config.POSTGRES_HOST,
20
22
  port=config.POSTGRES_PORT,
@@ -22,4 +24,7 @@ async def get_db_connection(
22
24
  password=config.POSTGRES_PASSWORD,
23
25
  database=config.POSTGRES_DB,
24
26
  )
25
- return DBConnection(conn)
27
+ try:
28
+ yield DBConnection(conn)
29
+ finally:
30
+ await conn.close()
File without changes
File without changes
@@ -1,6 +1,5 @@
1
1
  from collections import defaultdict
2
2
  from contextlib import asynccontextmanager
3
- from json import dumps as json_dumps
4
3
  from typing import (
5
4
  Any,
6
5
  Literal,
@@ -13,6 +12,7 @@ from typing import (
13
12
  )
14
13
 
15
14
  import asyncpg
15
+ from typing_extensions import TypeVarTuple
16
16
 
17
17
  from iceaxe.base import TableBase
18
18
  from iceaxe.logging import LOGGER
@@ -27,6 +27,7 @@ from iceaxe.session_optimized import optimize_exec_casting
27
27
 
28
28
  P = ParamSpec("P")
29
29
  T = TypeVar("T")
30
+ Ts = TypeVarTuple("Ts")
30
31
 
31
32
 
32
33
  class DBConnection:
@@ -94,23 +95,11 @@ class DBConnection:
94
95
  return
95
96
 
96
97
  for model, model_objects in self._aggregate_models_by_table(objects):
97
- # We let the DB handle autoincrement keys
98
- auto_increment_keys = [
99
- field
100
- for field, info in model.model_fields.items()
101
- if info.autoincrement
102
- ]
103
-
104
98
  table_name = QueryIdentifier(model.get_table_name())
105
- fields = [
106
- field
99
+ fields = {
100
+ field: info
107
101
  for field, info in model.model_fields.items()
108
- if not info.exclude
109
- and not info.autoincrement
110
- and field not in auto_increment_keys
111
- ]
112
- json_fields = {
113
- field for field, info in model.model_fields.items() if info.is_json
102
+ if (not info.exclude and not info.autoincrement)
114
103
  }
115
104
  field_string = ", ".join(f'"{field}"' for field in fields)
116
105
  primary_key = self._get_primary_key(model)
@@ -124,10 +113,8 @@ class DBConnection:
124
113
  for obj in model_objects:
125
114
  obj_values = obj.model_dump()
126
115
  values = [
127
- obj_values[field]
128
- if field not in json_fields
129
- else json_dumps(obj_values[field])
130
- for field in fields
116
+ info.to_db_value(obj_values[field])
117
+ for field, info in fields.items()
131
118
  ]
132
119
  result = await self.conn.fetchrow(query, *values)
133
120
 
@@ -135,6 +122,119 @@ class DBConnection:
135
122
  setattr(obj, primary_key, result[primary_key])
136
123
  obj.clear_modified_attributes()
137
124
 
125
+ @overload
126
+ async def upsert(
127
+ self,
128
+ objects: Sequence[TableBase],
129
+ *,
130
+ conflict_fields: tuple[Any, ...],
131
+ update_fields: tuple[Any, ...] | None = None,
132
+ returning_fields: tuple[T, *Ts],
133
+ ) -> list[tuple[T, *Ts]]: ...
134
+
135
+ @overload
136
+ async def upsert(
137
+ self,
138
+ objects: Sequence[TableBase],
139
+ *,
140
+ conflict_fields: tuple[Any, ...],
141
+ update_fields: tuple[Any, ...] | None = None,
142
+ returning_fields: None,
143
+ ) -> None: ...
144
+
145
+ async def upsert(
146
+ self,
147
+ objects: Sequence[TableBase],
148
+ *,
149
+ conflict_fields: tuple[Any, ...],
150
+ update_fields: tuple[Any, ...] | None = None,
151
+ returning_fields: tuple[T, *Ts] | None = None,
152
+ ) -> list[tuple[T, *Ts]] | None:
153
+ """
154
+ Performs an upsert (INSERT ... ON CONFLICT DO UPDATE) operation for the given objects.
155
+
156
+ :param objects: Sequence of TableBase objects to upsert
157
+ :param conflict_fields: Fields to check for conflicts (ON CONFLICT)
158
+ :param update_fields: Fields to update on conflict. If None, updates all non-excluded fields
159
+ :param returning_fields: Fields to return after the operation. If None, returns nothing
160
+
161
+ :return List of dictionaries containing the returned fields if returning_fields is specified
162
+
163
+ """
164
+ if not objects:
165
+ return None
166
+
167
+ # Evaluate column types
168
+ conflict_fields_cols = [field for field in conflict_fields if is_column(field)]
169
+ update_fields_cols = [
170
+ field for field in update_fields or [] if is_column(field)
171
+ ]
172
+ returning_fields_cols = [
173
+ field for field in returning_fields or [] if is_column(field)
174
+ ]
175
+
176
+ results: list[tuple[T, *Ts]] = []
177
+ async with self._ensure_transaction():
178
+ for model, model_objects in self._aggregate_models_by_table(objects):
179
+ table_name = QueryIdentifier(model.get_table_name())
180
+ fields = {
181
+ field: info
182
+ for field, info in model.model_fields.items()
183
+ if (not info.exclude and not info.autoincrement)
184
+ }
185
+
186
+ field_string = ", ".join(f'"{field}"' for field in fields)
187
+ placeholders = ", ".join(f"${i}" for i in range(1, len(fields) + 1))
188
+ query = (
189
+ f"INSERT INTO {table_name} ({field_string}) VALUES ({placeholders})"
190
+ )
191
+ if conflict_fields_cols:
192
+ conflict_field_string = ", ".join(
193
+ f'"{field.key}"' for field in conflict_fields_cols
194
+ )
195
+ query += f" ON CONFLICT ({conflict_field_string})"
196
+
197
+ if update_fields_cols:
198
+ set_values = ", ".join(
199
+ f'"{field.key}" = EXCLUDED."{field.key}"'
200
+ for field in update_fields_cols
201
+ )
202
+ query += f" DO UPDATE SET {set_values}"
203
+ else:
204
+ query += " DO NOTHING"
205
+
206
+ if returning_fields_cols:
207
+ returning_string = ", ".join(
208
+ f'"{field.key}"' for field in returning_fields_cols
209
+ )
210
+ query += f" RETURNING {returning_string}"
211
+
212
+ # Execute for each object
213
+ for obj in model_objects:
214
+ obj_values = obj.model_dump()
215
+ values = [
216
+ info.to_db_value(obj_values[field])
217
+ for field, info in fields.items()
218
+ ]
219
+
220
+ if returning_fields_cols:
221
+ result = await self.conn.fetchrow(query, *values)
222
+ if result:
223
+ results.append(
224
+ tuple(
225
+ [
226
+ result[field.key]
227
+ for field in returning_fields_cols
228
+ ]
229
+ )
230
+ )
231
+ else:
232
+ await self.conn.execute(query, *values)
233
+
234
+ obj.clear_modified_attributes()
235
+
236
+ return results if returning_fields_cols else None
237
+
138
238
  async def update(self, objects: Sequence[TableBase]):
139
239
  if not objects:
140
240
  return
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "iceaxe"
3
- version = "0.2.1"
3
+ version = "0.2.3.dev1"
4
4
  description = "A modern, fast ORM for Python."
5
5
  authors = ["Pierce Freeman <pierce@freeman.vc>"]
6
6
  readme = "README.md"
@@ -6,6 +6,8 @@ packages = \
6
6
  'iceaxe.__tests__',
7
7
  'iceaxe.__tests__.benchmarks',
8
8
  'iceaxe.__tests__.migrations',
9
+ 'iceaxe.__tests__.mountaineer',
10
+ 'iceaxe.__tests__.mountaineer.dependencies',
9
11
  'iceaxe.__tests__.schemas',
10
12
  'iceaxe.migrations',
11
13
  'iceaxe.mountaineer',
@@ -20,7 +22,7 @@ install_requires = \
20
22
 
21
23
  setup_kwargs = {
22
24
  'name': 'iceaxe',
23
- 'version': '0.2.1',
25
+ 'version': '0.2.3.dev1',
24
26
  'description': 'A modern, fast ORM for Python.',
25
27
  'long_description': '# iceaxe\n\nA modern, fast ORM for Python. We have the following goals:\n\n- 🏎️ **Performance**: We want to exceed or match the fastest ORMs in Python. We want our ORM\nto be as close as possible to raw-[asyncpg](https://github.com/MagicStack/asyncpg) speeds. See the "Benchmarks" section for more.\n- 📝 **Typehinting**: Everything should be typehinted with expected types. Declare your data as you\nexpect in Python and it should bidirectionally sync to the database.\n- 🐘 **Postgres only**: Leverage native Postgres features and simplify the implementation.\n- ⚡ **Common things are easy, rare things are possible**: 99% of the SQL queries we write are\nvanilla SELECT/INSERT/UPDATEs. These should be natively supported by your ORM. If you\'re writing _really_\ncomplex queries, these are better done by hand so you can see exactly what SQL will be run.\n\nIceaxe is in early alpha. It\'s also an independent project. It\'s compatible with the [Mountaineer](https://github.com/piercefreeman/mountaineer) ecosystem, but you can use it in whatever\nproject and web framework you\'re using.\n\n## Installation\n\nIf you\'re using poetry to manage your dependencies:\n\n```bash\npoetry add iceaxe\n```\n\nOtherwise install with pip:\n\n```bash\npip install iceaxe\n```\n\n## Usage\n\nDefine your models as a `TableBase` subclass:\n\n```python\nfrom iceaxe import TableBase\n\nclass Person(TableBase):\n id: int\n name: str\n age: int\n```\n\nTableBase is a subclass of Pydantic\'s `BaseModel`, so you get all of the validation and Field customization\nout of the box. We provide our own `Field` constructor that adds database-specific configuration. For instance, to make the\n`id` field a primary key / auto-incrementing you can do:\n\n```python\nfrom iceaxe import Field\n\nclass Person(TableBase):\n id: int = Field(primary_key=True)\n name: str\n age: int\n```\n\nOkay now you have a model. How do you interact with it?\n\nDatabases are based on a few core primitives to insert data, update it, and fetch it out again.\nTo do so you\'ll need a _database connection_, which is a connection over the network from your code\nto your Postgres database. The `DBConnection` is the core class for all ORM actions against the database.\n\n```python\nfrom iceaxe import DBConnection\nimport asyncpg\n\nconn = DBConnection(\n await asyncpg.connect(\n host="localhost",\n port=5432,\n user="db_user",\n password="yoursecretpassword",\n database="your_db",\n )\n)\n```\n\nThe Person class currently just lives in memory. To back it with a full\ndatabase table, we can run raw SQL or run a migration to add it:\n\n```python\nawait conn.conn.execute(\n """\n CREATE TABLE IF NOT EXISTS person (\n id SERIAL PRIMARY KEY,\n name TEXT NOT NULL,\n age INT NOT NULL\n )\n """\n)\n```\n\n### Inserting Data\n\nInstantiate object classes as you normally do:\n\n```python\npeople = [\n Person(name="Alice", age=30),\n Person(name="Bob", age=40),\n Person(name="Charlie", age=50),\n]\nawait conn.insert(people)\n\nprint(people[0].id) # 1\nprint(people[1].id) # 2\n```\n\nBecause we\'re using an auto-incrementing primary key, the `id` field will be populated after the insert.\nIceaxe will automatically update the object in place with the newly assigned value.\n\n### Updating data\n\nNow that we have these lovely people, let\'s modify them.\n\n```python\nperson = people[0]\nperson.name = "Blice"\n```\n\nRight now, we have a Python object that\'s out of state with the database. But that\'s often okay. We can inspect it\nand further write logic - it\'s fully decoupled from the database.\n\n```python\ndef ensure_b_letter(person: Person):\n if person.name[0].lower() != "b":\n raise ValueError("Name must start with \'B\'")\n\nensure_b_letter(person)\n```\n\nTo sync the values back to the database, we can call `update`:\n\n```python\nawait conn.update([person])\n```\n\nIf we were to query the database directly, we see that the name has been updated:\n\n```\nid | name | age\n----+-------+-----\n 1 | Blice | 31\n 2 | Bob | 40\n 3 | Charlie | 50\n```\n\nBut no other fields have been touched. This lets a potentially concurrent process\nmodify `Alice`\'s record - say, updating the age to 31. By the time we update the data, we\'ll\nchange the name but nothing else. Under the hood we do this by tracking the fields that\nhave been modified in-memory and creating a targeted UPDATE to modify only those values.\n\n### Selecting data\n\nTo select data, we can use a `QueryBuilder`. For a shortcut to `select` query functions,\nyou can also just import select directly. This method takes the desired value parameters\nand returns a list of the desired objects.\n\n```python\nfrom iceaxe import select\n\nquery = select(Person).where(Person.name == "Blice", Person.age > 25)\nresults = await conn.exec(query)\n```\n\nIf we inspect the typing of `results`, we see that it\'s a `list[Person]` objects. This matches\nthe typehint of the `select` function. You can also target columns directly:\n\n```python\nquery = select((Person.id, Person.name)).where(Person.age > 25)\nresults = await conn.exec(query)\n```\n\nThis will return a list of tuples, where each tuple is the id and name of the person: `list[tuple[int, str]]`.\n\nWe support most of the common SQL operations. Just like the results, these are typehinted\nto their proper types as well. Static typecheckers and your IDE will throw an error if you try to compare\na string column to an integer, for instance. A more complex example of a query:\n\n```python\nquery = select((\n Person.id,\n FavoriteColor,\n)).join(\n FavoriteColor,\n Person.id == FavoriteColor.person_id,\n).where(\n Person.age > 25,\n Person.name == "Blice",\n).order_by(\n Person.age.desc(),\n).limit(10)\nresults = await conn.exec(query)\n```\n\nAs expected this will deliver results - and typehint - as a `list[tuple[int, FavoriteColor]]`\n\n## Production\n\n> [!IMPORTANT]\n> Iceaxe is in early alpha. We\'re using it internally and showly rolling out to our production\napplications, but we\'re not yet ready to recommend it for general use. The API and larger\nstability is subject to change.\n\nNote that underlying Postgres connection wrapped by `conn` will be alive for as long as your object is in memory. This uses up one\nof the allowable connections to your database. Your overall limit depends on your Postgres configuration\nor hosting provider, but most managed solutions top out around 150-300. If you need more concurrent clients\nconnected (and even if you don\'t - connection creation at the Postgres level is expensive), you can adopt\na load balancer like `pgbouncer` to better scale to traffic. More deployment notes to come.\n\nIt\'s also worth noting the absence of request pooling in this initialization. This is a feature of many ORMs that lets you limit\nthe overall connections you make to Postgres, and re-use these over time. We specifically don\'t offer request\npooling as part of Iceaxe, despite being supported by our underlying engine `asyncpg`. This is a bit more\naligned to how things should be structured in production. Python apps are always bound to one process thanks to\nthe GIL. So no matter what your connection pool will always be tied to the current Python process / runtime. When you\'re deploying onto a server with multiple cores, the pool will be duplicated across CPUs and largely defeats the purpose of capping\nnetwork connections in the first place.\n\n## Benchmarking\n\nWe have basic benchmarking tests in the `__tests__/benchmarks` directory. To run them, you\'ll need to execute the pytest suite:\n\n```bash\npoetry run pytest -m integration_tests\n```\n\nCurrent benchmarking as of October 11 2024 is:\n\n| | raw asyncpg | iceaxe | external overhead | |\n|-------------------|-------------|--------|-----------------------------------------------|---|\n| TableBase columns | 0.098s | 0.093s | | |\n| TableBase full | 0.164s | 1.345s | 10%: dict construction | 90%: pydantic overhead | |\n\n## Development\n\nIf you update your Cython implementation during development, you\'ll need to re-compile the Cython code. This can be done with\na simple poetry install. Poetry is set up to create a dynamic `setup.py` based on our `build.py` definition.\n\n```bash\npoetry install\n```\n\n## TODOs\n\n- [ ] Additional documentation with usage examples.\n',
26
28
  'author': 'Pierce Freeman',
@@ -1,41 +0,0 @@
1
- from contextlib import contextmanager
2
- from pathlib import Path
3
-
4
- from pyinstrument import Profiler
5
-
6
- from iceaxe.base import Field, TableBase
7
-
8
-
9
- class UserDemo(TableBase):
10
- id: int = Field(primary_key=True, default=None)
11
- name: str
12
- email: str
13
-
14
-
15
- class ArtifactDemo(TableBase):
16
- id: int = Field(primary_key=True, default=None)
17
- title: str
18
- user_id: int = Field(foreign_key="userdemo.id")
19
-
20
-
21
- class ComplexDemo(TableBase):
22
- id: int = Field(primary_key=True, default=None)
23
- string_list: list[str]
24
- json_data: dict[str, str] = Field(is_json=True)
25
-
26
-
27
- @contextmanager
28
- def run_profile(request):
29
- TESTS_ROOT = Path.cwd()
30
- PROFILE_ROOT = TESTS_ROOT / ".profiles"
31
-
32
- # Turn profiling on
33
- profiler = Profiler()
34
- profiler.start()
35
-
36
- yield # Run test
37
-
38
- profiler.stop()
39
- PROFILE_ROOT.mkdir(exist_ok=True)
40
- results_file = PROFILE_ROOT / f"{request.node.name}.html"
41
- profiler.write_html(results_file)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes