ransacklib 1.1.0.dev6__tar.gz → 1.1.0.dev7__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 (20) hide show
  1. {ransacklib-1.1.0.dev6/ransacklib.egg-info → ransacklib-1.1.0.dev7}/PKG-INFO +1 -1
  2. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/pyproject.toml +1 -1
  3. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/ransack/operator.py +74 -4
  4. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/ransack/parser.py +10 -2
  5. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/ransack/transformer.py +39 -11
  6. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7/ransacklib.egg-info}/PKG-INFO +1 -1
  7. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/tests/test_transformer.py +87 -0
  8. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/LICENSE +0 -0
  9. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/README.rst +0 -0
  10. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/ransack/__init__.py +0 -0
  11. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/ransack/exceptions.py +0 -0
  12. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/ransack/function.py +0 -0
  13. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/ransack/py.typed +0 -0
  14. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/ransacklib.egg-info/SOURCES.txt +0 -0
  15. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/ransacklib.egg-info/dependency_links.txt +0 -0
  16. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/ransacklib.egg-info/requires.txt +0 -0
  17. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/ransacklib.egg-info/top_level.txt +0 -0
  18. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/setup.cfg +0 -0
  19. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/tests/test_operator.py +0 -0
  20. {ransacklib-1.1.0.dev6 → ransacklib-1.1.0.dev7}/tests/test_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ransacklib
3
- Version: 1.1.0.dev6
3
+ Version: 1.1.0.dev7
4
4
  Summary: A modern, extensible language for manipulation with structured data
5
5
  Author-email: "Rajmund H. Hruška" <rajmund.hruska@cesnet.cz>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ransacklib"
7
- version = "1.1.0.dev6"
7
+ version = "1.1.0.dev7"
8
8
  description = "A modern, extensible language for manipulation with structured data"
9
9
  license = "MIT"
10
10
  readme = "README.rst"
@@ -501,9 +501,40 @@ def binary_operation(operator: str, left: Any, right: Any) -> Any:
501
501
 
502
502
 
503
503
  def _operator_map_sql(op, l_sql, r_sql, t1, t2) -> tuple[str, Any, bool]:
504
+ """
505
+ Maps an expression into its SQL representation.
506
+
507
+ Args:
508
+ op: Operator string (e.g., "=", "+", "and", "contains").
509
+ l_sql: SQL string representing the left operand.
510
+ r_sql: SQL string representing the right operand.
511
+ t1: Resolved type of the left operand.
512
+ t2: Resolved type of the right operand.
513
+
514
+ Returns:
515
+ A tuple of:
516
+ - SQL expression string,
517
+ - result type,
518
+ - switch flag (True if operands were reversed semantically).
519
+
520
+ Raises:
521
+ KeyError:
522
+ If no mapping exists for the given operator and operand types.
523
+ """
524
+
504
525
  def _get_comp_dict_sql(
505
526
  op: str, tuple_repr_f
506
527
  ) -> dict[tuple[Operand, Operand], tuple[str, bool]]:
528
+ """
529
+ Generate comparison operator mappings for multiple type combinations.
530
+
531
+ Args:
532
+ op: SQL comparison operator (e.g., ">", "<=").
533
+ tuple_repr_f: SQL function name used for tuple bounds (e.g. "lower").
534
+
535
+ Returns:
536
+ Mapping from (type1, type2) -> (SQL string, switch flag).
537
+ """
507
538
  return {
508
539
  (Number, Number): (f"{l_sql} {op} {r_sql}", False),
509
540
  (Number, list): (f"{l_sql} {op} ANY({r_sql})", False),
@@ -515,10 +546,10 @@ def _operator_map_sql(op, l_sql, r_sql, t1, t2) -> tuple[str, Any, bool]:
515
546
  (timedelta, list): (f"{l_sql} {op} ANY({r_sql})", False),
516
547
  ("ip", "ip"): (f"{l_sql} {op} {r_sql}", False),
517
548
  ("ip", list): (f"{l_sql} {op} ANY({r_sql})", False),
518
- # (list, list): f"{l_sql} && {r_sql}",
519
- # (tuple, tuple): f"upper({l_sql}) > lower({r_sql})",
520
549
  }
521
550
 
