python-corekit 0.1.0__py3-none-any.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.
- corekit/__init__.py +0 -0
- corekit/api/__init__.py +9 -0
- corekit/api/handler.py +76 -0
- corekit/api/responses.py +40 -0
- corekit/api/routers.py +115 -0
- corekit/concurrency/__init__.py +9 -0
- corekit/concurrency/decorators.py +72 -0
- corekit/concurrency/thread_local.py +99 -0
- corekit/concurrency/worker.py +65 -0
- corekit/config/__init__.py +47 -0
- corekit/config/loader.py +153 -0
- corekit/config/settings.py +161 -0
- corekit/config/sources.py +125 -0
- corekit/connections/__init__.py +31 -0
- corekit/connections/connectable.py +212 -0
- corekit/connections/decorators.py +92 -0
- corekit/connections/redis/__init__.py +7 -0
- corekit/connections/redis/connection.py +239 -0
- corekit/connections/registry.py +80 -0
- corekit/connections/sql/__init__.py +10 -0
- corekit/connections/sql/connection.py +342 -0
- corekit/connections/sql/fields/__init__.py +7 -0
- corekit/connections/sql/fields/jsonb.py +67 -0
- corekit/connections/sql/migration/__init__.py +57 -0
- corekit/connections/sql/migration/base.py +40 -0
- corekit/connections/sql/migration/operations.py +416 -0
- corekit/connections/sql/migration/registry.py +166 -0
- corekit/connections/sql/migration/table.py +27 -0
- corekit/connections/sql/query.py +68 -0
- corekit/connections/sql/table.py +96 -0
- corekit/constants.py +45 -0
- corekit/crypto/__init__.py +1 -0
- corekit/crypto/constants.py +7 -0
- corekit/crypto/enum.py +11 -0
- corekit/crypto/hasher.py +89 -0
- corekit/data/__init__.py +81 -0
- corekit/data/dataset.py +340 -0
- corekit/data/expressions/__init__.py +46 -0
- corekit/data/expressions/comparison.py +252 -0
- corekit/data/expressions/expression.py +98 -0
- corekit/data/record.py +147 -0
- corekit/data/stats.py +157 -0
- corekit/decorators/__init__.py +2 -0
- corekit/decorators/exception_handling.py +43 -0
- corekit/decorators/warnings.py +35 -0
- corekit/docker/__init__.py +7 -0
- corekit/docker/watchdog.py +222 -0
- corekit/etl/__init__.py +44 -0
- corekit/etl/connection.py +44 -0
- corekit/etl/extract/__init__.py +0 -0
- corekit/etl/extract/extractor.py +48 -0
- corekit/etl/extract/schemas.py +18 -0
- corekit/etl/load/__init__.py +0 -0
- corekit/etl/load/loader.py +53 -0
- corekit/etl/load/schemas.py +33 -0
- corekit/etl/orchestrator.py +201 -0
- corekit/etl/schemas.py +22 -0
- corekit/etl/transform/__init__.py +0 -0
- corekit/etl/transform/schemas.py +15 -0
- corekit/etl/transform/transformer.py +28 -0
- corekit/events/__init__.py +38 -0
- corekit/events/enum.py +58 -0
- corekit/events/frames.py +51 -0
- corekit/events/models.py +23 -0
- corekit/events/publisher.py +75 -0
- corekit/events/reader.py +132 -0
- corekit/events/sse.py +109 -0
- corekit/events/websocket.py +97 -0
- corekit/exceptions/__init__.py +0 -0
- corekit/exceptions/base.py +45 -0
- corekit/exceptions/custom/__init__.py +0 -0
- corekit/exceptions/http/__init__.py +0 -0
- corekit/exceptions/http/exceptions.py +37 -0
- corekit/exceptions/types.py +17 -0
- corekit/files/__init__.py +25 -0
- corekit/files/base.py +117 -0
- corekit/files/enum.py +30 -0
- corekit/files/json.py +12 -0
- corekit/files/pickle.py +12 -0
- corekit/files/toml.py +43 -0
- corekit/http/__init__.py +0 -0
- corekit/http/client.py +176 -0
- corekit/http/exponential_backoff.py +100 -0
- corekit/http/response.py +12 -0
- corekit/log_monitor/__init__.py +23 -0
- corekit/log_monitor/constants.py +8 -0
- corekit/log_monitor/models.py +150 -0
- corekit/log_monitor/service.py +418 -0
- corekit/notifications/__init__.py +8 -0
- corekit/notifications/base.py +51 -0
- corekit/notifications/models.py +34 -0
- corekit/observability/__init__.py +21 -0
- corekit/observability/benchmarkable.py +12 -0
- corekit/observability/loggable.py +29 -0
- corekit/observability/timing/__init__.py +0 -0
- corekit/observability/timing/constants.py +1 -0
- corekit/observability/timing/split.py +20 -0
- corekit/observability/timing/timer.py +30 -0
- corekit/py.typed +0 -0
- corekit/registry/__init__.py +12 -0
- corekit/registry/registry.py +134 -0
- corekit/schemas/__init__.py +0 -0
- corekit/schemas/dataclasses/__init__.py +0 -0
- corekit/schemas/enum.py +49 -0
- corekit/schemas/models/__init__.py +0 -0
- corekit/schemas/models/arbitrary.py +11 -0
- corekit/schemas/models/date_models.py +18 -0
- corekit/schemas/pydantic/__init__.py +0 -0
- corekit/schemas/pydantic/fields.py +35 -0
- corekit/schemas/types.py +40 -0
- corekit/serialization/__init__.py +0 -0
- corekit/serialization/enum.py +21 -0
- corekit/serialization/serializable.py +42 -0
- corekit/serialization/serializer.py +179 -0
- corekit/utils/__init__.py +5 -0
- corekit/utils/ids.py +5 -0
- corekit/utils/raise_exc.py +8 -0
- corekit/utils/time.py +21 -0
- corekit/utils/validators.py +15 -0
- corekit/utils/void.py +8 -0
- python_corekit-0.1.0.dist-info/METADATA +417 -0
- python_corekit-0.1.0.dist-info/RECORD +125 -0
- python_corekit-0.1.0.dist-info/WHEEL +5 -0
- python_corekit-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_corekit-0.1.0.dist-info/top_level.txt +1 -0
corekit/data/dataset.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from typing import Any, Callable, Iterable, Iterator, Optional
|
|
3
|
+
|
|
4
|
+
from corekit.data.record import BaseRecord, resolve_default
|
|
5
|
+
from corekit.data.stats import DatasetStats
|
|
6
|
+
|
|
7
|
+
BASE_IDENTIFICATION_KEY = "id"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Dataset:
|
|
11
|
+
"""
|
|
12
|
+
A schema-fixed, order-preserving collection of records.
|
|
13
|
+
|
|
14
|
+
Every record in a dataset shares one schema and one generated record
|
|
15
|
+
class. Supports O(1) access by list position or by id, in-place field
|
|
16
|
+
editing, and dataset-wide schema evolution via add_field/remove_field.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# -- construction & protocol methods -----------------------------------
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
id_key: str = BASE_IDENTIFICATION_KEY,
|
|
24
|
+
schema: Iterable[Any] | None = None,
|
|
25
|
+
missing_default: Any = None,
|
|
26
|
+
missing_default_factory: Callable[[], Any] | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""
|
|
29
|
+
schema: explicit field order to lock in immediately. If omitted,
|
|
30
|
+
inferred from the first record passed to add().
|
|
31
|
+
missing_default / missing_default_factory: value (or zero-arg
|
|
32
|
+
factory, for mutables) used for a field a record omits.
|
|
33
|
+
"""
|
|
34
|
+
self._id_key = id_key
|
|
35
|
+
self._cursor = 0
|
|
36
|
+
self._records: list[BaseRecord] = []
|
|
37
|
+
self._id_index: dict[Any, Any] = {}
|
|
38
|
+
self._missing_default_factory = resolve_default(missing_default, missing_default_factory)
|
|
39
|
+
self._schema: tuple | None = None
|
|
40
|
+
self._record_cls: type | None = None
|
|
41
|
+
if schema is not None:
|
|
42
|
+
self._set_schema(schema)
|
|
43
|
+
|
|
44
|
+
def __getitem__(self, key: int) -> Any:
|
|
45
|
+
"""
|
|
46
|
+
Positional access, like a list. Use get_record() for id lookup
|
|
47
|
+
"""
|
|
48
|
+
return self._records[key]
|
|
49
|
+
|
|
50
|
+
def __len__(self) -> int:
|
|
51
|
+
return len(self._records)
|
|
52
|
+
|
|
53
|
+
def __iter__(self) -> Iterator[Any]:
|
|
54
|
+
return iter(self._records)
|
|
55
|
+
|
|
56
|
+
def __contains__(self, record_id: Any) -> bool:
|
|
57
|
+
return record_id in self._id_index
|
|
58
|
+
|
|
59
|
+
def __eq__(self, other: Any) -> bool:
|
|
60
|
+
if not isinstance(other, Dataset):
|
|
61
|
+
return NotImplemented
|
|
62
|
+
return self._schema == other._schema and self.to_dict() == other.to_dict()
|
|
63
|
+
|
|
64
|
+
def __repr__(self) -> str:
|
|
65
|
+
return f"Dataset({len(self._records)} records, schema={self._schema})"
|
|
66
|
+
|
|
67
|
+
def __getstate__(self) -> dict:
|
|
68
|
+
"""
|
|
69
|
+
_record_cls is a dynamic type() class with no module path, so it
|
|
70
|
+
can't pickle by reference -- dropped here and rebuilt in
|
|
71
|
+
__setstate__. Records are flattened to plain value tuples so every
|
|
72
|
+
record reconstructs through the same rebuilt class.
|
|
73
|
+
"""
|
|
74
|
+
state = self.__dict__.copy()
|
|
75
|
+
state["_record_cls"] = None
|
|
76
|
+
if self._schema is not None:
|
|
77
|
+
state["_records"] = [tuple(rec[f] for f in self._schema) for rec in self._records]
|
|
78
|
+
return state
|
|
79
|
+
|
|
80
|
+
def __setstate__(self, state: dict) -> None:
|
|
81
|
+
self.__dict__.update(state)
|
|
82
|
+
if self._schema is not None:
|
|
83
|
+
self._record_cls = BaseRecord.create_new(self._schema)
|
|
84
|
+
rebuilt = []
|
|
85
|
+
id_index = {}
|
|
86
|
+
for values in self._records:
|
|
87
|
+
rec = self._new_record(zip(self._schema, values))
|
|
88
|
+
rebuilt.append(rec)
|
|
89
|
+
id_index[rec[self._id_key]] = rec
|
|
90
|
+
self._records = rebuilt
|
|
91
|
+
self._id_index = id_index
|
|
92
|
+
|
|
93
|
+
# -- statistics -----------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def stats(self) -> DatasetStats:
|
|
97
|
+
"""
|
|
98
|
+
A fresh, lightweight stats view over this dataset's current records.
|
|
99
|
+
"""
|
|
100
|
+
return DatasetStats(self._records, self._schema)
|
|
101
|
+
|
|
102
|
+
# -- schema management --------------------------------------------------
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def schema(self) -> tuple | None:
|
|
106
|
+
return self._schema
|
|
107
|
+
|
|
108
|
+
def _set_schema(self, fields: Iterable[Any]) -> None:
|
|
109
|
+
fields = tuple(fields)
|
|
110
|
+
if self._id_key not in fields:
|
|
111
|
+
fields = (self._id_key,) + fields
|
|
112
|
+
self._schema = fields
|
|
113
|
+
self._record_cls = BaseRecord.create_new(fields)
|
|
114
|
+
|
|
115
|
+
def add_field(self, name: Any, default: Any = None, default_factory: Callable[[], Any] | None = None) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Add a field to the schema, seeded on every existing record. O(n)
|
|
118
|
+
"""
|
|
119
|
+
if name == self._id_key:
|
|
120
|
+
raise ValueError(f"Cannot add the {self._id_key!r} field. It is set as the id key.")
|
|
121
|
+
if self._schema is not None and name in self._schema:
|
|
122
|
+
raise KeyError(f"field {name!r} already exists")
|
|
123
|
+
|
|
124
|
+
value_fn = resolve_default(default, default_factory)
|
|
125
|
+
new_fields = (self._schema or (self._id_key,)) + (name,)
|
|
126
|
+
self._migrate_schema(new_fields, fill={name: value_fn})
|
|
127
|
+
|
|
128
|
+
def remove_field(self, name: Any) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Drop a field from the schema across every record. O(n)
|
|
131
|
+
"""
|
|
132
|
+
if name == self._id_key:
|
|
133
|
+
raise ValueError(f"Cannot remove the {self._id_key!r} field. It is set as the id key.")
|
|
134
|
+
if self._schema is None or name not in self._schema:
|
|
135
|
+
raise KeyError(name)
|
|
136
|
+
|
|
137
|
+
new_fields = tuple(f for f in self._schema if f != name)
|
|
138
|
+
self._migrate_schema(new_fields)
|
|
139
|
+
|
|
140
|
+
def _new_record(self, field_values: Iterable[tuple[Any, Any]]) -> "BaseRecord":
|
|
141
|
+
"""
|
|
142
|
+
Build one instance of this dataset's record class from (field, value) pairs
|
|
143
|
+
"""
|
|
144
|
+
instance = self._record_cls()
|
|
145
|
+
for field, value in field_values:
|
|
146
|
+
instance[field] = value
|
|
147
|
+
return instance
|
|
148
|
+
|
|
149
|
+
def _migrate_record(self, existing: "BaseRecord", fill: dict[str, Callable[[], Any]] | None = None) -> "BaseRecord":
|
|
150
|
+
"""
|
|
151
|
+
Rebuild `existing` under the current schema.
|
|
152
|
+
|
|
153
|
+
`fill` maps a field to a zero-arg factory for values that don't
|
|
154
|
+
come from `existing` (e.g. a field add_field() just introduced).
|
|
155
|
+
Checked before reading existing[field], since a brand-new field
|
|
156
|
+
won't exist on the old record at all.
|
|
157
|
+
"""
|
|
158
|
+
fill = fill or {}
|
|
159
|
+
pairs = ((field, fill[field]() if field in fill else existing[field]) for field in self.schema)
|
|
160
|
+
return self._new_record(pairs)
|
|
161
|
+
|
|
162
|
+
def _migrate_schema(self, new_fields: tuple, fill: dict[str, Callable[[], Any]] | None = None) -> None:
|
|
163
|
+
fill = fill or {}
|
|
164
|
+
old_records = self._records
|
|
165
|
+
self._set_schema(new_fields)
|
|
166
|
+
new_records = []
|
|
167
|
+
new_id_index = {}
|
|
168
|
+
for old in old_records:
|
|
169
|
+
new_record = self._migrate_record(old, fill)
|
|
170
|
+
new_records.append(new_record)
|
|
171
|
+
new_id_index[new_record[self._id_key]] = new_record
|
|
172
|
+
|
|
173
|
+
self.set_data(new_records, new_id_index)
|
|
174
|
+
|
|
175
|
+
def create_empty_clone(self) -> "Dataset":
|
|
176
|
+
"""
|
|
177
|
+
A new, empty Dataset with the same schema and missing-default policy
|
|
178
|
+
"""
|
|
179
|
+
return Dataset(
|
|
180
|
+
id_key=self._id_key,
|
|
181
|
+
schema=self._schema,
|
|
182
|
+
missing_default_factory=self._missing_default_factory,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def set_data(self, data: list[BaseRecord], id_to_index: dict[str, Any] | None = None) -> None:
|
|
186
|
+
"""
|
|
187
|
+
Replace the dataset's records directly. Used internally by schema
|
|
188
|
+
migration, sample(), and filter().
|
|
189
|
+
|
|
190
|
+
If `id_to_index` is supplied, the caller is asserting every record
|
|
191
|
+
is already an instance of this dataset's class (true for
|
|
192
|
+
_migrate_schema's output) and no rebuild/check is done. Otherwise,
|
|
193
|
+
records may belong to a different dataset's class (sample()/filter()
|
|
194
|
+
cloning from a parent) -- each is checked and rebuilt through
|
|
195
|
+
_migrate_record if needed, with the id index accumulated in the
|
|
196
|
+
same pass.
|
|
197
|
+
"""
|
|
198
|
+
if id_to_index is None:
|
|
199
|
+
records = []
|
|
200
|
+
id_to_index = {}
|
|
201
|
+
for rec in data:
|
|
202
|
+
if type(rec) is not self._record_cls:
|
|
203
|
+
rec = self._migrate_record(rec)
|
|
204
|
+
records.append(rec)
|
|
205
|
+
id_to_index[rec[self._id_key]] = rec
|
|
206
|
+
data = records
|
|
207
|
+
|
|
208
|
+
self._records = data
|
|
209
|
+
self._id_index = id_to_index
|
|
210
|
+
self.reset_cursor()
|
|
211
|
+
|
|
212
|
+
def add(self, data: dict[str, Any]) -> Any:
|
|
213
|
+
"""
|
|
214
|
+
Add a new record. Must contain the id field. If no schema is set
|
|
215
|
+
yet, this record's keys define it. Fields the schema expects but
|
|
216
|
+
this record omits get missing_default_factory(). A field not in
|
|
217
|
+
the schema raises -- evolve the schema via add_field() first.
|
|
218
|
+
"""
|
|
219
|
+
if self._id_key not in data:
|
|
220
|
+
raise ValueError(f"record must contain the identity field: {self._id_key}")
|
|
221
|
+
|
|
222
|
+
if self._schema is None:
|
|
223
|
+
self._set_schema(data.keys())
|
|
224
|
+
else:
|
|
225
|
+
unknown = set(data.keys()) - set(self._schema)
|
|
226
|
+
if unknown:
|
|
227
|
+
raise KeyError(
|
|
228
|
+
f"field(s) {unknown} not in schema {self._schema}; "
|
|
229
|
+
f"call add_field() first if this is an intentional schema change"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if data[self._id_key] in self._id_index:
|
|
233
|
+
raise KeyError(f"Duplicate id key: {data[self._id_key]!r}")
|
|
234
|
+
|
|
235
|
+
rec = self._record_cls()
|
|
236
|
+
for field in self._schema:
|
|
237
|
+
rec[field] = data[field] if field in data else self._missing_default_factory()
|
|
238
|
+
|
|
239
|
+
self._records.append(rec)
|
|
240
|
+
self._id_index[data[self._id_key]] = rec
|
|
241
|
+
return rec
|
|
242
|
+
|
|
243
|
+
def extend(self, items: Iterable[dict[str, Any]]) -> None:
|
|
244
|
+
for item in items:
|
|
245
|
+
self.add(item)
|
|
246
|
+
|
|
247
|
+
def clear(self) -> None:
|
|
248
|
+
self._records.clear()
|
|
249
|
+
self._id_index.clear()
|
|
250
|
+
self.reset_cursor()
|
|
251
|
+
|
|
252
|
+
# -- cursor & positional/id access --------------------------------------
|
|
253
|
+
|
|
254
|
+
def reset_cursor(self) -> None:
|
|
255
|
+
self._cursor = 0
|
|
256
|
+
|
|
257
|
+
def next(self) -> Any:
|
|
258
|
+
"""
|
|
259
|
+
Manual, stateful walk over records; pairs with reset_cursor().
|
|
260
|
+
Independent of __iter__, which always starts fresh -- see class docs.
|
|
261
|
+
"""
|
|
262
|
+
if self._cursor >= len(self._records):
|
|
263
|
+
raise StopIteration
|
|
264
|
+
rec = self._records[self._cursor]
|
|
265
|
+
self._cursor += 1
|
|
266
|
+
return rec
|
|
267
|
+
|
|
268
|
+
def at(self, idx: int) -> Any:
|
|
269
|
+
return self._records[idx]
|
|
270
|
+
|
|
271
|
+
def get_record(self, record_id: Any) -> Any:
|
|
272
|
+
return self._id_index[record_id]
|
|
273
|
+
|
|
274
|
+
# -- removing whole records ----------------------------------------------
|
|
275
|
+
|
|
276
|
+
def remove_by_id(self, record_id: Any) -> None:
|
|
277
|
+
rec = self._id_index.pop(record_id)
|
|
278
|
+
self._records.remove(rec) # O(n)
|
|
279
|
+
|
|
280
|
+
def remove_by_index(self, i: int) -> None:
|
|
281
|
+
rec = self._records.pop(i) # O(n) unless last element
|
|
282
|
+
del self._id_index[rec[self._id_key]]
|
|
283
|
+
|
|
284
|
+
# -- ordering, sampling, querying -----------------------------------------
|
|
285
|
+
|
|
286
|
+
def get_random(self) -> Any:
|
|
287
|
+
return random.choice(self._records)
|
|
288
|
+
|
|
289
|
+
def shuffle(self) -> None:
|
|
290
|
+
"""
|
|
291
|
+
Reorders records only. _id_index maps id -> record object
|
|
292
|
+
regardless of list position, so it needs no rebuild here.
|
|
293
|
+
"""
|
|
294
|
+
random.shuffle(self._records)
|
|
295
|
+
self.reset_cursor()
|
|
296
|
+
|
|
297
|
+
def sort(self, key: Callable[[Any], Any], reverse: bool = False) -> None:
|
|
298
|
+
"""
|
|
299
|
+
Same reasoning as shuffle(): _id_index stays valid untouched
|
|
300
|
+
"""
|
|
301
|
+
self._records.sort(key=key, reverse=reverse)
|
|
302
|
+
self.reset_cursor()
|
|
303
|
+
|
|
304
|
+
def sample(self, n: int) -> "Dataset":
|
|
305
|
+
new = self.create_empty_clone()
|
|
306
|
+
new.set_data(random.sample(self._records, n))
|
|
307
|
+
return new
|
|
308
|
+
|
|
309
|
+
def _filter_iterator(self, func: Callable[[Any], bool]) -> Iterator[BaseRecord]:
|
|
310
|
+
for rec in self._records:
|
|
311
|
+
if func(rec):
|
|
312
|
+
yield rec
|
|
313
|
+
|
|
314
|
+
def filter(self, func: Callable[[Any], bool]) -> "Dataset":
|
|
315
|
+
"""
|
|
316
|
+
`func` may be a plain callable/lambda, or a Field(...) expression
|
|
317
|
+
"""
|
|
318
|
+
new = self.create_empty_clone()
|
|
319
|
+
new.set_data(list(self._filter_iterator(func)))
|
|
320
|
+
return new
|
|
321
|
+
|
|
322
|
+
def find_first(self, func: Callable[[Any], bool]) -> Optional["BaseRecord"]:
|
|
323
|
+
"""
|
|
324
|
+
`func` may be a plain callable/lambda, or a Field(...) expression
|
|
325
|
+
"""
|
|
326
|
+
return next(self._filter_iterator(func), None)
|
|
327
|
+
|
|
328
|
+
def group(self, field: str) -> dict[Any, "Dataset"]:
|
|
329
|
+
grouped = {}
|
|
330
|
+
for rec in self._records:
|
|
331
|
+
key = rec[field]
|
|
332
|
+
if key not in grouped:
|
|
333
|
+
grouped[key] = self.create_empty_clone()
|
|
334
|
+
grouped[key].add(rec.to_dict())
|
|
335
|
+
return grouped
|
|
336
|
+
|
|
337
|
+
# -- output ------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
def to_dict(self) -> list[dict[str, Any]]:
|
|
340
|
+
return [rec.to_dict() for rec in self._records]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Composable predicates for filtering datasets.
|
|
3
|
+
|
|
4
|
+
Expressions combine with ``&``, ``|`` and ``~``, and compare fields against
|
|
5
|
+
values or against each other::
|
|
6
|
+
|
|
7
|
+
Field("age") >= 18
|
|
8
|
+
Field("first_name") == Field("nickname")
|
|
9
|
+
(Field("age") > 20) & ~Field("name").contains("test")
|
|
10
|
+
|
|
11
|
+
Every node can also emit a backend query via ``to_sql``, ``to_mongo`` or
|
|
12
|
+
``to_elasticsearch``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from corekit.data.expressions.comparison import (
|
|
16
|
+
Comparison,
|
|
17
|
+
Contains,
|
|
18
|
+
Equals,
|
|
19
|
+
FieldExpression,
|
|
20
|
+
GreaterThan,
|
|
21
|
+
GreaterThanOrEquals,
|
|
22
|
+
IsIn,
|
|
23
|
+
LessThan,
|
|
24
|
+
LessThanOrEquals,
|
|
25
|
+
NotEquals,
|
|
26
|
+
ValueExpression,
|
|
27
|
+
)
|
|
28
|
+
from corekit.data.expressions.expression import And, Expression, Not, Or
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"And",
|
|
32
|
+
"Comparison",
|
|
33
|
+
"Contains",
|
|
34
|
+
"Equals",
|
|
35
|
+
"Expression",
|
|
36
|
+
"FieldExpression",
|
|
37
|
+
"GreaterThan",
|
|
38
|
+
"GreaterThanOrEquals",
|
|
39
|
+
"IsIn",
|
|
40
|
+
"LessThan",
|
|
41
|
+
"LessThanOrEquals",
|
|
42
|
+
"Not",
|
|
43
|
+
"NotEquals",
|
|
44
|
+
"Or",
|
|
45
|
+
"ValueExpression",
|
|
46
|
+
]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
from typing import Any, Iterable
|
|
2
|
+
|
|
3
|
+
from corekit.data.expressions.expression import Expression
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Comparison(Expression):
|
|
7
|
+
"""
|
|
8
|
+
Base for comparing two values (Equals, GreaterThan, IsIn, ...).
|
|
9
|
+
|
|
10
|
+
`left`, `right`, and `symbol` are public so external code --
|
|
11
|
+
e.g. a future translator to SQL/Mongo/Elasticsearch -- can walk the
|
|
12
|
+
tree and read out what each node represents without evaluating it.
|
|
13
|
+
`symbol` is a human-readable operator string; the class itself
|
|
14
|
+
(Equals, GreaterThan, etc.) is the canonical, type-safe identifier of
|
|
15
|
+
*which* operation a node represents, so a translator can dispatch on
|
|
16
|
+
isinstance(node, GreaterThan) rather than string-matching on symbol.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
symbol: str
|
|
20
|
+
|
|
21
|
+
def __init__(self, left: Any, right: Any) -> None:
|
|
22
|
+
self.left = left
|
|
23
|
+
self.right = right
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return f"({self.left!r} {self.symbol} {self.right!r})"
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def _resolve_value(value: Any, *args: Any, **kwargs: Any) -> Any:
|
|
30
|
+
"""
|
|
31
|
+
Resolves a value to a concrete value. This method utilizes a neat trick
|
|
32
|
+
to be able to resolve the final value of complex expressions without
|
|
33
|
+
having to evaluate the entire tree.
|
|
34
|
+
"""
|
|
35
|
+
if isinstance(value, ValueExpression):
|
|
36
|
+
return value.get(*args, **kwargs)
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
def get_left(self, *args: Any, **kwargs: Any) -> Any:
|
|
40
|
+
return self._resolve_value(self.left, *args, **kwargs)
|
|
41
|
+
|
|
42
|
+
def get_right(self, *args, **kwargs) -> Any:
|
|
43
|
+
"""
|
|
44
|
+
Returns the right value of the comparison.
|
|
45
|
+
"""
|
|
46
|
+
return self._resolve_value(self.right, *args, **kwargs)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Equals(Comparison):
|
|
50
|
+
symbol = "=="
|
|
51
|
+
|
|
52
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
53
|
+
return self.get_left(*args, **kwargs) == self.get_right(*args, **kwargs)
|
|
54
|
+
|
|
55
|
+
def to_sql(self, *args: Any, **kwargs: Any) -> str:
|
|
56
|
+
return f"{self.get_left(*args, **kwargs)} = {self.get_right(*args, **kwargs)}"
|
|
57
|
+
|
|
58
|
+
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
59
|
+
return {self.get_left(*args, **kwargs): self.get_right(*args, **kwargs)}
|
|
60
|
+
|
|
61
|
+
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
62
|
+
return {"term": {self.get_left(*args, **kwargs): self.get_right(*args, **kwargs)}}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NotEquals(Comparison):
|
|
66
|
+
symbol = "!="
|
|
67
|
+
|
|
68
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
69
|
+
return self.get_left(*args, **kwargs) != self.get_right(*args, **kwargs)
|
|
70
|
+
|
|
71
|
+
def to_sql(self, *args: Any, **kwargs: Any) -> str:
|
|
72
|
+
return f"{self.get_left(*args, **kwargs)} != {self.get_right(*args, **kwargs)}"
|
|
73
|
+
|
|
74
|
+
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
75
|
+
return {self.get_left(*args, **kwargs): {"$ne": self.get_right(*args, **kwargs)}}
|
|
76
|
+
|
|
77
|
+
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
78
|
+
return {"term": {self.get_left(*args, **kwargs): {"$ne": self.get_right(*args, **kwargs)}}}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class LessThan(Comparison):
|
|
82
|
+
symbol = "<"
|
|
83
|
+
|
|
84
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
85
|
+
return self.get_left(*args, **kwargs) < self.get_right(*args, **kwargs)
|
|
86
|
+
|
|
87
|
+
def to_sql(self, *args: Any, **kwargs: Any) -> str:
|
|
88
|
+
return f"{self.get_left(*args, **kwargs)} < {self.get_right(*args, **kwargs)}"
|
|
89
|
+
|
|
90
|
+
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
91
|
+
return {self.get_left(*args, **kwargs): {"$lt": self.get_right(*args, **kwargs)}}
|
|
92
|
+
|
|
93
|
+
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
94
|
+
return {"range": {self.get_left(*args, **kwargs): {"lt": self.get_right(*args, **kwargs)}}}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class LessThanOrEquals(Comparison):
|
|
98
|
+
symbol = "<="
|
|
99
|
+
|
|
100
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
101
|
+
return self.get_left(*args, **kwargs) <= self.get_right(*args, **kwargs)
|
|
102
|
+
|
|
103
|
+
def to_sql(self, *args: Any, **kwargs: Any) -> str:
|
|
104
|
+
return f"{self.get_left(*args, **kwargs)} <= {self.get_right(*args, **kwargs)}"
|
|
105
|
+
|
|
106
|
+
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
107
|
+
return {self.get_left(*args, **kwargs): {"$lte": self.get_right(*args, **kwargs)}}
|
|
108
|
+
|
|
109
|
+
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
110
|
+
return {"range": {self.get_left(*args, **kwargs): {"lte": self.get_right(*args, **kwargs)}}}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class GreaterThan(Comparison):
|
|
114
|
+
symbol = ">"
|
|
115
|
+
|
|
116
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
117
|
+
return self.get_left(*args, **kwargs) > self.get_right(*args, **kwargs)
|
|
118
|
+
|
|
119
|
+
def to_sql(self, *args: Any, **kwargs: Any) -> str:
|
|
120
|
+
return f"{self.get_left(*args, **kwargs)} > {self.get_right(*args, **kwargs)}"
|
|
121
|
+
|
|
122
|
+
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
123
|
+
return {self.get_left(*args, **kwargs): {"$gt": self.get_right(*args, **kwargs)}}
|
|
124
|
+
|
|
125
|
+
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
126
|
+
return {"range": {self.get_left(*args, **kwargs): {"gt": self.get_right(*args, **kwargs)}}}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class GreaterThanOrEquals(Comparison):
|
|
130
|
+
symbol = ">="
|
|
131
|
+
|
|
132
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
133
|
+
return self.get_left(*args, **kwargs) >= self.get_right(*args, **kwargs)
|
|
134
|
+
|
|
135
|
+
def to_sql(self, *args: Any, **kwargs: Any) -> str:
|
|
136
|
+
return f"{self.get_left(*args, **kwargs)} >= {self.get_right(*args, **kwargs)}"
|
|
137
|
+
|
|
138
|
+
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
139
|
+
return {self.get_left(*args, **kwargs): {"$gte": self.get_right(*args, **kwargs)}}
|
|
140
|
+
|
|
141
|
+
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
142
|
+
return {"range": {self.get_left(*args, **kwargs): {"gte": self.get_right(*args, **kwargs)}}}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class IsIn(Comparison):
|
|
146
|
+
symbol = "is in"
|
|
147
|
+
|
|
148
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
149
|
+
return self.get_left(*args, **kwargs) in self.get_right(*args, **kwargs)
|
|
150
|
+
|
|
151
|
+
def to_sql(self, *args: Any, **kwargs: Any) -> str:
|
|
152
|
+
# TODO: this might not actually be valid SQL
|
|
153
|
+
return f"{self.get_left(*args, **kwargs)} IN {self.get_right(*args, **kwargs)}"
|
|
154
|
+
|
|
155
|
+
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
156
|
+
return {self.get_left(*args, **kwargs): {"$in": self.get_right(*args, **kwargs)}}
|
|
157
|
+
|
|
158
|
+
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
159
|
+
return {"terms": {self.get_left(*args, **kwargs): self.get_right(*args, **kwargs)}}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Contains(Comparison):
|
|
163
|
+
symbol = "contains"
|
|
164
|
+
|
|
165
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
166
|
+
return self.get_right(*args, **kwargs) in self.get_left(*args, **kwargs)
|
|
167
|
+
|
|
168
|
+
def to_sql(self, *args: Any, **kwargs: Any) -> str:
|
|
169
|
+
return ""
|
|
170
|
+
|
|
171
|
+
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ValueExpression(Expression):
|
|
179
|
+
"""
|
|
180
|
+
Expression representing a single value to be compared.
|
|
181
|
+
|
|
182
|
+
The resulting tree (Equals/And/Or/... nodes) is also an inspectable structure,
|
|
183
|
+
not just a callable. These could be used in the future to emit structured queries
|
|
184
|
+
(SQL, MongoDB, Elasticsearch, etc.)
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(self, value: Any) -> None:
|
|
188
|
+
self.value = value
|
|
189
|
+
|
|
190
|
+
def __repr__(self) -> str:
|
|
191
|
+
return f"{self.value!r}"
|
|
192
|
+
|
|
193
|
+
def __eq__(self, other: Any) -> "Comparison":
|
|
194
|
+
return Equals(self, other)
|
|
195
|
+
|
|
196
|
+
def __ne__(self, other: Any) -> "Comparison":
|
|
197
|
+
return NotEquals(self, other)
|
|
198
|
+
|
|
199
|
+
def __lt__(self, other: Any) -> "Comparison":
|
|
200
|
+
return LessThan(self, other)
|
|
201
|
+
|
|
202
|
+
def __le__(self, other: Any) -> "Comparison":
|
|
203
|
+
return LessThanOrEquals(self, other)
|
|
204
|
+
|
|
205
|
+
def __gt__(self, other: Any) -> "Comparison":
|
|
206
|
+
return GreaterThan(self, other)
|
|
207
|
+
|
|
208
|
+
def __ge__(self, other: Any) -> "Comparison":
|
|
209
|
+
return GreaterThanOrEquals(self, other)
|
|
210
|
+
|
|
211
|
+
def isin(self, values: Iterable[Any]) -> "Comparison":
|
|
212
|
+
return IsIn(self, frozenset(values))
|
|
213
|
+
|
|
214
|
+
def contains(self, substring: Any) -> "Comparison":
|
|
215
|
+
return Contains(self, substring)
|
|
216
|
+
|
|
217
|
+
def get(self, *args: Any, **kwargs: Any) -> Any:
|
|
218
|
+
return self.value
|
|
219
|
+
|
|
220
|
+
def to_sql(self, *args: Any, **kwargs: Any) -> str:
|
|
221
|
+
return str(self.value)
|
|
222
|
+
|
|
223
|
+
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
224
|
+
return self.value
|
|
225
|
+
|
|
226
|
+
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
227
|
+
return self.value
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class FieldExpression(ValueExpression):
|
|
231
|
+
"""
|
|
232
|
+
A value expression representing a named field reference for building filter predicates.
|
|
233
|
+
FieldExpression instances will evaluate against anything that supports __getitem__ by field name.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __call__(self, record: Any, *args, **kwargs) -> bool:
|
|
237
|
+
return bool(record[self.value])
|
|
238
|
+
|
|
239
|
+
def __repr__(self) -> str:
|
|
240
|
+
return f"Field({self.value!r})"
|
|
241
|
+
|
|
242
|
+
def get(self, record: Any, *args: Any, **kwargs: Any) -> Any:
|
|
243
|
+
return record[self.value]
|
|
244
|
+
|
|
245
|
+
def to_sql(self, record: Any, *args: Any, **kwargs: Any) -> str:
|
|
246
|
+
return record[self.value]
|
|
247
|
+
|
|
248
|
+
def to_mongo(self, record: Any, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
249
|
+
return record[self.value]
|
|
250
|
+
|
|
251
|
+
def to_elasticsearch(self, record: Any, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
252
|
+
return record[self.value]
|