iceaxe 0.7.1__tar.gz → 0.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of iceaxe might be problematic. Click here for more details.

Files changed (81) hide show
  1. {iceaxe-0.7.1/iceaxe.egg-info → iceaxe-0.7.2}/PKG-INFO +1 -1
  2. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/test_queries.py +159 -0
  3. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/functions.py +527 -1
  4. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/queries.py +5 -1
  5. {iceaxe-0.7.1 → iceaxe-0.7.2/iceaxe.egg-info}/PKG-INFO +1 -1
  6. {iceaxe-0.7.1 → iceaxe-0.7.2}/pyproject.toml +1 -1
  7. {iceaxe-0.7.1 → iceaxe-0.7.2}/LICENSE +0 -0
  8. {iceaxe-0.7.1 → iceaxe-0.7.2}/MANIFEST.in +0 -0
  9. {iceaxe-0.7.1 → iceaxe-0.7.2}/README.md +0 -0
  10. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__init__.py +0 -0
  11. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/__init__.py +0 -0
  12. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/benchmarks/__init__.py +0 -0
  13. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/benchmarks/test_bulk_insert.py +0 -0
  14. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/benchmarks/test_select.py +0 -0
  15. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/conf_models.py +0 -0
  16. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/conftest.py +0 -0
  17. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/docker_helpers.py +0 -0
  18. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/helpers.py +0 -0
  19. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/migrations/__init__.py +0 -0
  20. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/migrations/conftest.py +0 -0
  21. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/migrations/test_action_sorter.py +0 -0
  22. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/migrations/test_generator.py +0 -0
  23. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/migrations/test_generics.py +0 -0
  24. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/mountaineer/__init__.py +0 -0
  25. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/mountaineer/dependencies/__init__.py +0 -0
  26. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/mountaineer/dependencies/test_core.py +0 -0
  27. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/schemas/__init__.py +0 -0
  28. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/schemas/test_actions.py +0 -0
  29. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/schemas/test_cli.py +0 -0
  30. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/schemas/test_db_memory_serializer.py +0 -0
  31. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/schemas/test_db_serializer.py +0 -0
  32. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/schemas/test_db_stubs.py +0 -0
  33. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/test_alias.py +0 -0
  34. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/test_base.py +0 -0
  35. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/test_comparison.py +0 -0
  36. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/test_field.py +0 -0
  37. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/test_helpers.py +0 -0
  38. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/test_modifications.py +0 -0
  39. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/test_queries_str.py +0 -0
  40. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/test_session.py +0 -0
  41. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/__tests__/test_text_search.py +0 -0
  42. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/alias_values.py +0 -0
  43. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/base.py +0 -0
  44. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/comparison.py +0 -0
  45. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/field.py +0 -0
  46. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/generics.py +0 -0
  47. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/io.py +0 -0
  48. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/logging.py +0 -0
  49. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/migrations/__init__.py +0 -0
  50. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/migrations/action_sorter.py +0 -0
  51. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/migrations/cli.py +0 -0
  52. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/migrations/client_io.py +0 -0
  53. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/migrations/generator.py +0 -0
  54. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/migrations/migration.py +0 -0
  55. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/migrations/migrator.py +0 -0
  56. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/modifications.py +0 -0
  57. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/mountaineer/__init__.py +0 -0
  58. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/mountaineer/cli.py +0 -0
  59. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/mountaineer/config.py +0 -0
  60. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/mountaineer/dependencies/__init__.py +0 -0
  61. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/mountaineer/dependencies/core.py +0 -0
  62. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/postgres.py +0 -0
  63. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/py.typed +0 -0
  64. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/queries_str.py +0 -0
  65. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/schemas/__init__.py +0 -0
  66. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/schemas/actions.py +0 -0
  67. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/schemas/cli.py +0 -0
  68. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/schemas/db_memory_serializer.py +0 -0
  69. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/schemas/db_serializer.py +0 -0
  70. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/schemas/db_stubs.py +0 -0
  71. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/session.py +0 -0
  72. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/session_optimized.c +0 -0
  73. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/session_optimized.pyx +0 -0
  74. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/sql_types.py +0 -0
  75. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe/typing.py +0 -0
  76. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe.egg-info/SOURCES.txt +0 -0
  77. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe.egg-info/dependency_links.txt +0 -0
  78. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe.egg-info/requires.txt +0 -0
  79. {iceaxe-0.7.1 → iceaxe-0.7.2}/iceaxe.egg-info/top_level.txt +0 -0
  80. {iceaxe-0.7.1 → iceaxe-0.7.2}/setup.cfg +0 -0
  81. {iceaxe-0.7.1 → iceaxe-0.7.2}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iceaxe
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: A modern, fast ORM for Python.
5
5
  Author-email: Pierce Freeman <pierce@freeman.vc>
