iceaxe 0.2.3.dev2__tar.gz → 0.2.3.dev4__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 (66) hide show
  1. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/PKG-INFO +1 -1
  2. iceaxe-0.2.3.dev4/iceaxe/__tests__/helpers.py +263 -0
  3. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/test_comparison.py +35 -0
  4. iceaxe-0.2.3.dev4/iceaxe/__tests__/test_helpers.py +9 -0
  5. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/test_queries.py +10 -0
  6. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/test_session.py +26 -0
  7. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/comparison.py +25 -2
  8. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/field.py +6 -1
  9. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/queries.py +30 -0
  10. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/session_optimized.pyx +21 -10
  11. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/typing.py +5 -2
  12. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/pyproject.toml +1 -1
  13. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/setup.py +1 -1
  14. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/LICENSE +0 -0
  15. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/README.md +0 -0
  16. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/build.py +0 -0
  17. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/.DS_Store +0 -0
  18. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__init__.py +0 -0
  19. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/__init__.py +0 -0
  20. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/benchmarks/__init__.py +0 -0
  21. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/benchmarks/test_select.py +0 -0
  22. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/conf_models.py +0 -0
  23. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/conftest.py +0 -0
  24. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/migrations/__init__.py +0 -0
  25. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/migrations/conftest.py +0 -0
  26. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/migrations/test_action_sorter.py +0 -0
  27. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/migrations/test_generator.py +0 -0
  28. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/migrations/test_generics.py +0 -0
  29. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/mountaineer/__init__.py +0 -0
  30. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/mountaineer/dependencies/__init__.py +0 -0
  31. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/mountaineer/dependencies/test_core.py +0 -0
  32. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/schemas/__init__.py +0 -0
  33. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/schemas/test_actions.py +0 -0
  34. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/schemas/test_cli.py +0 -0
  35. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/schemas/test_db_memory_serializer.py +0 -0
  36. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/schemas/test_db_serializer.py +0 -0
  37. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/schemas/test_db_stubs.py +0 -0
  38. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/test_base.py +0 -0
  39. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/test_field.py +0 -0
  40. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/base.py +0 -0
  41. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/functions.py +0 -0
  42. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/generics.py +0 -0
  43. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/io.py +0 -0
  44. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/logging.py +0 -0
  45. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/migrations/__init__.py +0 -0
  46. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/migrations/action_sorter.py +0 -0
  47. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/migrations/cli.py +0 -0
  48. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/migrations/client_io.py +0 -0
  49. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/migrations/generator.py +0 -0
  50. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/migrations/migration.py +0 -0
  51. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/migrations/migrator.py +0 -0
  52. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/mountaineer/__init__.py +0 -0
  53. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/mountaineer/cli.py +0 -0
  54. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/mountaineer/config.py +0 -0
  55. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/mountaineer/dependencies/__init__.py +0 -0
  56. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/mountaineer/dependencies/core.py +0 -0
  57. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/postgres.py +0 -0
  58. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/py.typed +0 -0
  59. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/queries_str.py +0 -0
  60. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/schemas/__init__.py +0 -0
  61. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/schemas/actions.py +0 -0
  62. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/schemas/cli.py +0 -0
  63. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/schemas/db_memory_serializer.py +0 -0
  64. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/schemas/db_serializer.py +0 -0
  65. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/schemas/db_stubs.py +0 -0
  66. {iceaxe-0.2.3.dev2 → iceaxe-0.2.3.dev4}/iceaxe/session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: iceaxe
3
- Version: 0.2.3.dev2
3
+ Version: 0.2.3.dev4
4
4
  Summary: A modern, fast ORM for Python.
5
5
  Author: Pierce Freeman
6
6
  Author-email: pierce@freeman.vc
