pydoptic-elastic 0.0.1.post1.dev2__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.
- pydoptic_elastic-0.0.1.post1.dev2/LICENSE +21 -0
- pydoptic_elastic-0.0.1.post1.dev2/PKG-INFO +63 -0
- pydoptic_elastic-0.0.1.post1.dev2/README.md +45 -0
- pydoptic_elastic-0.0.1.post1.dev2/pyproject.toml +38 -0
- pydoptic_elastic-0.0.1.post1.dev2/setup.cfg +4 -0
- pydoptic_elastic-0.0.1.post1.dev2/src/pydoptic_elastic/__init__.py +9 -0
- pydoptic_elastic-0.0.1.post1.dev2/src/pydoptic_elastic/elastic_model.py +88 -0
- pydoptic_elastic-0.0.1.post1.dev2/src/pydoptic_elastic/elastic_query.py +154 -0
- pydoptic_elastic-0.0.1.post1.dev2/src/pydoptic_elastic/elastic_service.py +73 -0
- pydoptic_elastic-0.0.1.post1.dev2/src/pydoptic_elastic/py.typed +0 -0
- pydoptic_elastic-0.0.1.post1.dev2/src/pydoptic_elastic.egg-info/PKG-INFO +63 -0
- pydoptic_elastic-0.0.1.post1.dev2/src/pydoptic_elastic.egg-info/SOURCES.txt +17 -0
- pydoptic_elastic-0.0.1.post1.dev2/src/pydoptic_elastic.egg-info/dependency_links.txt +1 -0
- pydoptic_elastic-0.0.1.post1.dev2/src/pydoptic_elastic.egg-info/requires.txt +8 -0
- pydoptic_elastic-0.0.1.post1.dev2/src/pydoptic_elastic.egg-info/scm_file_list.json +14 -0
- pydoptic_elastic-0.0.1.post1.dev2/src/pydoptic_elastic.egg-info/scm_version.json +8 -0
- pydoptic_elastic-0.0.1.post1.dev2/src/pydoptic_elastic.egg-info/top_level.txt +1 -0
- pydoptic_elastic-0.0.1.post1.dev2/test/test_elastic_model.py +33 -0
- pydoptic_elastic-0.0.1.post1.dev2/test/test_elastic_service.py +68 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Hungerford
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydoptic-elastic
|
|
3
|
+
Version: 0.0.1.post1.dev2
|
|
4
|
+
Summary: An Elasticsearch integration built on pydoptic
|
|
5
|
+
Author: John Hungerford
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Source, https://github.com/johnhungerford/pydoptic
|
|
8
|
+
Requires-Python: <3.14,>=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: pydoptic
|
|
12
|
+
Requires-Dist: elasticsearch==8.14.0
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest~=8.4.2; extra == "test"
|
|
15
|
+
Provides-Extra: types
|
|
16
|
+
Requires-Dist: mypy~=1.18.2; extra == "types"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# pydoptic-elastic
|
|
20
|
+
|
|
21
|
+
An Elasticsearch integration built on [pydoptic](https://pypi.org/project/pydoptic/).
|
|
22
|
+
|
|
23
|
+
`ElasticModel` captures index and field-mapping metadata directly on the model's `Prop`s, and `Query`
|
|
24
|
+
lets you build Elasticsearch queries by referencing those `Prop`s directly -- no field or index names
|
|
25
|
+
passed as bare strings.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install pydoptic-elastic
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Includes the `elasticsearch` client as a regular dependency, so no extras are needed.
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python3
|
|
38
|
+
from pydoptic import Prop
|
|
39
|
+
from pydoptic_elastic import ElasticModel, ElasticService, Query, elastic_prop, ESMapping
|
|
40
|
+
from elasticsearch import Elasticsearch
|
|
41
|
+
|
|
42
|
+
class Person(ElasticModel):
|
|
43
|
+
name: Prop['Person', str] = elastic_prop(mapping=ESMapping.keyword)
|
|
44
|
+
age: Prop['Person', int]
|
|
45
|
+
|
|
46
|
+
service = ElasticService(Elasticsearch('http://localhost:9200'))
|
|
47
|
+
service.create_index(Person)
|
|
48
|
+
|
|
49
|
+
service.index(Person(name='John', age=42))
|
|
50
|
+
|
|
51
|
+
query = Query.match(Person.name, 'John')
|
|
52
|
+
for person in service.search(query):
|
|
53
|
+
print(Person.name.get_val(person))
|
|
54
|
+
# John
|
|
55
|
+
|
|
56
|
+
# Retrieve only specific fields, as a PartialModel:
|
|
57
|
+
for person in service.search_partial(query, source=[Person.name]):
|
|
58
|
+
print(Person.name.get_val_unsafe(person))
|
|
59
|
+
# John
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
See the [pydoptic README](https://github.com/johnhungerford/pydoptic/tree/main/packages/pydoptic) for
|
|
63
|
+
the underlying `Prop`/`Select` model this is built on.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# pydoptic-elastic
|
|
2
|
+
|
|
3
|
+
An Elasticsearch integration built on [pydoptic](https://pypi.org/project/pydoptic/).
|
|
4
|
+
|
|
5
|
+
`ElasticModel` captures index and field-mapping metadata directly on the model's `Prop`s, and `Query`
|
|
6
|
+
lets you build Elasticsearch queries by referencing those `Prop`s directly -- no field or index names
|
|
7
|
+
passed as bare strings.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install pydoptic-elastic
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Includes the `elasticsearch` client as a regular dependency, so no extras are needed.
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```python3
|
|
20
|
+
from pydoptic import Prop
|
|
21
|
+
from pydoptic_elastic import ElasticModel, ElasticService, Query, elastic_prop, ESMapping
|
|
22
|
+
from elasticsearch import Elasticsearch
|
|
23
|
+
|
|
24
|
+
class Person(ElasticModel):
|
|
25
|
+
name: Prop['Person', str] = elastic_prop(mapping=ESMapping.keyword)
|
|
26
|
+
age: Prop['Person', int]
|
|
27
|
+
|
|
28
|
+
service = ElasticService(Elasticsearch('http://localhost:9200'))
|
|
29
|
+
service.create_index(Person)
|
|
30
|
+
|
|
31
|
+
service.index(Person(name='John', age=42))
|
|
32
|
+
|
|
33
|
+
query = Query.match(Person.name, 'John')
|
|
34
|
+
for person in service.search(query):
|
|
35
|
+
print(Person.name.get_val(person))
|
|
36
|
+
# John
|
|
37
|
+
|
|
38
|
+
# Retrieve only specific fields, as a PartialModel:
|
|
39
|
+
for person in service.search_partial(query, source=[Person.name]):
|
|
40
|
+
print(Person.name.get_val_unsafe(person))
|
|
41
|
+
# John
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
See the [pydoptic README](https://github.com/johnhungerford/pydoptic/tree/main/packages/pydoptic) for
|
|
45
|
+
the underlying `Prop`/`Select` model this is built on.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "setuptools-scm>=8"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pydoptic-elastic"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
authors = [{ name = "John Hungerford" }]
|
|
9
|
+
urls = { Source = "https://github.com/johnhungerford/pydoptic" }
|
|
10
|
+
description = "An Elasticsearch integration built on pydoptic"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
requires-python = ">=3.8,<3.14"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"pydoptic",
|
|
17
|
+
"elasticsearch==8.14.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = [
|
|
22
|
+
"pytest~=8.4.2",
|
|
23
|
+
]
|
|
24
|
+
types = [
|
|
25
|
+
"mypy~=1.18.2",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools_scm]
|
|
29
|
+
# See packages/pydoptic/pyproject.toml -- same repo-wide lockstep version, same reasoning.
|
|
30
|
+
root = "../.."
|
|
31
|
+
version_scheme = "no-guess-dev"
|
|
32
|
+
local_scheme = "no-local-version"
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
markers = [
|
|
36
|
+
"integration: requires a live Elasticsearch instance started via docker-compose.yml; excluded by default, opt in with `-m integration`",
|
|
37
|
+
]
|
|
38
|
+
addopts = "-m 'not integration'"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from pydoptic_elastic.elastic_model import ESFieldData, ESMapping, ElasticModel, elastic_prop
|
|
2
|
+
from pydoptic_elastic.elastic_service import ElasticService
|
|
3
|
+
from pydoptic_elastic.elastic_query import Query
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
'ESFieldData', 'ESMapping', 'ElasticModel', 'elastic_prop',
|
|
7
|
+
'ElasticService',
|
|
8
|
+
'Query',
|
|
9
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from datetime import date, datetime
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any, ClassVar, Dict, Optional, TypeVar, TypedDict, Unpack, cast
|
|
4
|
+
|
|
5
|
+
from pydoptic import BaseModel, select
|
|
6
|
+
from pydoptic.selector import Discrim, PropSelect, LinkedSelect, Select
|
|
7
|
+
|
|
8
|
+
class ESFieldData(TypedDict):
|
|
9
|
+
mapping: Optional['ESMapping']
|
|
10
|
+
|
|
11
|
+
TERM_SUFFIX = 'term'
|
|
12
|
+
|
|
13
|
+
class ESMapping(Enum):
|
|
14
|
+
text_keyword = 'text_keyword'
|
|
15
|
+
text = 'text'
|
|
16
|
+
keyword = 'keyword'
|
|
17
|
+
long = 'long'
|
|
18
|
+
double = 'double'
|
|
19
|
+
boolean = 'boolean'
|
|
20
|
+
date = 'date'
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_select(self, prop: Select[Any, Any]) -> 'ESMapping':
|
|
24
|
+
match prop:
|
|
25
|
+
case PropSelect():
|
|
26
|
+
data = cast(ESFieldData, prop.data)
|
|
27
|
+
mapping = data.get('mapping')
|
|
28
|
+
if mapping is not None:
|
|
29
|
+
return mapping
|
|
30
|
+
return _mappings_lookup.get(prop.target) or ESMapping.text_keyword
|
|
31
|
+
case LinkedSelect(select_2=prop):
|
|
32
|
+
return self.from_select(prop)
|
|
33
|
+
case _:
|
|
34
|
+
raise ValueError()
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
37
|
+
match self:
|
|
38
|
+
case ESMapping.text_keyword:
|
|
39
|
+
return {'type': 'text', 'fields': {TERM_SUFFIX: {'type': 'keyword'}}}
|
|
40
|
+
case ESMapping.text:
|
|
41
|
+
return {'type': 'text'}
|
|
42
|
+
case ESMapping.keyword:
|
|
43
|
+
return {'type': 'keyword'}
|
|
44
|
+
case ESMapping.long:
|
|
45
|
+
return {'type': 'long'}
|
|
46
|
+
case ESMapping.double:
|
|
47
|
+
return {'type': 'double'}
|
|
48
|
+
case ESMapping.boolean:
|
|
49
|
+
return {'type': 'boolean'}
|
|
50
|
+
case ESMapping.date:
|
|
51
|
+
return {'type': 'date'}
|
|
52
|
+
|
|
53
|
+
_mappings_lookup = {
|
|
54
|
+
str: ESMapping.text_keyword,
|
|
55
|
+
int: ESMapping.long,
|
|
56
|
+
float: ESMapping.double,
|
|
57
|
+
bool: ESMapping.boolean,
|
|
58
|
+
date: ESMapping.date,
|
|
59
|
+
datetime: ESMapping.date,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class ElasticModel(BaseModel):
|
|
63
|
+
index_name: ClassVar[str]
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def _get_index_name(cls) -> str:
|
|
67
|
+
try:
|
|
68
|
+
return cls.index_name
|
|
69
|
+
except AttributeError:
|
|
70
|
+
return cls.__name__.lower()
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def _get_mappings(cls) -> Dict[str, Any]:
|
|
74
|
+
mappings: Dict[str, Any] = {}
|
|
75
|
+
for prop in cls.properties().values():
|
|
76
|
+
if isinstance(prop, PropSelect):
|
|
77
|
+
if issubclass(prop.target, ElasticModel):
|
|
78
|
+
mappings[prop.label] = {'type': 'object', 'properties': prop.target._get_mappings()}
|
|
79
|
+
else:
|
|
80
|
+
mappings[prop.label] = ESMapping.from_select(prop).to_dict()
|
|
81
|
+
elif isinstance(prop, Discrim):
|
|
82
|
+
mappings[prop.property.label] = ESMapping.keyword
|
|
83
|
+
return mappings
|
|
84
|
+
|
|
85
|
+
M = TypeVar('M', bound=ElasticModel)
|
|
86
|
+
|
|
87
|
+
def elastic_prop(name: str | None = None, **field_data: Unpack[ESFieldData]) -> Any:
|
|
88
|
+
return select(name, **field_data)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from typing import TypeVar, Generic, Dict, Any, Type, List
|
|
2
|
+
|
|
3
|
+
from pydoptic import Select
|
|
4
|
+
from pydoptic_elastic.elastic_model import M, TERM_SUFFIX, ESMapping
|
|
5
|
+
|
|
6
|
+
A = TypeVar('A')
|
|
7
|
+
|
|
8
|
+
class Query(Generic[M]):
|
|
9
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
10
|
+
raise NotImplementedError()
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def model(self) -> Type[M]:
|
|
14
|
+
raise NotImplementedError()
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def match(cls, select: Select[M, A], value: A, **kwargs) -> 'MatchQuery[M]':
|
|
18
|
+
return MatchQuery(select, value)
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def bool(
|
|
22
|
+
cls,
|
|
23
|
+
should: List['Query[M]'] | None = None,
|
|
24
|
+
must: List['Query[M]'] | None = None,
|
|
25
|
+
filter: List['Query[M]'] | None = None,
|
|
26
|
+
must_not: List['Query[M]'] | None = None,
|
|
27
|
+
minimum_should_match: int | None = None,
|
|
28
|
+
) -> 'BoolQuery[M]':
|
|
29
|
+
return BoolQuery(should=should or [], must=must or [], filter=filter or [], must_not=must_not or [], minimum_should_match=minimum_should_match)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def term(cls, select: Select[M, str], value: str, **kwargs) -> 'TermQuery[M]':
|
|
33
|
+
return TermQuery(select, value, **kwargs)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def exists(cls, select: Select[M, A]) -> 'ExistsQuery[M]':
|
|
37
|
+
return ExistsQuery(select)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def manual(cls, model: Type[M], query: Dict[str, Any]) -> 'ManualQuery[M]':
|
|
41
|
+
return ManualQuery(model, query)
|
|
42
|
+
|
|
43
|
+
class ManualQuery(Generic[M]):
|
|
44
|
+
def __init__(self, mdl: Type[M], query: Dict[str, Any]):
|
|
45
|
+
self.mdl = mdl
|
|
46
|
+
self.query = query
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
49
|
+
return self.query
|
|
50
|
+
|
|
51
|
+
def model(self) -> Type[M]:
|
|
52
|
+
return self.mdl
|
|
53
|
+
|
|
54
|
+
class ExistsQuery(Query[M]):
|
|
55
|
+
def __init__(self, select: Select[M, A]) -> None:
|
|
56
|
+
self.select = select
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
59
|
+
return {'exists': {'field': self.select.path}}
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def model(self) -> Type[M]:
|
|
63
|
+
return self.select.origin
|
|
64
|
+
|
|
65
|
+
class TermQuery(Query[M]):
|
|
66
|
+
def __init__(self, select: Select[M, str], value: str, **options) -> None:
|
|
67
|
+
self.select = select
|
|
68
|
+
self.value = value
|
|
69
|
+
self.options = options
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
72
|
+
match ESMapping.from_select(self.select):
|
|
73
|
+
case ESMapping.keyword:
|
|
74
|
+
path = self.select.path
|
|
75
|
+
case ESMapping.text_keyword:
|
|
76
|
+
path = self.select.path + '.' + TERM_SUFFIX
|
|
77
|
+
case other:
|
|
78
|
+
raise ValueError(f'Term query unsupported on {self.select.path} (mapping type: {other.value})')
|
|
79
|
+
return {'term': {path: {'value': self.value, **self.options}}}
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def model(self) -> Type[M]:
|
|
83
|
+
return self.select.origin
|
|
84
|
+
|
|
85
|
+
class BoolQuery(Query[M]):
|
|
86
|
+
def __init__(self, should: List[Query[M]], must: List[Query[M]], filter: List[Query[M]], must_not: List[Query[M]], minimum_should_match: int | None = None) -> None:
|
|
87
|
+
self.should = should
|
|
88
|
+
self.must = must
|
|
89
|
+
self.filter = filter
|
|
90
|
+
self.must_not = must_not
|
|
91
|
+
self.mdl: Type[M] | None = None
|
|
92
|
+
self.minimum_should_match = minimum_should_match
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
95
|
+
bool_obj: Dict[str, Any] = {}
|
|
96
|
+
if len(self.should) > 0:
|
|
97
|
+
bool_obj['should'] = self.should
|
|
98
|
+
if len(self.must) > 0:
|
|
99
|
+
bool_obj['must'] = self.must
|
|
100
|
+
if len(self.filter) > 0:
|
|
101
|
+
bool_obj['filter'] = self.filter
|
|
102
|
+
if len(self.must_not) > 0:
|
|
103
|
+
bool_obj['must_not'] = self.must_not
|
|
104
|
+
if self.minimum_should_match is not None:
|
|
105
|
+
bool_obj['minimum_should_match'] = self.minimum_should_match
|
|
106
|
+
return {'bool': bool_obj}
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def model(self) -> Type[M]:
|
|
110
|
+
if self.mdl is not None:
|
|
111
|
+
return self.mdl
|
|
112
|
+
else:
|
|
113
|
+
for batch in [self.should, self.must, self.must_not, self.filter]:
|
|
114
|
+
for query in batch:
|
|
115
|
+
self.mdl = query.model
|
|
116
|
+
return self.mdl
|
|
117
|
+
raise ValueError(f'Bool query contains no queries and no valid models: {self}')
|
|
118
|
+
|
|
119
|
+
def add_should(self, query: Query[M], *queries: Query[M]):
|
|
120
|
+
if self.mdl is None:
|
|
121
|
+
self.mdl = query.model
|
|
122
|
+
self.should.append(query)
|
|
123
|
+
self.should.extend(queries)
|
|
124
|
+
|
|
125
|
+
def add_must(self, query: Query[M], *queries: Query[M]):
|
|
126
|
+
if self.mdl is None:
|
|
127
|
+
self.mdl = query.model
|
|
128
|
+
self.must.append(query)
|
|
129
|
+
self.must.extend(queries)
|
|
130
|
+
|
|
131
|
+
def add_filter(self, query: Query[M], *queries: Query[M]):
|
|
132
|
+
if self.mdl is None:
|
|
133
|
+
self.mdl = query.model
|
|
134
|
+
self.filter.append(query)
|
|
135
|
+
self.filter.extend(queries)
|
|
136
|
+
|
|
137
|
+
def add_must_not(self, query: Query[M], *queries: Query[M]):
|
|
138
|
+
if self.mdl is None:
|
|
139
|
+
self.mdl = query.model
|
|
140
|
+
self.must_not.append(query)
|
|
141
|
+
self.must_not.extend(queries)
|
|
142
|
+
|
|
143
|
+
class MatchQuery(Query[M]):
|
|
144
|
+
def __init__(self, select: Select[M, A], value: A, **options):
|
|
145
|
+
self.select = select
|
|
146
|
+
self.value = value
|
|
147
|
+
self.options = options
|
|
148
|
+
|
|
149
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
150
|
+
return {'match': {self.select.path: {'query': self.value}, **self.options}}
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def model(self) -> Type[M]:
|
|
154
|
+
return self.select.origin
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from typing import List, Iterable, Any, Dict, Type
|
|
2
|
+
|
|
3
|
+
from elasticsearch import Elasticsearch, NotFoundError
|
|
4
|
+
|
|
5
|
+
from pydoptic import BaseModel, Select, PartialModel
|
|
6
|
+
from pydoptic.selector import Prop
|
|
7
|
+
from pydoptic_elastic.elastic_model import M
|
|
8
|
+
from pydoptic_elastic.elastic_query import Query
|
|
9
|
+
from pydoptic.base_model import select
|
|
10
|
+
|
|
11
|
+
class _IndexResponse(BaseModel):
|
|
12
|
+
id: Prop['_IndexResponse', str] = select('_id')
|
|
13
|
+
|
|
14
|
+
class _GetResponse(BaseModel):
|
|
15
|
+
id: Prop['_GetResponse', str] = select('_id')
|
|
16
|
+
doc: Prop['_GetResponse', Dict[str, Any]] = select('_source')
|
|
17
|
+
|
|
18
|
+
class ElasticService:
|
|
19
|
+
def __init__(self, client: Elasticsearch):
|
|
20
|
+
self.__client = client
|
|
21
|
+
|
|
22
|
+
def create_index(self, model: Type[M]):
|
|
23
|
+
self.__client.indices.create(index=model._get_index_name(), mappings={'properties': model._get_mappings()})
|
|
24
|
+
|
|
25
|
+
def delete_index(self, model: Type[M]):
|
|
26
|
+
self.__client.indices.delete(index=model._get_index_name())
|
|
27
|
+
|
|
28
|
+
def refresh_index(self, model: Type[M]):
|
|
29
|
+
self.__client.indices.refresh(index=model._get_index_name())
|
|
30
|
+
|
|
31
|
+
def index(self, document: M, **kwargs) -> str:
|
|
32
|
+
response = self.__client.index(index=document.__class__._get_index_name(), document=document.as_dict_full(), **kwargs)
|
|
33
|
+
return _IndexResponse.id.get_val_unsafe(response.body)
|
|
34
|
+
|
|
35
|
+
def get(self, cls: Type[M], id: str, **kwargs) -> M | None:
|
|
36
|
+
try:
|
|
37
|
+
response = self.__client.get(index = cls._get_index_name(), id=id, **kwargs)
|
|
38
|
+
return _GetResponse.doc.get_unsafe(response.body).map(lambda d: cls(**d)).as_opt
|
|
39
|
+
except NotFoundError:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def get_partial(self, cls: Type[M], id: str, **kwargs) -> PartialModel[M] | None:
|
|
43
|
+
try:
|
|
44
|
+
response = self.__client.get(index = cls._get_index_name(), id=id, **kwargs)
|
|
45
|
+
return _GetResponse.doc.get_unsafe(response.body).map(lambda d: cls.partial(**d)).as_opt
|
|
46
|
+
except NotFoundError:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def get_raw(self, cls: Type[M], id: str, **kwargs) -> Dict[str, Any] | None:
|
|
50
|
+
try:
|
|
51
|
+
response = self.__client.get(index = cls._get_index_name(), id=id, **kwargs)
|
|
52
|
+
return _GetResponse.doc.get_unsafe(response.body).as_opt
|
|
53
|
+
except NotFoundError:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def search(self, query: Query[M], **kwargs) -> Iterable[M]:
|
|
57
|
+
search_results = self.__client.search(index = query.model._get_index_name(), query=query.to_dict(), **kwargs)
|
|
58
|
+
for hit in search_results['hits']['hits']:
|
|
59
|
+
_source = hit['_source']
|
|
60
|
+
yield query.model(**_source)
|
|
61
|
+
|
|
62
|
+
def search_partial(self, query: Query[M], source: Iterable[Select[M, Any]] | None = None, **kwargs) -> Iterable[PartialModel[M]]:
|
|
63
|
+
_kwargs = kwargs if source is None else {'source': [sel.path for sel in source], **kwargs}
|
|
64
|
+
search_results = self.__client.search(index=query.model._get_index_name(), query=query.to_dict(), **_kwargs)
|
|
65
|
+
for hit in search_results['hits']['hits']:
|
|
66
|
+
_source = hit['_source']
|
|
67
|
+
yield query.model.partial(**_source)
|
|
68
|
+
|
|
69
|
+
def search_raw(self, query: Query[M], source: Iterable[Select[M, Any]] | None = None, **kwargs) -> Iterable[Dict[str, Any]]:
|
|
70
|
+
_kwargs = kwargs if source is None else {'source': [sel.path for sel in source], **kwargs}
|
|
71
|
+
search_results = self.__client.search(index=query.model._get_index_name(), query=query.to_dict(), **_kwargs)
|
|
72
|
+
for hit in search_results['hits']['hits']:
|
|
73
|
+
yield hit['_source']
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydoptic-elastic
|
|
3
|
+
Version: 0.0.1.post1.dev2
|
|
4
|
+
Summary: An Elasticsearch integration built on pydoptic
|
|
5
|
+
Author: John Hungerford
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Source, https://github.com/johnhungerford/pydoptic
|
|
8
|
+
Requires-Python: <3.14,>=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: pydoptic
|
|
12
|
+
Requires-Dist: elasticsearch==8.14.0
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest~=8.4.2; extra == "test"
|
|
15
|
+
Provides-Extra: types
|
|
16
|
+
Requires-Dist: mypy~=1.18.2; extra == "types"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# pydoptic-elastic
|
|
20
|
+
|
|
21
|
+
An Elasticsearch integration built on [pydoptic](https://pypi.org/project/pydoptic/).
|
|
22
|
+
|
|
23
|
+
`ElasticModel` captures index and field-mapping metadata directly on the model's `Prop`s, and `Query`
|
|
24
|
+
lets you build Elasticsearch queries by referencing those `Prop`s directly -- no field or index names
|
|
25
|
+
passed as bare strings.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install pydoptic-elastic
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Includes the `elasticsearch` client as a regular dependency, so no extras are needed.
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python3
|
|
38
|
+
from pydoptic import Prop
|
|
39
|
+
from pydoptic_elastic import ElasticModel, ElasticService, Query, elastic_prop, ESMapping
|
|
40
|
+
from elasticsearch import Elasticsearch
|
|
41
|
+
|
|
42
|
+
class Person(ElasticModel):
|
|
43
|
+
name: Prop['Person', str] = elastic_prop(mapping=ESMapping.keyword)
|
|
44
|
+
age: Prop['Person', int]
|
|
45
|
+
|
|
46
|
+
service = ElasticService(Elasticsearch('http://localhost:9200'))
|
|
47
|
+
service.create_index(Person)
|
|
48
|
+
|
|
49
|
+
service.index(Person(name='John', age=42))
|
|
50
|
+
|
|
51
|
+
query = Query.match(Person.name, 'John')
|
|
52
|
+
for person in service.search(query):
|
|
53
|
+
print(Person.name.get_val(person))
|
|
54
|
+
# John
|
|
55
|
+
|
|
56
|
+
# Retrieve only specific fields, as a PartialModel:
|
|
57
|
+
for person in service.search_partial(query, source=[Person.name]):
|
|
58
|
+
print(Person.name.get_val_unsafe(person))
|
|
59
|
+
# John
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
See the [pydoptic README](https://github.com/johnhungerford/pydoptic/tree/main/packages/pydoptic) for
|
|
63
|
+
the underlying `Prop`/`Select` model this is built on.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/pydoptic_elastic/__init__.py
|
|
5
|
+
src/pydoptic_elastic/elastic_model.py
|
|
6
|
+
src/pydoptic_elastic/elastic_query.py
|
|
7
|
+
src/pydoptic_elastic/elastic_service.py
|
|
8
|
+
src/pydoptic_elastic/py.typed
|
|
9
|
+
src/pydoptic_elastic.egg-info/PKG-INFO
|
|
10
|
+
src/pydoptic_elastic.egg-info/SOURCES.txt
|
|
11
|
+
src/pydoptic_elastic.egg-info/dependency_links.txt
|
|
12
|
+
src/pydoptic_elastic.egg-info/requires.txt
|
|
13
|
+
src/pydoptic_elastic.egg-info/scm_file_list.json
|
|
14
|
+
src/pydoptic_elastic.egg-info/scm_version.json
|
|
15
|
+
src/pydoptic_elastic.egg-info/top_level.txt
|
|
16
|
+
test/test_elastic_model.py
|
|
17
|
+
test/test_elastic_service.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": [
|
|
3
|
+
"LICENSE",
|
|
4
|
+
"README.md",
|
|
5
|
+
"pyproject.toml",
|
|
6
|
+
"src/pydoptic_elastic/__init__.py",
|
|
7
|
+
"src/pydoptic_elastic/elastic_model.py",
|
|
8
|
+
"src/pydoptic_elastic/elastic_query.py",
|
|
9
|
+
"src/pydoptic_elastic/elastic_service.py",
|
|
10
|
+
"src/pydoptic_elastic/py.typed",
|
|
11
|
+
"test/test_elastic_model.py",
|
|
12
|
+
"test/test_elastic_service.py"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pydoptic_elastic
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from datetime import date, datetime
|
|
2
|
+
from elasticsearch import Elasticsearch
|
|
3
|
+
|
|
4
|
+
from pydoptic.selector import Prop, PropOptArr
|
|
5
|
+
from pydoptic_elastic import ElasticModel, elastic_prop, ESMapping
|
|
6
|
+
|
|
7
|
+
class Nested(ElasticModel):
|
|
8
|
+
prop_a: Prop['Nested', str] = elastic_prop(mapping=ESMapping.keyword)
|
|
9
|
+
|
|
10
|
+
class SimpleModel(ElasticModel):
|
|
11
|
+
index_name = 'name_override'
|
|
12
|
+
|
|
13
|
+
prop_1: Prop['SimpleModel', str] = elastic_prop(mapping=ESMapping.text)
|
|
14
|
+
prop_2: Prop['SimpleModel', str]
|
|
15
|
+
prop_3: Prop['SimpleModel', int]
|
|
16
|
+
prop_4: Prop['SimpleModel', float]
|
|
17
|
+
prop_5: Prop['SimpleModel', bool]
|
|
18
|
+
prop_6: Prop['SimpleModel', date]
|
|
19
|
+
prop_7: Prop['SimpleModel', datetime]
|
|
20
|
+
prop_8: PropOptArr['SimpleModel', Nested]
|
|
21
|
+
|
|
22
|
+
def test_elastic_model_mapping_should_reflect_custom_and_default_mappings():
|
|
23
|
+
mappings = SimpleModel._get_mappings()
|
|
24
|
+
assert mappings == {
|
|
25
|
+
'prop_1': {'type': 'text'},
|
|
26
|
+
'prop_2': {'type': 'text', 'fields': {'term': {'type': 'keyword'}}},
|
|
27
|
+
'prop_3': {'type': 'long'},
|
|
28
|
+
'prop_4': {'type': 'double'},
|
|
29
|
+
'prop_5': {'type': 'boolean'},
|
|
30
|
+
'prop_6': {'type': 'date'},
|
|
31
|
+
'prop_7': {'type': 'date'},
|
|
32
|
+
'prop_8': {'type': 'object', 'properties': {'prop_a': {'type': 'keyword'}}},
|
|
33
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from elasticsearch import Elasticsearch
|
|
3
|
+
|
|
4
|
+
from pydoptic.selector import Prop
|
|
5
|
+
from pydoptic_elastic.elastic_model import ElasticModel
|
|
6
|
+
from pydoptic_elastic.elastic_service import ElasticService
|
|
7
|
+
from pydoptic_elastic.elastic_query import Query
|
|
8
|
+
|
|
9
|
+
pytestmark = pytest.mark.integration
|
|
10
|
+
|
|
11
|
+
class SimpleModel(ElasticModel):
|
|
12
|
+
prop_1: Prop['SimpleModel', int]
|
|
13
|
+
prop_2: Prop['SimpleModel', str]
|
|
14
|
+
prop_3: Prop['SimpleModel', bool]
|
|
15
|
+
|
|
16
|
+
def test_index_and_retrieve_records():
|
|
17
|
+
client = Elasticsearch('http://localhost:9200')
|
|
18
|
+
|
|
19
|
+
service = ElasticService(client)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
service.create_index(SimpleModel)
|
|
23
|
+
except:
|
|
24
|
+
service.delete_index(SimpleModel)
|
|
25
|
+
service.create_index(SimpleModel)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
value = SimpleModel(prop_1=23, prop_2="hello", prop_3=True)
|
|
29
|
+
|
|
30
|
+
value_id = service.index(value)
|
|
31
|
+
service.refresh_index(SimpleModel)
|
|
32
|
+
|
|
33
|
+
value_retrieved = service.get(SimpleModel, value_id)
|
|
34
|
+
|
|
35
|
+
assert value_retrieved == value
|
|
36
|
+
|
|
37
|
+
finally:
|
|
38
|
+
service.delete_index(SimpleModel)
|
|
39
|
+
|
|
40
|
+
def test_search_records():
|
|
41
|
+
client = Elasticsearch('http://localhost:9200')
|
|
42
|
+
|
|
43
|
+
service = ElasticService(client)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
service.create_index(SimpleModel)
|
|
47
|
+
except:
|
|
48
|
+
service.delete_index(SimpleModel)
|
|
49
|
+
service.create_index(SimpleModel)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
value_1 = SimpleModel(prop_1=23, prop_2="hello", prop_3=True)
|
|
53
|
+
value_2 = SimpleModel(prop_1=24, prop_2="world", prop_3=False)
|
|
54
|
+
|
|
55
|
+
service.index(value_1)
|
|
56
|
+
service.index(value_2)
|
|
57
|
+
service.refresh_index(SimpleModel)
|
|
58
|
+
|
|
59
|
+
query_1 = Query[SimpleModel].match(SimpleModel.prop_1, SimpleModel.prop_1.get_val(value_1))
|
|
60
|
+
res_1 = list(service.search(query_1))
|
|
61
|
+
assert res_1 == [value_1]
|
|
62
|
+
|
|
63
|
+
query_2 = Query[SimpleModel].match(SimpleModel.prop_1, SimpleModel.prop_1.get_val(value_2))
|
|
64
|
+
res_2 = list(service.search(query_2))
|
|
65
|
+
assert res_2 == [value_2]
|
|
66
|
+
|
|
67
|
+
finally:
|
|
68
|
+
service.delete_index(SimpleModel)
|