551
+ # Comparison and logical operators (result is always boolean).
552
+ # The boolean flag indicates whether operand order was reversed.
522
553
  d: dict[str, dict] = {
523
554
  "=": {
524
555
  (Number, Number): (f"{l_sql} = {r_sql}", False),
@@ -546,8 +577,16 @@ def _operator_map_sql(op, l_sql, r_sql, t1, t2) -> tuple[str, Any, bool]:
546
577
  | {(tuple, tuple): (f"lower({l_sql}) <= upper({r_sql})", False)},
547
578
  "and": {(bool, bool): (f"{l_sql} AND {r_sql}", False)},
548
579
  "or": {(bool, bool): (f"({l_sql} OR {r_sql})", False)},
549
- "contains": {(str, str): (f"position({r_sql} in {l_sql})>0", True)},
580
+ "contains": {
581
+ (str, str): (f"position({r_sql} in {l_sql})>0", True),
582
+ (list, str): (
583
+ f"position({r_sql} in array_to_string({l_sql}, ','))>0",
584
+ True,
585
+ ),
586
+ },
550
587
  }
588
+
589
+ # Arithmetic operators (result type varies, operands are not swapped).
551
590
  d2: dict[str, dict] = {
552
591
  "+": {
553
592
  (Number, Number): (f"({l_sql} + {r_sql})", Number),
@@ -572,14 +611,21 @@ def _operator_map_sql(op, l_sql, r_sql, t1, t2) -> tuple[str, Any, bool]:
572
611
  (Number, Number): (f"({l_sql} % {r_sql})", Number),
573
612
  },
574
613
  }
575
- if op == "==":
614
+
615
+ if op == "==" and t1 == t2:
576
616
  return (f"{l_sql} = {r_sql}", bool, False)
617
+
618
+ # 'in' operator behaves the same as '=' operator.
577
619
  if op == "in":
578
620
  sql, switch = d["="][(t1, t2)]
579
621
  return sql, bool, switch
622
+
623
+ # Arithmetic operator handling.
580
624
  if op in d2:
581
625
  sql, type_ = d2[op][(t1, t2)]
582
626
  return sql, type_, False
627
+
628
+ # Default: comparison/logical operator.
583
629
  sql, switch = d[op][(t1, t2)]
584
630
  return sql, bool, switch
585
631
 
@@ -587,6 +633,30 @@ def _operator_map_sql(op, l_sql, r_sql, t1, t2) -> tuple[str, Any, bool]:
587
633
  def binary_operation_sql(
588
634
  operator: str, left: str, right: str, l_type: Any, r_type: Any
589
635
  ) -> tuple[str, bool, Any]:
636
+ """
637
+ Converts a binary operation into its SQL equivalent with type handling.
638
+
639
+ This function normalizes operand types (e.g., numeric types, IP types),
640
+ delegates SQL generation to `_operator_map_sql`, and handles operand
641
+ swapping if needed.
642
+
643
+ Args:
644
+ operator: Operator string (e.g., "+", "=", "in").
645
+ left: SQL string for the left operand.
646
+ right: SQL string for the right operand.
647
+ l_type: Type of the left operand.
648
+ r_type: Type of the right operand.
649
+
650
+ Returns:
651
+ A tuple of:
652
+ - SQL expression string,
653
+ - result type,
654
+ - switch flag (True if operands were reversed).
655
+
656
+ Raises:
657
+ OperatorNotFoundError:
658
+ If no valid SQL mapping exists for the operator/type combination.
659
+ """
590
660
  # Normalize numeric types
591
661
  l_type = Number if l_type in (int, float) else l_type
592
662
  r_type = Number if r_type in (int, float) else r_type
@@ -12,6 +12,7 @@ Classes:
12
12
  """
13
13
 
14
14
  import os
15
+ from datetime import tzinfo
15
16
  from pathlib import Path
16
17
  from typing import Any, no_type_check
17
18
 
@@ -120,7 +121,11 @@ class Parser:
120
121
  %ignore WS
121
122
  """ # noqa
122
123
 
123
- def __init__(self, context: dict[str, Any] | None = None) -> None:
124
+ def __init__(
125
+ self,
126
+ context: dict[str, Any] | None = None,
127
+ default_timezone: tzinfo | None = None,
128
+ ) -> None:
124
129
  """
125
130
  Initialize the Parser.
126
131
 
@@ -128,6 +133,9 @@ class Parser:
128
133
  context (dict[str, Any] | None): Optional dictionary of variables that can
129
134
  be referenced in queries. Context variables override data variables unless
130
135
  the data variable is explicitly accessed with a leading dot.
136
+ default_timezone (tzinfo | None): Time zone to apply to parsed datetime
137
+ values that do not include time zone information. If None, naive datetimes
138
+ remain naive.
131
139
  """
132
140
  # Determine the cache file of the grammar. By default, it's in the home
133
141
  # directory. But when the environment variable is set, use the value
@@ -142,7 +150,7 @@ class Parser:
142
150
  propagate_positions=True,
143
151
  cache=cache_path,
144
152
  )
