persistence-kit 3.0.0__tar.gz → 3.0.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.
Files changed (46) hide show
  1. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/PKG-INFO +1 -1
  2. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/filter_ops.py +34 -3
  3. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/mongo_repo/mongo_repo.py +53 -3
  4. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +5 -2
  5. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +25 -4
  6. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/settings/repo_settings.py +2 -0
  7. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/pyproject.toml +1 -1
  8. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/LICENSE +0 -0
  9. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/README.md +0 -0
  10. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/__init__.py +0 -0
  11. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/api/__init__.py +0 -0
  12. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/api/common.py +0 -0
  13. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/api/error_handlers.py +0 -0
  14. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/api/exceptions.py +0 -0
  15. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/api/route_loader.py +0 -0
  16. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/bootstrap/__init__.py +0 -0
  17. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/bootstrap/configuration.py +0 -0
  18. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/bootstrap/seeders.py +0 -0
  19. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/bootstrap/startup.py +0 -0
  20. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/contracts/__init__.py +0 -0
  21. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/contracts/repository.py +0 -0
  22. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/contracts/view_repository.py +0 -0
  23. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/py.typed +0 -0
  24. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/__init__.py +0 -0
  25. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
  26. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
  27. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/memory_repo/__init__.py +0 -0
  28. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
  29. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
  30. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
  31. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
  32. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
  33. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
  34. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
  35. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository_factory/__init__.py +0 -0
  36. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository_factory/factory/__init__.py +0 -0
  37. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
  38. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository_factory/registry/__init__.py +0 -0
  39. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
  40. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository_factory/view/__init__.py +0 -0
  41. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
  42. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/settings/__init__.py +0 -0
  43. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/settings/constants.py +0 -0
  44. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/settings/parsers.py +0 -0
  45. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/utils/__init__.py +0 -0
  46. {persistence_kit-3.0.0 → persistence_kit-3.0.2}/persistence_kit/utils/upsert.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: persistence-kit
3
- Version: 3.0.0
3
+ Version: 3.0.2
4
4
  Summary: Reusable persistence and repository toolkit
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,11 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
4
+ import unicodedata
3
5
  from typing import Any, Callable, Iterable, Mapping
4
6
 
5
7
 
6
8
  _SIMPLE_OPS = {"gte", "gt", "lte", "lt", "eq", "ne"}
7
9
  _STRING_OPS = {"contains", "icontains", "startswith", "istartswith", "endswith", "iendswith"}
8
10
  _LOGICAL_KEYS = {"or", "and"}
11
+ _NON_ALNUM_RE = re.compile(r"[^0-9a-z]+")
12
+ _WHITESPACE_RE = re.compile(r"\s+")
9
13
 
10
14
 
11
15
  def is_multi_value(value: Any) -> bool:
@@ -49,6 +53,23 @@ def iter_range_ops(value: Mapping[str, Any]) -> Iterable[tuple[str, Any]]:
49
53
  return items
50
54
 
51
55
 
56
+ def normalize_search_text(value: Any) -> str:
57
+ if value is None:
58
+ return ""
59
+ normalized = unicodedata.normalize("NFKD", str(value))
60
+ without_marks = "".join(ch for ch in normalized if not unicodedata.combining(ch))
61
+ lowered = without_marks.lower()
62
+ collapsed = _NON_ALNUM_RE.sub(" ", lowered)
63
+ return _WHITESPACE_RE.sub(" ", collapsed).strip()
64
+
65
+
66
+ def tokenize_search_text(value: Any) -> list[str]:
67
+ normalized = normalize_search_text(value)
68
+ if not normalized:
69
+ return []
70
+ return normalized.split()
71
+
72
+
52
73
  def _matches_string_op(current: Any, op: str, expected: Any) -> bool:
53
74
  if current is None:
54
75
  return False
@@ -57,15 +78,25 @@ def _matches_string_op(current: Any, op: str, expected: Any) -> bool:
57
78
  if op == "contains":
58
79
  return expected_text in current_text
59
80
  if op == "icontains":
60
- return expected_text.lower() in current_text.lower()
81
+ expected_tokens = tokenize_search_text(expected)
82
+ if not expected_tokens:
83
+ return False
84
+ normalized_current = normalize_search_text(current)
85
+ return all(token in normalized_current for token in expected_tokens)
61
86
  if op == "startswith":
62
87
  return current_text.startswith(expected_text)
63
88
  if op == "istartswith":
64
- return current_text.lower().startswith(expected_text.lower())
89
+ normalized_expected = normalize_search_text(expected)
90
+ if not normalized_expected:
91
+ return False
92
+ return normalize_search_text(current).startswith(normalized_expected)
65
93
  if op == "endswith":
66
94
  return current_text.endswith(expected_text)
67
95
  if op == "iendswith":