6
6
  Requires-Python: >=3.11
@@ -5,6 +5,7 @@ import pytest
5
5
 
6
6
  from iceaxe.__tests__.conf_models import (
7
7
  ArtifactDemo,
8
+ ComplexDemo,
8
9
  Employee,
9
10
  FunctionDemoModel,
10
11
  UserDemo,
@@ -316,6 +317,13 @@ def test_function_transformations():
316
317
  [],
317
318
  )
318
319
 
320
+ # Test unnest function
321
+ new_query = QueryBuilder().select(func.unnest(ComplexDemo.string_list))
322
+ assert new_query.build() == (
323
+ 'SELECT unnest("complexdemo"."string_list") AS aggregate_0 FROM "complexdemo"',
324
+ [],
325
+ )
326
+
319
327
  # Test type conversion functions
320
328
  new_query = QueryBuilder().select(
321
329
  (
@@ -337,6 +345,157 @@ def test_function_transformations():
337
345
  )
338
346
 
339
347
 
348
+ def test_array_operators():
349
+ # Test ANY operator
350
+ new_query = (
351
+ QueryBuilder()
352
+ .select(ComplexDemo)
353
+ .where(func.any(ComplexDemo.string_list) == "python")
354
+ )
355
+ assert new_query.build() == (
356
+ 'SELECT "complexdemo"."id" AS "complexdemo_id", "complexdemo"."string_list" AS "complexdemo_string_list", '
357
+ '"complexdemo"."json_data" AS "complexdemo_json_data" FROM "complexdemo" WHERE \'python\' = ANY("complexdemo"."string_list")',
358
+ [],
359
+ )
360
+
361
+ # Test ALL operator
362
+ new_query = (
363
+ QueryBuilder()
364
+ .select(ComplexDemo)
365
+ .where(func.all(ComplexDemo.string_list) == "active")
366
+ )
367
+ assert new_query.build() == (
368
+ 'SELECT "complexdemo"."id" AS "complexdemo_id", "complexdemo"."string_list" AS "complexdemo_string_list", '
369
+ '"complexdemo"."json_data" AS "complexdemo_json_data" FROM "complexdemo" WHERE \'active\' = ALL("complexdemo"."string_list")',
370
+ [],
371
+ )
372
+
373
+ # Test array_contains operator (@>)
374
+ new_query = (
375
+ QueryBuilder()
376
+ .select(ComplexDemo)
377
+ .where(
378
+ func.array_contains(ComplexDemo.string_list, ["python", "django"]) == True # noqa: E712
379
+ )
380
+ )
381
+ assert new_query.build() == (
382
+ 'SELECT "complexdemo"."id" AS "complexdemo_id", "complexdemo"."string_list" AS "complexdemo_string_list", '
383
+ '"complexdemo"."json_data" AS "complexdemo_json_data" FROM "complexdemo" WHERE "complexdemo"."string_list" @> ARRAY[\'python\',\'django\'] = $1',
384
+ [True],
385
+ )
386
+
387
+ # Test array_contained_by operator (<@)
388
+ new_query = (
389
+ QueryBuilder()
390
+ .select(ComplexDemo)
391
+ .where(
392
+ func.array_contained_by( # noqa: E712
393
+ ComplexDemo.string_list, ["python", "java", "go", "rust"]
394
+ )
395
+ == True
396
+ )
397
+ )
398
+ assert new_query.build() == (
399
+ 'SELECT "complexdemo"."id" AS "complexdemo_id", "complexdemo"."string_list" AS "complexdemo_string_list", '
400
+ '"complexdemo"."json_data" AS "complexdemo_json_data" FROM "complexdemo" WHERE "complexdemo"."string_list" <@ ARRAY[\'python\',\'java\',\'go\',\'rust\'] = $1',
401
+ [True],
402
+ )
403
+
404
+ # Test array_overlaps operator (&&)
405
+ new_query = (
406
+ QueryBuilder()
407
+ .select(ComplexDemo)
408
+ .where(
409
+ func.array_overlaps( # noqa: E712
410
+ ComplexDemo.string_list, ["python", "data-science", "ml"]
411
+ )
412
+ == True
413
+ )
414
+ )
415
+ assert new_query.build() == (
416
+ 'SELECT "complexdemo"."id" AS "complexdemo_id", "complexdemo"."string_list" AS "complexdemo_string_list", '
417
+ '"complexdemo"."json_data" AS "complexdemo_json_data" FROM "complexdemo" WHERE "complexdemo"."string_list" && ARRAY[\'python\',\'data-science\',\'ml\'] = $1',
418
+ [True],
419
+ )
420
+
421
+
422
+ def test_array_comparison_operators():
423
+ # Test ANY with different operators
424
+ new_query = (
425
+ QueryBuilder()
426
+ .select(ComplexDemo)
427
+ .where(func.any(ComplexDemo.string_list) != "inactive")
428
+ )
429
+ assert new_query.build() == (
430
+ 'SELECT "complexdemo"."id" AS "complexdemo_id", "complexdemo"."string_list" AS "complexdemo_string_list", '
431
+ '"complexdemo"."json_data" AS "complexdemo_json_data" FROM "complexdemo" WHERE \'inactive\' != ANY("complexdemo"."string_list")',
432
+ [],
433
+ )
434
+
435
+ # Test ALL with >= operator
436
+ new_query = (
437
+ QueryBuilder()
438
+ .select(ComplexDemo)
439
+ .where(func.all(ComplexDemo.string_list) >= "a")
440
+ )
441
+ assert new_query.build() == (
442
+ 'SELECT "complexdemo"."id" AS "complexdemo_id", "complexdemo"."string_list" AS "complexdemo_string_list", '
443
+ '"complexdemo"."json_data" AS "complexdemo_json_data" FROM "complexdemo" WHERE \'a\' >= ALL("complexdemo"."string_list")',
444
+ [],
445
+ )
446
+
447
+
448
+ def test_array_manipulation_functions():
449
+ # Test array_append
450
+ new_query = QueryBuilder().select(
451
+ func.array_append(ComplexDemo.string_list, "new-tag")
452
+ )
453
+ assert new_query.build() == (
454
+ 'SELECT array_append("complexdemo"."string_list", \'new-tag\') AS aggregate_0 FROM "complexdemo"',
455
+ [],
456
+ )
457
+
458
+ # Test array_prepend
459
+ new_query = QueryBuilder().select(
460
+ func.array_prepend("featured", ComplexDemo.string_list)
461
+ )
462
+ assert new_query.build() == (
463
+ 'SELECT array_prepend(\'featured\', "complexdemo"."string_list") AS aggregate_0 FROM "complexdemo"',
464
+ [],
465
+ )
466
+
467
+ # Test array_cat with field - this would require a join in practice
468
+ # For now, let's test with a simpler case using the same table
469
+ # or we could test array_cat with a literal array which is more common
470
+
471
+ # Test array_cat with literal array
472
+ new_query = QueryBuilder().select(
473
+ func.array_cat(ComplexDemo.string_list, ["admin", "superuser"])
474
+ )
475
+ assert new_query.build() == (
476
+ 'SELECT array_cat("complexdemo"."string_list", ARRAY[\'admin\',\'superuser\']) AS aggregate_0 FROM "complexdemo"',
477
+ [],
478
+ )
479
+
480
+ # Test array_position
481
+ new_query = QueryBuilder().select(
482
+ func.array_position(ComplexDemo.string_list, "python")
483
+ )
484
+ assert new_query.build() == (
485
+ 'SELECT array_position("complexdemo"."string_list", \'python\') AS aggregate_0 FROM "complexdemo"',
486
+ [],
487
+ )
488
+
489
+ # Test array_remove
490
+ new_query = QueryBuilder().select(
491
+ func.array_remove(ComplexDemo.string_list, "deprecated")
492
+ )
493
+ assert new_query.build() == (
494
+ 'SELECT array_remove("complexdemo"."string_list", \'deprecated\') AS aggregate_0 FROM "complexdemo"',
495
+ [],
496
+ )
497
+
498
+
340
499
  def test_invalid_where_condition():
341
500
  with pytest.raises(ValueError):
342
501
  QueryBuilder().select(UserDemo.id).where("invalid condition") # type: ignore
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from datetime import datetime
4
4
  from enum import Enum
5
- from typing import Any, Literal, Type, TypeVar, cast
5
+ from typing import Any, Generic, Literal, Type, TypeVar, cast
6
6
 
7
7
  from iceaxe.base import (
8
8
  DBFieldClassDefinition,
@@ -193,6 +193,140 @@ class TSVectorFunctionMetadata(FunctionMetadata):
193
193
  return self
194
194
 
195
195
 
196
+ class BooleanExpression(FieldComparison):
197
+ """
198
+ A FieldComparison that represents a complete boolean expression.
199
+ """
200
+
201
+ def __init__(self, expression: FunctionMetadata):
202
+ # Initialize with dummy values since we override to_query
203
+ super().__init__(left=expression, comparison=ComparisonType.EQ, right=True)
204
+ self.expression = expression
205
+
206
+ def to_query(self, start: int = 1) -> tuple[QueryLiteral, list[Any]]:
207
+ """
208
+ Return the expression directly without additional comparison.
209
+ """
210
+ return self.expression.literal, []
211
+
212
+
213
+ class ArrayComparison(Generic[T], ComparisonBase[bool]):
214
+ """
215
+ Provides comparison methods for SQL array operations (ANY/ALL).
216
+ This class enables ergonomic syntax for array comparisons.
217
+
218
+ ```python {{sticky: True}}
219
+ # Using ArrayComparison for ANY operations:
220
+ func.any(Article.tags) == 'python'
221
+ func.any(User.follower_ids) == current_user_id
222
+
223
+ # Using ArrayComparison for ALL operations:
224
+ func.all(Project.member_statuses) == 'active'
225
+ func.all(Student.test_scores) >= 70
226
+ ```
227
+ """
228
+
229
+ def __init__(
230
+ self, array_metadata: FunctionMetadata, operation: Literal["ANY", "ALL"]
231
+ ):
232
+ """
233
+ Initialize an ArrayComparison object.
234
+
235
+ :param array_metadata: The metadata for the array field
236
+ :param operation: The SQL operation (ANY or ALL)
237
+ """
238
+ self.array_metadata = array_metadata
239
+ self.operation = operation
240
+
241
+ def __eq__(self, other: T) -> BooleanExpression: # type: ignore
242
+ """
243
+ Creates an equality comparison with the array elements.
244
+
245
+ :param other: The value to compare with array elements
246
+ :return: A boolean expression object that resolves to a boolean
247
+ """
248
+ return self._create_comparison(other, "=")
249
+
250
+ def __ne__(self, other: T) -> BooleanExpression: # type: ignore
251
+ """
252
+ Creates a not-equal comparison with the array elements.
253
+
254
+ :param other: The value to compare with array elements
255
+ :return: A boolean expression object that resolves to a boolean
256
+ """
257
+ return self._create_comparison(other, "!=")
258
+
259
+ def __lt__(self, other: T) -> BooleanExpression:
260
+ """
261
+ Creates a less-than comparison with the array elements.
262
+
263
+ :param other: The value to compare with array elements
264
+ :return: A boolean expression object that resolves to a boolean
265
+ """
266
+ return self._create_comparison(other, "<")
267
+
268
+ def __le__(self, other: T) -> BooleanExpression:
269
+ """
270
+ Creates a less-than-or-equal comparison with the array elements.
271
+
272
+ :param other: The value to compare with array elements
273
+ :return: A boolean expression object that resolves to a boolean
274
+ """
275
+ return self._create_comparison(other, "<=")
276
+
277
+ def __gt__(self, other: T) -> BooleanExpression:
278
+ """
279
+ Creates a greater-than comparison with the array elements.
280
+
281
+ :param other: The value to compare with array elements
282
+ :return: A boolean expression object that resolves to a boolean
283
+ """
284
+ return self._create_comparison(other, ">")
285
+
286
+ def __ge__(self, other: T) -> BooleanExpression:
287
+ """
288
+ Creates a greater-than-or-equal comparison with the array elements.
289
+
290
+ :param other: The value to compare with array elements
291
+ :return: A boolean expression object that resolves to a boolean
292
+ """
293
+ return self._create_comparison(other, ">=")
294
+
295
+ def _create_comparison(self, value: T, operator: str) -> BooleanExpression:
296
+ """
297
+ Internal method to create the comparison SQL.
298
+
299
+ :param value: The value to compare with array elements
300
+ :param operator: The SQL comparison operator
301
+ :return: A boolean expression object that resolves to a boolean
302
+ """
303
+ # Handle simple values
304
+ if isinstance(value, (str, int, float, bool)):
305
+ value_literal = f"'{value}'" if isinstance(value, str) else str(value)
306
+ else:
307
+ # For complex values, use the metadata
308
+ value_metadata = FunctionBuilder._column_to_metadata(value)
309
+ value_literal = str(value_metadata.literal)
310
+
311
+ # Create the comparison as a FunctionMetadata
312
+ result = FunctionMetadata(
313
+ literal=QueryLiteral(
314
+ f"{value_literal} {operator} {self.operation}({self.array_metadata.literal})"
315
+ ),
316
+ original_field=self.array_metadata.original_field,
317
+ )
318
+ # Return a BooleanExpression that generates the SQL directly
319
+ return BooleanExpression(result)
320
+
321
+ def to_query(self) -> tuple[QueryLiteral, list[Any]]:
322
+ """
323
+ Converts the array comparison to its SQL representation.
324
+
325
+ :return: A tuple of the SQL query string and list of parameter values
326
+ """
327
+ return self.array_metadata.literal, []
328
+
329
+
196
330
  class FunctionBuilder:
197
331
  """
198
332
  Builder class for SQL aggregate functions and other SQL operations.
@@ -595,6 +729,398 @@ class FunctionBuilder:
595
729
  )
596
730
  return cast(str, metadata)
597
731
 
732
+ def unnest(self, field: list[T]) -> T:
733
+ """
734
+ Expands an array into a set of rows.
735
+
736
+ :param field: The array field to unnest
737
+ :return: A function metadata object that resolves to the element type
738
+
739
+ ```python {{sticky: True}}
740
+ # Unnest an array column
741
+ tags = await conn.execute(select(func.unnest(Article.tags)))
742
+
743
+ # Use with joins
744
+ result = await conn.execute(
745
+ select((User.name, func.unnest(User.favorite_colors)))
746
+ .where(User.id == user_id)
747
+ )
748
+ ```
749
+ """
750
+ metadata = self._column_to_metadata(field)
751
+ metadata.literal = QueryLiteral(f"unnest({metadata.literal})")
752
+ return cast(T, metadata)
753
+
754
+ # Array Operators and Functions
755
+ def any(self, array_field: list[T]) -> ArrayComparison[T]:
756
+ """
757
+ Creates an ANY array comparison that can be used with comparison operators.
758
+
759
+ :param array_field: The array field to check against
760
+ :return: An ArrayComparison object that supports comparison operators
761
+
762
+ ```python {{sticky: True}}
763
+ # Check if 'python' is in the tags array
764
+ has_python = await conn.execute(
765
+ select(Article)
766
+ .where(func.any(Article.tags) == 'python')
767
+ )
768
+
769
+ # Check if user id is in the follower_ids array
770
+ is_follower = await conn.execute(
771
+ select(User)
772
+ .where(func.any(User.follower_ids) == current_user_id)
773
+ )
774
+
775
+ # Check if any score is above threshold
776
+ has_passing = await conn.execute(
777
+ select(Student)
778
+ .where(func.any(Student.test_scores) >= 70)
779
+ )
780
+ ```
781
+ """
782
+ array_metadata = self._column_to_metadata(array_field)
783
+ return ArrayComparison(array_metadata, "ANY")
784
+
785
+ def all(self, array_field: list[T]) -> ArrayComparison[T]:
786
+ """
787
+ Creates an ALL array comparison that can be used with comparison operators.
788
+
789
+ :param array_field: The array field to check against
790
+ :return: An ArrayComparison object that supports comparison operators
791
+
792
+ ```python {{sticky: True}}
793
+ # Check if all elements in status array are 'active'
794
+ all_active = await conn.execute(
795
+ select(Project)
796
+ .where(func.all(Project.member_statuses) == 'active')
797
+ )
798
+
799
+ # Check if all scores are above threshold
800
+ all_passing = await conn.execute(
801
+ select(Student)
802
+ .where(func.all(Student.test_scores) >= 70)
803
+ )
804
+
805
+ # Check if all values are non-null
806
+ all_present = await conn.execute(
807
+ select(Survey)
808
+ .where(func.all(Survey.responses) != None)
809
+ )
810
+ ```
811
+ """
812
+ array_metadata = self._column_to_metadata(array_field)
813
+ return ArrayComparison(array_metadata, "ALL")
814
+
815
+ def array_contains(
816
+ self, array_field: list[T], contained: list[T]
817
+ ) -> FunctionMetadata:
818
+ """
819
+ Creates an array contains comparison using the @> operator.
820
+ Checks if the array field contains all elements of the contained array.
821
+
822
+ :param array_field: The array field to check
823
+ :param contained: The array that should be contained
824
+ :return: A function metadata object that resolves to a boolean
825
+
826
+ ```python {{sticky: True}}
827
+ # Check if tags contain both 'python' and 'django'
828
+ has_both = await conn.execute(
829
+ select(Article)
830
+ .where(func.array_contains(Article.tags, ['python', 'django']) == True)
831
+ )
832
+
833
+ # Check if permissions contain required permissions
834
+ has_perms = await conn.execute(
835
+ select(User)
836
+ .where(func.array_contains(User.permissions, ['read', 'write']) == True)
837
+ )
838
+ ```
839
+ """
840
+ array_metadata = self._column_to_metadata(array_field)
841
+
842
+ # Convert the contained list to PostgreSQL array syntax
843
+ if all(isinstance(x, str) for x in contained):
844
+ contained_literal = "ARRAY[" + ",".join(f"'{x}'" for x in contained) + "]"
845
+ else:
846
+ contained_literal = "ARRAY[" + ",".join(str(x) for x in contained) + "]"
847
+
848
+ # Create the @> comparison as a FunctionMetadata
849
+ array_metadata.literal = QueryLiteral(
850
+ f"{array_metadata.literal} @> {contained_literal}"
851
+ )
852
+ return array_metadata
853
+
854
+ def array_contained_by(
855
+ self, array_field: list[T], container: list[T]
856
+ ) -> FunctionMetadata:
857
+ """
858
+ Creates an array contained by comparison using the <@ operator.
859
+ Checks if all elements of the array field are contained in the container array.
860
+
861
+ :param array_field: The array field to check
862
+ :param container: The array that should contain all elements
863
+ :return: A function metadata object that resolves to a boolean
864
+
865
+ ```python {{sticky: True}}
866
+ # Check if user's skills are all from allowed skills
867
+ valid_skills = await conn.execute(
868
+ select(User)
869
+ .where(func.array_contained_by(User.skills, ['python', 'java', 'go', 'rust']) == True)
870
+ )
871
+
872
+ # Check if selected options are all from valid options
873
+ valid_selection = await conn.execute(
874
+ select(Survey)
875
+ .where(func.array_contained_by(Survey.selected, [1, 2, 3, 4, 5]) == True)
876
+ )
877
+ ```
878
+ """
879
+ array_metadata = self._column_to_metadata(array_field)
880
+
881
+ # Convert the container list to PostgreSQL array syntax
882
+ if all(isinstance(x, str) for x in container):
883
+ container_literal = "ARRAY[" + ",".join(f"'{x}'" for x in container) + "]"
884
+ else:
885
+ container_literal = "ARRAY[" + ",".join(str(x) for x in container) + "]"
886
+
887
+ # Create the <@ comparison as a FunctionMetadata
888
+ array_metadata.literal = QueryLiteral(
889
+ f"{array_metadata.literal} <@ {container_literal}"
890
+ )
891
+ return array_metadata
892
+
893
+ def array_overlaps(self, array_field: list[T], other: list[T]) -> FunctionMetadata:
894
+ """
895
+ Creates an array overlap comparison using the && operator.
896
+ Checks if the arrays have any elements in common.
897
+
898
+ :param array_field: The first array field
899
+ :param other: The second array to check for overlap
900
+ :return: A function metadata object that resolves to a boolean
901
+
902
+ ```python {{sticky: True}}
903
+ # Check if article tags overlap with user interests
904
+ matching_interests = await conn.execute(
905
+ select(Article)
906
+ .where(func.array_overlaps(Article.tags, ['python', 'data-science', 'ml']) == True)
907
+ )
908
+
909
+ # Check if available times overlap
910
+ has_common_time = await conn.execute(
911
+ select(Meeting)
912
+ .where(func.array_overlaps(Meeting.available_slots, [1, 2, 3]) == True)
913
+ )
914
+ ```
915
+ """
916
+ array_metadata = self._column_to_metadata(array_field)
917
+
918
+ # Convert the other list to PostgreSQL array syntax
919
+ if all(isinstance(x, str) for x in other):
920
+ other_literal = "ARRAY[" + ",".join(f"'{x}'" for x in other) + "]"
921
+ else:
922
+ other_literal = "ARRAY[" + ",".join(str(x) for x in other) + "]"
923
+
924
+ # Create the && comparison as a FunctionMetadata
925
+ array_metadata.literal = QueryLiteral(
926
+ f"{array_metadata.literal} && {other_literal}"
927
+ )
928
+ return array_metadata
929
+
930
+ def array_append(self, array_field: list[T], element: T) -> list[T]:
931
+ """
932
+ Appends an element to the end of an array.
933
+
934
+ :param array_field: The array field to append to
935
+ :param element: The element to append
936
+ :return: A function metadata object that resolves to an array
937
+
938
+ ```python {{sticky: True}}
939
+ # Append a tag to the array
940
+ updated = await conn.execute(
941
+ update(Article)
942
+ .set(tags=func.array_append(Article.tags, 'new-tag'))
943
+ .where(Article.id == article_id)
944
+ )
945
+
946
+ # Select with appended element
947
+ with_extra = await conn.execute(
948
+ select(func.array_append(User.skills, 'python'))
949
+ )
950
+ ```
951
+ """
952
+ array_metadata = self._column_to_metadata(array_field)
953
+
954
+ # Handle element literal
955
+ if isinstance(element, str):
956
+ element_literal = f"'{element}'"
957
+ elif isinstance(element, (int, float, bool)):
958
+ element_literal = str(element)
959
+ else:
960
+ element_metadata = self._column_to_metadata(element)
961
+ element_literal = str(element_metadata.literal)
962
+
963
+ array_metadata.literal = QueryLiteral(
964
+ f"array_append({array_metadata.literal}, {element_literal})"
965
+ )
966
+ return cast(list[T], array_metadata)
967
+
968
+ def array_prepend(self, element: T, array_field: list[T]) -> list[T]:
969
+ """
970
+ Prepends an element to the beginning of an array.
971
+
972
+ :param element: The element to prepend
973
+ :param array_field: The array field to prepend to
974
+ :return: A function metadata object that resolves to an array
975
+
976
+ ```python {{sticky: True}}
977
+ # Prepend a tag to the array
978
+ updated = await conn.execute(
979
+ update(Article)
980
+ .set(tags=func.array_prepend('featured', Article.tags))
981
+ .where(Article.id == article_id)
982
+ )
983
+
984
+ # Select with prepended element
985
+ with_prefix = await conn.execute(
986
+ select(func.array_prepend('beginner', User.skill_levels))
987
+ )
988
+ ```
989
+ """
990
+ array_metadata = self._column_to_metadata(array_field)
991
+
992
+ # Handle element literal
993
+ if isinstance(element, str):
994
+ element_literal = f"'{element}'"
995
+ elif isinstance(element, (int, float, bool)):
996
+ element_literal = str(element)
997
+ else:
998
+ element_metadata = self._column_to_metadata(element)
999
+ element_literal = str(element_metadata.literal)
1000
+
1001
+ array_metadata.literal = QueryLiteral(
1002
+ f"array_prepend({element_literal}, {array_metadata.literal})"
1003
+ )
1004
+ return cast(list[T], array_metadata)
1005
+
1006
+ def array_cat(self, array1: list[T], array2: list[T]) -> list[T]:
1007
+ """
1008
+ Concatenates two arrays.
1009
+
1010
+ :param array1: The first array
1011
+ :param array2: The second array
1012
+ :return: A function metadata object that resolves to an array
1013
+
1014
+ ```python {{sticky: True}}
1015
+ # Concatenate two arrays
1016
+ combined_tags = await conn.execute(
1017
+ select(func.array_cat(Article.tags, Article.categories))
1018
+ )
1019
+
1020
+ # Merge user permissions
1021
+ all_perms = await conn.execute(
1022
+ update(User)
1023
+ .set(permissions=func.array_cat(User.permissions, ['admin', 'superuser']))
1024
+ .where(User.id == user_id)
1025
+ )
1026
+ ```
1027
+ """
1028
+ array1_metadata = self._column_to_metadata(array1)
1029
+
1030
+ # Handle array2 - could be a field or a literal list
1031
+ if isinstance(array2, list):
1032
+ # Convert literal list to PostgreSQL array
1033
+ if all(isinstance(x, str) for x in array2):
1034
+ array2_literal = "ARRAY[" + ",".join(f"'{x}'" for x in array2) + "]"
1035
+ else:
1036
+ array2_literal = "ARRAY[" + ",".join(str(x) for x in array2) + "]"
1037
+ else:
1038
+ array2_metadata = self._column_to_metadata(array2)
1039
+ array2_literal = str(array2_metadata.literal)
1040
+
1041
+ array1_metadata.literal = QueryLiteral(
1042
+ f"array_cat({array1_metadata.literal}, {array2_literal})"
1043
+ )
1044
+ return cast(list[T], array1_metadata)
1045
+
1046
+ def array_position(self, array_field: list[T], element: T) -> int:
1047
+ """
1048
+ Returns the position of the first occurrence of an element in an array (1-based).
1049
+ Returns NULL if the element is not found.
1050
+
1051
+ :param array_field: The array field to search in
1052
+ :param element: The element to find
1053
+ :return: A function metadata object that resolves to an integer
1054
+
1055
+ ```python {{sticky: True}}
1056
+ # Find position of a tag
1057
+ position = await conn.execute(
1058
+ select(func.array_position(Article.tags, 'python'))
1059
+ )
1060
+
1061
+ # Find position in a list of ids
1062
+ rank = await conn.execute(
1063
+ select(func.array_position(Contest.winner_ids, User.id))
1064
+ .where(Contest.id == contest_id)
1065
+ )
1066
+ ```
1067
+ """
1068
+ array_metadata = self._column_to_metadata(array_field)
1069
+
1070
+ # Handle element literal
1071
+ if isinstance(element, str):
1072
+ element_literal = f"'{element}'"
1073
+ elif isinstance(element, (int, float, bool)):
1074
+ element_literal = str(element)
1075
+ else:
1076
+ element_metadata = self._column_to_metadata(element)
1077
+ element_literal = str(element_metadata.literal)
1078
+
1079
+ array_metadata.literal = QueryLiteral(
1080
+ f"array_position({array_metadata.literal}, {element_literal})"
1081
+ )
1082
+ return cast(int, array_metadata)
1083
+
1084
+ def array_remove(self, array_field: list[T], element: T) -> list[T]:
1085
+ """
1086
+ Removes all occurrences of an element from an array.
1087
+
1088
+ :param array_field: The array field to remove from
1089
+ :param element: The element to remove
1090
+ :return: A function metadata object that resolves to an array
1091
+
1092
+ ```python {{sticky: True}}
1093
+ # Remove a tag from the array
1094
+ updated = await conn.execute(
1095
+ update(Article)
1096
+ .set(tags=func.array_remove(Article.tags, 'deprecated'))
1097
+ .where(Article.id == article_id)
1098
+ )
1099
+
1100
+ # Remove a skill from user's skill list
1101
+ cleaned = await conn.execute(
1102
+ update(User)
1103
+ .set(skills=func.array_remove(User.skills, 'obsolete-skill'))
1104
+ .where(User.id == user_id)
1105
+ )
1106
+ ```
1107
+ """
1108
+ array_metadata = self._column_to_metadata(array_field)
1109
+
1110
+ # Handle element literal
1111
+ if isinstance(element, str):
1112
+ element_literal = f"'{element}'"
1113
+ elif isinstance(element, (int, float, bool)):
1114
+ element_literal = str(element)
1115
+ else:
1116
+ element_metadata = self._column_to_metadata(element)
1117
+ element_literal = str(element_metadata.literal)
1118
+
1119
+ array_metadata.literal = QueryLiteral(
1120
+ f"array_remove({array_metadata.literal}, {element_literal})"
1121
+ )
1122
+ return cast(list[T], array_metadata)
1123
+
598
1124
  # Type Conversion Functions
599
1125
  def cast(self, field: Any, type_name: Type[T]) -> T:
600
1126
  """
@@ -11,7 +11,11 @@ from iceaxe.base import (
11
11
  DBModelMetaclass,
12
12
  TableBase,
13
13
  )
14
- from iceaxe.comparison import ComparisonGroupType, FieldComparison, FieldComparisonGroup
14
+ from iceaxe.comparison import (
15
+ ComparisonGroupType,
16
+ FieldComparison,
17
+ FieldComparisonGroup,
18
+ )
15
19
  from iceaxe.functions import FunctionMetadata
16
20
  from iceaxe.queries_str import (
17
21
  QueryElementBase,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iceaxe
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: A modern, fast ORM for Python.
5
5
  Author-email: Pierce Freeman <pierce@freeman.vc>
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "iceaxe"
3
- version = "0.7.1"
3
+ version = "0.7.2"
4
4
  description = "A modern, fast ORM for Python."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
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
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