iceaxe 0.7.1__cp313-cp313-macosx_11_0_arm64.whl
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.
Potentially problematic release.
This version of iceaxe might be problematic. Click here for more details.
- iceaxe/__init__.py +20 -0
- iceaxe/__tests__/__init__.py +0 -0
- iceaxe/__tests__/benchmarks/__init__.py +0 -0
- iceaxe/__tests__/benchmarks/test_bulk_insert.py +45 -0
- iceaxe/__tests__/benchmarks/test_select.py +114 -0
- iceaxe/__tests__/conf_models.py +133 -0
- iceaxe/__tests__/conftest.py +204 -0
- iceaxe/__tests__/docker_helpers.py +208 -0
- iceaxe/__tests__/helpers.py +268 -0
- iceaxe/__tests__/migrations/__init__.py +0 -0
- iceaxe/__tests__/migrations/conftest.py +36 -0
- iceaxe/__tests__/migrations/test_action_sorter.py +237 -0
- iceaxe/__tests__/migrations/test_generator.py +140 -0
- iceaxe/__tests__/migrations/test_generics.py +91 -0
- iceaxe/__tests__/mountaineer/__init__.py +0 -0
- iceaxe/__tests__/mountaineer/dependencies/__init__.py +0 -0
- iceaxe/__tests__/mountaineer/dependencies/test_core.py +76 -0
- iceaxe/__tests__/schemas/__init__.py +0 -0
- iceaxe/__tests__/schemas/test_actions.py +1264 -0
- iceaxe/__tests__/schemas/test_cli.py +25 -0
- iceaxe/__tests__/schemas/test_db_memory_serializer.py +1525 -0
- iceaxe/__tests__/schemas/test_db_serializer.py +398 -0
- iceaxe/__tests__/schemas/test_db_stubs.py +190 -0
- iceaxe/__tests__/test_alias.py +83 -0
- iceaxe/__tests__/test_base.py +52 -0
- iceaxe/__tests__/test_comparison.py +383 -0
- iceaxe/__tests__/test_field.py +11 -0
- iceaxe/__tests__/test_helpers.py +9 -0
- iceaxe/__tests__/test_modifications.py +151 -0
- iceaxe/__tests__/test_queries.py +605 -0
- iceaxe/__tests__/test_queries_str.py +173 -0
- iceaxe/__tests__/test_session.py +1511 -0
- iceaxe/__tests__/test_text_search.py +287 -0
- iceaxe/alias_values.py +67 -0
- iceaxe/base.py +350 -0
- iceaxe/comparison.py +560 -0
- iceaxe/field.py +250 -0
- iceaxe/functions.py +906 -0
- iceaxe/generics.py +140 -0
- iceaxe/io.py +107 -0
- iceaxe/logging.py +91 -0
- iceaxe/migrations/__init__.py +5 -0
- iceaxe/migrations/action_sorter.py +98 -0
- iceaxe/migrations/cli.py +228 -0
- iceaxe/migrations/client_io.py +62 -0
- iceaxe/migrations/generator.py +404 -0
- iceaxe/migrations/migration.py +86 -0
- iceaxe/migrations/migrator.py +101 -0
- iceaxe/modifications.py +176 -0
- iceaxe/mountaineer/__init__.py +10 -0
- iceaxe/mountaineer/cli.py +74 -0
- iceaxe/mountaineer/config.py +46 -0
- iceaxe/mountaineer/dependencies/__init__.py +6 -0
- iceaxe/mountaineer/dependencies/core.py +67 -0
- iceaxe/postgres.py +133 -0
- iceaxe/py.typed +0 -0
- iceaxe/queries.py +1455 -0
- iceaxe/queries_str.py +294 -0
- iceaxe/schemas/__init__.py +0 -0
- iceaxe/schemas/actions.py +864 -0
- iceaxe/schemas/cli.py +30 -0
- iceaxe/schemas/db_memory_serializer.py +705 -0
- iceaxe/schemas/db_serializer.py +346 -0
- iceaxe/schemas/db_stubs.py +525 -0
- iceaxe/session.py +860 -0
- iceaxe/session_optimized.c +12035 -0
- iceaxe/session_optimized.cpython-313-darwin.so +0 -0
- iceaxe/session_optimized.pyx +212 -0
- iceaxe/sql_types.py +148 -0
- iceaxe/typing.py +73 -0
- iceaxe-0.7.1.dist-info/METADATA +261 -0
- iceaxe-0.7.1.dist-info/RECORD +75 -0
- iceaxe-0.7.1.dist-info/WHEEL +6 -0
- iceaxe-0.7.1.dist-info/licenses/LICENSE +21 -0
- iceaxe-0.7.1.dist-info/top_level.txt +1 -0
|
Binary file
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from typing import Any, List, Tuple
|
|
2
|
+
from iceaxe.base import TableBase
|
|
3
|
+
from iceaxe.queries import FunctionMetadata
|
|
4
|
+
from iceaxe.alias_values import Alias
|
|
5
|
+
from json import loads as json_loads
|
|
6
|
+
from cpython.ref cimport PyObject
|
|
7
|
+
from cpython.object cimport PyObject_GetItem
|
|
8
|
+
from libc.stdlib cimport malloc, free
|
|
9
|
+
from libc.string cimport memcpy
|
|
10
|
+
from cpython.ref cimport Py_INCREF, Py_DECREF
|
|
11
|
+
|
|
12
|
+
cdef struct FieldInfo:
|
|
13
|
+
char* name # Field name
|
|
14
|
+
char* select_attribute # Corresponding attribute in the select_raw
|
|
15
|
+
bint is_json # Flag indicating if the field is JSON
|
|
16
|
+
|
|
17
|
+
cdef char* allocate_cstring(bytes data):
|
|
18
|
+
cdef Py_ssize_t length = len(data)
|
|
19
|
+
cdef char* c_str = <char*>malloc((length + 1) * sizeof(char))
|
|
20
|
+
if not c_str:
|
|
21
|
+
raise MemoryError("Failed to allocate memory for C string.")
|
|
22
|
+
memcpy(c_str, <char*>data, length) # Cast bytes to char* for memcpy
|
|
23
|
+
c_str[length] = 0 # Null-terminate the string
|
|
24
|
+
return c_str
|
|
25
|
+
|
|
26
|
+
cdef void free_fields(FieldInfo** fields, Py_ssize_t* num_fields_array, Py_ssize_t num_selects):
|
|
27
|
+
cdef Py_ssize_t j, k
|
|
28
|
+
if fields:
|
|
29
|
+
for j in range(num_selects):
|
|
30
|
+
if fields[j]:
|
|
31
|
+
for k in range(num_fields_array[j]):
|
|
32
|
+
free(fields[j][k].name)
|
|
33
|
+
free(fields[j][k].select_attribute)
|
|
34
|
+
free(fields[j])
|
|
35
|
+
free(fields)
|
|
36
|
+
if num_fields_array:
|
|
37
|
+
free(num_fields_array)
|
|
38
|
+
|
|
39
|
+
cdef FieldInfo** precompute_fields(list select_raws, list select_types, Py_ssize_t num_selects, Py_ssize_t* num_fields_array):
|
|
40
|
+
cdef FieldInfo** fields = <FieldInfo**>malloc(num_selects * sizeof(FieldInfo*))
|
|
41
|
+
cdef Py_ssize_t j, k, num_fields
|
|
42
|
+
cdef dict field_dict
|
|
43
|
+
cdef bytes select_bytes, field_bytes
|
|
44
|
+
cdef char* c_select
|
|
45
|
+
cdef char* c_field
|
|
46
|
+
cdef object select_raw
|
|
47
|
+
cdef bint raw_is_table, raw_is_column, raw_is_function_metadata
|
|
48
|
+
|
|
49
|
+
if not fields:
|
|
50
|
+
raise MemoryError("Failed to allocate memory for fields.")
|
|
51
|
+
|
|
52
|
+
for j in range(num_selects):
|
|
53
|
+
select_raw = select_raws[j]
|
|
54
|
+
raw_is_table, raw_is_column, raw_is_function_metadata = select_types[j]
|
|
55
|
+
|
|
56
|
+
if raw_is_table:
|
|
57
|
+
field_dict = {field: info.is_json for field, info in select_raw.get_client_fields().items() if not info.exclude}
|
|
58
|
+
num_fields = len(field_dict)
|
|
59
|
+
num_fields_array[j] = num_fields
|
|
60
|
+
fields[j] = <FieldInfo*>malloc(num_fields * sizeof(FieldInfo))
|
|
61
|
+
if not fields[j]:
|
|
62
|
+
raise MemoryError("Failed to allocate memory for FieldInfo.")
|
|
63
|
+
|
|
64
|
+
for k, (field, is_json) in enumerate(field_dict.items()):
|
|
65
|
+
select_bytes = f"{select_raw.get_table_name()}_{field}".encode('utf-8')
|
|
66
|
+
c_select = allocate_cstring(select_bytes)
|
|
67
|
+
|
|
68
|
+
field_bytes = field.encode('utf-8')
|
|
69
|
+
c_field = allocate_cstring(field_bytes)
|
|
70
|
+
|
|
71
|
+
fields[j][k].select_attribute = c_select
|
|
72
|
+
fields[j][k].name = c_field
|
|
73
|
+
fields[j][k].is_json = is_json
|
|
74
|
+
else:
|
|
75
|
+
num_fields_array[j] = 0
|
|
76
|
+
fields[j] = NULL
|
|
77
|
+
|
|
78
|
+
return fields
|
|
79
|
+
|
|
80
|
+
cdef list process_values(
|
|
81
|
+
list values,
|
|
82
|
+
FieldInfo** fields,
|
|
83
|
+
Py_ssize_t* num_fields_array,
|
|
84
|
+
list select_raws,
|
|
85
|
+
list select_types,
|
|
86
|
+
Py_ssize_t num_selects
|
|
87
|
+
):
|
|
88
|
+
cdef Py_ssize_t num_values = len(values)
|
|
89
|
+
cdef list result_all = [None] * num_values
|
|
90
|
+
cdef Py_ssize_t i, j, k, num_fields
|
|
91
|
+
cdef PyObject** result_value
|
|
92
|
+
cdef object value, obj, item
|
|
93
|
+
cdef dict obj_dict
|
|
94
|
+
cdef bint raw_is_table, raw_is_column, raw_is_function_metadata, raw_is_alias
|
|
95
|
+
cdef char* field_name_c
|
|
96
|
+
cdef char* select_name_c
|
|
97
|
+
cdef str field_name
|
|
98
|
+
cdef str select_name
|
|
99
|
+
cdef object field_value
|
|
100
|
+
cdef object select_raw
|
|
101
|
+
cdef PyObject* temp_obj
|
|
102
|
+
cdef bint all_none
|
|
103
|
+
|
|
104
|
+
for i in range(num_values):
|
|
105
|
+
value = values[i]
|
|
106
|
+
result_value = <PyObject**>malloc(num_selects * sizeof(PyObject*))
|
|
107
|
+
if not result_value:
|
|
108
|
+
raise MemoryError("Failed to allocate memory for result_value.")
|
|
109
|
+
try:
|
|
110
|
+
for j in range(num_selects):
|
|
111
|
+
select_raw = select_raws[j]
|
|
112
|
+
raw_is_table, raw_is_column, raw_is_function_metadata = select_types[j]
|
|
113
|
+
raw_is_alias = isinstance(select_raw, Alias)
|
|
114
|
+
|
|
115
|
+
if raw_is_table:
|
|
116
|
+
obj_dict = {}
|
|
117
|
+
num_fields = num_fields_array[j]
|
|
118
|
+
all_none = True
|
|
119
|
+
|
|
120
|
+
# First pass: collect all fields and check if they're all None
|
|
121
|
+
for k in range(num_fields):
|
|
122
|
+
field_name_c = fields[j][k].name
|
|
123
|
+
select_name_c = fields[j][k].select_attribute
|
|
124
|
+
field_name = field_name_c.decode('utf-8')
|
|
125
|
+
select_name = select_name_c.decode('utf-8')
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
field_value = value[select_name]
|
|
129
|
+
except KeyError:
|
|
130
|
+
raise KeyError(f"Key '{select_name}' not found in value.")
|
|
131
|
+
|
|
132
|
+
if field_value is not None:
|
|
133
|
+
all_none = False
|
|
134
|
+
if fields[j][k].is_json:
|
|
135
|
+
field_value = json_loads(field_value)
|
|
136
|
+
|
|
137
|
+
obj_dict[field_name] = field_value
|
|
138
|
+
|
|
139
|
+
# If all fields are None, store None instead of creating the table object
|
|
140
|
+
if all_none:
|
|
141
|
+
result_value[j] = <PyObject*>None
|
|
142
|
+
Py_INCREF(None)
|
|
143
|
+
else:
|
|
144
|
+
obj = select_raw(**obj_dict)
|
|
145
|
+
result_value[j] = <PyObject*>obj
|
|
146
|
+
Py_INCREF(obj)
|
|
147
|
+
|
|
148
|
+
elif raw_is_column:
|
|
149
|
+
try:
|
|
150
|
+
# Use the table-qualified column name
|
|
151
|
+
table_name = select_raw.root_model.get_table_name()
|
|
152
|
+
column_name = select_raw.key
|
|
153
|
+
item = value[f"{table_name}_{column_name}"]
|
|
154
|
+
except KeyError:
|
|
155
|
+
raise KeyError(f"Key '{table_name}_{column_name}' not found in value.")
|
|
156
|
+
result_value[j] = <PyObject*>item
|
|
157
|
+
Py_INCREF(item)
|
|
158
|
+
|
|
159
|
+
elif raw_is_function_metadata:
|
|
160
|
+
try:
|
|
161
|
+
item = value[select_raw.local_name]
|
|
162
|
+
except KeyError:
|
|
163
|
+
raise KeyError(f"Key '{select_raw.local_name}' not found in value.")
|
|
164
|
+
result_value[j] = <PyObject*>item
|
|
165
|
+
Py_INCREF(item)
|
|
166
|
+
|
|
167
|
+
elif raw_is_alias:
|
|
168
|
+
try:
|
|
169
|
+
item = value[select_raw.name]
|
|
170
|
+
except KeyError:
|
|
171
|
+
raise KeyError(f"Key '{select_raw.name}' not found in value.")
|
|
172
|
+
result_value[j] = <PyObject*>item
|
|
173
|
+
Py_INCREF(item)
|
|
174
|
+
|
|
175
|
+
# Assemble the result
|
|
176
|
+
if num_selects == 1:
|
|
177
|
+
result_all[i] = <object>result_value[0]
|
|
178
|
+
Py_DECREF(<object>result_value[0])
|
|
179
|
+
else:
|
|
180
|
+
result_tuple = tuple([<object>result_value[j] for j in range(num_selects)])
|
|
181
|
+
for j in range(num_selects):
|
|
182
|
+
Py_DECREF(<object>result_value[j])
|
|
183
|
+
result_all[i] = result_tuple
|
|
184
|
+
|
|
185
|
+
finally:
|
|
186
|
+
free(result_value)
|
|
187
|
+
|
|
188
|
+
return result_all
|
|
189
|
+
|
|
190
|
+
cdef list optimize_casting(list values, list select_raws, list select_types):
|
|
191
|
+
cdef Py_ssize_t num_selects = len(select_raws)
|
|
192
|
+
cdef Py_ssize_t* num_fields_array = <Py_ssize_t*>malloc(num_selects * sizeof(Py_ssize_t))
|
|
193
|
+
cdef FieldInfo** fields
|
|
194
|
+
cdef list result_all
|
|
195
|
+
|
|
196
|
+
if not num_fields_array:
|
|
197
|
+
raise MemoryError("Failed to allocate memory for num_fields_array.")
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
fields = precompute_fields(select_raws, select_types, num_selects, num_fields_array)
|
|
201
|
+
result_all = process_values(values, fields, num_fields_array, select_raws, select_types, num_selects)
|
|
202
|
+
finally:
|
|
203
|
+
free_fields(fields, num_fields_array, num_selects)
|
|
204
|
+
|
|
205
|
+
return result_all
|
|
206
|
+
|
|
207
|
+
def optimize_exec_casting(
|
|
208
|
+
values: List[Any],
|
|
209
|
+
select_raws: List[Any],
|
|
210
|
+
select_types: List[Tuple[bool, bool, bool]]
|
|
211
|
+
) -> List[Any]:
|
|
212
|
+
return optimize_casting(values, select_raws, select_types)
|
iceaxe/sql_types.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from datetime import date, datetime, time, timedelta
|
|
2
|
+
from enum import Enum, StrEnum
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ColumnType(StrEnum):
|
|
7
|
+
# The values of the enum are the actual SQL types. When constructing
|
|
8
|
+
# the column they can be case-insensitive, but when we're casting from
|
|
9
|
+
# the database to memory they must align with the on-disk representation
|
|
10
|
+
# which is lowercase.
|
|
11
|
+
#
|
|
12
|
+
# Note: The SQL standard requires that writing just "timestamp" be equivalent
|
|
13
|
+
# to "timestamp without time zone", and PostgreSQL honors that behavior.
|
|
14
|
+
# Similarly, "time" is equivalent to "time without time zone".
|
|
15
|
+
|
|
16
|
+
# Numeric Types
|
|
17
|
+
SMALLINT = "smallint"
|
|
18
|
+
INTEGER = "integer"
|
|
19
|
+
BIGINT = "bigint"
|
|
20
|
+
DECIMAL = "decimal"
|
|
21
|
+
NUMERIC = "numeric"
|
|
22
|
+
REAL = "real"
|
|
23
|
+
DOUBLE_PRECISION = "double precision"
|
|
24
|
+
SERIAL = "serial"
|
|
25
|
+
BIGSERIAL = "bigserial"
|
|
26
|
+
|
|
27
|
+
# Monetary Type
|
|
28
|
+
MONEY = "money"
|
|
29
|
+
|
|
30
|
+
# Character Types
|
|
31
|
+
CHAR = "char"
|
|
32
|
+
VARCHAR = "character varying"
|
|
33
|
+
TEXT = "text"
|
|
34
|
+
|
|
35
|
+
# Binary Data Types
|
|
36
|
+
BYTEA = "bytea"
|
|
37
|
+
|
|
38
|
+
# Date/Time Types
|
|
39
|
+
DATE = "date"
|
|
40
|
+
TIME_WITHOUT_TIME_ZONE = "time without time zone"
|
|
41
|
+
TIME_WITH_TIME_ZONE = "time with time zone"
|
|
42
|
+
TIMESTAMP_WITHOUT_TIME_ZONE = "timestamp without time zone"
|
|
43
|
+
TIMESTAMP_WITH_TIME_ZONE = "timestamp with time zone"
|
|
44
|
+
INTERVAL = "interval"
|
|
45
|
+
|
|
46
|
+
# Boolean Type
|
|
47
|
+
BOOLEAN = "boolean"
|
|
48
|
+
|
|
49
|
+
# Geometric Types
|
|
50
|
+
POINT = "point"
|
|
51
|
+
LINE = "line"
|
|
52
|
+
LSEG = "lseg"
|
|
53
|
+
BOX = "box"
|
|
54
|
+
PATH = "path"
|
|
55
|
+
POLYGON = "polygon"
|
|
56
|
+
CIRCLE = "circle"
|
|
57
|
+
|
|
58
|
+
# Network Address Types
|
|
59
|
+
CIDR = "cidr"
|
|
60
|
+
INET = "inet"
|
|
61
|
+
MACADDR = "macaddr"
|
|
62
|
+
MACADDR8 = "macaddr8"
|
|
63
|
+
|
|
64
|
+
# Bit String Types
|
|
65
|
+
BIT = "bit"
|
|
66
|
+
BIT_VARYING = "bit varying"
|
|
67
|
+
|
|
68
|
+
# Text Search Types
|
|
69
|
+
TSVECTOR = "tsvector"
|
|
70
|
+
TSQUERY = "tsquery"
|
|
71
|
+
|
|
72
|
+
# UUID Type
|
|
73
|
+
UUID = "uuid"
|
|
74
|
+
|
|
75
|
+
# XML Type
|
|
76
|
+
XML = "xml"
|
|
77
|
+
|
|
78
|
+
# JSON Types
|
|
79
|
+
JSON = "json"
|
|
80
|
+
JSONB = "jsonb"
|
|
81
|
+
|
|
82
|
+
# Range Types
|
|
83
|
+
INT4RANGE = "int4range"
|
|
84
|
+
NUMRANGE = "numrange"
|
|
85
|
+
TSRANGE = "tsrange"
|
|
86
|
+
TSTZRANGE = "tstzrange"
|
|
87
|
+
DATERANGE = "daterange"
|
|
88
|
+
|
|
89
|
+
# Object Identifier Type
|
|
90
|
+
OID = "oid"
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def _missing_(cls, value: object):
|
|
94
|
+
"""
|
|
95
|
+
Handle SQL standard aliases when the exact enum value is not found.
|
|
96
|
+
|
|
97
|
+
The SQL standard requires that "timestamp" be equivalent to "timestamp without time zone"
|
|
98
|
+
and "time" be equivalent to "time without time zone".
|
|
99
|
+
"""
|
|
100
|
+
# Only handle string values for SQL type aliases
|
|
101
|
+
if not isinstance(value, str):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
aliases = {
|
|
105
|
+
"timestamp": "timestamp without time zone",
|
|
106
|
+
"time": "time without time zone",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# Check if this is an alias we can resolve
|
|
110
|
+
if value in aliases:
|
|
111
|
+
# Return the actual enum member for the aliased value
|
|
112
|
+
return cls(aliases[value])
|
|
113
|
+
|
|
114
|
+
# If not an alias, let the default enum behavior handle it
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ConstraintType(StrEnum):
|
|
119
|
+
PRIMARY_KEY = "PRIMARY KEY"
|
|
120
|
+
FOREIGN_KEY = "FOREIGN KEY"
|
|
121
|
+
UNIQUE = "UNIQUE"
|
|
122
|
+
CHECK = "CHECK"
|
|
123
|
+
INDEX = "INDEX"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_python_to_sql_mapping():
|
|
127
|
+
"""
|
|
128
|
+
Returns a mapping of Python types to their corresponding SQL types.
|
|
129
|
+
"""
|
|
130
|
+
return {
|
|
131
|
+
int: ColumnType.INTEGER,
|
|
132
|
+
float: ColumnType.DOUBLE_PRECISION,
|
|
133
|
+
str: ColumnType.VARCHAR,
|
|
134
|
+
bool: ColumnType.BOOLEAN,
|
|
135
|
+
bytes: ColumnType.BYTEA,
|
|
136
|
+
UUID: ColumnType.UUID,
|
|
137
|
+
datetime: ColumnType.TIMESTAMP_WITHOUT_TIME_ZONE,
|
|
138
|
+
date: ColumnType.DATE,
|
|
139
|
+
time: ColumnType.TIME_WITHOUT_TIME_ZONE,
|
|
140
|
+
timedelta: ColumnType.INTERVAL,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def enum_to_name(enum: Enum) -> str:
|
|
145
|
+
"""
|
|
146
|
+
Returns the name of the enum as a string.
|
|
147
|
+
"""
|
|
148
|
+
return enum.__name__.lower()
|
iceaxe/typing.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime, time, timedelta
|
|
4
|
+
from enum import Enum, IntEnum, StrEnum
|
|
5
|
+
from inspect import isclass
|
|
6
|
+
from typing import (
|
|
7
|
+
TYPE_CHECKING,
|
|
8
|
+
Any,
|
|
9
|
+
Type,
|
|
10
|
+
TypeGuard,
|
|
11
|
+
TypeVar,
|
|
12
|
+
)
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from iceaxe.alias_values import Alias
|
|
17
|
+
from iceaxe.base import (
|
|
18
|
+
DBFieldClassDefinition,
|
|
19
|
+
TableBase,
|
|
20
|
+
)
|
|
21
|
+
from iceaxe.comparison import FieldComparison, FieldComparisonGroup
|
|
22
|
+
from iceaxe.functions import FunctionMetadata
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ALL_ENUM_TYPES = Type[Enum | StrEnum | IntEnum]
|
|
26
|
+
PRIMITIVE_TYPES = int | float | str | bool | bytes | UUID
|
|
27
|
+
PRIMITIVE_WRAPPER_TYPES = list[PRIMITIVE_TYPES] | PRIMITIVE_TYPES
|
|
28
|
+
DATE_TYPES = datetime | date | time | timedelta
|
|
29
|
+
JSON_WRAPPER_FALLBACK = list[Any] | dict[Any, Any]
|
|
30
|
+
|
|
31
|
+
T = TypeVar("T")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_base_table(obj: Any) -> TypeGuard[type[TableBase]]:
|
|
35
|
+
from iceaxe.base import TableBase
|
|
36
|
+
|
|
37
|
+
return isclass(obj) and issubclass(obj, TableBase)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_column(obj: T) -> TypeGuard[DBFieldClassDefinition[T]]:
|
|
41
|
+
from iceaxe.base import DBFieldClassDefinition
|
|
42
|
+
|
|
43
|
+
return isinstance(obj, DBFieldClassDefinition)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_comparison(obj: Any) -> TypeGuard[FieldComparison]:
|
|
47
|
+
from iceaxe.comparison import FieldComparison
|
|
48
|
+
|
|
49
|
+
return isinstance(obj, FieldComparison)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_comparison_group(obj: Any) -> TypeGuard[FieldComparisonGroup]:
|
|
53
|
+
from iceaxe.comparison import FieldComparisonGroup
|
|
54
|
+
|
|
55
|
+
return isinstance(obj, FieldComparisonGroup)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_function_metadata(obj: Any) -> TypeGuard[FunctionMetadata]:
|
|
59
|
+
from iceaxe.functions import FunctionMetadata
|
|
60
|
+
|
|
61
|
+
return isinstance(obj, FunctionMetadata)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_alias(obj: Any) -> TypeGuard[Alias]:
|
|
65
|
+
from iceaxe.alias_values import Alias
|
|
66
|
+
|
|
67
|
+
return isinstance(obj, Alias)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def column(obj: T) -> DBFieldClassDefinition[T]:
|
|
71
|
+
if not is_column(obj):
|
|
72
|
+
raise ValueError(f"Invalid column: {obj}")
|
|
73
|
+
return obj
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iceaxe
|
|
3
|
+
Version: 0.7.1
|
|
4
|
+
Summary: A modern, fast ORM for Python.
|
|
5
|
+
Author-email: Pierce Freeman <pierce@freeman.vc>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: asyncpg<1,>=0.30
|
|
10
|
+
Requires-Dist: pydantic<3,>=2
|
|
11
|
+
Requires-Dist: rich<14,>=13
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# iceaxe
|
|
15
|
+
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
 [](https://github.com/piercefreeman/iceaxe/actions)
|
|
19
|
+
|
|
20
|
+
A modern, fast ORM for Python. We have the following goals:
|
|
21
|
+
|
|
22
|
+
- 🏎️ **Performance**: We want to exceed or match the fastest ORMs in Python. We want our ORM
|
|
23
|
+
to be as close as possible to raw-[asyncpg](https://github.com/MagicStack/asyncpg) speeds. See the "Benchmarks" section for more.
|
|
24
|
+
- 📝 **Typehinting**: Everything should be typehinted with expected types. Declare your data as you
|
|
25
|
+
expect in Python and it should bidirectionally sync to the database.
|
|
26
|
+
- 🐘 **Postgres only**: Leverage native Postgres features and simplify the implementation.
|
|
27
|
+
- ⚡ **Common things are easy, rare things are possible**: 99% of the SQL queries we write are
|
|
28
|
+
vanilla SELECT/INSERT/UPDATEs. These should be natively supported by your ORM. If you're writing _really_
|
|
29
|
+
complex queries, these are better done by hand so you can see exactly what SQL will be run.
|
|
30
|
+
|
|
31
|
+
Iceaxe is used in production at several companies. 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
|
|
32
|
+
project and web framework you're using.
|
|
33
|
+
|
|
34
|
+
For comprehensive documentation, visit [https://iceaxe.sh](https://iceaxe.sh).
|
|
35
|
+
|
|
36
|
+
To auto-optimize your self hosted Postgres install, check out our new [autopg](https://github.com/piercefreeman/autopg) project.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
If you're using poetry to manage your dependencies:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv add iceaxe
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Otherwise install with pip:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install iceaxe
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
Define your models as a `TableBase` subclass:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from iceaxe import TableBase
|
|
58
|
+
|
|
59
|
+
class Person(TableBase):
|
|
60
|
+
id: int
|
|
61
|
+
name: str
|
|
62
|
+
age: int
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
TableBase is a subclass of Pydantic's `BaseModel`, so you get all of the validation and Field customization
|
|
66
|
+
out of the box. We provide our own `Field` constructor that adds database-specific configuration. For instance, to make the
|
|
67
|
+
`id` field a primary key / auto-incrementing you can do:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from iceaxe import Field
|
|
71
|
+
|
|
72
|
+
class Person(TableBase):
|
|
73
|
+
id: int = Field(primary_key=True)
|
|
74
|
+
name: str
|
|
75
|
+
age: int
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Okay now you have a model. How do you interact with it?
|
|
79
|
+
|
|
80
|
+
Databases are based on a few core primitives to insert data, update it, and fetch it out again.
|
|
81
|
+
To do so you'll need a _database connection_, which is a connection over the network from your code
|
|
82
|
+
to your Postgres database. The `DBConnection` is the core class for all ORM actions against the database.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from iceaxe import DBConnection
|
|
86
|
+
import asyncpg
|
|
87
|
+
|
|
88
|
+
conn = DBConnection(
|
|
89
|
+
await asyncpg.connect(
|
|
90
|
+
host="localhost",
|
|
91
|
+
port=5432,
|
|
92
|
+
user="db_user",
|
|
93
|
+
password="yoursecretpassword",
|
|
94
|
+
database="your_db",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The Person class currently just lives in memory. To back it with a full
|
|
100
|
+
database table, we can run raw SQL or run a migration to add it:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
await conn.conn.execute(
|
|
104
|
+
"""
|
|
105
|
+
CREATE TABLE IF NOT EXISTS person (
|
|
106
|
+
id SERIAL PRIMARY KEY,
|
|
107
|
+
name TEXT NOT NULL,
|
|
108
|
+
age INT NOT NULL
|
|
109
|
+
)
|
|
110
|
+
"""
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Inserting Data
|
|
115
|
+
|
|
116
|
+
Instantiate object classes as you normally do:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
people = [
|
|
120
|
+
Person(name="Alice", age=30),
|
|
121
|
+
Person(name="Bob", age=40),
|
|
122
|
+
Person(name="Charlie", age=50),
|
|
123
|
+
]
|
|
124
|
+
await conn.insert(people)
|
|
125
|
+
|
|
126
|
+
print(people[0].id) # 1
|
|
127
|
+
print(people[1].id) # 2
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Because we're using an auto-incrementing primary key, the `id` field will be populated after the insert.
|
|
131
|
+
Iceaxe will automatically update the object in place with the newly assigned value.
|
|
132
|
+
|
|
133
|
+
### Updating data
|
|
134
|
+
|
|
135
|
+
Now that we have these lovely people, let's modify them.
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
person = people[0]
|
|
139
|
+
person.name = "Blice"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Right now, we have a Python object that's out of state with the database. But that's often okay. We can inspect it
|
|
143
|
+
and further write logic - it's fully decoupled from the database.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
def ensure_b_letter(person: Person):
|
|
147
|
+
if person.name[0].lower() != "b":
|
|
148
|
+
raise ValueError("Name must start with 'B'")
|
|
149
|
+
|
|
150
|
+
ensure_b_letter(person)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
To sync the values back to the database, we can call `update`:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
await conn.update([person])
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
If we were to query the database directly, we see that the name has been updated:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
id | name | age
|
|
163
|
+
----+-------+-----
|
|
164
|
+
1 | Blice | 31
|
|
165
|
+
2 | Bob | 40
|
|
166
|
+
3 | Charlie | 50
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
But no other fields have been touched. This lets a potentially concurrent process
|
|
170
|
+
modify `Alice`'s record - say, updating the age to 31. By the time we update the data, we'll
|
|
171
|
+
change the name but nothing else. Under the hood we do this by tracking the fields that
|
|
172
|
+
have been modified in-memory and creating a targeted UPDATE to modify only those values.
|
|
173
|
+
|
|
174
|
+
### Selecting data
|
|
175
|
+
|
|
176
|
+
To select data, we can use a `QueryBuilder`. For a shortcut to `select` query functions,
|
|
177
|
+
you can also just import select directly. This method takes the desired value parameters
|
|
178
|
+
and returns a list of the desired objects.
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from iceaxe import select
|
|
182
|
+
|
|
183
|
+
query = select(Person).where(Person.name == "Blice", Person.age > 25)
|
|
184
|
+
results = await conn.exec(query)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
If we inspect the typing of `results`, we see that it's a `list[Person]` objects. This matches
|
|
188
|
+
the typehint of the `select` function. You can also target columns directly:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
query = select((Person.id, Person.name)).where(Person.age > 25)
|
|
192
|
+
results = await conn.exec(query)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
This will return a list of tuples, where each tuple is the id and name of the person: `list[tuple[int, str]]`.
|
|
196
|
+
|
|
197
|
+
We support most of the common SQL operations. Just like the results, these are typehinted
|
|
198
|
+
to their proper types as well. Static typecheckers and your IDE will throw an error if you try to compare
|
|
199
|
+
a string column to an integer, for instance. A more complex example of a query:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
query = select((
|
|
203
|
+
Person.id,
|
|
204
|
+
FavoriteColor,
|
|
205
|
+
)).join(
|
|
206
|
+
FavoriteColor,
|
|
207
|
+
Person.id == FavoriteColor.person_id,
|
|
208
|
+
).where(
|
|
209
|
+
Person.age > 25,
|
|
210
|
+
Person.name == "Blice",
|
|
211
|
+
).order_by(
|
|
212
|
+
Person.age.desc(),
|
|
213
|
+
).limit(10)
|
|
214
|
+
results = await conn.exec(query)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
As expected this will deliver results - and typehint - as a `list[tuple[int, FavoriteColor]]`
|
|
218
|
+
|
|
219
|
+
## Production
|
|
220
|
+
|
|
221
|
+
> [!IMPORTANT]
|
|
222
|
+
> Iceaxe is in early alpha. We're using it internally and showly rolling out to our production
|
|
223
|
+
applications, but we're not yet ready to recommend it for general use. The API and larger
|
|
224
|
+
stability is subject to change.
|
|
225
|
+
|
|
226
|
+
Note that underlying Postgres connection wrapped by `conn` will be alive for as long as your object is in memory. This uses up one
|
|
227
|
+
of the allowable connections to your database. Your overall limit depends on your Postgres configuration
|
|
228
|
+
or hosting provider, but most managed solutions top out around 150-300. If you need more concurrent clients
|
|
229
|
+
connected (and even if you don't - connection creation at the Postgres level is expensive), you can adopt
|
|
230
|
+
a load balancer like `pgbouncer` to better scale to traffic. More deployment notes to come.
|
|
231
|
+
|
|
232
|
+
It's also worth noting the absence of request pooling in this initialization. This is a feature of many ORMs that lets you limit
|
|
233
|
+
the overall connections you make to Postgres, and re-use these over time. We specifically don't offer request
|
|
234
|
+
pooling as part of Iceaxe, despite being supported by our underlying engine `asyncpg`. This is a bit more
|
|
235
|
+
aligned to how things should be structured in production. Python apps are always bound to one process thanks to
|
|
236
|
+
the 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
|
|
237
|
+
network connections in the first place.
|
|
238
|
+
|
|
239
|
+
## Benchmarking
|
|
240
|
+
|
|
241
|
+
We have basic benchmarking tests in the `__tests__/benchmarks` directory. To run them, you'll need to execute the pytest suite:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
uv run pytest -m integration_tests
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Current benchmarking as of October 11 2024 is:
|
|
248
|
+
|
|
249
|
+
| | raw asyncpg | iceaxe | external overhead | |
|
|
250
|
+
|-------------------|-------------|--------|-----------------------------------------------|---|
|
|
251
|
+
| TableBase columns | 0.098s | 0.093s | | |
|
|
252
|
+
| TableBase full | 0.164s | 1.345s | 10%: dict construction | 90%: pydantic overhead | |
|
|
253
|
+
|
|
254
|
+
## Development
|
|
255
|
+
|
|
256
|
+
If you update your Cython implementation during development, you'll need to re-compile the Cython code. This can be done with
|
|
257
|
+
a simple uv sync.
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
uv sync
|
|
261
|
+
```
|