68
- return current_text.lower().endswith(expected_text.lower())
96
+ normalized_expected = normalize_search_text(expected)
97
+ if not normalized_expected:
98
+ return False
99
+ return normalize_search_text(current).endswith(normalized_expected)
69
100
  raise ValueError(f"Unsupported operator: {op}")
70
101
 
71
102
 
@@ -16,10 +16,21 @@ from persistence_kit.repository.filter_ops import (
16
16
  is_multi_value,
17
17
  is_range_dict,
18
18
  iter_range_ops,
19
+ normalize_search_text,
20
+ tokenize_search_text,
19
21
  )
20
22
 
21
23
  T = TypeVar("T")
22
24
  TId = TypeVar("TId", bound=Hashable)
25
+ _ACCENT_CLASSES = {
26
+ "a": "[aáàäâã]",
27
+ "e": "[eéèëê]",
28
+ "i": "[iíìïî]",
29
+ "o": "[oóòöôõ]",
30
+ "u": "[uúùüû]",
31
+ "n": "[nñ]",
32
+ "c": "[cç]",
33
+ }
23
34
 
24
35
  class EntityMapper(Protocol[T, TId]):
25
36
  def collection(self) -> str: ...
@@ -34,6 +45,36 @@ class EntityMapper(Protocol[T, TId]):
34
45
  def _normalize_field(mapper: EntityMapper[T, TId], field: str) -> str:
35
46
  return mapper.attr_to_storage(field)
36
47
 
48
+
49
+ def _token_to_regex(token: str) -> str:
50
+ parts: list[str] = []
51
+ for ch in token:
52
+ parts.append(_ACCENT_CLASSES.get(ch, re.escape(ch)))
53
+ return "".join(parts)
54
+
55
+
56
+ def _build_icontains_pattern(value: Any) -> str | None:
57
+ terms = tokenize_search_text(value)
58
+ if not terms:
59
+ return None
60
+ return "".join(f"(?=.*{_token_to_regex(term)})" for term in terms)
61
+
62
+
63
+ def _build_ordered_pattern(value: Any, *, anchor_start: bool = False, anchor_end: bool = False) -> str | None:
64
+ normalized = normalize_search_text(value)
65
+ if not normalized:
66
+ return None
67
+ terms = tokenize_search_text(normalized)
68
+ if not terms:
69
+ return None
70
+ core = ".*".join(_token_to_regex(term) for term in terms)
71
+ if anchor_start:
72
+ core = f"^{core}"
73
+ if anchor_end:
74
+ core = f"{core}$"
75
+ return core
76
+
77
+
37
78
  def _range_to_mongo(value: Mapping[str, Any]) -> Mapping[str, Any]:
38
79
  query: dict[str, Any] = {}
39
80
  for op, v in iter_range_ops(value):
@@ -57,17 +98,26 @@ def _range_to_mongo(value: Mapping[str, Any]) -> Mapping[str, Any]:
57
98
  elif op == "contains":
58
99
  query["$regex"] = re.escape(str(v))
59
100
  elif op == "icontains":
60
- query["$regex"] = re.escape(str(v))
101
+ pattern = _build_icontains_pattern(v)
102
+ if pattern is None:
103
+ continue
104
+ query["$regex"] = pattern
61
105
  query["$options"] = "i"
62
106
  elif op == "startswith":
63
107
  query["$regex"] = f"^{re.escape(str(v))}"
64
108
  elif op == "istartswith":
65
- query["$regex"] = f"^{re.escape(str(v))}"
109
+ pattern = _build_ordered_pattern(v, anchor_start=True)
110
+ if pattern is None:
111
+ continue
112
+ query["$regex"] = pattern
66
113
  query["$options"] = "i"
67
114
  elif op == "endswith":
68
115
  query["$regex"] = f"{re.escape(str(v))}$"
69
116
  elif op == "iendswith":
70
- query["$regex"] = f"{re.escape(str(v))}$"
117
+ pattern = _build_ordered_pattern(v, anchor_end=True)
118
+ if pattern is None:
119
+ continue
120
+ query["$regex"] = pattern
71
121
  query["$options"] = "i"
72
122
  return query
73
123
 
@@ -5,7 +5,10 @@ from persistence_kit.settings.repo_settings import RepoSettings
5
5
  @lru_cache
6
6
  def get_engine() -> AsyncEngine:
7
7
  s = RepoSettings()
8
- url = getattr(s, "postgres_dsn", None)
8
+ url = s.postgres_dsn
9
9
  if not url:
10
10
  url = f"postgresql+asyncpg://{s.postgres_user}:{s.postgres_password}@{s.postgres_host}:{s.postgres_port}/{s.postgres_db}"
11
- return create_async_engine(url, future=True)
11
+ kwargs: dict = {"future": True}
12
+ if s.postgres_ssl:
13
+ kwargs["connect_args"] = {"ssl": "require"}
14
+ return create_async_engine(url, **kwargs)
@@ -6,7 +6,7 @@ try:
6
6
  except ImportError:
7
7
  from typing_extensions import override
8
8
 
9
- from sqlalchemy import Table, select, insert, update as sql_update, delete as sql_delete, Index, and_, or_, func, distinct
9
+ from sqlalchemy import Table, select, insert, update as sql_update, delete as sql_delete, Index, and_, or_, func, distinct, String, cast
10
10
  from sqlalchemy.ext.asyncio import AsyncEngine
11
11
 
12
12
  from persistence_kit.contracts.repository import Repository
@@ -16,6 +16,8 @@ from persistence_kit.repository.filter_ops import (
16
16
  is_multi_value,
17
17
  is_range_dict,
18
18
  iter_range_ops,
19
+ normalize_search_text,
20
+ tokenize_search_text,
19
21
  )
20
22
  from persistence_kit.repository.sqlalchemy_repo.schema_evolve import (
21
23
  ensure_missing_columns,
@@ -25,6 +27,8 @@ from persistence_kit.repository_factory.registry.entity_registry import build_fk
25
27
 
26
28
  T = TypeVar("T")
27
29
  TId = TypeVar("TId", bound=Hashable)
30
+ _ACCENTED_CHARS = "ÁÀÄÂÃáàäâãÉÈËÊéèëêÍÌÏÎíìïîÓÒÖÔÕóòöôõÚÙÜÛúùüûÑñÇç"
31
+ _PLAIN_CHARS = "AAAAAaaaaaEEEEeeeeIIIIiiiiOOOOOoooooUUUUuuuuNnCc"
28
32
 
29
33
 
30
34
  class SqlAlchemyEntityMapper(Protocol[T, TId]):
@@ -39,6 +43,12 @@ class SqlAlchemyEntityMapper(Protocol[T, TId]):
39
43
  def attr_to_storage(self, name: str) -> str: ...
40
44
 
41
45
 
46
+ def _normalized_text_expr(col: Any) -> Any:
47
+ text_col = cast(func.coalesce(col, ""), String)
48
+ lowered = func.lower(func.translate(text_col, _ACCENTED_CHARS, _PLAIN_CHARS))
49
+ return func.regexp_replace(lowered, r"[^a-z0-9]+", " ", "g")
50
+
51
+
42
52
  def _build_where_clauses(
43
53
  table: Table,
44
54
  mapper: SqlAlchemyEntityMapper[T, TId],
@@ -104,15 +114,26 @@ def _build_where_clauses(
104
114
  elif op == "contains":
105
115
  clauses.append(col.contains(str(v)))
106
116
  elif op == "icontains":
107
- clauses.append(col.ilike(f"%{v}%"))
117
+ normalized_terms = tokenize_search_text(v)
118
+ if not normalized_terms:
119
+ return None
120
+ normalized_col = _normalized_text_expr(col)
121
+ for term in normalized_terms:
122
+ clauses.append(normalized_col.like(f"%{term}%"))
108
123
  elif op == "startswith":
109
124
  clauses.append(col.startswith(str(v)))
110
125
  elif op == "istartswith":
111
- clauses.append(col.ilike(f"{v}%"))
126
+ normalized_value = normalize_search_text(v)
127
+ if not normalized_value:
128
+ return None
129
+ clauses.append(_normalized_text_expr(col).like(f"{normalized_value}%"))
112
130
  elif op == "endswith":
113
131
  clauses.append(col.endswith(str(v)))
114
132
  elif op == "iendswith":
115
- clauses.append(col.ilike(f"%{v}"))
133
+ normalized_value = normalize_search_text(v)
134
+ if not normalized_value:
135
+ return None
136
+ clauses.append(_normalized_text_expr(col).like(f"%{normalized_value}"))
116
137
  else:
117
138
  clauses.append(col == value)
118
139
  return clauses
@@ -12,11 +12,13 @@ class RepoSettings(BaseSettings):
12
12
  mongo_dsn: str | None = None
13
13
  mongo_db: str | None = None
14
14
 
15
+ postgres_dsn: str | None = None
15
16
  postgres_user: str | None = None
16
17
  postgres_password: str | None = None
17
18
  postgres_host: str | None = None
18
19
  postgres_port: int | None = 5432
19
20
  postgres_db: str | None = None
21
+ postgres_ssl: bool = False
20
22
 
21
23
  dynamodb_table_prefix: str = ""
22
24
  dynamodb_region: str = "us-east-1"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "persistence-kit"
3
- version = "3.0.0"
3
+ version = "3.0.2"
4
4
  description = "Reusable persistence and repository toolkit"
5
5
  authors = ["Andres Felipe Serrano Barrios <andresfserrano1@gmail.com>"]
6
6
  readme = "README.md"
File without changes