145
- self.shaper = ExpressionTransformer(context)
153
+ self.shaper = ExpressionTransformer(context, default_timezone)
146
154
 
147
155
  @no_type_check
148
156
  def parse(self, data: str) -> Tree[Token]:
@@ -20,7 +20,7 @@ Classes:
20
20
 
21
21
  import re
22
22
  from collections.abc import Mapping, MutableSequence
23
- from datetime import datetime, timedelta
23
+ from datetime import datetime, timedelta, tzinfo
24
24
  from typing import Any, cast
25
25
 
26
26
  from ipranges import IP4, IP6, IP4Net, IP4Range, IP6Net, IP6Range
@@ -194,7 +194,11 @@ class ExpressionTransformer(Transformer):
194
194
  into appropriate objects (ipranges, datetime, list, ...).
195
195
  """
196
196
 
197
- def __init__(self, context: dict[str, Any] | None = None) -> None:
197
+ def __init__(
198
+ self,
199
+ context: dict[str, Any] | None = None,
200
+ default_timezone: tzinfo | None = None,
201
+ ) -> None:
198
202
  """
199
203
  Initializes the ExpressionTransformer with a context dictionary
200
204
  for constants.
@@ -203,8 +207,12 @@ class ExpressionTransformer(Transformer):
203
207
  context: A dictionary containing constant values that can be
204
208
  accessed by variable names within expressions.
205
209
  Defaults to an empty dictionary if none is provided.
210
+ default_timezone: Time zone to apply to parsed datetime values
211
+ that do not include time zone information.
212
+ If None, naive datetimes remain naive.
206
213
  """
207
214
  self.context = context if context is not None else {}
215
+ self.default_timezone = default_timezone
208
216
 
209
217
  def number(self, data: Token) -> Tree[TokenWrapper]:
210
218
  """
@@ -322,6 +330,11 @@ class ExpressionTransformer(Transformer):
322
330
 
323
331
  # Convert to datetime object
324
332
  dtime = datetime.fromisoformat(datetime_str)
333
+
334
+ # If no time zone was specified, then set to default time zone
335
+ if dtime.tzinfo is None:
336
+ dtime = dtime.replace(tzinfo=self.default_timezone)
337
+
325
338
  tw = TokenWrapper(_add_tokens(date, time), dtime)
326
339
 
327
340
  return Tree("datetime", [tw], _create_meta(tw))
@@ -337,7 +350,14 @@ class ExpressionTransformer(Transformer):
337
350
  """
338
351
  return Tree(
339
352
  "datetime",
340
- [TokenWrapper(date, datetime.fromisoformat(date + "T00:00:00"))],
353
+ [
354
+ TokenWrapper(
355
+ date,
356
+ datetime.fromisoformat(date + "T00:00:00").replace(
357
+ tzinfo=self.default_timezone
358
+ ),
359
+ )
360
+ ],
341
361
  _create_meta(date),
342
362
  )
343
363
 
@@ -819,7 +839,8 @@ class SQLInterpreter(Interpreter):
819
839
  data: Optional dictionary containing the definition of queried columns.
820
840
 
821
841
  Returns:
822
- A part of the SQL query - the part after 'SELECT' or 'WHERE'.
842
+ A part of the SQL query (the part after 'SELECT' or 'WHERE') and its
843
+ parameters.
823
844
  """