@@ -0,0 +1,263 @@
1
+ import ast
2
+ import inspect
3
+ import os
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass
6
+ from json import JSONDecodeError, dump as json_dump, loads as json_loads
7
+ from re import Pattern
8
+ from tempfile import NamedTemporaryFile, TemporaryDirectory
9
+ from textwrap import dedent
10
+
11
+ from pyright import run
12
+
13
+
14
+ @dataclass
15
+ class PyrightDiagnostic:
16
+ file: str
17
+ severity: str
18
+ message: str
19
+ rule: str | None
20
+ line: int
21
+ column: int
22
+
23
+
24
+ class ExpectedPyrightError(Exception):
25
+ """
26
+ Exception raised when Pyright doesn't produce the expected error
27
+
28
+ """
29
+
30
+ pass
31
+
32
+
33
+ def get_imports_from_module(module_source: str) -> set[str]:
34
+ """
35
+ Extract all import statements from module source
36
+
37
+ """
38
+ tree = ast.parse(module_source)
39
+ imports: set[str] = set()
40
+
41
+ for node in ast.walk(tree):
42
+ if isinstance(node, ast.Import):
43
+ for name in node.names:
44
+ imports.add(f"import {name.name}")
45
+ elif isinstance(node, ast.ImportFrom):
46
+ names = ", ".join(name.name for name in node.names)
47
+ if node.module is None:
48
+ # Handle "from . import x" case
49
+ imports.add(f"from . import {names}")
50
+ else:
51
+ imports.add(f"from {node.module} import {names}")
52
+
53
+ return imports
54
+
55
+
56
+ def strip_type_ignore(line: str) -> str:
57
+ """
58
+ Strip type: ignore comments from a line while preserving the line content
59
+
60
+ """
61
+ if "#" not in line:
62
+ return line
63
+
64
+ # Split only on the first #
65
+ code_part, *comment_parts = line.split("#", 1)
66
+ if not comment_parts:
67
+ return line
68
+
69
+ comment = comment_parts[0]
70
+ # If this is a type: ignore comment, return just the code
71
+ if "type:" in comment and "ignore" in comment:
72
+ return code_part.rstrip()
73
+
74
+ # Otherwise return the full line
75
+ return line
76
+
77
+
78
+ def extract_current_function_code():
79
+ """
80
+ Extracts the source code of the function calling this utility,
81
+ along with any necessary imports at the module level. This only works for
82
+ functions in a pytest testing context that are prefixed with `test_`.
83
+
84
+ """
85
+ # Get the frame of the calling function
86
+ frame = inspect.currentframe()
87
+
88
+ try:
89
+ # Go up until we find the test function; workaround to not
90
+ # knowing the entrypoint of our contextmanager at runtime
91
+ while frame is not None:
92
+ func_name = frame.f_code.co_name
93
+ if func_name.startswith("test_"):
94
+ test_frame = frame
95
+ break
96
+ frame = frame.f_back
97
+ else:
98
+ raise RuntimeError("Could not find test function frame")
99
+
100
+ # Source code of the function
101
+ func_source = inspect.getsource(test_frame.f_code)
102
+
103
+ # Source code of the larger test file, which contains the test function
104
+ # All the imports used by the test function should be within this file
105
+ module = inspect.getmodule(test_frame)
106
+ if not module:
107
+ raise RuntimeError("Could not find module for test function")
108
+
109
+ module_source = inspect.getsource(module)
110
+
111
+ # Postprocess the source code to build into a valid new module
112
+ imports = get_imports_from_module(module_source)
113
+ filtered_lines = [strip_type_ignore(line) for line in func_source.split("\n")]
114
+ return "\n".join(sorted(imports)) + "\n\n" + dedent("\n".join(filtered_lines))
115
+
116
+ finally:
117
+ del frame # Avoid reference cycles
118
+
119
+
120
+ def create_pyright_config():
121
+ """
122
+ Creates a new pyright configuration that ignores unused imports or other
123
+ issues that are not related to context-manager wrapped type checking.
124
+
125
+ """
126
+ return {
127
+ "include": ["."],
128
+ "exclude": [],
129
+ "ignore": [],
130
+ "strict": [],
131
+ "typeCheckingMode": "strict",
132
+ "reportUnusedImport": False,
133
+ "reportUnusedVariable": False,
134
+ # Focus only on type checking
135
+ "reportOptionalMemberAccess": True,
136
+ "reportGeneralTypeIssues": True,
137
+ "reportPropertyTypeMismatch": True,
138
+ "reportFunctionMemberAccess": True,
139
+ "reportTypeCommentUsage": True,
140
+ "reportMissingTypeStubs": False,
141
+ # Only typehint intentional typehints, not inferred values
142
+ "reportUnknownParameterType": False,
143
+ "reportUnknownVariableType": False,
144
+ "reportUnknownMemberType": False,
145
+ "reportUnknownArgumentType": False,
146
+ "reportMissingParameterType": False,
147
+ }
148
+
149
+
150
+ def run_pyright(file_path: str) -> list[PyrightDiagnostic]:
151
+ """
152
+ Run pyright on a file and return the diagnostics
153
+
154
+ """
155
+ try:
156
+ with TemporaryDirectory() as temp_dir:
157
+ # Create pyright config
158
+ config_path = os.path.join(temp_dir, "pyrightconfig.json")
159
+ with open(config_path, "w") as f:
160
+ json_dump(create_pyright_config(), f)
161
+
162
+ # Copy the file to analyze into the project directory
163
+ test_file = os.path.join(temp_dir, "test.py")
164
+ with open(file_path, "r") as src, open(test_file, "w") as dst:
165
+ dst.write(src.read())
166
+
167
+ # Run pyright with the config
168
+ result = run(
169
+ "--project",
170
+ temp_dir,
171
+ "--outputjson",
172
+ test_file,
173
+ capture_output=True,
174
+ text=True,
175
+ )
176
+
177
+ try:
178
+ output = json_loads(result.stdout)
179
+ except JSONDecodeError:
180
+ print(f"Failed to parse pyright output: {result.stdout}") # noqa: T201
181
+ print(f"Stderr: {result.stderr}") # noqa: T201
182
+ raise
183
+
184
+ if "generalDiagnostics" not in output:
185
+ raise RuntimeError(
186
+ f"Unknown pyright output, missing generalDiagnostics: {output}"
187
+ )
188
+
189
+ diagnostics: list[PyrightDiagnostic] = []
190
+ for diag in output["generalDiagnostics"]:
191
+ diagnostics.append(
192
+ PyrightDiagnostic(
193
+ file=diag["file"],
194
+ severity=diag["severity"],
195
+ message=diag["message"],
196
+ rule=diag.get("rule"),
197
+ line=diag["range"]["start"]["line"] + 1, # Convert to 1-based
198
+ column=(
199
+ diag["range"]["start"]["character"]
200
+ + 1 # Convert to 1-based
201
+ ),
202
+ )
203
+ )
204
+
205
+ return diagnostics
206
+
207
+ except Exception as e:
208
+ raise RuntimeError(f"Failed to run pyright: {str(e)}")
209
+
210
+
211
+ @contextmanager
212
+ def pyright_raises(
213
+ expected_rule: str,
214
+ expected_line: int | None = None,
215
+ matches: Pattern | None = None,
216
+ ):
217
+ """
218
+ Context manager that verifies code produces a specific Pyright error.
219
+
220
+ :params expected_rule: The Pyright rule that should be violated
221
+ :params expected_line: Optional line number where the error should occur
222
+
223
+ :raises ExpectedPyrightError: If Pyright doesn't produce the expected error
224
+
225
+ """
226
+ # Create a temporary file to store the code
227
+ with NamedTemporaryFile(mode="w", suffix=".py") as temp_file:
228
+ temp_path = temp_file.name
229
+
230
+ # Extract the source code of the calling function
231
+ source_code = extract_current_function_code()
232
+ print(f"Running Pyright on:\n{source_code}") # noqa: T201
233
+
234
+ # Write the source code to the temporary file
235
+ temp_file.write(source_code)
236
+ temp_file.flush()
237
+
238
+ # At runtime, our actual code is probably a no-op but we still let it run
239
+ # inside the scope of the contextmanager
240
+ yield
241
+
242
+ # Run Pyright on the temporary file
243
+ diagnostics = run_pyright(temp_path)
244
+
245
+ # Check if any of the diagnostics match our expected error
246
+ for diagnostic in diagnostics:
247
+ if diagnostic.rule == expected_rule:
248
+ if expected_line is not None and diagnostic.line != expected_line:
249
+ continue
250
+ if matches and not matches.search(diagnostic.message):
251
+ continue
252
+ # Found matching error
253
+ return
254
+
255
+ # If we get here, we didn't find the expected error
256
+ actual_errors = [
257
+ f"{d.rule or 'unknown'} on line {d.line}: {d.message}" for d in diagnostics
258
+ ]
259
+ raise ExpectedPyrightError(
260
+ f"Expected Pyright error {expected_rule}"
261
+ f"{f' on line {expected_line}' if expected_line else ''}"
262
+ f" but got: {', '.join(actual_errors) if actual_errors else 'no errors'}"
263
+ )
@@ -1,10 +1,14 @@
1
+ from re import compile as re_compile
1
2
  from typing import Any