824
845
  self.data = data if data is not None else {}
825
846
 
@@ -867,7 +888,7 @@ class SQLInterpreter(Interpreter):
867
888
  elif issubclass(l_type, float) and issubclass(r_type, float):
868
889
  range_type = "numrange"
869
890
  elif issubclass(l_type, datetime) and issubclass(r_type, datetime):
870
- if "WITH TIME ZONE" in l_sql or "WITH TIME ZONE" in r_sql:
891
+ if "WITH TIME ZONE" in l_sql and "WITH TIME ZONE" in r_sql:
871
892
  range_type = "tstzrange"
872
893
  else:
873
894
  range_type = "tsrange"
@@ -879,7 +900,7 @@ class SQLInterpreter(Interpreter):
879
900
  )
880
901
  else:
881
902
  raise EvaluationError(
882
- f"Range operator is not supported for types '{l_type}' and '{r_type}'",
903
+ f"Range operator is not supported for types '{l_type}' and '{r_type}'.",
883
904
  line=l_tree.meta.line,
884
905
  column=l_tree.meta.column,
885
906
  start_pos=l_tree.meta.start_pos,
@@ -903,7 +924,7 @@ class SQLInterpreter(Interpreter):
903
924
  column, type_ = self.data[var.real_value]
904
925
  return (f'"{column}"', [], type_)
905
926
  raise EvaluationError(
906
- f"The type of column '{var.real_value}' was not defined",
927
+ f"The type of column '{var.real_value}' was not defined.",
907
928
  line=var.line,
908
929
  column=var.column,
909
930
  start_pos=var.start_pos,
@@ -988,14 +1009,21 @@ class SQLInterpreter(Interpreter):
988
1009
 
989
1010
  args_sql = []
990
1011
  params = []
1012
+ types = []
991
1013
 
992
1014
  for arg in args.children if args else []:
993
- sql, p, _ = self.visit(arg)
1015
+ sql, p, type_ = self.visit(arg)
994
1016
  args_sql.append(sql)
995
1017
  params.extend(p)
996
-
997
- if function_name == "now":
998
- return "localtimestamp", [], datetime
1018
+ types.append(type_)
1019
+
1020
+ if function_name == "now" and len(args_sql) == 0:
1021
+ return "localtimestamp", params, datetime
1022
+ if function_name in ("len", "length") and len(args_sql) == 1:
1023
+ if types[0] is list:
1024
+ return f"array_length({args_sql[0]}, 1)", params, int
1025
+ if types[0] is str:
1026
+ return f"length({args_sql[0]})", params, int
999
1027
  raise EvaluationError(
1000
1028
  f"Function '{name.real_value}' was not found.",
1001
1029
  line=name.line,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ransacklib
3
- Version: 1.1.0.dev6
3
+ Version: 1.1.0.dev7
4
4
  Summary: A modern, extensible language for manipulation with structured data
5
5
  Author-email: "Rajmund H. Hruška" <rajmund.hruska@cesnet.cz>
6
6
  License-Expression: MIT
@@ -213,6 +213,68 @@ class TestExpressionTransformer:
213
213
  assert result_data.children[0].real_value == "my_var"
214
214
 
215
215
 
216
+ class TestDefaultTimezone:
217
+ def test_naive_datetime_gets_default_timezone(self):
218
+ parser = Parser(default_timezone=timezone.utc)
219
+
220
+ result = parser.parse("2024-01-01T12:00:00").children[0].real_value
221
+
222
+ assert result.tzinfo == timezone.utc
223
+ assert str(result) == "2024-01-01 12:00:00+00:00"
224
+
225
+ result = parser.parse("2024-01-01").children[0].real_value
226
+
227
+ assert result.tzinfo == timezone.utc
228
+ assert str(result) == "2024-01-01 00:00:00+00:00"
229
+
230
+ def test_naive_datetime_with_custom_timezone(self):
231
+ custom_tz = timezone(timedelta(hours=2))
232
+ parser = Parser(default_timezone=custom_tz)
233
+
234
+ result = parser.parse("2024-01-01T12:00:00").children[0].real_value
235
+
236
+ assert result.tzinfo == custom_tz
237
+ assert str(result) == "2024-01-01 12:00:00+02:00"
238
+
239
+ result = parser.parse("2024-01-01").children[0].real_value
240
+
241
+ assert result.tzinfo == custom_tz
242
+ assert str(result) == "2024-01-01 00:00:00+02:00"
243
+
244
+ def test_datetime_with_timezone_not_overridden(self):
245
+ parser = Parser(default_timezone=timezone.utc)
246
+
247
+ result = parser.parse("2024-01-01T12:00:00+02:00").children[0].real_value
248
+
249
+ # Should keep original timezone
250
+ assert result.tzinfo.utcoffset(result) == timedelta(hours=2)
251
+ assert str(result) == "2024-01-01 12:00:00+02:00"
252
+
253
+ def test_no_default_timezone_keeps_naive_datetime(self):
254
+ parser = Parser(default_timezone=None)
255
+
256
+ result = parser.parse("2024-01-01T12:00:00").children[0].real_value
257
+
258
+ assert result.tzinfo is None
259
+ assert str(result) == "2024-01-01 12:00:00"
260
+
261
+ result = parser.parse("2024-01-01").children[0].real_value
262
+
263
+ assert result.tzinfo is None
264
+ assert str(result) == "2024-01-01 00:00:00"
265
+
266
+ def test_expression_transformer_default_timezone_direct(self, parser):
267
+ transformer = ExpressionTransformer(default_timezone=timezone.utc)
268
+
269
+ result = (
270
+ transformer.transform(parser.parse_only("2024-01-01T12:00:00"))
271
+ .children[0]
272
+ .real_value
273
+ )
274
+
275
+ assert result.tzinfo == timezone.utc
276
+
277
+
216
278
  class TestFilter:
217
279
  def parse_filter(self, parser, filter_, expression):
218
280
  """Helper to parse, evaluate atoms to Python object, then evaluate."""
@@ -522,6 +584,7 @@ class TestSQLInterpreter:
522
584
  "c_int": ("c", int),
523
585
  "d_int": ("d", int),
524
586
  "Description": ("description", str),
587
+ "Category": ("category", list),
525
588
  }
526
589
  return sql.to_sql(tree, data)
527
590
 
@@ -676,6 +739,16 @@ class TestSQLInterpreter:
676
739
  ("a_int??14", 'COALESCE("a", %s)', [14]),
677
740
  # Test contains operator.
678
741
  ("'abcdfg' contains 'bcd'", "position(%s in %s)>0", ["bcd", "abcdfg"]),
742
+ (
743
+ "Category contains 'Attempt'",
744
+ "position(%s in array_to_string(\"category\", ','))>0",
745
+ ["Attempt"],
746
+ ),
747
+ (
748
+ "['abcdfg', 'qwerty', 'azerty'] contains 'bcd'",
749
+ "position(%s in array_to_string(ARRAY[%s, %s, %s], ','))>0",
750
+ ["bcd", "abcdfg", "qwerty", "azerty"],
751
+ ),
679
752
  # Test = operator.
680
753
  ("1 = 1", "%s = %s", [1, 1]),
681
754
  ("1 = [1, 2, 3]", "%s = ANY(ARRAY[%s, %s, %s])", [1, 1, 2, 3]),
@@ -762,6 +835,20 @@ class TestSQLInterpreter:
762
835
  from_str("192.168.0.0/16"),
763
836
  ],
764
837
  ),
838
+ # Test functions.
839
+ (
840
+ "now() > 2025-12-12T12:34:56Z",
841
+ "localtimestamp > %s::TIMESTAMP WITH TIME ZONE",
842
+ [datetime(2025, 12, 12, 12, 34, 56, tzinfo=timezone.utc)],
843
+ ),
844
+ ("length('')", "length(%s)", [""]),
845
+ ("length('abcde')", "length(%s)", ["abcde"]),
846
+ (
847
+ "length([1, 2, 3]) == 3",
848
+ "array_length(ARRAY[%s, %s, %s], 1) = %s",
849
+ [1, 2, 3, 3],
850
+ ),
851
+ ("len(Category) > 1", 'array_length("category", 1) > %s', [1]),
765
852
  ],
766
853
  )
767
854
  def test_parse_sql(self, parser, sql, expr, expected_sql, expected_params):
File without changes