2
3
 
3
4
  import pytest
5
+ from typing_extensions import assert_type
4
6
 
7
+ from iceaxe.__tests__.helpers import pyright_raises
5
8
  from iceaxe.base import TableBase
6
9
  from iceaxe.comparison import ComparisonType, FieldComparison
7
10
  from iceaxe.field import DBFieldClassDefinition, DBFieldInfo
11
+ from iceaxe.typing import column
8
12
 
9
13
 
10
14
  def test_comparison_type_enum():
@@ -17,6 +21,9 @@ def test_comparison_type_enum():
17
21
  assert ComparisonType.IN == "IN"
18
22
  assert ComparisonType.NOT_IN == "NOT IN"
19
23
  assert ComparisonType.LIKE == "LIKE"
24
+ assert ComparisonType.NOT_LIKE == "NOT LIKE"
25
+ assert ComparisonType.ILIKE == "ILIKE"
26
+ assert ComparisonType.NOT_ILIKE == "NOT ILIKE"
20
27
  assert ComparisonType.IS == "IS"
21
28
  assert ComparisonType.IS_NOT == "IS NOT"
22
29
 
@@ -158,3 +165,31 @@ def test_comparison_with_different_types(db_field: DBFieldClassDefinition, value
158
165
  assert result.left == db_field
159
166
  assert isinstance(result.comparison, ComparisonType)
160
167
  assert result.right == value
168
+
169
+
170
+ #
171
+ # Typehinting
172
+ # These checks are run as part of the static typechecking we do
173
+ # for our codebase, not as part of the pytest runtime.
174
+ #
175
+
176
+
177
+ def test_typehint_ilike():
178
+ class UserDemo(TableBase):
179
+ id: int
180
+ value_str: str
181
+ value_int: int
182
+
183
+ str_col = column(UserDemo.value_str)
184
+ int_col = column(UserDemo.value_int)
185
+
186
+ assert_type(str_col, DBFieldClassDefinition[str])
187
+ assert_type(int_col, DBFieldClassDefinition[int])
188
+
189
+ assert_type(str_col.ilike("test"), bool)
190
+
191
+ with pyright_raises(
192
+ "reportAttributeAccessIssue",
193
+ matches=re_compile('Cannot access attribute "ilike"'),
194
+ ):
195
+ int_col.ilike(5) # type: ignore
@@ -0,0 +1,9 @@
1
+ from iceaxe.__tests__.helpers import pyright_raises
2
+
3
+
4
+ def test_basic_type_error():
5
+ def type_error_func(x: int) -> int:
6
+ return 10
7
+
8
+ with pyright_raises("reportArgumentType"):
9
+ type_error_func("20") # type: ignore
@@ -267,3 +267,13 @@ def test_select_multiple_typehints():
267
267
  query = select((UserDemo, UserDemo.id, UserDemo.name))
268
268
  if TYPE_CHECKING:
269
269
  _: QueryBuilder[tuple[UserDemo, int, str], Literal["SELECT"]] = query
270
+
271
+
272
+ def test_allow_branching():
273
+ base_query = select(UserDemo)
274
+
275
+ query_1 = base_query.limit(1)
276
+ query_2 = base_query.limit(2)
277
+
278
+ assert query_1.limit_value == 1
279
+ assert query_2.limit_value == 2
@@ -442,6 +442,32 @@ async def test_select_with_left_join(db_connection: DBConnection):
442
442
  assert result[1] == ("John", 2)
443
443
 
444
444
 
445
+ @pytest.mark.asyncio
446
+ async def test_select_with_left_join_object(db_connection: DBConnection):
447
+ users = [
448
+ UserDemo(name="John", email="john@example.com"),
449
+ UserDemo(name="Jane", email="jane@example.com"),
450
+ ]
451
+ await db_connection.insert(users)
452
+
453
+ posts = [
454
+ ArtifactDemo(title="John's Post", user_id=users[0].id),
455
+ ArtifactDemo(title="Another Post", user_id=users[0].id),
456
+ ]
457
+ await db_connection.insert(posts)
458
+
459
+ query = (
460
+ QueryBuilder()
461
+ .select((UserDemo, ArtifactDemo))
462
+ .join(ArtifactDemo, UserDemo.id == ArtifactDemo.user_id, "LEFT")
463
+ )
464
+ result = await db_connection.exec(query)
465
+ assert len(result) == 3
466
+ assert result[0] == (users[0], posts[0])
467
+ assert result[1] == (users[0], posts[1])
468
+ assert result[2] == (users[1], None)
469
+
470
+
445
471
  # @pytest.mark.asyncio
446
472
  # async def test_select_with_subquery(db_connection: DBConnection):
447
473
  # users = [
@@ -7,6 +7,7 @@ from iceaxe.queries_str import QueryElementBase, QueryLiteral
7
7
  from iceaxe.typing import is_column, is_comparison, is_comparison_group
8
8
 
9
9
  T = TypeVar("T", bound="ComparisonBase")
10
+ J = TypeVar("J")
10
11
 
11
12
 
12
13
  class ComparisonType(StrEnum):
@@ -18,7 +19,12 @@ class ComparisonType(StrEnum):
18
19
  GE = ">="
19
20
  IN = "IN"
20
21
  NOT_IN = "NOT IN"
22
+
21
23
  LIKE = "LIKE"
24
+ NOT_LIKE = "NOT LIKE"
25
+ ILIKE = "ILIKE"
26
+ NOT_ILIKE = "NOT ILIKE"
27
+
22
28
  IS = "IS"
23
29
  IS_NOT = "IS NOT"
24
30
 
@@ -95,7 +101,7 @@ class FieldComparisonGroup:
95
101
  return QueryLiteral(queries), all_variables
96
102
 
97
103
 
98
- class ComparisonBase(ABC):
104
+ class ComparisonBase(ABC, Generic[J]):
99
105
  def __eq__(self, other): # type: ignore
100
106
  if other is None:
101
107
  return self._compare(ComparisonType.IS, None)
@@ -124,9 +130,26 @@ class ComparisonBase(ABC):
124
130
  def not_in(self, other) -> bool:
125
131
  return self._compare(ComparisonType.NOT_IN, other) # type: ignore
126
132
 
127
- def like(self, other) -> bool:
133
+ def like(
134
+ self: "ComparisonBase[str] | ComparisonBase[str | None]", other: str
135
+ ) -> bool:
128
136
  return self._compare(ComparisonType.LIKE, other) # type: ignore
129
137
 
138
+ def not_like(
139
+ self: "ComparisonBase[str] | ComparisonBase[str | None]", other: str
140
+ ) -> bool:
141
+ return self._compare(ComparisonType.NOT_LIKE, other) # type: ignore
142
+
143
+ def ilike(
144
+ self: "ComparisonBase[str] | ComparisonBase[str | None]", other: str
145
+ ) -> bool:
146
+ return self._compare(ComparisonType.ILIKE, other) # type: ignore
147
+
148
+ def not_ilike(
149
+ self: "ComparisonBase[str] | ComparisonBase[str | None]", other: str
150
+ ) -> bool:
151
+ return self._compare(ComparisonType.NOT_ILIKE, other) # type: ignore
152
+
130
153
  def _compare(self, comparison: ComparisonType, other: Any) -> FieldComparison[Self]:
131
154
  return FieldComparison(left=self, comparison=comparison, right=other)
132
155
 
@@ -4,8 +4,10 @@ from typing import (
4
4
  Any,
5
5
  Callable,
6
6
  Concatenate,
7
+ Generic,
7
8
  ParamSpec,
8
9
  Type,
10
+ TypeVar,
9
11
  Unpack,
10
12
  cast,
11
13
  )
@@ -139,7 +141,10 @@ def __get_db_field(_: Callable[Concatenate[Any, P], Any] = PydanticField): # ty
139
141
  return func
140
142
 
141
143
 
142
- class DBFieldClassDefinition(ComparisonBase):
144
+ T = TypeVar("T")
145
+
146
+
147
+ class DBFieldClassDefinition(Generic[T], ComparisonBase[T]):
143
148
  root_model: Type["TableBase"]
144
149
  key: str
145
150
  field_definition: DBFieldInfo
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from copy import copy
4
+ from functools import wraps
3
5
  from typing import Any, Generic, Literal, Type, TypeVar, TypeVarTuple, cast, overload
4
6
 
5
7
  from iceaxe.base import (
@@ -53,6 +55,22 @@ JoinType = Literal["INNER", "LEFT", "RIGHT", "FULL"]
53
55
  OrderDirection = Literal["ASC", "DESC"]
54
56
 
55
57
 
58
+ def allow_branching(fn):
59
+ """
60
+ Allows query method modifiers to implement their logic as if `self` is being
61
+ modified, but in the background we'll actually return a new instance of the
62
+ query builder to allow for branching of the same underlying query.
63
+
64
+ """
65
+
66
+ @wraps(fn)
67
+ def new_fn(self, *args, **kwargs):
68
+ self = copy(self)
69
+ return fn(self, *args, **kwargs)
70
+
71
+ return new_fn
72
+
73
+
56
74
  class QueryBuilder(Generic[P, QueryType]):
57
75
  """
58
76
  The QueryBuilder owns all construction of the SQL string given
@@ -118,6 +136,7 @@ class QueryBuilder(Generic[P, QueryType]):
118
136
  self, fields: tuple[T | Type[T], T2 | Type[T2], T3 | Type[T3], *Ts]
119
137
  ) -> QueryBuilder[tuple[T, T2, T3, *Ts], Literal["SELECT"]]: ...
120
138
 
139
+ @allow_branching
121
140
  def select(
122
141
  self,
123
142
  fields: (
@@ -212,6 +231,7 @@ class QueryBuilder(Generic[P, QueryType]):
212
231
  self.select_raw.append(field)
213
232
  self.select_aggregate_count += 1
214
233
 
234
+ @allow_branching
215
235
  def update(self, model: Type[TableBase]) -> QueryBuilder[None, Literal["UPDATE"]]:
216
236
  """
217
237
  Creates a new update query for the given model. Returns the same
@@ -222,6 +242,7 @@ class QueryBuilder(Generic[P, QueryType]):
222
242
  self.main_model = model
223
243
  return self # type: ignore
224
244
 
245
+ @allow_branching
225
246
  def delete(self, model: Type[TableBase]) -> QueryBuilder[None, Literal["DELETE"]]:
226
247
  """
227
248
  Creates a new delete query for the given model. Returns the same
@@ -232,6 +253,7 @@ class QueryBuilder(Generic[P, QueryType]):
232
253
  self.main_model = model
233
254
  return self # type: ignore
234
255
 
256
+ @allow_branching
235
257
  def where(self, *conditions: bool):
236
258
  """
237
259
  Adds a where condition to the query. The conditions are combined with
@@ -250,6 +272,7 @@ class QueryBuilder(Generic[P, QueryType]):
250
272
  self.where_conditions += validated_comparisons
251
273
  return self
252
274
 
275
+ @allow_branching
253
276
  def order_by(self, field: Any, direction: OrderDirection = "ASC"):
254
277
  """
255
278
  Adds an order by clause to the query. The field must be a column.
@@ -265,6 +288,7 @@ class QueryBuilder(Generic[P, QueryType]):
265
288
  self.order_by_clauses.append(f"{field_token} {direction}")
266
289
  return self
267
290
 
291
+ @allow_branching
268
292
  def join(self, table: Type[TableBase], on: bool, join_type: JoinType = "INNER"):
269
293
  """
270
294
  Adds a join clause to the query. The `on` parameter should be a comparison
@@ -289,6 +313,7 @@ class QueryBuilder(Generic[P, QueryType]):
289
313
  self.join_clauses.append(join_sql)
290
314
  return self
291
315
 
316
+ @allow_branching
292
317
  def set(self, column: T, value: T | None):
293
318
  """
294
319
  Sets a column to a specific value in an update query.
@@ -300,6 +325,7 @@ class QueryBuilder(Generic[P, QueryType]):
300
325
  self.update_values.append((column, value))
301
326
  return self
302
327
 
328
+ @allow_branching
303
329
  def limit(self, value: int):
304
330
  """
305
331
  Limit the number of rows returned by the query. Useful in pagination
@@ -309,6 +335,7 @@ class QueryBuilder(Generic[P, QueryType]):
309
335
  self.limit_value = value
310
336
  return self
311
337
 
338
+ @allow_branching
312
339
  def offset(self, value: int):
313
340
  """
314
341
  Offset the number of rows returned by the query.
@@ -317,6 +344,7 @@ class QueryBuilder(Generic[P, QueryType]):
317
344
  self.offset_value = value
318
345
  return self
319
346
 
347
+ @allow_branching
320
348
  def group_by(self, *fields: Any):
321
349
  """
322
350
  Groups the results of the query by the given fields. This allows
@@ -334,6 +362,7 @@ class QueryBuilder(Generic[P, QueryType]):
334
362
  self.group_by_fields = valid_fields
335
363
  return self
336
364
 
365
+ @allow_branching
337
366
  def having(self, *conditions: bool):
338
367
  """
339
368
  Require the result of an aggregation query like func.sum(MyTable.column)
@@ -351,6 +380,7 @@ class QueryBuilder(Generic[P, QueryType]):
351
380
  self.having_conditions += valid_conditions
352
381
  return self
353
382
 
383
+ @allow_branching
354
384
  def text(self, query: str, *variables: Any):
355
385
  """
356
386
  Override the ORM builder and use a raw SQL query instead.
@@ -98,6 +98,7 @@ cdef list process_values(
98
98
  cdef object field_value
99
99
  cdef object select_raw
100
100
  cdef PyObject* temp_obj
101
+ cdef bint all_none
101
102
 
102
103
  for i in range(num_values):
103
104
  value = values[i]
@@ -112,6 +113,9 @@ cdef list process_values(
112
113
  if raw_is_table:
113
114
  obj_dict = {}
114
115
  num_fields = num_fields_array[j]
116
+ all_none = True
117
+
118
+ # First pass: collect all fields and check if they're all None
115
119
  for k in range(num_fields):
116
120
  field_name_c = fields[j][k].name
117
121
  select_name_c = fields[j][k].select_attribute
@@ -119,22 +123,29 @@ cdef list process_values(
119
123
  select_name = select_name_c.decode('utf-8')
120
124
 
121
125
  try:
122
- field_value = value[select_name] # Use Python dictionary access instead of PyObject_GetItem
126
+ field_value = value[select_name]
123
127
  except KeyError:
124
128
  raise KeyError(f"Key '{select_name}' not found in value.")
125
129
 
126
- if fields[j][k].is_json:
127
- field_value = json_loads(field_value)
130
+ if field_value is not None:
131
+ all_none = False
132
+ if fields[j][k].is_json:
133
+ field_value = json_loads(field_value)
128
134
 
129
135
  obj_dict[field_name] = field_value
130
136
 
131
- obj = select_raw(**obj_dict)
132
- result_value[j] = <PyObject*>obj
133
- Py_INCREF(obj) # Increment reference count for the stored object
137
+ # If all fields are None, store None instead of creating the table object
138
+ if all_none:
139
+ result_value[j] = <PyObject*>None
140
+ Py_INCREF(None)
141
+ else:
142
+ obj = select_raw(**obj_dict)
143
+ result_value[j] = <PyObject*>obj
144
+ Py_INCREF(obj)
134
145
 
135
146
  elif raw_is_column:
136
147
  try:
137
- item = value[select_raw.key] # Use Python dictionary access
148
+ item = value[select_raw.key]
138
149
  except KeyError:
139
150
  raise KeyError(f"Key '{select_raw.key}' not found in value.")
140
151
  result_value[j] = <PyObject*>item
@@ -142,7 +153,7 @@ cdef list process_values(
142
153
 
143
154
  elif raw_is_function_metadata:
144
155
  try:
145
- item = value[select_raw.local_name] # Use Python dictionary access
156
+ item = value[select_raw.local_name]
146
157
  except KeyError:
147
158
  raise KeyError(f"Key '{select_raw.local_name}' not found in value.")
148
159
  result_value[j] = <PyObject*>item
@@ -151,11 +162,11 @@ cdef list process_values(
151
162
  # Assemble the result
152
163
  if num_selects == 1:
153
164
  result_all[i] = <object>result_value[0]
154
- Py_DECREF(<object>result_value[0]) # Decrement reference count
165
+ Py_DECREF(<object>result_value[0])
155
166
  else:
156
167
  result_tuple = tuple([<object>result_value[j] for j in range(num_selects)])
157
168
  for j in range(num_selects):
158
- Py_DECREF(<object>result_value[j]) # Decrement reference count for each item
169
+ Py_DECREF(<object>result_value[j])
159
170
  result_all[i] = result_tuple
160
171
 
161
172
  finally:
@@ -8,6 +8,7 @@ from typing import (
8
8
  Any,
9
9
  Type,
10
10
  TypeGuard,
11
+ TypeVar,
11
12
  )
12
13
  from uuid import UUID
13
14
 
@@ -27,6 +28,8 @@ PRIMITIVE_WRAPPER_TYPES = list[PRIMITIVE_TYPES] | PRIMITIVE_TYPES
27
28
  DATE_TYPES = datetime | date | time | timedelta
28
29
  JSON_WRAPPER_FALLBACK = list[Any] | dict[Any, Any]
29
30
 
31
+ T = TypeVar("T")
32
+
30
33
 
31
34
  def is_base_table(obj: Any) -> TypeGuard[type[TableBase]]:
32
35
  from iceaxe.base import TableBase
@@ -34,7 +37,7 @@ def is_base_table(obj: Any) -> TypeGuard[type[TableBase]]:
34
37
  return isclass(obj) and issubclass(obj, TableBase)
35
38
 
36
39
 
37
- def is_column(obj: Any) -> TypeGuard[DBFieldClassDefinition]:
40
+ def is_column(obj: T) -> TypeGuard[DBFieldClassDefinition[T]]:
38
41
  from iceaxe.base import DBFieldClassDefinition
39
42
 
40
43
  return isinstance(obj, DBFieldClassDefinition)
@@ -64,7 +67,7 @@ def is_function_metadata(obj: Any) -> TypeGuard[FunctionMetadata]:
64
67
  return isinstance(obj, FunctionMetadata)
65
68
 
66
69
 
67
- def column(obj: Any):
70
+ def column(obj: T) -> DBFieldClassDefinition[T]:
68
71
  if not is_column(obj):
69
72
  raise ValueError(f"Invalid column: {obj}")
70
73
  return obj
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "iceaxe"
3
- version = "0.2.3.dev2"
3
+ version = "0.2.3.dev4"
4
4
  description = "A modern, fast ORM for Python."
5
5
  authors = ["Pierce Freeman <pierce@freeman.vc>"]
6
6
  readme = "README.md"
@@ -22,7 +22,7 @@ install_requires = \
22
22
 
23
23
  setup_kwargs = {
24
24
  'name': 'iceaxe',
25
- 'version': '0.2.3.dev2',
25
+ 'version': '0.2.3.dev4',
26
26
  'description': 'A modern, fast ORM for Python.',
27
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',
28
28
  'author': 'Pierce Freeman',
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes